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