From 08245fdb2d9ae8cc5b470f4e99e325af8d44e2a6 Mon Sep 17 00:00:00 2001 From: Eric Proulx Date: Fri, 11 Sep 2026 11:35:49 +0200 Subject: [PATCH] Translate ValidationErrors#full_messages once instead of on every call ValidationErrors builds its #message from #full_messages when it is created, and #full_messages translated every error again on each call: an I18n lookup for the format and one per attribute name, a few microseconds apiece. The README's recipe for answering with the list, rescue_from Grape::Exceptions::ValidationErrors do |e| error!({ messages: e.full_messages }, 400) end therefore paid for every translation twice. The list is now kept from the first call and handed out as a copy, so a caller changing it cannot change what the next caller gets; a spec pins that. The list now stays what #message was built from. Before, a handler that switched the locale and then asked for it got the format and attribute names in the new locale around messages still in the old one. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 1 + lib/grape/exceptions/validation_errors.rb | 14 ++++++++++++-- spec/grape/exceptions/validation_errors_spec.rb | 9 +++++++++ 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fdc7d3a3e..d2d34b2d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ * [#2918](https://github.com/ruby-grape/grape/pull/2918): Skip the dry-types round trip when a value already is the declared type - [@ericproulx](https://github.com/ericproulx). * [#2917](https://github.com/ruby-grape/grape/pull/2917): Read path captures out of the router's union match instead of re-running the route's pattern - [@ericproulx](https://github.com/ericproulx). * [#2921](https://github.com/ruby-grape/grape/pull/2921): Pin the router's request-time isolation regressions through requests instead of its instance variables - [@ericproulx](https://github.com/ericproulx). +* [#2934](https://github.com/ruby-grape/grape/pull/2934): Translate `ValidationErrors#full_messages` once instead of on every call - [@ericproulx](https://github.com/ericproulx). * Your contribution here. ### 4.0.0 (2026-09-07) diff --git a/lib/grape/exceptions/validation_errors.rb b/lib/grape/exceptions/validation_errors.rb index 9fad6f94c..71a0723a7 100644 --- a/lib/grape/exceptions/validation_errors.rb +++ b/lib/grape/exceptions/validation_errors.rb @@ -23,7 +23,19 @@ def to_json(*_opts) as_json.to_json end + # Translated once, when the error is built for its #message, and handed + # out as a copy from then on. Every lookup here is an I18n call of a few + # microseconds, and the README's recipe for answering with the list, + # +error!({ messages: e.full_messages }, 400)+, asked for all of them a + # second time. It also keeps the list the same as #message, which was + # already fixed at that point, if the locale changes in between. def full_messages + (@full_messages ||= translate_full_messages).dup + end + + private + + def translate_full_messages messages = errors.flat_map do |attributes, errs| errs.map do |error| translate( @@ -39,8 +51,6 @@ def full_messages messages end - private - def translate_attributes(keys) keys.map do |key| translate(key, scope: 'grape.errors.attributes', default: key.to_s) diff --git a/spec/grape/exceptions/validation_errors_spec.rb b/spec/grape/exceptions/validation_errors_spec.rb index 8c0839f9b..a80416fc2 100644 --- a/spec/grape/exceptions/validation_errors_spec.rb +++ b/spec/grape/exceptions/validation_errors_spec.rb @@ -65,6 +65,15 @@ expect(subject.first).to eq('admin_field Can not set admin-only field') end end + + context 'when the caller changes the array it was given' do + subject(:error) { described_class.new(exceptions: [Grape::Exceptions::Validation.new(params: ['id'], message: :presence)]) } + + it 'returns the same messages the next time' do + error.full_messages << 'name is missing' + expect(error.full_messages).to eq(['id is missing']) + end + end end context 'api' do