diff --git a/CHANGELOG.md b/CHANGELOG.md index 3cd19e2..8904076 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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). diff --git a/README.md b/README.md index 2f30de4..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) @@ -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 ``` @@ -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. 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/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..7e3c88c 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,13 @@ def endpoint_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) 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 c2293c8..b7d2e04 100644 --- a/spec/features/grape-active_model_serializers/render_spec.rb +++ b/spec/features/grape-active_model_serializers/render_spec.rb @@ -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