Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
## 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.

* [#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).
Expand Down
67 changes: 63 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -86,14 +87,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
```
Expand All @@ -102,10 +111,60 @@ In the following example the array root is `people`.

```ruby
get "people" do
@user = User.all
User.all
end
```

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
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.

### 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

If your Grape API is versioned you must namespace your serializers accordingly.
Expand Down
31 changes: 31 additions & 0 deletions UPGRADING.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion lib/grape-active_model_serializers/endpoint_extension.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
8 changes: 8 additions & 0 deletions lib/grape-active_model_serializers/options_builder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,21 @@ def endpoint_options
[
endpoint.default_serializer_options || {},
endpoint.namespace_options,
namespace_root_options,
endpoint.route_options,
endpoint_metadata,
endpoint.options,
endpoint.options.fetch(:route_options)
].reduce(:merge)
end

def namespace_root_options
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)
Expand Down
2 changes: 1 addition & 1 deletion lib/grape-active_model_serializers/version.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
module Grape
module ActiveModelSerializers
VERSION = '2.0.2'.freeze
VERSION = '3.0.0'.freeze
end
end
157 changes: 157 additions & 0 deletions spec/features/grape-active_model_serializers/render_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -115,3 +115,160 @@ 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(:outer_namespace_options) { { root: 'directory_members' } }
let(:inner_namespace_options) { { root: 'members' } }
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
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
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 'uses the same explicit namespace root as collections' do
expect(JSON.parse(last_response.body)).to eq(
'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' } }

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
Loading