concerns_on_rails 1.12.1 → 1.14.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: 56664d3e955f53dbeec1d586c31b57c860a208cedff76990236a728de1c6f9ec
4
- data.tar.gz: 6a427597a32d8a2d848d2f51ed9cf45d6a3ea41dd9ea77c2b215b4a66846170e
3
+ metadata.gz: b5c234af5baa9aa52269898bb63da24e4cc373bc5e2b3d7db7467ef89a86141b
4
+ data.tar.gz: 20cd7d9bb802447d7fd38be485e623216774ffa3dba9d5aae9f225ad48fb0b8a
5
5
  SHA512:
6
- metadata.gz: b34bc02259d6a33a0d1fa1dac2f2e71473cc543515df2088766b736eed4cdc8e9925616cca6397a5318d22bdcafbf910e8bea6ea9f93e1de4746613f3aa747b2
7
- data.tar.gz: 52e3f5a2dda49da303b118a4962ca479bb43b628d363ff133c23939fc523235a01065441cea4002dccd33130ace25149da275ddb9839dc864b2e442377f88a28
6
+ metadata.gz: ebef3ed5411bf6fa388b9a1d47ab92eb9a871eeda6e478aee4dc0dfd586a10bcbf05497dba93325b896a897a0a0cc3a296ce833aaef72343decacce42c0087aa
7
+ data.tar.gz: efc7d26aca5aeff71308135c2617374d98ee8c0e955038a783e2b3c8c2b14a1827ae27fd2a7c64d6637e6c2fe61b2711da796f388e172bdc9dac827dc56e6743
data/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  <!-- CHANGELOG.md -->
2
2
 
3
+ ## 1.14.0 (2026-06-06)
4
+
5
+ ### Added
6
+ - **Controllers::Authorizable**: declarative, block-only per-action authorization gate. `authorize_by { current_user.admin? }` (arity-safe — also `|action|` / `|action, user|`) halts the first failing rule with 403; `require_role :admin, :editor, only: :publish` is sugar over the common role check. `only:` / `except:` scope a rule to a subset of actions. When `Respondable` is included the denial delegates to `render_error` (`code: "forbidden"`), otherwise the same envelope is rendered inline. Deliberately not a policy/ability framework (no policy objects, no ability DSL, no resource inference). Zero new runtime dependencies.
7
+ - **Controllers::Throttleable**: per-request rate limiting with a store-agnostic, injectable backend. `throttle_by limit: 100, period: 1.minute` (bucketed per-IP by default, or by any `by:` lambda) halts an over-limit request with 429 plus `Retry-After` and `X-RateLimit-Limit` / `X-RateLimit-Remaining` / `X-RateLimit-Reset`. Fixed-window counter keyed by a floored time bucket; requires an explicit atomic store (`self.throttleable_store = Rails.cache`) — there is no silent in-process default, so the first throttled request raises until one is configured. Backports the essentials of Rails 7.2's `rate_limit` (with standardized headers) to Rails 5.0+. Zero new runtime dependencies.
8
+ - **Controllers::Timezoneable**: per-request `Time.zone` selection via an `around_action` (`Time.use_zone`) — the time analogue of `Controllers::Localizable`. `timezoneable available: [...], default: "UTC"` resolves from `params` → `Time-Zone` header → cookie → default, every candidate validated through `ActiveSupport::TimeZone[...]` so it can never raise at request time; an unknown configured zone fails fast at declaration. Options `param:` / `header:` / `cookie:`. Zero new runtime dependencies.
9
+
10
+ ### Notes
11
+ - All changes are additive and backward-compatible, with zero new runtime dependencies. The three new controller concerns stay namespace-only (`ConcernsOnRails::Controllers::*`), matching the existing controller concerns.
12
+
13
+ ## 1.13.0 (2026-06-06)
14
+
15
+ ### Added
16
+ - **Models::Maskable**: non-destructive display masking for sensitive attributes. `maskable :email, with: :email` adds a `masked_<field>` reader and never writes the column (the raw value stays in the DB). Presets `:email` / `:phone` / `:credit_card` / `:last4` / `:all`, a configurable `mask:` character, and a `Proc` escape hatch. Backed by `Support::Masker`; complements `Sanitizable`.
17
+ - **Models::Monetizable**: exact, float-free money handling over an integer subunit column. `monetizable :price_cents` derives `price` / `price=` / `formatted_price` (the `_cents` suffix is stripped, or name them with `as:`). Options `unit:` / `precision:` / `delimiter:` / `separator:` / `subunit_to_unit:`. Uses `BigDecimal` throughout; backed by `Support::Money`.
18
+ - **Controllers::Localizable**: per-request locale selection from `params` and/or the `Accept-Language` header via an `around_action` (`I18n.with_locale`). `localizable available: %i[en fr], default: :en`; options `param:` / `header:`. The resolved locale is always validated against `I18n.available_locales`, so it can never raise `I18n::InvalidLocale`.
19
+
20
+ ### Notes
21
+ - All changes are additive and backward-compatible, with zero new runtime dependencies (BigDecimal and I18n already ship with the existing stack). `ConcernsOnRails::Maskable` / `ConcernsOnRails::Monetizable` are aliased to their `Models::*` modules; the controller concern stays namespace-only (`ConcernsOnRails::Controllers::Localizable`).
22
+
3
23
  ## 1.12.1 (2026-06-06)
4
24
 
5
25
  ### Added
