Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
* [#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).
* [#46](https://github.com/ruby-grape/grape-roar/pull/46): Document Grape's built-in representer lookup through model Entity constants and API-local decorator mappings, including explicit overrides - [@dblock](https://github.com/dblock).
* [#48](https://github.com/ruby-grape/grape-roar/pull/48): Document presenter parameter types using Grape's custom parsing contract with explicit input validation and writable properties; keep legacy test bundles on JSON 2.x for MultiJson compatibility - [@dblock](https://github.com/dblock).
* Your contribution here.

### 0.5.0 (2025/09/25)
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ GRAPE_VERSION='~> 4.0' bundle update
GRAPE_VERSION='~> 4.0' bundle exec rake
```

Grape 1.x requires Rack 2.x:
Grape 1.x requires Rack 2.x. On Ruby versions older than 3.2, the Gemfile also selects JSON 2.x because the compatible MultiJson releases use `JSON.load` options removed in JSON 3:

```sh
GRAPE_VERSION='~> 1.8' RACK_VERSION='~> 2.2' bundle update
Expand Down
3 changes: 3 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ gem 'rack', ENV['RACK_VERSION'] if ENV.key?('RACK_VERSION')

gemspec

# MultiJson versions supporting older Rubies use JSON.load options removed in JSON 3.
gem 'json', '< 3' if Gem::Version.new(RUBY_VERSION) < Gem::Version.new('3.2')

group :test do
gem 'rack-test'
gem 'rspec'
Expand Down
41 changes: 41 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ Use [Roar](https://github.com/apotonick/roar) with [Grape](https://github.com/in
- [Hash-backed Representers](#hash-backed-representers)
- [Accessing the Request Inside a Representer](#accessing-the-request-inside-a-representer)
- [Decorators](#decorators)
- [Presenter Parameter Types](#presenter-parameter-types)
- [Nested Representers](#nested-representers)
- [Caching Representations](#caching-representations)
- [Relation Extensions](#relation-extensions)
Expand Down Expand Up @@ -236,6 +237,46 @@ get 'products' do
end
```

### Presenter Parameter Types

Grape supports `type: Presenter` when the class implements its custom-type `.parse` contract. Define this on an application-specific input decorator: validate the input, parse only declared writable properties, and return a decorator instance so Grape's type check succeeds.

```ruby
class ParameterProductRepresenter < Grape::Roar::Decorator
include Roar::JSON

property :title

def self.parse(value)
raise ArgumentError, 'Expected a product object' unless value.is_a?(::Hash)

attributes = value.transform_keys(&:to_s)
title = attributes['title']
raise ArgumentError, 'Expected a nonblank title' unless title.is_a?(::String) && !title.strip.empty?

decorator = new(Product.new)
decorator.from_hash(attributes)
decorator
end
end

class ParameterAPI < Grape::API
format :json
formatter :json, Grape::Formatter::Roar

params do
requires :product, type: ParameterProductRepresenter
end
post 'products' do
params[:product]
end
end
```

Send a JSON body such as `{"product":{"title":"Lonestar"}}` with `Content-Type: application/json`. Grape converts parse failures into HTTP 400 validation errors. The response is `{"title":"Lonestar"}`; undeclared properties are not assigned to the model. The coerced parameter is the decorator, and `params[:product].represented` gives the model for application-specific processing or persistence.

Roar properties and Swagger response metadata do not automatically define required fields, validation, or a model constructor. Keep input validation explicit, use a separate input representer when writable fields differ from output fields, and do not add a generic parser that guesses which model to construct. `from_hash` returns the represented model, so return the decorator explicitly from `.parse` when using the decorator as the parameter type.

### Nested Representers

Use `property` for a single nested object and `collection` for multiple objects, specifying the representer at each level with `extend:`. For example, notifications can contain comments, posts, and images:
Expand Down
51 changes: 51 additions & 0 deletions spec/grape/roar_parameter_types_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# frozen_string_literal: true

describe Grape::Roar do
subject(:app) { ParameterAPI }

before do
stub_const('ParameterProductRepresenter', Class.new(Grape::Roar::Decorator))
stub_const('ParameterAPI', Class.new(Grape::API))
readme = File.expand_path('../../README.md', __dir__)
section = File.read(readme).split("### Presenter Parameter Types\n", 2).fetch(1).split("\n### ", 2).first
example = section[/```ruby\n(.*?)```/m, 1]
TOPLEVEL_BINDING.eval(example, readme)
end

def post_product(value)
post '/products', JSON.generate(product: value), 'CONTENT_TYPE' => 'application/json'
end

it 'coerces an object through the documented presenter and excludes undeclared fields' do
post_product(title: 'Lonestar', id: 666, admin: true)

expect(last_response.status).to eq(201), last_response.body
expect(JSON.parse(last_response.body)).to eq('title' => 'Lonestar')
end

it 'rejects malformed or incomplete objects before constructing a model' do
expect(Product).not_to receive(:new)
statuses = [nil, 'not an object', [], {}, { title: 12 }].map do |value|
post_product(value)
last_response.status
end

expect(statuses).to eq([400, 400, 400, 400, 400])
end

it 'rejects blank titles' do
statuses = ['', " \t"].map do |title|
post_product(title: title)
last_response.status
end

expect(statuses).to eq([400, 400])
end

it 'rejects a missing required parameter' do
post '/products', '{}', 'CONTENT_TYPE' => 'application/json'

expect(last_response.status).to eq(400)
expect(JSON.parse(last_response.body).fetch('error')).to include('product')
end
end
Loading