eluvia-base 3.38.0 → 3.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5504d4c333c7a6ec87a0752207016b6ba9e9b4b6627a1de04afde70b755a907e
4
- data.tar.gz: 0d666609cd85101862b53301e4a31f08f99719b02164ddb2c43aff83edb4a698
3
+ metadata.gz: dcf7c769d60f4347beee0d63aff1e1e8944e5fb2412985f531465dd833cfe58e
4
+ data.tar.gz: dc7491fcac983ef84e9f33396139370ce94fb65ce8718742acc4806d4e35e0b6
5
5
  SHA512:
6
- metadata.gz: 1da58aaaef1574dc606baa60d941ac243554808301a5a94d4ccea92b21c65375895a3e66c8b749dc181d22c8d5da50955e0c34840d1818a0001a5ba4821f3f74
7
- data.tar.gz: 9ca68b4c7e319abbb95228e0639c1d13c0158571491fd3e355363e5cf1237abac9cf5e83513f5dc70da3a86ed9b4768bd83c47ccc6f23bbc4a6c42ac42b752fe
6
+ metadata.gz: 1a8eff124c578195160772b542e7ece4419b76ada79214f3b7d7ba3cb5c24768249b8d8c24ea207e37a33e6dc5d4d3b7196f83d369df428ae9724b36702af1e4
7
+ data.tar.gz: 2df71bbb4195fde554b62bc8052d02894be13b14c652325f08af43b94d4b23385499bf0dbe5847ae16786519e59de04be8b6d8a65f4e734f8202bc99f793a55a
data/CHANGELOG.md ADDED
@@ -0,0 +1,56 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [3.40.0] — 2026-09-23
9
+
10
+ ### Added
11
+ - `Eluvia::ActiveRecord::EnvFilterable` — a generic, model-agnostic filter driven by deploy-time
12
+ config (typically a base64-encoded JSON ENV var). `env_filter_fields` declares the column
13
+ whitelist (the only part ever interpolated into SQL; all values stay bound parameters),
14
+ `parse_env_filter_config` decodes the base64 JSON, and `apply_env_filter(scope, config)` applies
15
+ the AND-combined `field`/`operator`/`value` conditions to a scope. Operators: `eq`, `not_eq`,
16
+ `in`, `not_in`, `like`, `not_like` (case-insensitive substring; negative operators are
17
+ NULL-safe). Extracted from the payments service so services need only declare their whitelist
18
+ (VD-2811).
19
+
20
+ ## [3.39.0] — 2026-09-18
21
+
22
+ ### Added
23
+ - This changelog, in the format the ci-toolbox release job expects: on release the unreleased
24
+ heading is rewritten to the released version and date, and the section becomes the description
25
+ of the GitLab release.
26
+ - `Eluvia::Base::Config.max_page_limit` — upper bound for the `limit` pagination param on
27
+ `set_pagination` (default `1000`, `nil` disables the check), overridable per controller via
28
+ `pagination_max_limit` or per call via the `max_limit:` keyword.
29
+ - `Eluvia::Base::Config.disable_pagination_errors` and the `disable_errors:` keyword on
30
+ `set_pagination`, to restore the previous silent-fallback behavior for an invalid `limit`
31
+ instead of raising.
32
+ - `allow_meta_only_limit:` keyword on `set_pagination` to reject `limit=0` like any other invalid
33
+ value, for resources that don't support a meta-only request.
34
+ - Translations (`lib/eluvia/locales/*.yml`) for all validation error messages raised by this gem
35
+ (ordering, filtering, fieldset, pagination), in the same languages supported by `habarico`
36
+ (`cs`, `de`, `en`, `es`, `fr`, `it`, `ja`, `nl`, `pl`, `pt`, `ro`, `sk`, `uk`).
37
+ - `Eluvia::PaginationHandler.order_by_param_name` — resolves the `order_by`/`orderBy` query
38
+ parameter name the same way `set_pagination` does, for callers outside this concern (e.g.
39
+ request-level validators run in a `before_action` ahead of it) that need to read the same
40
+ parameter without duplicating the `Config.api_case` check.
41
+
42
+ ### Security
43
+ - **BREAKING:** `set_pagination`'s `limit` param is now capped and validated (HAB-946, from the
44
+ HAB-894 audit). A non-numeric, negative, or above-`max_page_limit` `limit` is rejected with a
45
+ `422 Unprocessable Entity` (error detail on the `limit` field) instead of being silently
46
+ truncated or defaulted. `limit=0` no longer raises `ZeroDivisionError` — it's now accepted as a
47
+ deliberate meta-only request (see `allow_meta_only_limit:`), mirroring `ListPagination` in
48
+ `eluvia-base-django`. Only a missing or blank `limit` still falls back to `default_limit`,
49
+ unchanged.
50
+ - **BREAKING:** `set_pagination`'s `order_by` param is now strictly validated (HAB-915, from the
51
+ HAB-894 audit). Each `field:direction` entry must have exactly one `:` separator, a non-blank
52
+ field name, and a lower-case `asc`/`desc` direction; anything else is rejected with a
53
+ `422 Unprocessable Entity` (format errors on `order_by`, direction errors on
54
+ `order_by__<field>`) instead of being silently truncated to two segments or defaulted to `asc`.
55
+ A blank `order_by=` no longer raises `NoMethodError` — it's treated as "not supplied" and falls
56
+ back to `default_order_by`/`fallback_order_by`.
data/README.md CHANGED
@@ -82,6 +82,58 @@ class TestRecordsController < ApplicationController
82
82
  end
