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 @@ -11,6 +11,7 @@
* [#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).
* [#47](https://github.com/ruby-grape/grape-roar/pull/47): Document Swagger response metadata using the optional grape-swagger-representable parser and Roar decorators; run MongoDB 8.0 CI jobs on Ubuntu 22.04 to avoid unsupported Linux kernels - [@dblock](https://github.com/dblock).
* [#49](https://github.com/ruby-grape/grape-roar/pull/49): Document application-local CRUD endpoints with input/output representers, validated writable fields, partial updates, and explicit persistence failures; use product-oriented class names in CRUD and parameter examples - [@dblock](https://github.com/dblock).
* Your contribution here.

### 0.5.0 (2025/09/25)
Expand Down
74 changes: 71 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ Use [Roar](https://github.com/apotonick/roar) with [Grape](https://github.com/in
- [Decorators](#decorators)
- [Presenter Parameter Types](#presenter-parameter-types)
- [Swagger Documentation](#swagger-documentation)
- [CRUD Endpoints](#crud-endpoints)
- [Nested Representers](#nested-representers)
- [Caching Representations](#caching-representations)
- [Relation Extensions](#relation-extensions)
Expand Down Expand Up @@ -243,7 +244,7 @@ end
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
class ProductInputRepresenter < Grape::Roar::Decorator
include Roar::JSON

property :title
Expand All @@ -261,12 +262,12 @@ class ParameterProductRepresenter < Grape::Roar::Decorator
end
end

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

params do
requires :product, type: ParameterProductRepresenter
requires :product, type: ProductInputRepresenter
end
post 'products' do
params[:product]
Expand Down Expand Up @@ -314,6 +315,73 @@ end

`/swagger_doc/product` describes an integer `id` and string `title`, including their descriptions, while `/product/:id` renders the same properties with Roar. The parser registers decorator classes, not representer modules; use a decorator for a documented response model. Response documentation does not validate incoming parameters, and custom links, getters, and envelopes still need schemas that match their actual output.

### CRUD Endpoints

Keep persistence application-local: use a dedicated input representer for writable fields, validate parameters with Grape, and call your model's persistence methods explicitly. For example, with an ActiveRecord-style model exposing `find`, `save!`, and `destroy!`:

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

property :title
end

class ProductOutputRepresenter < Grape::Roar::Decorator
include Roar::JSON

property :id
property :title
end

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

helpers do
def parse_product(product)
attributes = declared(params, include_missing: false).fetch(:product).transform_keys(&:to_s)
ProductInputRepresenter.new(product).from_hash(attributes)
end
end

params do
requires :product, type: Hash do
requires :title, type: String, allow_blank: false
end
end
post 'products' do
product = parse_product(Product.new)
product.save!
present product, with: ProductOutputRepresenter
end

params do
requires :id, type: Integer
requires :product, type: Hash do
optional :title, type: String, allow_blank: false
end
end
patch 'products/:id' do
product = parse_product(Product.find(params[:id]))
product.save!
present product, with: ProductOutputRepresenter
end

params do
requires :id, type: Integer
end
delete 'products/:id' do
product = Product.find(params[:id])
product.destroy!
present product, with: ProductOutputRepresenter
end
end
```

Send create/update input as `{"product":{"title":"Lonestar"}}` with `Content-Type: application/json`. `declared` filters request fields and the input representer only assigns `title`; output fields such as `id` are not writable. `include_missing: false` preserves existing values on partial updates. `from_hash` returns the model, while `present` applies the output representer after a successful write.

Scope record lookups to the authorized user and apply your application's authorization policy before mutations. Adapt persistence calls to your ORM. Configure application-specific `rescue_from` handlers for expected not-found, validation, or failed-destruction exceptions, mapping them to appropriate HTTP errors. The example deliberately uses raising persistence methods and does not swallow failures or return success when a write fails. Roar does not infer persistence, authorization, or validation rules from output properties.

### 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
72 changes: 72 additions & 0 deletions spec/grape/roar_crud_recipe_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# frozen_string_literal: true

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

let(:record) { Product.new(id: 1, title: 'Original') }

before do
stub_const('ProductInputRepresenter', Class.new(Grape::Roar::Decorator))
stub_const('ProductOutputRepresenter', Class.new(Grape::Roar::Decorator))
stub_const('ProductsAPI', Class.new(Grape::API))
readme = File.expand_path('../../README.md', __dir__)
section = File.read(readme).split("### CRUD Endpoints\n", 2).fetch(1).split("\n### ", 2).first
TOPLEVEL_BINDING.eval(section[/```ruby\n(.*?)```/m, 1], readme)

allow(Product).to receive(:new).and_return(record)
allow(Product).to receive(:find).with(1).and_return(record)
allow(record).to receive_messages(save!: true, destroy!: record)
end

it 'creates a record without assigning output-only or undeclared fields' do
post '/products', JSON.generate(product: { title: 'Lonestar', id: 999, admin: true }), 'CONTENT_TYPE' => 'application/json'

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

it 'updates an existing record and saves it' do
expect(record).to receive(:save!)

patch '/products/1', JSON.generate(product: { title: 'Updated' }), 'CONTENT_TYPE' => 'application/json'

expect(JSON.parse(last_response.body)).to eq('id' => 1, 'title' => 'Updated')
end

it 'does not clear fields omitted from a partial update' do
patch '/products/1', JSON.generate(product: {}), 'CONTENT_TYPE' => 'application/json'

expect(last_response.status).to eq(200)
expect(record.title).to eq('Original')
end

it 'rejects invalid create input without saving' do
expect(record).not_to receive(:save!)

post '/products', JSON.generate(product: { title: '' }), 'CONTENT_TYPE' => 'application/json'

expect(last_response.status).to eq(400)
end

it 'deletes the requested record' do
expect(record).to receive(:destroy!)

delete '/products/1'

expect(last_response.status).to eq(200)
end

it 'propagates save failures instead of returning a successful representation' do
allow(record).to receive(:save!).and_raise(ArgumentError, 'Save rejected')

expect do
post '/products', JSON.generate(product: { title: 'Lonestar' }), 'CONTENT_TYPE' => 'application/json'
end.to raise_error(ArgumentError, 'Save rejected')
end

it 'propagates destruction failures instead of returning success' do
allow(record).to receive(:destroy!).and_raise(ArgumentError, 'Destruction rejected')

expect { delete '/products/1' }.to raise_error(ArgumentError, 'Destruction rejected')
end
end
6 changes: 3 additions & 3 deletions spec/grape/roar_parameter_types_spec.rb
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
# frozen_string_literal: true

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

before do
stub_const('ParameterProductRepresenter', Class.new(Grape::Roar::Decorator))
stub_const('ParameterAPI', Class.new(Grape::API))
stub_const('ProductInputRepresenter', Class.new(Grape::Roar::Decorator))
stub_const('ProductsAPI', 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]
Expand Down
Loading