From 81bac6bd1d267cd034329ed1e4286974fc5cdd95 Mon Sep 17 00:00:00 2001 From: "Daniel (dB.) Doubrovkine" Date: Mon, 26 Oct 2026 09:14:39 -0400 Subject: [PATCH 1/2] Fix namespace and render collection roots Merge serializer options from actual namespace scopes, preserving outer-to-inner precedence, and forward root through render. Document adapter selection, root precedence, empty collections, root:false behavior, and unwrapped arrays. Cover each documented response shape with request-level regressions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 1 + README.md | 52 +++++++- .../endpoint_extension.rb | 2 +- .../options_builder.rb | 10 ++ .../render_spec.rb | 112 ++++++++++++++++++ 5 files changed, 172 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3cd19e2..81f41cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ * Your contribution here. +* [#107](https://github.com/ruby-grape/grape-active_model_serializers/pull/107): Honor namespace and `render` collection roots without changing inferred single-object roots; document adapter behavior, root precedence, and unwrapped arrays - [@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..9517ba0 100644 --- a/README.md +++ b/README.md @@ -86,14 +86,22 @@ In this case, as User objects are being returned, grape-active_model_serializers ### Array Roots -When serializing an array, the array root is set to the innermost namespace name if there is one, otherwise it is set to the route name. +Root wrapping depends on the ActiveModelSerializers adapter. The default `:attributes` adapter ignores `root` and returns a bare array. Select the `:json` adapter to wrap the collection in a named root: + +```ruby +ActiveModelSerializers.config.adapter = :json +``` + +Grape's `format :json` selects the response format, not the AMS adapter. The `:json_api` adapter follows JSON:API's `data` structure instead of using an arbitrary root. + +With the `:json` adapter, the default array root is the innermost namespace name if there is one, otherwise it is the route name. In the following API the array root is `users`. ```ruby namespace :users do - get ":id" do - @user = User.find(params[:id]) + get '/' do + User.all end end ``` @@ -102,10 +110,46 @@ In the following example the array root is `people`. ```ruby get "people" do - @user = User.all + User.all end ``` +For collections, set `root` on a namespace or route to override the default. Inner namespace roots override outer ones, and a route option takes precedence over its namespace: + +```ruby +namespace :users, root: 'members' do + get '/' do + User.all + end + + get '/recent', root: 'recent_members' do + User.all + end +end +``` + +Pass `root` to `render` to override namespace and route options for that response, including empty collections: + +```ruby +get '/people', root: 'people' do + render User.all, adapter: :json, each_serializer: UserSerializer, root: 'members' +end +``` + +This returns an object such as `{ "members": [{ "first_name": "Jane", "last_name": "Doe" }] }`; an empty collection returns `{ "members": [] }`. + +In AMS 0.10, `root: false` does not remove the `:json` adapter's envelope. To return a bare array for one response, choose the `:attributes` adapter: + +```ruby +get '/people' do + render User.all, adapter: :attributes, each_serializer: UserSerializer +end +``` + +Return the model collection rather than a manually constructed `ActiveModel::Serializer::CollectionSerializer`. The formatter constructs the serializer and adapter; options such as `root` only affect the final response when the adapter handles them. + +Namespace roots apply only to collections. Single-object responses retain the root inferred by AMS from the serializer or model, even inside a namespace with an explicit `root`. Set `root` on the route or pass it to `render` to override a single-object root explicitly. + ### API Versioning If your Grape API is versioned you must namespace your serializers accordingly. diff --git a/lib/grape-active_model_serializers/endpoint_extension.rb b/lib/grape-active_model_serializers/endpoint_extension.rb index a9abce1..b3303c6 100644 --- a/lib/grape-active_model_serializers/endpoint_extension.rb +++ b/lib/grape-active_model_serializers/endpoint_extension.rb @@ -44,7 +44,7 @@ def render(resources, extra_options = {}) :meta, :meta_key ) env['ams_adapter'] = options.slice( - :adapter, :serializer, :each_serializer, :include, + :adapter, :serializer, :each_serializer, :include, :root, :fields, :key_transform, :links, :namespace ) env['ams_extra'] = options[:extra] diff --git a/lib/grape-active_model_serializers/options_builder.rb b/lib/grape-active_model_serializers/options_builder.rb index ea8fcff..d57c6e7 100644 --- a/lib/grape-active_model_serializers/options_builder.rb +++ b/lib/grape-active_model_serializers/options_builder.rb @@ -26,6 +26,7 @@ def endpoint_options [ endpoint.default_serializer_options || {}, endpoint.namespace_options, + namespace_root_options, endpoint.route_options, endpoint_metadata, endpoint.options, @@ -33,6 +34,15 @@ def endpoint_options ].reduce(:merge) end + def namespace_root_options + return {} unless resource.respond_to?(:to_ary) + + scopes = endpoint.inheritable_setting.namespace_stackable[:namespace] || [] + scopes.each_with_object({}) do |scope, options| + options.merge!(scope.options.slice(:root)) + end + end + def endpoint_metadata options = {} options[:for] = endpoint.api if endpoint.respond_to?(:api) diff --git a/spec/features/grape-active_model_serializers/render_spec.rb b/spec/features/grape-active_model_serializers/render_spec.rb index c2293c8..3732bd8 100644 --- a/spec/features/grape-active_model_serializers/render_spec.rb +++ b/spec/features/grape-active_model_serializers/render_spec.rb @@ -115,3 +115,115 @@ def get_resource_with(meta) end end end + +describe 'single-object and collection roots' do + let(:app) { Class.new(Grape::API) } + let(:resources) { [User.new(first_name: 'Jane', last_name: 'Doe')] } + let(:route_options) { {} } + let(:render_options) { {} } + + before do + ActiveModelSerializers.config.adapter = :json + app.format :json + app.formatter :json, Grape::Formatter::ActiveModelSerializers + + resource = resources + options = render_options + route_settings = route_options + app.namespace :directory, root: 'directory_members' do + namespace :users, root: 'members' do + get '/people', **route_settings do + render resource, options + end + end + end + + get '/directory/users/people' + end + + it 'uses the innermost namespace root' do + expect(JSON.parse(last_response.body)).to eq( + 'members' => [{ 'first_name' => 'Jane', 'last_name' => 'Doe' }] + ) + end + + context 'route options' do + let(:route_options) { { root: 'recent_members' } } + + it 'overrides the namespace root' do + expect(JSON.parse(last_response.body)).to eq( + 'recent_members' => [{ 'first_name' => 'Jane', 'last_name' => 'Doe' }] + ) + end + end + + context 'render options' do + let(:route_options) { { root: 'recent_members' } } + let(:render_options) { { root: 'selected_members' } } + + it 'overrides the route and namespace roots' do + expect(JSON.parse(last_response.body)).to eq( + 'selected_members' => [{ 'first_name' => 'Jane', 'last_name' => 'Doe' }] + ) + end + + context 'an empty collection' do + let(:resources) { [] } + + it 'keeps the explicit root' do + expect(JSON.parse(last_response.body)).to eq('selected_members' => []) + end + end + end + + context 'root is false' do + let(:render_options) { { root: false } } + + it 'uses the AMS-inferred root rather than removing the JSON envelope' do + expect(JSON.parse(last_response.body)).to eq( + 'users' => [{ 'first_name' => 'Jane', 'last_name' => 'Doe' }] + ) + end + end + + context 'the attributes adapter' do + let(:render_options) { { adapter: :attributes, root: 'ignored' } } + + it 'returns a bare array regardless of the root option' do + expect(JSON.parse(last_response.body)).to eq( + [{ 'first_name' => 'Jane', 'last_name' => 'Doe' }] + ) + end + end + + context 'a single object' do + let(:resources) { User.new(first_name: 'Jane', last_name: 'Doe') } + + it 'keeps the inferred model root despite explicit namespace roots' do + expect(JSON.parse(last_response.body)).to eq( + 'user' => { 'first_name' => 'Jane', 'last_name' => 'Doe' } + ) + end + + context 'route options' do + let(:route_options) { { root: 'person' } } + + it 'honors an explicit route root' do + expect(JSON.parse(last_response.body)).to eq( + 'person' => { 'first_name' => 'Jane', 'last_name' => 'Doe' } + ) + end + end + + context 'render options' do + let(:route_options) { { root: 'person' } } + let(:render_options) { { root: 'selected_person' } } + + it 'overrides the route root' do + expect(JSON.parse(last_response.body)).to eq( + 'selected_person' => { 'first_name' => 'Jane', 'last_name' => 'Doe' } + ) + end + end + end +end From 7d726f447fd024f2f735f14f1b56a94084d543e0 Mon Sep 17 00:00:00 2001 From: "Daniel (dB.) Doubrovkine" Date: Mon, 26 Oct 2026 22:45:54 -0400 Subject: [PATCH 2/2] Prepare 3.0 with consistent explicit namespace roots Apply explicit namespace roots to single objects as well as collections. Preserve root precedence, inferred defaults, and adapter behavior, and document the breaking response-key change and migration options. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 6 +++ README.md | 19 ++++++- UPGRADING.md | 31 +++++++++++ .../options_builder.rb | 2 - lib/grape-active_model_serializers/version.rb | 2 +- .../render_spec.rb | 53 +++++++++++++++++-- 6 files changed, 104 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 81f41cb..8904076 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ ## Changelog +### 3.0.0 (Next) + +* Your contribution here. + +* [#111](https://github.com/ruby-grape/grape-active_model_serializers/pull/111): Apply explicit namespace roots consistently to single objects and collections, intentionally changing single-object response keys; document migration and preserve adapter behavior - [@dblock](https://github.com/dblock). + ### 2.0.2 (Next) * Your contribution here. diff --git a/README.md b/README.md index 9517ba0..c278e9a 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ - [Writing Serializers](#writing-serializers) - [Serializers are inferred by active_record model names](#serializers-are-inferred-by-active_record-model-names) - [Array Roots](#array-roots) + - [Single-Object Roots](#single-object-roots) - [API Versioning](#api-versioning) - [Manually specifying serializer / adapter options](#manually-specifying-serializer--adapter-options) - [Error Serialization](#error-serialization) @@ -114,7 +115,7 @@ get "people" do end ``` -For collections, set `root` on a namespace or route to override the default. Inner namespace roots override outer ones, and a route option takes precedence over its namespace: +Set `root` on a namespace or route to override the inferred root for collections or single objects. Inner namespace roots override outer ones, and a route option takes precedence over its namespace: ```ruby namespace :users, root: 'members' do @@ -148,7 +149,21 @@ end Return the model collection rather than a manually constructed `ActiveModel::Serializer::CollectionSerializer`. The formatter constructs the serializer and adapter; options such as `root` only affect the final response when the adapter handles them. -Namespace roots apply only to collections. Single-object responses retain the root inferred by AMS from the serializer or model, even inside a namespace with an explicit `root`. Set `root` on the route or pass it to `render` to override a single-object root explicitly. +### Single-Object Roots + +Starting in 3.0, explicit namespace roots apply to single-object responses as well as collections, with the same precedence: `render` overrides the route, the route overrides the innermost explicit namespace root, and inner namespace roots override outer ones. + +```ruby +namespace :users, root: 'members' do + get '/:id' do + User.find(params[:id]) + end +end +``` + +With the `:json` adapter, this returns `{ "members": { "first_name": "Jane", "last_name": "Doe" } }`. The root is used literally, without singularizing it for a single object. In 2.x the namespace root was ignored for this response, which used `"user"` instead. See [UPGRADING](UPGRADING.md#upgrading-to-v300) for migration options. + +Without an explicit root, AMS continues to infer the single-object root from the serializer or model, not the namespace or route name. Adapter behavior is unchanged: `:attributes` returns an unwrapped object and `:json_api` retains its standard document structure. ### API Versioning diff --git a/UPGRADING.md b/UPGRADING.md index c1469c1..b54d315 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -1,3 +1,34 @@ +### Upgrading to v3.0.0 + +#### Explicit namespace roots apply to single objects + +With the AMS `:json` adapter, an explicit namespace `root` now applies consistently to both collections and single objects. This intentionally changes single-object response keys that previously ignored the namespace root: + +```ruby +namespace :users, root: 'members' do + get('/') { User.all } + get('/:id') { User.find(params[:id]) } +end +``` + +The collection uses `{ "members": [...] }`. In 3.0 the single-object response also uses `"members"`, returning `{ "members": {...} }` rather than the previous `{ "user": {...} }`. Roots are literal: a single-object response does not automatically singularize `"members"`. + +To preserve an existing single-object response key, override the namespace root on that route: + +```ruby +namespace :users, root: 'members' do + get '/:id', root: 'user' do + User.find(params[:id]) + end +end +``` + +Alternatively, pass `root: 'user'` to `render` or remove the namespace root and specify collection roots on individual collection routes. Update API clients and response schemas if adopting the new namespace-derived key instead. + +Root precedence is `render` > route > innermost explicit namespace root > outer namespace roots > default serializer options. Without an explicit root, single objects retain AMS model/serializer inference; collections retain the innermost namespace or route-name default. + +The change is limited to explicit namespace roots. It does not change serializer inference, merge other namespace options differently, or alter adapter semantics. The `:attributes` adapter still omits envelopes, the `:json_api` adapter keeps its standard document structure, and `root: false` still does not remove a `:json` envelope. + ### Upgrading to v1.5.0 #### ASM v0.10.x support added, v0.9.x support dropped diff --git a/lib/grape-active_model_serializers/options_builder.rb b/lib/grape-active_model_serializers/options_builder.rb index d57c6e7..7e3c88c 100644 --- a/lib/grape-active_model_serializers/options_builder.rb +++ b/lib/grape-active_model_serializers/options_builder.rb @@ -35,8 +35,6 @@ def endpoint_options end def namespace_root_options - return {} unless resource.respond_to?(:to_ary) - scopes = endpoint.inheritable_setting.namespace_stackable[:namespace] || [] scopes.each_with_object({}) do |scope, options| options.merge!(scope.options.slice(:root)) diff --git a/lib/grape-active_model_serializers/version.rb b/lib/grape-active_model_serializers/version.rb index 97956cf..97e3c4a 100644 --- a/lib/grape-active_model_serializers/version.rb +++ b/lib/grape-active_model_serializers/version.rb @@ -1,5 +1,5 @@ module Grape module ActiveModelSerializers - VERSION = '2.0.2'.freeze + VERSION = '3.0.0'.freeze end end diff --git a/spec/features/grape-active_model_serializers/render_spec.rb b/spec/features/grape-active_model_serializers/render_spec.rb index 3732bd8..b7d2e04 100644 --- a/spec/features/grape-active_model_serializers/render_spec.rb +++ b/spec/features/grape-active_model_serializers/render_spec.rb @@ -119,6 +119,8 @@ def get_resource_with(meta) describe 'single-object and collection roots' do let(:app) { Class.new(Grape::API) } let(:resources) { [User.new(first_name: 'Jane', last_name: 'Doe')] } + let(:outer_namespace_options) { { root: 'directory_members' } } + let(:inner_namespace_options) { { root: 'members' } } let(:route_options) { {} } let(:render_options) { {} } @@ -130,8 +132,10 @@ def get_resource_with(meta) resource = resources options = render_options route_settings = route_options - app.namespace :directory, root: 'directory_members' do - namespace :users, root: 'members' do + outer_options = outer_namespace_options + inner_options = inner_namespace_options + app.namespace :directory, **outer_options do + namespace :users, **inner_options do get '/people', **route_settings do render resource, options end @@ -199,12 +203,53 @@ def get_resource_with(meta) context 'a single object' do let(:resources) { User.new(first_name: 'Jane', last_name: 'Doe') } - it 'keeps the inferred model root despite explicit namespace roots' do + it 'uses the same explicit namespace root as collections' do expect(JSON.parse(last_response.body)).to eq( - 'user' => { 'first_name' => 'Jane', 'last_name' => 'Doe' } + 'members' => { 'first_name' => 'Jane', 'last_name' => 'Doe' } ) end + context 'an inner namespace without a root' do + let(:inner_namespace_options) { {} } + + it 'inherits the outer namespace root' do + expect(JSON.parse(last_response.body)).to eq( + 'directory_members' => { 'first_name' => 'Jane', 'last_name' => 'Doe' } + ) + end + end + + context 'no explicit namespace roots' do + let(:outer_namespace_options) { {} } + let(:inner_namespace_options) { {} } + + it 'keeps the inferred model root' do + expect(JSON.parse(last_response.body)).to eq( + 'user' => { 'first_name' => 'Jane', 'last_name' => 'Doe' } + ) + end + end + + context 'an inner namespace root is false' do + let(:inner_namespace_options) { { root: false } } + + it 'uses the AMS-inferred root instead of inheriting the outer root' do + expect(JSON.parse(last_response.body)).to eq( + 'user' => { 'first_name' => 'Jane', 'last_name' => 'Doe' } + ) + end + end + + context 'the attributes adapter' do + let(:render_options) { { adapter: :attributes } } + + it 'keeps the response unwrapped despite the namespace root' do + expect(JSON.parse(last_response.body)).to eq( + 'first_name' => 'Jane', 'last_name' => 'Doe' + ) + end + end + context 'route options' do let(:route_options) { { root: 'person' } }