83
83
  ```
84
84
 
85
+ **BREAKING CHANGE:** the `limit` param is now capped. Requests with a `limit` above the configured ceiling are
86
+ rejected with a `422 Unprocessable Entity` (error detail on the `limit` field) instead of being silently accepted.
87
+ A negative or non-numeric `limit` no longer falls through to Kaminari — it now also raises the same `422`. Only a
88
+ missing or blank `limit` param still falls back to `default_limit`, unchanged. `limit=0` is treated as a deliberate
89
+ meta-only request (see below) rather than an error, mirroring `ListPagination` in eluvia-base-django.
90
+
91
+ The ceiling defaults to `Eluvia::Base::Config.max_page_limit` (`1000` by default, `nil` disables the check):
92
+
93
+ ```ruby
94
+ Eluvia::Base::Config.max_page_limit = 500
95
+ ```
96
+
97
+ It can be overridden per controller by overriding `pagination_max_limit`, or per call via the `max_limit:` keyword
98
+ (an explicit `max_limit: nil` removes the ceiling for that call only, regardless of `pagination_max_limit`):
99
+
100
+ ```ruby
101
+ class TestRecordsController < ApplicationController
102
+ def pagination_max_limit
103
+ 50
104
+ end
105
+ end
106
+ ```
107
+
108
+ ```ruby
109
+ before_action -> { set_pagination(default_limit: 20, default_offset: 0, max_limit: 50) }, only: [:index]
110
+ ```
111
+
112
+ To restore the pre-validation behavior (invalid or above-ceiling `limit` silently falls back to `default_limit`
113
+ instead of raising `422`), enable `disable_errors` — globally via `Eluvia::Base::Config.disable_pagination_errors =
114
+ true` (default: `false`), or per call via the `disable_errors:` keyword, which takes precedence over the global
115
+ setting:
116
+
117
+ ```ruby
118
+ before_action -> { set_pagination(default_limit: 20, default_offset: 0, disable_errors: true) }, only: [:index]
119
+ ```
120
+
121
+ `limit=0` is accepted by default as a way to request only pagination metadata without fetching any records — check
122
+ `@limit.zero?` in the action and skip the query:
123
+
124
+ ```ruby
125
+ def index
126
+ @test_records = @limit.zero? ? TestRecord.none : TestRecord.all.order(@order_by).page(@page).per(@limit).padding(@padding)
127
+ end
128
+ ```
129
+
130
+ Pass `allow_meta_only_limit: false` (e.g. for a search-backed resource, mirroring
131
+ `AnySearchCursorBasedPagination` in eluvia-base-django) to reject `limit=0` like any other invalid value instead:
132
+
133
+ ```ruby
134
+ before_action -> { set_pagination(default_limit: 20, default_offset: 0, allow_meta_only_limit: false) }, only: [:index]
135
+ ```
136
+
85
137
  ### 2.3. Params parser
86
138
 
87
139
  You can use `parse_json_param` helper to parse JSON without predefined structure. In case you know the input JSON
@@ -223,3 +275,76 @@ end
223
275
 
224
276
  If `upload_key` is present on the assigned `Eluvia::File` but no finalizer is registered, a
225
277
  `Eluvia::Errors::StandardError` is raised.
278
+
279
+ ### 2.7. ENV-driven scope restriction
280
+
281
+ `Eluvia::ActiveRecord::EnvFilterable` applies a deploy-time restriction — typically a base64-encoded
282
+ JSON blob in an ENV var — to any scope. It is generic and model-agnostic: a model only declares which
283
+ columns the config may reference, and the config lists `field` / `operator` / `value` conditions that
284
+ are AND-combined. Only declared fields are ever interpolated into SQL (as a column name); every value
285
+ stays a bound parameter, so the config cannot inject SQL. Conditions with an unknown field or operator
286
+ are silently ignored.
287
+
288
+ ```ruby
289
+
290
+ class BankTransaction < ApplicationRecord
291
+ include Eluvia::ActiveRecord::EnvFilterable
292
+
293
+ # Only these columns may appear in a filter config.
294
+ env_filter_fields %i[id direction variable_symbol date amount is_hidden]
295
+ end
296
+ ```
297
+
298
+ Read and apply the config (e.g. from a Pundit `policy_scope`):
299
+
300
+ ```ruby
301
+
302
+ config = BankTransaction.parse_env_filter_config(ENV['ADMIN_BANK_TRANSACTIONS_FILTER_B64'])
303
+ scope = BankTransaction.apply_env_filter(BankTransaction.all, config)
304
+ ```
305
+
306
+ `parse_env_filter_config` returns `nil` for a blank/missing ENV var (which disables the filter) and
307
+ raises `ArgumentError` on malformed input, so a broken deploy config fails loudly instead of silently
308
+ letting everything through. The config shape is:
309
+
310
+ ```json
311
+ {
312
+ "conditions": [
313
+ { "field": "direction", "operator": "eq", "value": "in" },
314
+ { "field": "is_hidden", "operator": "eq", "value": false },
315
+ { "field": "variable_symbol", "operator": "like", "value": ["12", "34"] }
316
+ ]
317
+ }
318
+ ```
319
+
320
+ Operators: `eq` / `not_eq` (equality), `in` / `not_in` (membership; value may be a scalar or array),
321
+ `like` / `not_like` (case-insensitive `ILIKE '%…%'` substring match; value may be a scalar or array —
322
+ `like` matches any, `not_like` excludes any). The negative operators (`not_eq`, `not_in`, `not_like`)
323
+ are NULL-safe: a row whose value is `NULL` is kept.
324
+
325
+ ### 2.8. Translations
326
+
327
+ All validation error messages raised by this gem (ordering, filtering, fieldset and pagination errors) go through
328
+ `I18n.t` with an English `default:`, so the gem works out of the box without any locale setup. In addition,
329
+ `lib/eluvia/locales/*.yml` ships ready-made translations — under the `eluvia.errors.*` key namespace — for the
330
+ same set of languages supported by `habarico`: `cs`, `de`, `en`, `es`, `fr`, `it`, `ja`, `nl`, `pl`, `pt`, `ro`,
331
+ `sk`, `uk`. These are appended to `I18n.load_path` automatically when the gem loads; a host application only
332
+ needs to add the relevant locale to its own `I18n.available_locales` and set `I18n.locale` as usual. A host app's
333
+ own `config/locales/*.yml` takes precedence over these if it defines the same key.
334
+
335
+ ## 3. Development
336
+
337
+ ### 3.1. Versioning and releases
338
+
339
+ The gem version lives in `version.json` and is read at load time by `Eluvia::Base.version`. Releases are
340
+ driven by the `ruby-library` component of the
341
+ [ci-toolbox](https://gitlab.eluvia.dev/gitlab-ci/ci-toolbox) — the release job bumps the version, tags it,
342
+ and the tag pipeline publishes the gem to [geminabox](https://geminabox.eluvia.dev) and to
343
+ [rubygems.org](https://rubygems.org/gems/eluvia-base), and creates a GitLab release.
344
+
345
+ ### 3.2. Changelog
346
+
347
+ Notable changes go to [CHANGELOG.md](CHANGELOG.md) under the `## [Unreleased]` heading, in the
348
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) format. The release job rewrites that heading to
349
+ the released version and the release date, and uses the section as the description of the GitLab release —
350
+ so what you write there is what the Releases page shows.
@@ -0,0 +1,110 @@
1
+ require 'base64'
2
+
3
+ module Eluvia
4
+ module ActiveRecord
5
+ # Generic, model-agnostic filter driven by deploy-time configuration (typically a base64-encoded
6
+ # JSON blob in an ENV var). The config is a list of `field` / `operator` / `value` conditions,
7
+ # AND-combined, that narrow a scope — e.g. to restrict what an admin interface may ever see.
8
+ #
9
+ # Only fields declared via `env_filter_fields` are ever interpolated into SQL (as a column name);
10
+ # every value is a bound parameter, so the config cannot be used to inject arbitrary SQL. Unknown
11
+ # fields and operators are silently ignored.
12
+ #
13
+ # @example Declaring the whitelist on a model
14
+ # class BankTransaction < ApplicationRecord
15
+ # include Eluvia::ActiveRecord::EnvFilterable
16
+ # env_filter_fields %i[id direction variable_symbol date amount is_hidden]
17
+ # end
18
+ #
19
+ # @example Applying it to a scope (e.g. from a Pundit `policy_scope`)
20
+ # config = BankTransaction.parse_env_filter_config(ENV['ADMIN_BANK_TRANSACTIONS_FILTER_B64'])
21
+ # BankTransaction.apply_env_filter(scope, config)
22
+ module EnvFilterable
23
+ extend ::ActiveSupport::Concern
24
+
25
+ class_methods do
26
+
27
+ # Declares (with arguments) or returns (without arguments) the fields that may appear in an
28
+ # env filter config. Only these column names are ever interpolated into SQL. Names are stored
29
+ # as strings so they compare directly against the config's `field` values.
30
+ #
31
+ # @param field_names [Array<String, Symbol>] whitelisted column names
32
+ # @return [Array<String>] the declared whitelist
33
+ def env_filter_fields(*field_names)
34
+ if field_names.empty?
35
+ @_env_filter_fields ||= []
36
+ else
37
+ @_env_filter_fields = field_names.flatten.map(&:to_s)
38
+ end
39
+ end
40
+
41
+ # Decodes a base64-encoded JSON filter config (typically read straight from an ENV var) into a
42
+ # Hash. Returns `nil` for blank input so a missing ENV var disables the filter. Raises
43
+ # `ArgumentError` on malformed input so a broken deploy-time config fails loudly rather than
44
+ # silently letting everything through.
45
+ #
46
+ # @param encoded [String, nil] base64-encoded JSON, or `nil`/blank
47
+ # @return [Hash, nil]
48
+ def parse_env_filter_config(encoded)
49
+ return nil if encoded.blank?
50
+
51
+ JSON.parse(Base64.strict_decode64(encoded.strip))
52
+ rescue ArgumentError, JSON::ParserError => e
53
+ raise ArgumentError, "invalid env filter config: #{e.message}"
54
+ end
55
+
56
+ # Applies an env filter config to +scope+. Config is a Hash with a `"conditions"` array; each
57
+ # condition has `"field"`, `"operator"` and `"value"`. All conditions are AND-combined. A blank
58
+ # config returns the scope unchanged; conditions with an unknown field or operator are ignored.
59
+ #
60
+ # Operators:
61
+ # eq / not_eq equality (not_eq keeps NULLs)
62
+ # in / not_in membership; value may be a scalar or array (not_in keeps NULLs)
63
+ # like / not_like case-insensitive substring match; value may be a scalar or an array
64
+ # (like → matches any; not_like → excludes any, keeps NULLs)
65
+ #
66
+ # @param scope [ActiveRecord::Relation]
67
+ # @param config [Hash, nil]
68
+ # @return [ActiveRecord::Relation]
69
+ def apply_env_filter(scope, config)
70
+ return scope if config.blank?
71
+
72
+ Array(config['conditions']).each do |cond|
73
+ field = cond['field'].to_s
74
+ op = cond['operator'].to_s
75
+ next unless env_filter_fields.include?(field)
76
+
77
+ col = "#{table_name}.#{field}" # field is whitelisted → safe to interpolate
78
+ val = cond['value']
79
+
80
+ scope =
81
+ case op
82
+ when 'eq'
83
+ scope.where(field => val)
84
+ when 'not_eq'
85
+ scope.where("#{col} IS NULL OR #{col} <> ?", val)
86
+ when 'in'
87
+ scope.where(field => Array(val))
88
+ when 'not_in'
89
+ scope.where("#{col} IS NULL OR #{col} NOT IN (?)", Array(val))
90
+ when 'like'
91
+ Array(val).each_with_index.reduce(scope) do |s, (v, i)|
92
+ clause = scope.where("#{col} ILIKE ('%' || ? || '%')", v)
93
+ i.zero? ? clause : s.or(clause)
94
+ end
95
+ when 'not_like'
96
+ Array(val).reduce(scope) do |s, v|
97
+ s.where("#{col} IS NULL OR #{col} NOT ILIKE ('%' || ? || '%')", v)
98
+ end
99
+ else
100
+ scope
101
+ end
102
+ end
103
+
104
+ scope
105
+ end
106
+
107
+ end
108
+ end
109
+ end
110
+ end
@@ -40,6 +40,19 @@ module Eluvia
40
40
  mattr_accessor :search_use_unaccent
41
41
  @@search_use_unaccent = true
42
42
 
43
+ # Upper bound for the `limit` pagination param on `set_pagination`. Requests with a
44
+ # higher `limit` are rejected with 422 instead of being silently truncated. `nil`
45
+ # disables the check. Mirrors `MAX_PAGE_LIMIT` in eluvia-base-django. (default: 1000)
46
+ mattr_accessor :max_page_limit
47
+ @@max_page_limit = 1000
48
+
49
+ # Whether an invalid `limit` param (non-numeric, negative, above `max_page_limit`, or zero
50
+ # without `allow_meta_only_limit:`) on `set_pagination` silently falls back to
51
+ # `default_limit` instead of being rejected with 422. Mirrors `disable_errors` on
52
+ # `LimitResolutionMixin` in eluvia-base-django. (default: false)
53
+ mattr_accessor :disable_pagination_errors
54
+ @@disable_pagination_errors = false
55
+
43
56
  end
44
57
  end
45
58
  end
@@ -2,23 +2,104 @@ module Eluvia
2
2
  module PaginationHandler
3
3
  extend ::ActiveSupport::Concern
4
4
 
5
- def set_pagination(default_limit: 20, default_offset: 0, default_order_by: 'created_at:asc', fallback_order_by: 'created_at:asc', force_order_by: false)
5
+ # Resolves the query parameter name carrying the `order_by` input according to the configured
6
+ # API case. Exposed so callers outside this concern (e.g. request-level validators run in a
7
+ # `before_action` ahead of `set_pagination`) parse the exact same parameter, instead of
8
+ # duplicating this ternary and risking it drifting out of sync with `Config.api_case`.
9
+ def self.order_by_param_name
10
+ Eluvia::Base::Config.api_case == 'camel_case' ? :orderBy : :order_by
11
+ end
12
+
13
+ # Sentinel distinguishing an unset `max_limit:` kwarg (defer to `pagination_max_limit`) from
14
+ # an explicit `max_limit: nil` (no ceiling for this call only, regardless of the config/method).
15
+ NOT_SET = Object.new.freeze
16
+
17
+ # Ceiling applied to `limit` for this controller. Override to customize (e.g. a
18
+ # per-resource value pulled from a resource definition). Defaults to the library-wide config.
19
+ def pagination_max_limit
20
+ Eluvia::Base::Config.max_page_limit
21
+ end
22
+
23
+ def set_pagination(default_limit: 20, default_offset: 0, default_order_by: 'created_at:asc',
24
+ fallback_order_by: 'created_at:asc', force_order_by: false, max_limit: NOT_SET,
25
+ disable_errors: nil, allow_meta_only_limit: true)
6
26
 
7
27
  # Pagination
8
- @limit = params[:limit] ? params[:limit].to_i : default_limit
28
+ @limit = resolve_pagination_limit(default_limit: default_limit, max_limit: max_limit,
29
+ disable_errors: disable_errors, allow_meta_only_limit: allow_meta_only_limit)
30
+
9
31
  @offset = params[:offset] ? params[:offset].to_i : default_offset
10
- @page = (@offset / @limit) + 1
11
- @padding = @offset % @limit
32
+ if @limit.zero?
33
+ # Meta-only request (see `allow_meta_only_limit:`) - no records to page through.
34
+ @page = 1
35
+ @padding = 0
36
+ else
37
+ @page = (@offset / @limit) + 1
38
+ @padding = @offset % @limit
39
+ end
12
40
 
13
41
  # Order by
42
+ order_by_param = Eluvia::PaginationHandler.order_by_param_name
43
+ user_order_by = params[order_by_param].to_s.strip
44
+ # A blank `order_by` is treated as "not supplied" (falls through to defaults/fallback)
45
+ # rather than as a literal empty ordering, since a Kaminari scope always needs some order.
46
+ order_by = !force_order_by && user_order_by.present? ? user_order_by : default_order_by
47
+ order_by = "#{order_by},#{fallback_order_by}"
48
+
14
49
  @order_by = {}
15
- order_by_param = Eluvia::Base::Config.api_case == 'camel_case' ? :orderBy : :order_by
16
- order_by = "#{!force_order_by && params.key?(order_by_param) ? params[order_by_param] : default_order_by},#{fallback_order_by}"
17
- order_by.to_s.split(',').each do |o|
18
- s = o.to_s.split(':')
19
- @order_by[s.first] = s.length >= 2 && s[1].downcase == 'desc' ? :desc : :asc
50
+ order_by.split(',').each do |entry|
51
+ entry = entry.strip
52
+ # `-1` keeps a trailing empty segment (e.g. `name:`) instead of Ruby's default of
53
+ # dropping it, so it is caught below as an invalid direction rather than as a bare field.
54
+ parts = entry.split(':', -1)
55
+ field_name, direction = parts
56
+
57
+ if parts.length != 2 || field_name.blank?
58
+ raise Eluvia::Errors::UnprocessableEntity.new({
59
+ 'order_by' => I18n.t('eluvia.errors.ordering.invalid_format',
60
+ default: 'Invalid format, expected "field_name:asc" or "field_name:desc".')
61
+ })
62
+ end
63
+
64
+ unless %w[asc desc].include?(direction)
65
+ raise Eluvia::Errors::UnprocessableEntity.new({
66
+ "order_by__#{field_name}" => I18n.t('eluvia.errors.ordering.invalid_direction',
67
+ default: 'Invalid order direction. Allowed values are `asc` and `desc`.')
68
+ })
69
+ end
70
+
71
+ @order_by[field_name] = direction.to_sym
72
+ end
73
+
74
+ end
75
+
76
+ private
77
+
78
+ def resolve_pagination_limit(default_limit:, max_limit:, disable_errors:, allow_meta_only_limit:)
79
+ return default_limit unless params[:limit].present?
80
+
81
+ raw_limit = params[:limit].to_s
82
+ parsed_limit = raw_limit.match?(/\A-?\d+\z/) ? raw_limit.to_i : nil
83
+ errors_disabled = disable_errors.nil? ? Eluvia::Base::Config.disable_pagination_errors : disable_errors
84
+
85
+ if parsed_limit.nil? || parsed_limit.negative? || (parsed_limit.zero? && !allow_meta_only_limit)
86
+ return default_limit if errors_disabled
87
+
88
+ msg = I18n.t('eluvia.errors.pagination.invalid_limit',
89
+ default: 'Limit must be a positive integer, got %<limit>s.', limit: params[:limit])
90
+ raise Eluvia::Errors::UnprocessableEntity.new('limit' => msg)
91
+ end
92
+
93
+ ceiling = max_limit == NOT_SET ? pagination_max_limit : max_limit
94
+ if ceiling && parsed_limit > ceiling
95
+ return default_limit if errors_disabled
96
+
97
+ msg = I18n.t('eluvia.errors.pagination.max_limit_exceeded',
98
+ default: 'Limit %<limit>s exceeds the maximum allowed limit of %<max>s.', limit: parsed_limit, max: ceiling)
99
+ raise Eluvia::Errors::UnprocessableEntity.new('limit' => msg)
20
100
  end
21
101
 
102
+ parsed_limit
22
103
  end
23
104
 
24
105
  end
@@ -0,0 +1,22 @@
1
+ cs:
2
+ eluvia:
3
+ errors:
4
+ ordering:
5
+ invalid_format: 'Neplatný formát, očekává se "field_name:asc" nebo "field_name:desc".'
6
+ invalid_direction: "Neplatný směr řazení. Povolené hodnoty jsou `asc` a `desc`."
7
+ invalid_definition: "Neplatná definice atributu `order by`. Očekává se pouze jeden oddělovač `__`."
8
+ association_not_found: "Asociace nenalezena."
9
+ not_found_on_association: "Na asociaci neexistuje."
10
+ not_found: "Neexistuje."
11
+ filter:
12
+ unknown_param: "Neznámý parametr filtru."
13
+ invalid_term: "Neplatný filtrovací výraz."
14
+ invalid_value_format: "Neplatný formát hodnoty."
15
+ invalid_uuid_format: "Neplatný formát UUID."
16
+ invalid_integer_format: "Neplatný formát celého čísla."
17
+ invalid_date_format: "Neplatný formát data."
18
+ fieldset:
19
+ unknown_field: "Neznámé pole."
20
+ pagination:
21
+ invalid_limit: "Limit musí být kladné celé číslo, zadáno %<limit>s."
22
+ max_limit_exceeded: "Limit %<limit>s překračuje maximální povolený limit %<max>s."
@@ -0,0 +1,22 @@
1
+ de:
2
+ eluvia:
3
+ errors:
4
+ ordering:
5
+ invalid_format: 'Ungültiges Format, erwartet wird "field_name:asc" oder "field_name:desc".'
6
+ invalid_direction: "Ungültige Sortierrichtung. Erlaubte Werte sind `asc` und `desc`."
7
+ invalid_definition: "Ungültige Definition für ein `order by`-Attribut. Es wird genau ein `__`-Trennzeichen erwartet."
8
+ association_not_found: "Assoziation nicht gefunden."
9
+ not_found_on_association: "Existiert nicht in der Assoziation."
10
+ not_found: "Existiert nicht."
11
+ filter:
12
+ unknown_param: "Unbekannter Filterparameter."
13
+ invalid_term: "Ungültiger Filterbegriff."
14
+ invalid_value_format: "Ungültiges Wertformat."
15
+ invalid_uuid_format: "Ungültiges UUID-Format."
16
+ invalid_integer_format: "Ungültiges Ganzzahlformat."
17
+ invalid_date_format: "Ungültiges Datumsformat."
18
+ fieldset:
19
+ unknown_field: "Unbekanntes Feld."
20
+ pagination:
21
+ invalid_limit: "Limit muss eine positive Ganzzahl sein, erhalten: %<limit>s."
22
+ max_limit_exceeded: "Limit %<limit>s überschreitet das maximal zulässige Limit von %<max>s."
@@ -0,0 +1,25 @@
1
+ # Translations for the validation error messages raised by eluvia-base (ordering.rb, filtering.rb,
2
+ # fieldset_serializer.rb, pagination_handler.rb). Each key mirrors the `default:` string passed to
3
+ # `I18n.t` at the call site, so a host app without these locale files still gets the same English text.
4
+ en:
5
+ eluvia:
6
+ errors:
7
+ ordering:
8
+ invalid_format: 'Invalid format, expected "field_name:asc" or "field_name:desc".'
9
+ invalid_direction: "Invalid order direction. Allowed values are `asc` and `desc`."
10
+ invalid_definition: "Invalid definition for an `order by` attribute. Expecting only one `__` delimiter."
11
+ association_not_found: "Association not found."
12
+ not_found_on_association: "Does not exist on association."
13
+ not_found: "Does not exist."
14
+ filter:
15
+ unknown_param: "Unknown filter param."
16
+ invalid_term: "Invalid filter term."
17
+ invalid_value_format: "Invalid value format."
18
+ invalid_uuid_format: "Invalid UUID format."
19
+ invalid_integer_format: "Invalid integer format."
20
+ invalid_date_format: "Invalid date format."
21
+ fieldset:
22
+ unknown_field: "Unknown field."
23
+ pagination:
24
+ invalid_limit: "Limit must be a positive integer, got %<limit>s."
25
+ max_limit_exceeded: "Limit %<limit>s exceeds the maximum allowed limit of %<max>s."
@@ -0,0 +1,22 @@
1
+ es:
2
+ eluvia:
3
+ errors:
4
+ ordering:
5
+ invalid_format: 'Formato no válido, se espera "field_name:asc" o "field_name:desc".'
6
+ invalid_direction: "Dirección de ordenación no válida. Los valores permitidos son `asc` y `desc`."
7
+ invalid_definition: "Definición no válida para un atributo `order by`. Se espera solo un delimitador `__`."
8
+ association_not_found: "Asociación no encontrada."
9
+ not_found_on_association: "No existe en la asociación."
10
+ not_found: "No existe."
11
+ filter:
12
+ unknown_param: "Parámetro de filtro desconocido."
13
+ invalid_term: "Término de filtro no válido."
14
+ invalid_value_format: "Formato de valor no válido."
15
+ invalid_uuid_format: "Formato de UUID no válido."
16
+ invalid_integer_format: "Formato de número entero no válido."
17
+ invalid_date_format: "Formato de fecha no válido."
18
+ fieldset:
19
+ unknown_field: "Campo desconocido."
20
+ pagination:
21
+ invalid_limit: "El límite debe ser un número entero positivo, recibido %<limit>s."
22
+ max_limit_exceeded: "El límite %<limit>s supera el límite máximo permitido de %<max>s."
@@ -0,0 +1,22 @@
1
+ fr:
2
+ eluvia:
3
+ errors:
4
+ ordering:
5
+ invalid_format: 'Format invalide, attendu "field_name:asc" ou "field_name:desc".'
6
+ invalid_direction: "Direction de tri invalide. Les valeurs autorisées sont `asc` et `desc`."
7
+ invalid_definition: "Définition invalide pour un attribut `order by`. Un seul délimiteur `__` est attendu."
8
+ association_not_found: "Association introuvable."
9
+ not_found_on_association: "N'existe pas sur l'association."
10
+ not_found: "N'existe pas."
11
+ filter:
12
+ unknown_param: "Paramètre de filtre inconnu."
13
+ invalid_term: "Terme de filtre invalide."
14
+ invalid_value_format: "Format de valeur invalide."
15
+ invalid_uuid_format: "Format UUID invalide."
16
+ invalid_integer_format: "Format d'entier invalide."
17
+ invalid_date_format: "Format de date invalide."
18
+ fieldset:
19
+ unknown_field: "Champ inconnu."
20
+ pagination:
21
+ invalid_limit: "La limite doit être un entier positif, valeur reçue %<limit>s."
22
+ max_limit_exceeded: "La limite %<limit>s dépasse la limite maximale autorisée de %<max>s."
@@ -0,0 +1,22 @@
1
+ it:
2
+ eluvia:
3
+ errors:
4
+ ordering:
5
+ invalid_format: 'Formato non valido, previsto "field_name:asc" oppure "field_name:desc".'
6
+ invalid_direction: "Direzione di ordinamento non valida. I valori consentiti sono `asc` e `desc`."
7
+ invalid_definition: "Definizione non valida per un attributo `order by`. È previsto un solo delimitatore `__`."
8
+ association_not_found: "Associazione non trovata."
9
+ not_found_on_association: "Non esiste nell'associazione."
10
+ not_found: "Non esiste."
11
+ filter:
12
+ unknown_param: "Parametro di filtro sconosciuto."
13
+ invalid_term: "Termine di filtro non valido."
14
+ invalid_value_format: "Formato del valore non valido."
15
+ invalid_uuid_format: "Formato UUID non valido."
16
+ invalid_integer_format: "Formato numero intero non valido."
17
+ invalid_date_format: "Formato data non valido."
18
+ fieldset:
19
+ unknown_field: "Campo sconosciuto."
20
+ pagination:
21
+ invalid_limit: "Il limite deve essere un numero intero positivo, ricevuto %<limit>s."
22
+ max_limit_exceeded: "Il limite %<limit>s supera il limite massimo consentito di %<max>s."
@@ -0,0 +1,22 @@
1
+ ja:
2
+ eluvia:
3
+ errors:
4
+ ordering:
5
+ invalid_format: '形式が無効です。"field_name:asc" または "field_name:desc" を指定してください。'
6
+ invalid_direction: "並び順が無効です。使用できる値は `asc` と `desc` です。"
7
+ invalid_definition: "`order by` 属性の定義が無効です。`__` 区切り文字は1つだけ使用できます。"
8
+ association_not_found: "アソシエーションが見つかりません。"
9
+ not_found_on_association: "アソシエーション上に存在しません。"
10
+ not_found: "存在しません。"
11
+ filter:
12
+ unknown_param: "不明なフィルターパラメータです。"
13
+ invalid_term: "フィルター条件が無効です。"
14
+ invalid_value_format: "値の形式が無効です。"
15
+ invalid_uuid_format: "UUID の形式が無効です。"
16
+ invalid_integer_format: "整数の形式が無効です。"
17
+ invalid_date_format: "日付の形式が無効です。"
18
+ fieldset:
19
+ unknown_field: "不明なフィールドです。"
20
+ pagination:
21
+ invalid_limit: "limit は正の整数である必要があります(指定値: %<limit>s)。"
22
+ max_limit_exceeded: "limit %<limit>s は許可されている最大値 %<max>s を超えています。"
@@ -0,0 +1,22 @@
1
+ nl:
2
+ eluvia:
3
+ errors:
4
+ ordering:
5
+ invalid_format: 'Ongeldig formaat, verwacht wordt "field_name:asc" of "field_name:desc".'
6
+ invalid_direction: "Ongeldige sorteerrichting. Toegestane waarden zijn `asc` en `desc`."
7
+ invalid_definition: "Ongeldige definitie voor een `order by`-attribuut. Er wordt precies één `__`-scheidingsteken verwacht."
8
+ association_not_found: "Associatie niet gevonden."
9
+ not_found_on_association: "Bestaat niet binnen de associatie."
10
+ not_found: "Bestaat niet."
11
+ filter:
12
+ unknown_param: "Onbekende filterparameter."
13
+ invalid_term: "Ongeldige filterterm."
14
+ invalid_value_format: "Ongeldig waardeformaat."
15
+ invalid_uuid_format: "Ongeldig UUID-formaat."
16
+ invalid_integer_format: "Ongeldig geheel-getalformaat."
17
+ invalid_date_format: "Ongeldig datumformaat."
18
+ fieldset:
19
+ unknown_field: "Onbekend veld."
20
+ pagination:
21
+ invalid_limit: "Limit moet een positief geheel getal zijn, ontvangen: %<limit>s."
22
+ max_limit_exceeded: "Limit %<limit>s overschrijdt de maximaal toegestane limiet van %<max>s."
@@ -0,0 +1,22 @@
1
+ pl:
2
+ eluvia:
3
+ errors:
4
+ ordering:
5
+ invalid_format: 'Nieprawidłowy format, oczekiwano "field_name:asc" lub "field_name:desc".'
6
+ invalid_direction: "Nieprawidłowy kierunek sortowania. Dozwolone wartości to `asc` i `desc`."
7
+ invalid_definition: "Nieprawidłowa definicja atrybutu `order by`. Oczekiwany jest dokładnie jeden separator `__`."
8
+ association_not_found: "Nie znaleziono powiązania."
9
+ not_found_on_association: "Nie istnieje w powiązaniu."
10
+ not_found: "Nie istnieje."
11
+ filter:
12
+ unknown_param: "Nieznany parametr filtra."
13
+ invalid_term: "Nieprawidłowy termin filtra."
14
+ invalid_value_format: "Nieprawidłowy format wartości."
15
+ invalid_uuid_format: "Nieprawidłowy format UUID."
16
+ invalid_integer_format: "Nieprawidłowy format liczby całkowitej."
17
+ invalid_date_format: "Nieprawidłowy format daty."
18
+ fieldset:
19
+ unknown_field: "Nieznane pole."
20
+ pagination:
21
+ invalid_limit: "Limit musi być dodatnią liczbą całkowitą, otrzymano %<limit>s."
22
+ max_limit_exceeded: "Limit %<limit>s przekracza maksymalny dozwolony limit %<max>s."
@@ -0,0 +1,22 @@
1
+ pt:
2
+ eluvia:
3
+ errors:
4
+ ordering:
5
+ invalid_format: 'Formato inválido, esperado "field_name:asc" ou "field_name:desc".'
6
+ invalid_direction: "Direção de ordenação inválida. Os valores permitidos são `asc` e `desc`."
7
+ invalid_definition: "Definição inválida para o atributo `order by`. É esperado apenas um delimitador `__`."
8
+ association_not_found: "Associação não encontrada."
9
+ not_found_on_association: "Não existe na associação."
10
+ not_found: "Não existe."
11
+ filter:
12
+ unknown_param: "Parâmetro de filtro desconhecido."
13
+ invalid_term: "Termo de filtro inválido."
14
+ invalid_value_format: "Formato de valor inválido."
15
+ invalid_uuid_format: "Formato de UUID inválido."
16
+ invalid_integer_format: "Formato de número inteiro inválido."
17
+ invalid_date_format: "Formato de data inválido."
18
+ fieldset:
19
+ unknown_field: "Campo desconhecido."
20
+ pagination:
21
+ invalid_limit: "O limite deve ser um número inteiro positivo, recebido %<limit>s."
22
+ max_limit_exceeded: "O limite %<limit>s excede o limite máximo permitido de %<max>s."
@@ -0,0 +1,22 @@
1
+ ro:
2
+ eluvia:
3
+ errors:
4
+ ordering:
5
+ invalid_format: 'Format invalid, se așteaptă "field_name:asc" sau "field_name:desc".'
6
+ invalid_direction: "Direcție de sortare invalidă. Valorile permise sunt `asc` și `desc`."
7
+ invalid_definition: "Definiție invalidă pentru atributul `order by`. Este permis un singur delimitator `__`."
8
+ association_not_found: "Asocierea nu a fost găsită."
9
+ not_found_on_association: "Nu există în asociere."
10
+ not_found: "Nu există."
11
+ filter:
12
+ unknown_param: "Parametru de filtrare necunoscut."
13
+ invalid_term: "Termen de filtrare invalid."
14
+ invalid_value_format: "Format de valoare invalid."
15
+ invalid_uuid_format: "Format UUID invalid."
16
+ invalid_integer_format: "Format de număr întreg invalid."
17
+ invalid_date_format: "Format de dată invalid."
18
+ fieldset:
19
+ unknown_field: "Câmp necunoscut."
20
+ pagination:
21
+ invalid_limit: "Limita trebuie să fie un număr întreg pozitiv, valoare primită %<limit>s."
22
+ max_limit_exceeded: "Limita %<limit>s depășește limita maximă permisă de %<max>s."
@@ -0,0 +1,22 @@
1
+ sk:
2
+ eluvia:
3
+ errors:
4
+ ordering:
5
+ invalid_format: 'Neplatný formát, očakáva sa "field_name:asc" alebo "field_name:desc".'
6
+ invalid_direction: "Neplatný smer radenia. Povolené hodnoty sú `asc` a `desc`."
7
+ invalid_definition: "Neplatná definícia atribútu `order by`. Očakáva sa iba jeden oddeľovač `__`."
8
+ association_not_found: "Asociácia nebola nájdená."
9
+ not_found_on_association: "Na asociácii neexistuje."
10
+ not_found: "Neexistuje."
11
+ filter:
12
+ unknown_param: "Neznámy parameter filtra."
13
+ invalid_term: "Neplatný filtrovací výraz."
14
+ invalid_value_format: "Neplatný formát hodnoty."
15
+ invalid_uuid_format: "Neplatný formát UUID."
16
+ invalid_integer_format: "Neplatný formát celého čísla."
17
+ invalid_date_format: "Neplatný formát dátumu."
18
+ fieldset:
19
+ unknown_field: "Neznáme pole."
20
+ pagination:
21
+ invalid_limit: "Limit musí byť kladné celé číslo, zadané %<limit>s."
22
+ max_limit_exceeded: "Limit %<limit>s prekračuje maximálny povolený limit %<max>s."
@@ -0,0 +1,22 @@
1
+ uk:
2
+ eluvia:
3
+ errors:
4
+ ordering:
5
+ invalid_format: 'Недійсний формат, очікується "field_name:asc" або "field_name:desc".'
6
+ invalid_direction: "Недійсний напрямок сортування. Дозволені значення: `asc` і `desc`."
7
+ invalid_definition: "Недійсне визначення атрибута `order by`. Очікується рівно один роздільник `__`."
8
+ association_not_found: "Асоціацію не знайдено."
9
+ not_found_on_association: "Не існує в асоціації."
10
+ not_found: "Не існує."
11
+ filter:
12
+ unknown_param: "Невідомий параметр фільтра."
13
+ invalid_term: "Недійсний термін фільтра."
14
+ invalid_value_format: "Недійсний формат значення."
15
+ invalid_uuid_format: "Недійсний формат UUID."
16
+ invalid_integer_format: "Недійсний формат цілого числа."
17
+ invalid_date_format: "Недійсний формат дати."
18
+ fieldset:
19
+ unknown_field: "Невідоме поле."
20
+ pagination:
21
+ invalid_limit: "Ліміт має бути додатним цілим числом, отримано %<limit>s."
22
+ max_limit_exceeded: "Ліміт %<limit>s перевищує максимально допустимий ліміт %<max>s."
data/lib/eluvia-base.rb CHANGED
@@ -4,6 +4,9 @@ require 'eluvia/base/config'
4
4
  # Version
5
5
  require 'eluvia/base/version'
6
6
 
7
+ # Locales (translations for the validation error messages raised across this gem)
8
+ I18n.load_path += Dir[File.expand_path('eluvia/locales/*.yml', __dir__)]
9
+
7
10
  # Uploads
8
11
  require 'eluvia/uploads'
9
12
 
@@ -17,6 +20,7 @@ require 'eluvia/active_model/object_attribute'
17
20
  require 'eluvia/active_model/range_attribute' # depends on object_attribute
18
21
 
19
22
  # Active record
23
+ require 'eluvia/active_record/env_filterable'
20
24
  require 'eluvia/active_record/file_array_attribute'
21
25
  require 'eluvia/active_record/file_attribute'
22
26
  require 'eluvia/active_record/filtering'
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: eluvia-base
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.38.0
4
+ version: 3.40.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Matěj Outlý
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-09-14 00:00:00.000000000 Z
11
+ date: 2026-09-23 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rest-client
@@ -66,6 +66,20 @@ dependencies:
66
66
  - - "~>"
67
67
  - !ruby/object:Gem::Version
68
68
  version: '2.19'
69
+ - !ruby/object:Gem::Dependency
70
+ name: base64
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '0.2'
76
+ type: :runtime
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '0.2'
69
83
  - !ruby/object:Gem::Dependency
70
84
  name: ostruct
71
85
  requirement: !ruby/object:Gem::Requirement
@@ -117,6 +131,7 @@ executables: []
117
131
  extensions: []
118
132
  extra_rdoc_files: []
119
133
  files:
134
+ - CHANGELOG.md
120
135
  - LICENSE
121
136
  - README.md
122
137
  - lib/eluvia-base.rb
@@ -127,6 +142,7 @@ files:
127
142
  - lib/eluvia/active_model/enum_attribute.rb
128
143
  - lib/eluvia/active_model/object_attribute.rb
129
144
  - lib/eluvia/active_model/range_attribute.rb
145
+ - lib/eluvia/active_record/env_filterable.rb
130
146
  - lib/eluvia/active_record/file_array_attribute.rb
131
147
  - lib/eluvia/active_record/file_attribute.rb
132
148
  - lib/eluvia/active_record/filtering.rb
@@ -149,6 +165,19 @@ files:
149
165
  - lib/eluvia/handlers/params_handler.rb
150
166
  - lib/eluvia/helpers/attachment_helper.rb
151
167
  - lib/eluvia/integrations/eluvia_integration.rb
168
+ - lib/eluvia/locales/cs.yml
169
+ - lib/eluvia/locales/de.yml
170
+ - lib/eluvia/locales/en.yml
171
+ - lib/eluvia/locales/es.yml
172
+ - lib/eluvia/locales/fr.yml
173
+ - lib/eluvia/locales/it.yml
174
+ - lib/eluvia/locales/ja.yml
175
+ - lib/eluvia/locales/nl.yml
176
+ - lib/eluvia/locales/pl.yml
177
+ - lib/eluvia/locales/pt.yml
178
+ - lib/eluvia/locales/ro.yml
179
+ - lib/eluvia/locales/sk.yml
180
+ - lib/eluvia/locales/uk.yml
152
181
  - lib/eluvia/models/file.rb
153
182
  - lib/eluvia/models/file_wrapper.rb
154
183
  - lib/eluvia/models/image_wrapper.rb