From fe431d12b7bbb241e4db721029dcc3b9e32b91dd Mon Sep 17 00:00:00 2001 From: "Daniel (dB.) Doubrovkine" Date: Tue, 27 Oct 2026 06:37:44 -0400 Subject: [PATCH] Document presenter parameter coercion with explicit input validation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 1 + CONTRIBUTING.md | 2 +- Gemfile | 3 ++ README.md | 41 ++++++++++++++++++++ spec/grape/roar_parameter_types_spec.rb | 51 +++++++++++++++++++++++++ 5 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 spec/grape/roar_parameter_types_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 25b357a..ce597e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f47fdd6..ed5970f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 diff --git a/Gemfile b/Gemfile index 451f1ac..0c9cbe1 100644 --- a/Gemfile +++ b/Gemfile @@ -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' diff --git a/README.md b/README.md index b1fa111..fde9edf 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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: diff --git a/spec/grape/roar_parameter_types_spec.rb b/spec/grape/roar_parameter_types_spec.rb new file mode 100644 index 0000000..ee9bd16 --- /dev/null +++ b/spec/grape/roar_parameter_types_spec.rb @@ -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