diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b2cfc6..668144a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ * [#41](https://github.com/ruby-grape/grape-roar/pull/41): Document hash-backed representers with explicit key getters and verify module/decorator rendering for symbol and string keys - [@dblock](https://github.com/dblock). * [#43](https://github.com/ruby-grape/grape-roar/pull/43): Add SimpleCov LCOV reports and a coverage badge; merge Coveralls reports from selected existing ORM matrix entries without duplicate test runs - [@dblock](https://github.com/dblock). * [#44](https://github.com/ruby-grape/grape-roar/pull/44): Document dynamic Roar rendering options with native Hash responses and verify wrapping, callback data, request context, and request isolation for modules and decorators - [@dblock](https://github.com/dblock). +* [#45](https://github.com/ruby-grape/grape-roar/pull/45): Document native array rendering with Roar's collection factory while preserving collection envelopes and request-aware links - [@dblock](https://github.com/dblock). * Your contribution here. ### 0.5.0 (2025/09/25) diff --git a/README.md b/README.md index 10e7349..9e87018 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ Use [Roar](https://github.com/apotonick/roar) with [Grape](https://github.com/in - [Tell your API to use Grape::Formatter::Roar](#tell-your-api-to-use-grapeformatterroar) - [Use Grape’s Present](#use-grapes-present) - [Dynamic Rendering Options](#dynamic-rendering-options) + - [Native Array Representations](#native-array-representations) - [Hash-backed Representers](#hash-backed-representers) - [Accessing the Request Inside a Representer](#accessing-the-request-inside-a-representer) - [Decorators](#decorators) @@ -122,6 +123,20 @@ This returns a JSON object wrapped under `"product"`, not a JSON-encoded string. `present product, with: ProductRepresenter` does not forward arbitrary options to Roar's later rendering step. Use the explicit `to_hash` call above instead, and do not return `to_json` output through the formatter: the formatter would encode that string again. Keep options local to each rendering call rather than modifying shared representer definitions. +### Native Array Representations + +To render a plain array using a single-item representer, use Roar's native collection factory explicitly. This works with both representer modules and decorators: + +```ruby +get 'products' do + ProductRepresenter.for_collection.prepare(Product.all.to_a).to_hash(user_options: { env: env }) +end +``` + +The response is an array of individually represented products. Empty collections return `[]`, and callbacks receive the request environment. Return the native Array from `to_hash`, not a JSON string, so the formatter encodes it exactly once. + +`present products, with: ProductsRepresenter` still treats `ProductsRepresenter` as the representation of the whole collection. This preserves existing collection properties, envelopes, and HAL `_embedded` responses. Automatically treating every Array as a collection of individually represented resources would change those responses; use the explicit factory above when that is what you want. + ### Hash-backed Representers A plain `property :some_property` reads `object.some_property`; it does not read `object[:some_property]`. When presenting a Hash, specify a getter instead of relying on implicit conversion to an object with attribute methods: diff --git a/spec/grape/roar_collections_spec.rb b/spec/grape/roar_collections_spec.rb new file mode 100644 index 0000000..13e4992 --- /dev/null +++ b/spec/grape/roar_collections_spec.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +require 'roar/json/hal' + +describe Grape::Roar do + include_context 'Grape API App' + + shared_examples 'native collection rendering' do + before do + representer.property :id + representer.property :title + representer.link(:self) do |opts| + "#{Grape::Request.new(opts.fetch(:env)).base_url}/products/#{represented.id}" + end + stub_const('CollectionItemRepresenter', representer) + + records = [Product.new(id: 1, title: 'First'), Product.new(id: 2, title: 'Second')] + subject.get('/products') do + products = params[:empty] ? [] : records + CollectionItemRepresenter.for_collection.prepare(products).to_hash(user_options: { env: env }) + end + + wrapper = Module.new do + include Roar::JSON::HAL + include Grape::Roar::Representer + end + wrapper.collection :entries, extend: representer, as: :products, embedded: true + subject.get('/hal') { present records, with: wrapper } + end + + it 'renders each item with request-aware links' do + get '/products' + + expect(JSON.parse(last_response.body)).to eq( + [ + { 'id' => 1, 'title' => 'First', 'links' => [{ 'rel' => 'self', 'href' => 'http://example.org/products/1' }] }, + { 'id' => 2, 'title' => 'Second', 'links' => [{ 'rel' => 'self', 'href' => 'http://example.org/products/2' }] } + ] + ) + end + + it 'renders an empty collection as an array' do + get '/products', empty: 'true' + + expect(JSON.parse(last_response.body)).to eq([]) + end + + it 'preserves existing collection representers with an embedded envelope' do + get '/hal' + + products = JSON.parse(last_response.body).fetch('_embedded').fetch('products') + expect(products.map { |product| product.fetch('title') }).to eq(%w[First Second]) + expect(products.first.fetch('links')).to eq([{ 'rel' => 'self', 'href' => 'http://example.org/products/1' }]) + end + end + + context 'with a module' do + let(:representer) do + Module.new do + include Roar::JSON + include Roar::Hypermedia + include Grape::Roar::Representer + end + end + + it_behaves_like 'native collection rendering' + end + + context 'with a decorator' do + let(:representer) do + Class.new(Grape::Roar::Decorator) do + include Roar::JSON + include Roar::Hypermedia + end + end + + it_behaves_like 'native collection rendering' + end +end