From 7e497a0afe54935669cd35915d56a78cbbb174dc Mon Sep 17 00:00:00 2001 From: "Daniel (dB.) Doubrovkine" Date: Tue, 27 Oct 2026 06:40:40 -0400 Subject: [PATCH] Document application-local CRUD with explicit persistence Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 1 + README.md | 74 ++++++++++++++++++++++++- spec/grape/roar_crud_recipe_spec.rb | 72 ++++++++++++++++++++++++ spec/grape/roar_parameter_types_spec.rb | 6 +- 4 files changed, 147 insertions(+), 6 deletions(-) create mode 100644 spec/grape/roar_crud_recipe_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index c539963..2137255 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/README.md b/README.md index ba58bef..33c9cbc 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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 @@ -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] @@ -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: diff --git a/spec/grape/roar_crud_recipe_spec.rb b/spec/grape/roar_crud_recipe_spec.rb new file mode 100644 index 0000000..222e9c5 --- /dev/null +++ b/spec/grape/roar_crud_recipe_spec.rb @@ -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 diff --git a/spec/grape/roar_parameter_types_spec.rb b/spec/grape/roar_parameter_types_spec.rb index ee9bd16..2410398 100644 --- a/spec/grape/roar_parameter_types_spec.rb +++ b/spec/grape/roar_parameter_types_spec.rb @@ -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]