data/README.md CHANGED
@@ -41,6 +41,8 @@ Article.published.without_deleted.find("hello-world")
41
41
  - [Addressable](#-addressable) — postal address normalization + format validation
42
42
  - [Taggable](#-taggable) — lightweight tagging over a single column
43
43
  - [Sanitizable](#-sanitizable) — opt-in HTML sanitization (XSS defense-in-depth)
44
+ - [Maskable](#-maskable) — non-destructive display masking of sensitive fields
45
+ - [Monetizable](#-monetizable) — integer-cents money columns (BigDecimal)
44
46
  - **Controller concerns**
45
47
  - [Paginatable](#-paginatable) — offset pagination with headers
46
48
  - [Filterable](#-filterable) — declarative URL-param filters
@@ -49,6 +51,10 @@ Article.published.without_deleted.find("hello-world")
49
51
  - [ErrorHandleable](#-errorhandleable) — JSON `rescue_from` handlers for common controller errors
50
52
  - [Includable](#-includable) — whitelisted association sideloading + sparse fieldsets
51
53
  - [SecureHeadable](#-secureheadable) — security response headers + native CSP DSL
54
+ - [Localizable](#-localizable) — per-request locale from params / Accept-Language
55
+ - [Authorizable](#-authorizable) — per-action 403 authorization gate (block-based)
56
+ - [Throttleable](#-throttleable) — rate limiting with 429 + `X-RateLimit-*` headers
57
+ - [Timezoneable](#-timezoneable) — per-request `Time.zone` from params / header / cookie
52
58
  - [Module paths & namespacing](#-module-paths--namespacing)
53
59
  - [Development](#-development)
54
60
  - [Contributing](#-contributing)
@@ -58,7 +64,7 @@ Article.published.without_deleted.find("hello-world")
58
64
 
59
65
  ## ✨ Why this gem?
60
66
 
61
- - **Sixteen model concerns + seven controller concerns**, all production-ready
67
+ - **Eighteen model concerns + eight controller concerns**, all production-ready
62
68
  - **One include, one macro** — no boilerplate, no glue code
63
69
  - **Lean dependencies** — only `acts_as_list` (Sortable) and `friendly_id` (Sluggable); controller concerns have zero extra deps
64
70
  - **Schema-validated configuration** — every macro checks that the configured column exists and raises `ArgumentError` early
@@ -871,6 +877,64 @@ article.sanitized_body # => "<b>Hi</b>alert(1)" (script tag
871
877
 
872
878
  ---
873
879
 
880
+ ## 🙈 Maskable
881
+
882
+ Non-destructive display masking for sensitive attributes. Each declaration adds a `masked_<field>` reader and **never writes the column** — the raw value stays in the database (masking is a presentation concern). Dependency-free.
883
+
884
+ ```ruby
885
+ class User < ApplicationRecord
886
+ include ConcernsOnRails::Maskable
887
+
888
+ maskable :email, with: :email # => user.masked_email "j****@example.com"
889
+ maskable :card, with: :credit_card # => user.masked_card "**** **** **** 4242"
890
+ maskable :ssn, with: :last4, mask: "•"
891
+ maskable :token, with: ->(v) { "#{v.to_s[0, 3]}…" }
892
+ end
893
+ ```
894
+
895
+ **Presets** (`with:`)
896
+
897
+ | Preset | Result |
898
+ |----------------|---------------------------------------------|
899
+ | `:email` | `j****@example.com` (first char + domain) |
900
+ | `:phone` | `***-2671` (last 4 digits) |
901
+ | `:credit_card` | `**** **** **** 4242` (last 4 digits) |
902
+ | `:last4` | keep the last 4 characters |
903
+ | `:all` | mask every character (the default) |
904
+ | `Proc` | used as-is (you own the non-String guard) |
905
+
906
+ `mask:` sets the mask character (default `*`). Nil and non-string values pass through untouched. To strip dangerous HTML instead, see [Sanitizable](#-sanitizable).
907
+
908
+ ---
909
+
910
+ ## 💰 Monetizable
911
+
912
+ Money handling for an integer "subunit" column (e.g. cents) — exact and float-free via `BigDecimal`. `monetizable :price_cents` derives three methods (the `_cents` suffix is stripped):
913
+
914
+ ```ruby
915
+ class Product < ApplicationRecord
916
+ include ConcernsOnRails::Monetizable
917
+
918
+ monetizable :price_cents # => price / price= / formatted_price
919
+ monetizable :shipping_cents, as: :shipping
920
+ monetizable :total_cents, unit: "€", delimiter: ".", separator: ","
921
+ end
922
+
923
+ product.price = 19.99 # stores price_cents = 1999 (rounded to whole cents)
924
+ product.price # => BigDecimal 19.99
925
+ product.formatted_price # => "$19.99"
926
+ ```
927
+
928
+ | Method | Returns |
929
+ |-------------------|-----------------------------------------------|
930
+ | `price` | the amount as a `BigDecimal` (cents ÷ 100) |
931
+ | `price=` | assign in major units; rounded to whole cents |
932
+ | `formatted_price` | a display string (`"$1,234.56"`) |
933
+
934
+ **Options**: `as:` (explicit method name — required when the column does not end in `_cents`), `unit:` (`"$"`), `precision:` (`2`), `delimiter:` (`","`), `separator:` (`"."`), `subunit_to_unit:` (`100`). `nil` stays `nil` across all three methods.
935
+
936
+ ---
937
+
874
938
  # 🎮 Controller Concerns
875
939
 
876
940
  Pure ActionController + ActiveRecord — **zero extra runtime dependencies** (no Kaminari, Pundit, or Ransack).
@@ -1134,6 +1198,107 @@ end
1134
1198
 
1135
1199
  ---
1136
1200
 
1201
+ ## 🌐 Localizable
1202
+
1203
+ Per-request locale selection from the request params and/or the `Accept-Language` header, wrapped in an `around_action` so `I18n.locale` is set for the action and restored afterwards. Dependency-free.
1204
+
1205
+ ```ruby
1206
+ class ApplicationController < ActionController::Base
1207
+ include ConcernsOnRails::Controllers::Localizable
1208
+
1209
+ localizable available: %i[en fr de], default: :en
1210
+ # localizable param: :lang, header: false # params[:lang] only
1211
+ end
1212
+ ```
1213
+
1214
+ Resolution order: `params[param]` → first match in `Accept-Language` → `default` → `I18n.default_locale`. The chosen locale is always validated against `I18n.available_locales`, so a stray param or a mismatched `available:` list can never raise `I18n::InvalidLocale`.
1215
+
1216
+ **Options**: `available:` (allow-list for matching; defaults to `I18n.available_locales`), `default:`, `param:` (default `:locale`), `header:` (default `true`).
1217
+
1218
+ ---
1219
+
1220
+ ## 🔒 Authorizable
1221
+
1222
+ A declarative, **block-only** per-action authorization gate. Each rule is a predicate; the first one that applies to the current action and returns falsey halts the request with **403**. Deliberately small — not a Pundit/CanCan replacement.
1223
+
1224
+ ```ruby
1225
+ class Api::BaseController < ApplicationController
1226
+ include ConcernsOnRails::Controllers::Authorizable
1227
+
1228
+ authorize_by { current_user.present? } # every action
1229
+ authorize_by(only: %i[update destroy]) { |_action, user| user.admin? }
1230
+ require_role :admin, :editor, only: :publish # role sugar
1231
+ end
1232
+ ```
1233
+
1234
+ The predicate runs via `instance_exec`, so `current_user` (and any helper) resolves on the controller. It is **arity-safe** — write it with zero, one (`|action|`), or two (`|action, user|`) parameters.
1235
+
1236
+ **API**
1237
+
1238
+ | Method | Signature |
1239
+ |----------------|--------------------------------------------------------------------------------------------|
1240
+ | `authorize_by` | `authorize_by(only: nil, except: nil, status: :forbidden, message: "Forbidden", &block)` |
1241
+ | `require_role` | `require_role(*roles, via: :current_user, role_method: :role, only:, except:, status:, message:)` |
1242
+
1243
+ **Notes**
1244
+ - Rules run in declaration order; the first failing rule renders and halts.
1245
+ - When `Respondable` is also included, denials delegate to `render_error` (envelope `{ success: false, error: { message:, code: "forbidden" } }`); otherwise the same envelope is rendered inline.
1246
+ - `only:` / `except:` are mutually exclusive (passing both raises `ArgumentError`); `authorize_by` requires a block and `require_role` requires at least one role.
1247
+ - **Non-goals**: no policy objects, no ability DSL, no resource inference — reach for [`pundit`](https://github.com/varvet/pundit) / [`cancancan`](https://github.com/CanCanCommunity/cancancan) when you outgrow a predicate per action.
1248
+
1249
+ ---
1250
+
1251
+ ## 🚦 Throttleable
1252
+
1253
+ Per-request rate limiting with a **store-agnostic, injectable** backend — no `rack-attack` needed. When a rule's limit is exceeded the request is halted with **429** plus `Retry-After` and `X-RateLimit-Limit` / `X-RateLimit-Remaining` / `X-RateLimit-Reset`.
1254
+
1255
+ ```ruby
1256
+ class Api::BaseController < ApplicationController
1257
+ include ConcernsOnRails::Controllers::Throttleable
1258
+
1259
+ self.throttleable_store = Rails.cache # must support atomic #increment
1260
+
1261
+ throttle_by limit: 100, period: 1.minute # by IP (default)
1262
+ throttle_by limit: 5, period: 1.minute, only: :create,
1263
+ by: -> { current_user&.id || request.remote_ip }
1264
+ end
1265
+ ```
1266
+
1267
+ Fixed-window counter: the key embeds a floored time bucket (`epoch / period`) so each window starts clean and `X-RateLimit-Reset` is exact.
1268
+
1269
+ **Options**: `limit:` (positive integer), `period:` (a `Duration` or seconds), `by:` (discriminator lambda, default per-IP), `only:` / `except:` (mutually exclusive action scoping), `name:` (disambiguates the counter key).
1270
+
1271
+ **Notes**
1272
+ - The store MUST support **atomic increment-with-expiry** (`Rails.cache` with `#increment`, or Redis) — a non-atomic store under-counts under concurrency.
1273
+ - There is **no in-process default store** on purpose: the first throttled request raises `ArgumentError` until you set `throttleable_store`, so you never silently rate-limit per-process.
1274
+ - When `Respondable` is included, the 429 body delegates to `render_error` (`code: "rate_limited"`).
1275
+ - Backports the essentials of Rails 7.2's `rate_limit` (with standardized headers) to Rails 5.0+. For richer rules (fail2ban, allow/deny lists, exponential backoff) reach for [`rack-attack`](https://github.com/rack/rack-attack).
1276
+
1277
+ ---
1278
+
1279
+ ## 🕒 Timezoneable
1280
+
1281
+ Per-request `Time.zone` selection wrapped in an `around_action` (`Time.use_zone`) — the time analogue of [Localizable](#-localizable). Dependency-free.
1282
+
1283
+ ```ruby
1284
+ class ApplicationController < ActionController::Base
1285
+ include ConcernsOnRails::Controllers::Timezoneable
1286
+
1287
+ timezoneable available: ["UTC", "Eastern Time (US & Canada)"], default: "UTC"
1288
+ # timezoneable param: :tz, header: false, cookie: :time_zone
1289
+ end
1290
+ ```
1291
+
1292
+ Resolution order: `params[param]` → `Time-Zone` header → cookie (if enabled) → `default` → the current `Time.zone`. Every value — the configured `available:` / `default:` **and** each request candidate — is resolved through `ActiveSupport::TimeZone[...]`, so a zone accepted at boot can never be rejected at request time.
1293
+
1294
+ **Options**: `available:` (allow-list applied to param/header/cookie matching; `default:` bypasses it, mirroring Localizable), `default:`, `param:` (default `:time_zone`), `header:` (default `true`, reads the `Time-Zone` header), `cookie:` (default `false`; `true` reads the `:time_zone` cookie, or pass a cookie name).
1295
+
1296
+ **Notes**
1297
+ - An unknown `available:` / `default:` zone raises `ArgumentError` at declaration time (fail-fast on misconfiguration).
1298
+ - Pairs naturally with the model concerns that read the clock (`Schedulable`, `Publishable`, `Expirable`, `SoftDeletable`).
1299
+
1300
+ ---
1301
+
1137
1302
  ## 🗂️ Module paths & namespacing
1138
1303
 
1139
1304
  Every concern is available under two paths:
@@ -0,0 +1,124 @@
1
+ require "active_support/concern"
2
+
3
+ module ConcernsOnRails
4
+ module Controllers
5
+ # Declarative, block-only per-action authorization gate. Each rule is a
6
+ # predicate; the first rule that applies to the current action and returns a
7
+ # falsey value halts the request with 403 (rendered via Respondable's
8
+ # `render_error` when available, otherwise an inline envelope).
9
+ #
10
+ # class Api::BaseController < ApplicationController
11
+ # include ConcernsOnRails::Controllers::Authorizable
12
+ #
13
+ # authorize_by { current_user.present? } # every action
14
+ # authorize_by(only: %i[update destroy]) { |_action, user| user.admin? }
15
+ # require_role :admin, :editor, only: :publish # role sugar
16
+ # end
17
+ #
18
+ # The block is invoked with `instance_exec` so `current_user` (and any other
19
+ # helper) resolves on the controller. It is arity-safe: write it with zero,
20
+ # one (`|action|`), or two (`|action, user|`) parameters.
21
+ #
22
+ # Non-goals (kept deliberately small): this is NOT a policy/ability framework.
23
+ # No policy objects, no ability DSL, no resource inference — reach for Pundit
24
+ # or CanCanCan when you outgrow a predicate per action.
25
+ module Authorizable
26
+ extend ActiveSupport::Concern
27
+
28
+ included do
29
+ class_attribute :authorizable_rules, instance_accessor: false, default: []
30
+ before_action :enforce_authorization
31
+ end
32
+
33
+ module ClassMethods
34
+ # Register an authorization predicate. `only:`/`except:` scope it to a
35
+ # subset of actions (mutually exclusive). `status:` (default :forbidden)
36
+ # and `message:` control the denial response.
37
+ def authorize_by(only: nil, except: nil, status: :forbidden, message: "Forbidden", &block)
38
+ raise ArgumentError, "ConcernsOnRails::Controllers::Authorizable: a block is required" unless block
39
+
40
+ add_authorization_rule(check: block, only: only, except: except, status: status, message: message)
41
+ end
42
+
43
+ # Sugar for the common "actor must have one of these roles" rule. The
44
+ # actor is read via `via:` (default `:current_user`) and its role via
45
+ # `role_method:` (default `:role`). Implemented as a proc, never a lambda,
46
+ # so arity slicing can't raise.
47
+ def require_role(*roles, via: :current_user, role_method: :role, only: nil, except: nil,
48
+ status: :forbidden, message: "Forbidden")
49
+ raise ArgumentError, "ConcernsOnRails::Controllers::Authorizable: at least one role is required" if roles.empty?
50
+
51
+ wanted = roles.map(&:to_s)
52
+ check = proc do
53
+ actor = respond_to?(via) ? send(via) : nil
54
+ actor.respond_to?(role_method) && wanted.include?(actor.public_send(role_method).to_s)
55
+ end
56
+ add_authorization_rule(check: check, only: only, except: except, status: status, message: message)
57
+ end
58
+
59
+ private
60
+
61
+ def add_authorization_rule(check:, only:, except:, status:, message:)
62
+ raise ArgumentError, "ConcernsOnRails::Controllers::Authorizable: pass either :only or :except, not both" if only && except
63
+
64
+ rule = {
65
+ check: check,
66
+ only: only && Array(only).map(&:to_s),
67
+ except: except && Array(except).map(&:to_s),
68
+ status: status,
69
+ message: message
70
+ }
71
+ self.authorizable_rules = authorizable_rules + [rule]
72
+ end
73
+ end
74
+
75
+ # Public so subclasses can override. Iterates the declared rules in order
76
+ # and denies on the first failing rule that applies to the current action.
77
+ def enforce_authorization
78
+ self.class.authorizable_rules.each do |rule|
79
+ next unless authorization_rule_applies?(rule)
80
+ next if invoke_authorization_check(rule[:check])
81
+
82
+ return authorization_denied(status: rule[:status], message: rule[:message])
83
+ end
84
+ nil
85
+ end
86
+
87
+ # Public override point for how a denial is rendered.
88
+ def authorization_denied(status:, message:)
89
+ return unless respond_to?(:response) && response
90
+
91
+ return render_error(message: message, status: status, code: "forbidden") if respond_to?(:render_error)
92
+
93
+ render json: { success: false, error: { message: message, code: "forbidden" } }, status: status
94
+ end
95
+
96
+ private
97
+
98
+ def authorization_rule_applies?(rule)
99
+ action = authorization_action_name
100
+ return rule[:only].include?(action) if rule[:only]
101
+ return !rule[:except].include?(action) if rule[:except]
102
+
103
+ true
104
+ end
105
+
106
+ # Arity-safe: slice the args to the predicate's arity before instance_exec
107
+ # so a zero/one/two-arg block all work. A negative arity (splat/optional)
108
+ # receives every arg.
109
+ def invoke_authorization_check(check)
110
+ args = [authorization_action_name, authorization_actor]
111
+ sliced = check.arity.negative? ? args : args.first(check.arity)
112
+ instance_exec(*sliced, &check)
113
+ end
114
+
115
+ def authorization_actor
116
+ respond_to?(:current_user) ? current_user : nil
117
+ end
118
+
119
+ def authorization_action_name
120
+ respond_to?(:action_name) ? action_name.to_s : nil
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,96 @@
1
+ require "active_support/concern"
2
+
3
+ module ConcernsOnRails
4
+ module Controllers
5
+ # Per-request locale selection from the request params and/or the
6
+ # `Accept-Language` header, wrapped in an `around_action` so `I18n.locale`
7
+ # is set for the action and restored afterwards.
8
+ #
9
+ # class ApplicationController < ActionController::Base
10
+ # include ConcernsOnRails::Controllers::Localizable
11
+ #
12
+ # localizable available: %i[en fr de], default: :en
13
+ # # localizable param: :lang, header: false # params[:lang] only
14
+ # end
15
+ #
16
+ # Resolution order: `params[param]` → first match in `Accept-Language` →
17
+ # `default` → `I18n.default_locale`. The chosen locale is always validated
18
+ # against `I18n.available_locales` before use, so a stray param or a
19
+ # mismatched `available:` list can never raise `I18n::InvalidLocale`.
20
+ #
21
+ # Options: `available:` (allow-list for param/header matching; defaults to
22
+ # `I18n.available_locales`), `default:`, `param:` (default `:locale`),
23
+ # `header:` (default `true`).
24
+ module Localizable
25
+ extend ActiveSupport::Concern
26
+
27
+ included do
28
+ class_attribute :localizable_options, instance_accessor: false, default: {}
29
+ around_action :switch_locale
30
+ end
31
+
32
+ class_methods do
33
+ def localizable(available: nil, default: nil, param: :locale, header: true)
34
+ self.localizable_options = {
35
+ available: available&.map(&:to_sym),
36
+ default: default&.to_sym,
37
+ param: param&.to_sym,
38
+ header: header
39
+ }
40
+ end
41
+ end
42
+
43
+ # Public so subclasses can override; runs the action under the resolved locale.
44
+ def switch_locale(&)
45
+ I18n.with_locale(resolved_locale, &)
46
+ end
47
+
48
+ # The locale chosen for this request — always one I18n can switch to.
49
+ def resolved_locale
50
+ opts = self.class.localizable_options
51
+ allowed = opts[:available].presence || I18n.available_locales
52
+ candidate = locale_from_param(opts, allowed) || locale_from_header(opts, allowed) || opts[:default]
53
+
54
+ candidate && I18n.available_locales.include?(candidate.to_sym) ? candidate.to_sym : I18n.default_locale
55
+ end
56
+
57
+ private
58
+
59
+ def locale_from_param(opts, allowed)
60
+ return nil unless opts[:param] && respond_to?(:params) && params
61
+
62
+ match_locale(params[opts[:param]], allowed)
63
+ end
64
+
65
+ def locale_from_header(opts, allowed)
66
+ return nil unless opts[:header]
67
+
68
+ header = accept_language_header
69
+ header.blank? ? nil : parse_accept_language(header, allowed)
70
+ end
71
+
72
+ def accept_language_header
73
+ return nil unless respond_to?(:request)
74
+
75
+ req = request
76
+ req.respond_to?(:headers) ? req.headers["Accept-Language"] : nil
77
+ end
78
+
79
+ def parse_accept_language(header, allowed)
80
+ header.split(",").each do |part|
81
+ lang = part.split(";").first.to_s.strip.split("-").first
82
+ match = match_locale(lang, allowed)
83
+ return match if match
84
+ end
85
+ nil
86
+ end
87
+
88
+ def match_locale(candidate, allowed)
89
+ return nil if candidate.blank?
90
+
91
+ wanted = candidate.to_s.downcase
92
+ allowed.find { |loc| loc.to_s.downcase == wanted }
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,159 @@
1
+ require "active_support/concern"
2
+
3
+ module ConcernsOnRails
4
+ module Controllers
5
+ # Per-request rate limiting with a store-agnostic, injectable backend. When a
6
+ # rule's limit is exceeded the request is halted with 429 plus
7
+ # `Retry-After` and `X-RateLimit-Limit` / `X-RateLimit-Remaining` /
8
+ # `X-RateLimit-Reset` headers.
9
+ #
10
+ # class Api::BaseController < ApplicationController
11
+ # include ConcernsOnRails::Controllers::Throttleable
12
+ #
13
+ # self.throttleable_store = Rails.cache # must support atomic #increment
14
+ #
15
+ # throttle_by limit: 100, period: 1.minute # by IP (default)
16
+ # throttle_by limit: 5, period: 1.minute, only: :create,
17
+ # by: -> { current_user&.id || request.remote_ip }
18
+ # end
19
+ #
20
+ # Fixed-window counter: the key embeds a floored time bucket
21
+ # (`epoch / period`) so each window starts clean and `X-RateLimit-Reset` is
22
+ # exact. The store MUST support atomic increment-with-expiry (`Rails.cache`
23
+ # with `#increment`, or Redis); a non-atomic store under-counts under
24
+ # concurrency. There is no in-process default store on purpose — configure
25
+ # one explicitly or the first throttled request raises ArgumentError.
26
+ module Throttleable
27
+ extend ActiveSupport::Concern
28
+
29
+ # Default discriminator — one counter per client IP. Evaluated with
30
+ # instance_exec on the controller, so `request` resolves normally.
31
+ DEFAULT_DISCRIMINATOR = -> { request.remote_ip }
32
+
33
+ included do
34
+ class_attribute :throttleable_rules, instance_accessor: false, default: []
35
+ class_attribute :throttleable_store, instance_accessor: false, default: nil
36
+ before_action :enforce_throttles
37
+ end
38
+
39
+ module ClassMethods
40
+ # Declare a rate-limit rule. `limit` requests per `period` (a Duration or
41
+ # seconds), bucketed by `by:` (a callable, default per-IP). `only:`/
42
+ # `except:` scope it to a subset of actions (mutually exclusive). `name:`
43
+ # disambiguates the counter key when several rules share a discriminator.
44
+ def throttle_by(limit:, period:, by: nil, only: nil, except: nil, name: nil)
45
+ validate_throttle!(limit: limit, period: period, by: by, only: only, except: except)
46
+
47
+ rule = {
48
+ limit: limit,
49
+ period: period.to_i,
50
+ by: by || DEFAULT_DISCRIMINATOR,
51
+ only: only && Array(only).map(&:to_s),
52
+ except: except && Array(except).map(&:to_s),
53
+ name: (name || "rule#{throttleable_rules.size}").to_s
54
+ }
55
+ self.throttleable_rules = throttleable_rules + [rule]
56
+ end
57
+
58
+ private
59
+
60
+ def validate_throttle!(limit:, period:, by:, only:, except:)
61
+ prefix = "ConcernsOnRails::Controllers::Throttleable"
62
+ raise ArgumentError, "#{prefix}: :limit must be a positive integer" unless positive_integer?(limit)
63
+ raise ArgumentError, "#{prefix}: :period must be a positive duration" unless period.to_i.positive?
64
+ raise ArgumentError, "#{prefix}: :by must be callable" unless callable_or_nil?(by)
65
+ raise ArgumentError, "#{prefix}: pass either :only or :except, not both" if only && except
66
+ end
67
+
68
+ def positive_integer?(value)
69
+ value.is_a?(Integer) && value.positive?
70
+ end
71
+
72
+ def callable_or_nil?(value)
73
+ value.nil? || value.respond_to?(:call)
74
+ end
75
+ end
76
+
77
+ # Public so subclasses can override. Applies each in-scope rule; the first
78
+ # rule that exceeds its limit halts the request with a 429.
79
+ def enforce_throttles
80
+ self.class.throttleable_rules.each do |rule|
81
+ next unless throttle_rule_applies?(rule)
82
+
83
+ result = register_throttle_hit(rule)
84
+ emit_throttle_headers(rule, result)
85
+
86
+ return throttled_response(rule, result) if result[:count] > rule[:limit]
87
+ end
88
+ nil
89
+ end
90
+
91
+ # Public override point for the 429 body.
92
+ def throttled_response(_rule, result)
93
+ return unless respond_to?(:response) && response
94
+
95
+ message = "Rate limit exceeded. Retry in #{result[:retry_after]}s."
96
+ return render_error(message: message, status: :too_many_requests, code: "rate_limited") if respond_to?(:render_error)
97
+
98
+ render json: { success: false, error: { message: message, code: "rate_limited" } }, status: :too_many_requests
99
+ end
100
+
101
+ private
102
+
103
+ def throttle_rule_applies?(rule)
104
+ action = throttle_action_name
105
+ return rule[:only].include?(action) if rule[:only]
106
+ return !rule[:except].include?(action) if rule[:except]
107
+
108
+ true
109
+ end
110
+
111
+ def register_throttle_hit(rule)
112
+ store = throttle_store!
113
+ now = Time.now.to_i
114
+ window = now / rule[:period]
115
+ reset_at = (window + 1) * rule[:period]
116
+ key = "throttleable:#{rule[:name]}:#{throttle_discriminator(rule)}:#{window}"
117
+
118
+ # Atomic increment-with-expiry. Some stores return nil on the first
119
+ # increment of a missing key — seed it to 1 in that case.
120
+ count = store.increment(key, 1, expires_in: rule[:period])
121
+ count ||= seed_throttle_key(store, key, rule[:period])
122
+
123
+ { count: count.to_i, reset_at: reset_at, retry_after: [reset_at - now, 0].max }
124
+ end
125
+
126
+ def seed_throttle_key(store, key, period)
127
+ store.write(key, 1, expires_in: period) if store.respond_to?(:write)
128
+ 1
129
+ end
130
+
131
+ def throttle_discriminator(rule)
132
+ instance_exec(&rule[:by])
133
+ end
134
+
135
+ def emit_throttle_headers(rule, result)
136
+ return unless respond_to?(:response) && response
137
+
138
+ remaining = [rule[:limit] - result[:count], 0].max
139
+ response.set_header("X-RateLimit-Limit", rule[:limit].to_s)
140
+ response.set_header("X-RateLimit-Remaining", remaining.to_s)
141
+ response.set_header("X-RateLimit-Reset", result[:reset_at].to_s)
142
+ response.set_header("Retry-After", result[:retry_after].to_s) if result[:count] > rule[:limit]
143
+ end
144
+
145
+ def throttle_store!
146
+ store = self.class.throttleable_store
147
+ return store if store
148
+
149
+ raise ArgumentError,
150
+ "ConcernsOnRails::Controllers::Throttleable: no store configured. " \
151
+ "Set `self.throttleable_store = Rails.cache` (must support atomic #increment)."
152
+ end
153
+
154
+ def throttle_action_name
155
+ respond_to?(:action_name) ? action_name.to_s : nil
156
+ end
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,129 @@
1
+ require "active_support/concern"
2
+
3
+ module ConcernsOnRails
4
+ module Controllers
5
+ # Per-request `Time.zone` selection from the request params, the `Time-Zone`
6
+ # header, and/or a cookie, wrapped in an `around_action` so `Time.zone` is set
7
+ # for the action and restored afterwards. The time analogue of Localizable
8
+ # (which does the same for `I18n.locale`). Dependency-free.
9
+ #
10
+ # class ApplicationController < ActionController::Base
11
+ # include ConcernsOnRails::Controllers::Timezoneable
12
+ #
13
+ # timezoneable available: ["UTC", "Eastern Time (US & Canada)"], default: "UTC"
14
+ # # timezoneable param: :tz, header: false, cookie: :time_zone
15
+ # end
16
+ #
17
+ # Resolution order: `params[param]` → `Time-Zone` header → cookie (if enabled)
18
+ # → `default` → the current `Time.zone`. Every value — the configured
19
+ # `available:`/`default:` AND each request candidate — is resolved through
20
+ # `ActiveSupport::TimeZone[...]`, so a zone accepted at boot can never be
21
+ # rejected at request time.
22
+ #
23
+ # Options: `available:` (allow-list applied to param/header/cookie matching;
24
+ # `default:` bypasses it, mirroring Localizable), `default:`, `param:`
25
+ # (default `:time_zone`), `header:` (default `true`), `cookie:` (default
26
+ # `false`; `true` reads the `:time_zone` cookie, or pass a cookie name).
27
+ module Timezoneable
28
+ extend ActiveSupport::Concern
29
+
30
+ included do
31
+ class_attribute :timezoneable_options, instance_accessor: false, default: {}
32
+ around_action :switch_time_zone
33
+ end
34
+
35
+ module ClassMethods
36
+ def timezoneable(available: nil, default: nil, param: :time_zone, header: true, cookie: false)
37
+ self.timezoneable_options = {
38
+ available: validate_time_zones(available),
39
+ default: validate_time_zone(default),
40
+ param: param&.to_sym,
41
+ header: header,
42
+ cookie: cookie == true ? :time_zone : cookie.presence
43
+ }
44
+ end
45
+
46
+ private
47
+
48
+ def validate_time_zones(zones)
49
+ return nil if zones.nil?
50
+
51
+ Array(zones).map { |zone| validate_time_zone(zone) }
52
+ end
53
+
54
+ # Resolve a single configured zone to an ActiveSupport::TimeZone at boot,
55
+ # raising on an unknown name so misconfiguration fails fast.
56
+ def validate_time_zone(zone)
57
+ return nil if zone.nil?
58
+
59
+ ActiveSupport::TimeZone[zone] ||
60
+ raise(ArgumentError, "ConcernsOnRails::Controllers::Timezoneable: unknown time zone '#{zone}'")
61
+ end
62
+ end
63
+
64
+ # Public so subclasses can override; runs the action under the resolved
65
+ # zone. UNGUARDED on purpose — it touches `Time` globally, not the response
66
+ # (mirrors Localizable#switch_locale).
67
+ def switch_time_zone(&)
68
+ Time.use_zone(resolved_time_zone, &)
69
+ end
70
+
71
+ # The ActiveSupport::TimeZone chosen for this request — always one `Time`
72
+ # can switch to (falls back to the current `Time.zone`).
73
+ def resolved_time_zone
74
+ opts = self.class.timezoneable_options
75
+ allowed = opts[:available]
76
+ candidate = zone_from_param(opts, allowed) ||
77
+ zone_from_header(opts, allowed) ||
78
+ zone_from_cookie(opts, allowed) ||
79
+ opts[:default]
80
+
81
+ resolve_zone(candidate) || Time.zone
82
+ end
83
+
84
+ private
85
+
86
+ # Match a raw source value against the allow-list (when present), returning
87
+ # the resolved TimeZone or nil so the resolution chain falls through.
88
+ def match_zone(raw, allowed)
89
+ return nil if raw.blank?
90
+
91
+ zone = ActiveSupport::TimeZone[raw.to_s]
92
+ return nil unless zone
93
+ return nil if allowed&.none? { |z| z.name == zone.name }
94
+
95
+ zone
96
+ end
97
+
98
+ # Final coercion: `default` is already a TimeZone; the Time.zone fallback is
99
+ # handled by the caller.
100
+ def resolve_zone(candidate)
101
+ return nil if candidate.blank?
102
+ return candidate if candidate.is_a?(ActiveSupport::TimeZone)
103
+
104
+ ActiveSupport::TimeZone[candidate.to_s]
105
+ end
106
+
107
+ def zone_from_param(opts, allowed)
108
+ return nil unless opts[:param] && respond_to?(:params) && params
109
+
110
+ match_zone(params[opts[:param]], allowed)
111
+ end
112
+
113
+ def zone_from_header(opts, allowed)
114
+ return nil unless opts[:header] && respond_to?(:request)
115
+
116
+ req = request
117
+ header = req.respond_to?(:headers) ? req.headers["Time-Zone"] : nil
118
+ match_zone(header, allowed)
119
+ end
120
+
121
+ def zone_from_cookie(opts, allowed)
122
+ key = opts[:cookie]
123
+ return nil unless key && respond_to?(:cookies) && cookies
124
+
125
+ match_zone(cookies[key], allowed)
126
+ end
127
+ end
128
+ end
129
+ end
@@ -18,4 +18,6 @@ module ConcernsOnRails
18
18
  Sequenceable = Models::Sequenceable
19
19
  Taggable = Models::Taggable
20
20
  Sanitizable = Models::Sanitizable
21
+ Maskable = Models::Maskable
22
+ Monetizable = Models::Monetizable
21
23
  end
@@ -0,0 +1,79 @@
1
+ require "active_support/concern"
2
+ require "concerns_on_rails/support/masker"
3
+
4
+ module ConcernsOnRails
5
+ module Models
6
+ # Non-destructive display masking for sensitive string attributes.
7
+ #
8
+ # Masking is ALWAYS read-only: each declaration adds a `masked_<field>`
9
+ # reader and never writes the stored column (the raw value stays in the DB,
10
+ # because masking is a presentation concern). For stripping dangerous HTML
11
+ # see Models::Sanitizable.
12
+ #
13
+ # class User < ApplicationRecord
14
+ # include ConcernsOnRails::Models::Maskable
15
+ #
16
+ # maskable :email, with: :email # => user.masked_email "j****@example.com"
17
+ # maskable :card, with: :credit_card # => user.masked_card "**** **** **** 4242"
18
+ # maskable :ssn, with: :last4, mask: "•"
19
+ # maskable :token, with: ->(v) { "#{v.to_s[0, 3]}…" }
20
+ # end
21
+ #
22
+ # Presets (the `with:` argument):
23
+ # :email — mask the local part, keep first char + domain
24
+ # :phone — keep the last 4 digits ("***-2671")
25
+ # :credit_card — keep the last 4 digits ("**** **** **** 4242")
26
+ # :last4 — keep the last 4 characters
27
+ # :all — mask every character (the default)
28
+ # Proc — used as-is (the caller owns the non-String guard)
29
+ #
30
+ # `mask:` sets the mask character (default "*") for the preset forms.
31
+ module Maskable
32
+ extend ActiveSupport::Concern
33
+
34
+ PRESETS = %i[email phone credit_card last4 all].freeze
35
+
36
+ included do
37
+ class_attribute :maskable_rules, instance_accessor: false, default: {}
38
+ end
39
+
40
+ class_methods do
41
+ include ConcernsOnRails::Support::ColumnGuard
42
+
43
+ def maskable(*fields, with: :all, mask: "*")
44
+ raise ArgumentError, "ConcernsOnRails::Models::Maskable: at least one field is required" if fields.empty?
45
+
46
+ masker = resolve_masker(with, mask)
47
+ ensure_columns!("ConcernsOnRails::Models::Maskable", fields)
48
+
49
+ fields.each do |field|
50
+ key = field.to_sym
51
+ self.maskable_rules = maskable_rules.merge(key => masker)
52
+ define_method("masked_#{field}") { masker.call(self[key]) }
53
+ end
54
+ end
55
+ end
56
+
57
+ class_methods do
58
+ private
59
+
60
+ def resolve_masker(with, mask)
61
+ case with
62
+ when Symbol
63
+ unless PRESETS.include?(with)
64
+ raise ArgumentError,
65
+ "ConcernsOnRails::Models::Maskable: unknown preset '#{with}'. " \
66
+ "Valid presets: #{PRESETS.join(', ')}"
67
+ end
68
+
69
+ ->(v) { ConcernsOnRails::Support::Masker.public_send(with, v, mask: mask) }
70
+ when Proc then with
71
+ else
72
+ raise ArgumentError,
73
+ "ConcernsOnRails::Models::Maskable: :with must be a preset symbol or a Proc/lambda, got #{with.class}"
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,89 @@
1
+ require "active_support/concern"
2
+ require "concerns_on_rails/support/money"
3
+
4
+ module ConcernsOnRails
5
+ module Models
6
+ # Money handling for an integer "subunit" column (e.g. cents) — exact,
7
+ # float-free, via BigDecimal.
8
+ #
9
+ # Declaring `monetizable :price_cents` adds three methods derived from the
10
+ # column name (the `_cents` suffix is stripped):
11
+ # * `price` — the amount as a BigDecimal (cents / 100)
12
+ # * `price=` — assign in major units; rounded to whole cents
13
+ # * `formatted_price` — a display string ("$1,234.56")
14
+ #
15
+ # class Product < ApplicationRecord
16
+ # include ConcernsOnRails::Models::Monetizable
17
+ #
18
+ # monetizable :price_cents # => price / price= / formatted_price
19
+ # monetizable :shipping_cents, as: :shipping
20
+ # monetizable :total_cents, unit: "€", separator: ",", delimiter: "."
21
+ # end
22
+ #
23
+ # product.price = 19.99 # stores price_cents = 1999
24
+ # product.price # => 0.1999e2 (BigDecimal 19.99)
25
+ # product.formatted_price # => "$19.99"
26
+ #
27
+ # Options: `as:` (explicit method name — required when the column does not
28
+ # end in `_cents`), `unit:` ("$"), `precision:` (2), `delimiter:` (","),
29
+ # `separator:` ("."), `subunit_to_unit:` (100).
30
+ module Monetizable
31
+ extend ActiveSupport::Concern
32
+
33
+ included do
34
+ class_attribute :monetizable_rules, instance_accessor: false, default: {}
35
+ end
36
+
37
+ class_methods do
38
+ include ConcernsOnRails::Support::ColumnGuard
39
+
40
+ def monetizable(*fields, as: nil, unit: "$", precision: 2, delimiter: ",", separator: ".", subunit_to_unit: 100)
41
+ raise ArgumentError, "ConcernsOnRails::Models::Monetizable: at least one field is required" if fields.empty?
42
+
43
+ raise ArgumentError, "ConcernsOnRails::Models::Monetizable: :as cannot be combined with multiple fields" if as && fields.size > 1
44
+
45
+ ensure_columns!("ConcernsOnRails::Models::Monetizable", fields)
46
+ config = { unit: unit, precision: precision, delimiter: delimiter, separator: separator, subunit_to_unit: subunit_to_unit }
47
+ fields.each { |cents_field| define_money_accessors(cents_field.to_sym, as, config) }
48
+ end
49
+ end
50
+
51
+ class_methods do # rubocop:disable Metrics/BlockLength
52
+ private
53
+
54
+ def define_money_accessors(cents_field, as, config)
55
+ name = money_name(cents_field, as)
56
+ subunit = config[:subunit_to_unit]
57
+ self.monetizable_rules = monetizable_rules.merge(cents_field => name)
58
+
59
+ define_method(name) do
60
+ cents = self[cents_field]
61
+ cents.nil? ? nil : BigDecimal(cents.to_s) / subunit
62
+ end
63
+
64
+ define_method("#{name}=") do |amount|
65
+ self[cents_field] = amount.nil? ? nil : (BigDecimal(amount.to_s) * subunit).round
66
+ end
67
+
68
+ define_method("formatted_#{name}") do
69
+ cents = self[cents_field]
70
+ cents.nil? ? nil : ConcernsOnRails::Support::Money.format(cents, config)
71
+ end
72
+ end
73
+
74
+ def money_name(cents_field, as)
75
+ return as.to_sym if as
76
+
77
+ str = cents_field.to_s
78
+ unless str.end_with?("_cents")
79
+ raise ArgumentError,
80
+ "ConcernsOnRails::Models::Monetizable: cannot derive a money method name from '#{cents_field}' " \
81
+ "(it does not end in '_cents'); pass `as:` to name it explicitly"
82
+ end
83
+
84
+ str.delete_suffix("_cents").to_sym
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,60 @@
1
+ require "active_support/concern"
2
+
3
+ module ConcernsOnRails
4
+ module Support
5
+ # Display-only value-masking helpers shared by Models::Maskable.
6
+ #
7
+ # Every method is string-safe: a non-String argument is returned untouched,
8
+ # exactly like the Normalizable / Sanitizable preset lambdas. Masking is for
9
+ # presentation only — callers keep the original value in the database.
10
+ module Masker
11
+ module_function
12
+
13
+ DEFAULT_MASK = "*".freeze
14
+
15
+ # Replace every character with the mask character.
16
+ def all(value, mask: DEFAULT_MASK)
17
+ value.is_a?(String) ? mask * value.length : value
18
+ end
19
+
20
+ # Keep only the last four characters visible.
21
+ def last4(value, mask: DEFAULT_MASK)
22
+ return value unless value.is_a?(String)
23
+
24
+ value.length <= 4 ? mask * value.length : (mask * (value.length - 4)) + value[-4..]
25
+ end
26
+
27
+ # Mask the local part of an email, keeping the first character + domain:
28
+ # "john.doe@example.com" => "j*******@example.com"
29
+ def email(value, mask: DEFAULT_MASK)
30
+ return value unless value.is_a?(String)
31
+
32
+ local, at, domain = value.partition("@")
33
+ return value if at.empty? # not an email-shaped string; leave it alone
34
+
35
+ masked_local = local.length <= 1 ? mask : local[0] + (mask * (local.length - 1))
36
+ "#{masked_local}@#{domain}"
37
+ end
38
+
39
+ # Keep the last four digits of a phone number visible: "***-2671".
40
+ def phone(value, mask: DEFAULT_MASK)
41
+ return value unless value.is_a?(String)
42
+
43
+ digits = value.gsub(/\D/, "")
44
+ return value if digits.empty?
45
+
46
+ "#{mask * 3}-#{digits.length <= 4 ? digits : digits[-4..]}"
47
+ end
48
+
49
+ # Keep the last four digits of a card number: "**** **** **** 4242".
50
+ def credit_card(value, mask: DEFAULT_MASK)
51
+ return value unless value.is_a?(String)
52
+
53
+ digits = value.gsub(/\D/, "")
54
+ return all(value, mask: mask) if digits.length <= 4
55
+
56
+ "#{mask * 4} #{mask * 4} #{mask * 4} #{digits[-4..]}"
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,38 @@
1
+ require "bigdecimal"
2
+
3
+ module ConcernsOnRails
4
+ module Support
5
+ # Formats an integer subunit amount (e.g. cents) as a human-readable money
6
+ # string. Pure and stateless; used by Models::Monetizable. Uses BigDecimal
7
+ # throughout so there is no binary-float rounding drift.
8
+ module Money
9
+ module_function
10
+
11
+ # format(199999) => "$1,999.99"
12
+ # format(-500, unit: "£") => "-£5.00"
13
+ # format(1234, unit: "¥", precision: 0, subunit_to_unit: 1) => "¥1,234"
14
+ def format(cents, options = {})
15
+ unit = options.fetch(:unit, "$")
16
+ precision = options.fetch(:precision, 2)
17
+ delimiter = options.fetch(:delimiter, ",")
18
+ separator = options.fetch(:separator, ".")
19
+ subunit = options.fetch(:subunit_to_unit, 100)
20
+
21
+ decimal = BigDecimal(cents.to_s) / subunit
22
+ # BigDecimal#round returns an Integer for precision <= 0, so re-wrap it
23
+ # in a BigDecimal before #to_s("F") (Integer#to_s would read "F" as a radix).
24
+ rounded = BigDecimal(decimal.abs.round(precision).to_s)
25
+ whole, _, frac = rounded.to_s("F").partition(".")
26
+ whole = delimit(whole, delimiter)
27
+ number = precision.positive? ? "#{whole}#{separator}#{frac.ljust(precision, '0')[0, precision]}" : whole
28
+
29
+ "#{'-' if decimal.negative?}#{unit}#{number}"
30
+ end
31
+
32
+ # Insert the thousands delimiter into a non-negative integer string.
33
+ def delimit(integer_string, delimiter)
34
+ integer_string.reverse.gsub(/(\d{3})(?=\d)/, "\\1#{delimiter}").reverse
35
+ end
36
+ end
37
+ end
38
+ end
@@ -1,3 +1,3 @@
1
1
  module ConcernsOnRails
2
- VERSION = "1.12.1".freeze
2
+ VERSION = "1.14.0".freeze
3
3
  end
@@ -13,6 +13,8 @@ require "concerns_on_rails/support/random_value"
13
13
  require "concerns_on_rails/support/address_data"
14
14
  require "concerns_on_rails/support/sequence_calculator"
15
15
  require "concerns_on_rails/support/html_sanitizers"
16
+ require "concerns_on_rails/support/masker"
17
+ require "concerns_on_rails/support/money"
16
18
 
17
19
  # Model concerns
18
20
  require "concerns_on_rails/models/sluggable"
@@ -31,6 +33,8 @@ require "concerns_on_rails/models/addressable"
31
33
  require "concerns_on_rails/models/sequenceable"
32
34
  require "concerns_on_rails/models/taggable"
33
35
  require "concerns_on_rails/models/sanitizable"
36
+ require "concerns_on_rails/models/maskable"
37
+ require "concerns_on_rails/models/monetizable"
34
38
 
35
39
  # Controller concerns
36
40
  require "concerns_on_rails/controllers/paginatable"
@@ -40,6 +44,10 @@ require "concerns_on_rails/controllers/respondable"
40
44
  require "concerns_on_rails/controllers/error_handleable"
41
45
  require "concerns_on_rails/controllers/includable"
42
46
  require "concerns_on_rails/controllers/secure_headable"
47
+ require "concerns_on_rails/controllers/localizable"
48
+ require "concerns_on_rails/controllers/authorizable"
49
+ require "concerns_on_rails/controllers/throttleable"
50
+ require "concerns_on_rails/controllers/timezoneable"
43
51
 
44
52
  # Backwards compatibility (top-level aliases for pre-1.6 module paths)
45
53
  require "concerns_on_rails/legacy_aliases"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: concerns_on_rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.12.1
4
+ version: 1.14.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ethan Nguyen
@@ -70,18 +70,24 @@ files:
70
70
  - CODE_OF_CONDUCT.md
71
71
  - README.md
72
72
  - lib/concerns_on_rails.rb
73
+ - lib/concerns_on_rails/controllers/authorizable.rb
73
74
  - lib/concerns_on_rails/controllers/error_handleable.rb
74
75
  - lib/concerns_on_rails/controllers/filterable.rb
75
76
  - lib/concerns_on_rails/controllers/includable.rb
77
+ - lib/concerns_on_rails/controllers/localizable.rb
76
78
  - lib/concerns_on_rails/controllers/paginatable.rb
77
79
  - lib/concerns_on_rails/controllers/respondable.rb
78
80
  - lib/concerns_on_rails/controllers/secure_headable.rb
79
81
  - lib/concerns_on_rails/controllers/sortable.rb
82
+ - lib/concerns_on_rails/controllers/throttleable.rb
83
+ - lib/concerns_on_rails/controllers/timezoneable.rb
80
84
  - lib/concerns_on_rails/legacy_aliases.rb
81
85
  - lib/concerns_on_rails/models/activatable.rb
82
86
  - lib/concerns_on_rails/models/addressable.rb
83
87
  - lib/concerns_on_rails/models/expirable.rb
84
88
  - lib/concerns_on_rails/models/hashable.rb
89
+ - lib/concerns_on_rails/models/maskable.rb
90
+ - lib/concerns_on_rails/models/monetizable.rb
85
91
  - lib/concerns_on_rails/models/normalizable.rb
86
92
  - lib/concerns_on_rails/models/publishable.rb
87
93
  - lib/concerns_on_rails/models/sanitizable.rb
@@ -97,6 +103,8 @@ files:
97
103
  - lib/concerns_on_rails/support/address_data.rb
98
104
  - lib/concerns_on_rails/support/column_guard.rb
99
105
  - lib/concerns_on_rails/support/html_sanitizers.rb
106
+ - lib/concerns_on_rails/support/masker.rb
107
+ - lib/concerns_on_rails/support/money.rb
100
108
  - lib/concerns_on_rails/support/random_value.rb
101
109
  - lib/concerns_on_rails/support/sequence_calculator.rb
102
110
  - lib/concerns_on_rails/version.rb