standard_health 0.1.0 → 0.3.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: 298b48147224460e942fb5dd9440596ef128f0967dc7212a507756b9b45ae1b5
4
- data.tar.gz: 5008923ddf09d5637f9e6c07b95bf9073b15d0e79b2d0e00646170478e5d7aee
3
+ metadata.gz: 4bc8cdc021df7485cb1443cf6902b6fee31627ad6135d9eef6f284cd5eed47e0
4
+ data.tar.gz: bfd4e8a50e602685430481b85a0ebf604d1e930db71ce47492036d772f4aa13c
5
5
  SHA512:
6
- metadata.gz: 61f79addc2cba3dc84019036dd38b0917b5c812193b6b8af551b4efdd8578f93499a8efaed5f95b38efa07d71bf5d3bfe19495ff7f54eea145895169289aa7e5
7
- data.tar.gz: ff348b35a931bed97044118e15b275ac1daed5fae93929c1f2976bc1db1947dbbeb8a05fe8029bc277311e6174e73e8be94c3afc060c880282a1fa1906406d63
6
+ metadata.gz: 32ec724c45eb9899e4d241d5681d296e1ac5745c3ff1852b5a187dea1fcc2a6b7ff7a922c08f925f0b59189ca8523d11fd034a5743f394186d91c4c1f2d8596d
7
+ data.tar.gz: 4711ceae81d4b9d0c19b440cc62fe8b2677ce569661adf741775ecc7933ee93c68402c8141ca830e76a6a75aa023c3f7759ef4d68a175d38280a2d971e83b964
data/CHANGELOG.md CHANGED
@@ -7,6 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.0] - 2026-04-29
11
+
12
+ ### Added
13
+
14
+ - `if:` and `unless:` Proc predicates on `required` and `recommended`. Evaluated at audit time; when `unless:` returns truthy or `if:` returns falsy the entry is reported with `status: :not_applicable` and a `reason` field. Solves the MyInfo mock-mode case where `MYINFO_PRIVATE_JWKS` is only required outside mock mode.
15
+ - `mode_alias` top-level DSL inside `EnvSpec.define`. Maps a Symbol to an `Array<String>` of `APP_ENVIRONMENT` values. `in:` now accepts a Symbol that is resolved against declared aliases at audit time; an undefined Symbol raises `StandardHealth::EnvSpec::UnknownModeAlias`.
16
+ - `consumed_by:` keyword on `required`/`recommended`. Accepts a String or `Array<String>` pointing at where the env var is read in the host app; flows through to audit rows verbatim.
17
+ - `group "Label" do ... end` block inside `EnvSpec.define`. Tags enclosed entries with a `group` field in their audit rows. Pure metadata for ops UX.
18
+ - New `:not_applicable` value for the `status` field, used when an `if:`/`unless:` predicate suppresses an entry.
19
+
20
+ ### Changed
21
+
22
+ - `EnvSpec` internals refactored: `Entry` struct extended with `consumed_by`, `if_predicate`, `unless_predicate`, and `group`. Backward-compatible — every 0.2.0 spec produces identical audit output (modulo `description` now appearing where it was previously suppressed and `group`/`consumed_by` appearing only when set).
23
+
24
+ ## [0.2.0] - 2026-04-29
25
+
26
+ ### Added
27
+
28
+ - `c.diagnostics_parent_controller` config option. When set, only `StandardHealth::DiagnosticsController` inherits from it; `HealthController` still uses `parent_controller`. Lets consuming apps put auth (e.g. HTTP Basic) on the diagnostics endpoint without needing `raise_on_missing_callback_actions = false` — the workaround three rarebit-one web apps applied when adopting v0.1.0.
29
+
30
+ ### Changed
31
+
32
+ - `StandardHealth::DiagnosticsController` now inherits from a new `StandardHealth::DiagnosticsApplicationController` that resolves to `diagnostics_parent_controller || parent_controller`. Backward-compatible — when the new option isn't set, behavior is identical to v0.1.0.
33
+
10
34
  ## [0.1.0] - 2026-04-28
11
35
 
12
36
  ### Added
data/README.md CHANGED
@@ -69,8 +69,10 @@ The DSL has two declarations:
69
69
 
70
70
  Both accept:
71
71
 
72
- - `in: %w[staging production]` — restricts the entry to those `APP_ENVIRONMENT` values; ignored otherwise
72
+ - `in: %w[staging production]` — restricts the entry to those `APP_ENVIRONMENT` values; ignored otherwise. May also be a Symbol resolved via `mode_alias` (see below).
73
73
  - `description: "..."` — surfaced verbatim in the audit JSON
74
+ - `consumed_by: "config/initializers/sentry.rb"` — pointer (or `Array<String>`) to where the value is read; surfaced verbatim
75
+ - `if: -> { ... }` / `unless: -> { ... }` — Proc predicates evaluated at audit time. When `unless:` returns truthy or `if:` returns falsy, the entry is reported with `status: :not_applicable`
74
76
 
75
77
  Audit output (one row per applicable entry):
76
78
 
@@ -83,7 +85,108 @@ Audit output (one row per applicable entry):
83
85
  }
84
86
  ```
85
87
 
86
- Possible `status` values are `ok`, `missing` (required + absent), and `should_set` (recommended + absent).
88
+ Possible `status` values are `ok`, `missing` (required + absent), `should_set` (recommended + absent), and `not_applicable` (suppressed by an `if:`/`unless:` predicate).
89
+
90
+ ### Predicates: `if:` and `unless:`
91
+
92
+ Use predicates when an env var is only meaningful under runtime conditions that aren't expressible as a fixed list of `APP_ENVIRONMENT` values — e.g. when a host app supports a "mock mode" toggle.
93
+
94
+ ```ruby
95
+ required :MYINFO_PRIVATE_JWKS,
96
+ in: %w[production],
97
+ unless: -> { ENV["MYINFO_MOCK_MODE"].present? }
98
+
99
+ recommended :SENTRY_DSN,
100
+ if: -> { ENV["SENTRY_DISABLED"].blank? }
101
+ ```
102
+
103
+ A suppressed entry surfaces as:
104
+
105
+ ```json
106
+ {
107
+ "name": "MYINFO_PRIVATE_JWKS",
108
+ "level": "required",
109
+ "status": "not_applicable",
110
+ "reason": "unless predicate matched",
111
+ "mode": "production"
112
+ }
113
+ ```
114
+
115
+ The `reason` is `"unless predicate matched"` or `"if predicate did not match"`. Both predicates may be combined; the entry only evaluates when `if:` is truthy and `unless:` is falsy.
116
+
117
+ ### Mode aliases: `mode_alias`
118
+
119
+ Declare reusable groupings of `APP_ENVIRONMENT` values inside the `define` block, then reference them as Symbols in `in:`. Common patterns ship as conventions (not built-ins): `:deployed` for staging-and-up, `:live` for production-only.
120
+
121
+ ```ruby
122
+ StandardHealth::EnvSpec.define do
123
+ mode_alias :deployed, %w[staging preview production]
124
+ mode_alias :live, %w[production]
125
+
126
+ required :APP_ENVIRONMENT
127
+ required :SENTRY_DSN, in: :deployed
128
+ required :STRIPE_LIVE_KEY, in: :live
129
+ end
130
+ ```
131
+
132
+ `in:` accepts:
133
+
134
+ - `nil` (omitted) — entry always applies
135
+ - `Array<String>` — literal mode list (existing behaviour)
136
+ - `Symbol` — resolved against `mode_alias` at audit time. An undeclared Symbol raises `StandardHealth::EnvSpec::UnknownModeAlias`.
137
+
138
+ ### `description:` and `consumed_by:`
139
+
140
+ Both flow through to audit rows verbatim. `description:` is a human hint; `consumed_by:` points at the file(s) that read the value, which makes "what does this env var actually do" much faster to answer in incident response.
141
+
142
+ ```ruby
143
+ required :APP_HOST,
144
+ in: :deployed,
145
+ description: "Canonical web host",
146
+ consumed_by: "config/initializers/sentry.rb"
147
+ ```
148
+
149
+ ```json
150
+ {
151
+ "name": "APP_HOST",
152
+ "level": "required",
153
+ "status": "ok",
154
+ "mode": "production",
155
+ "description": "Canonical web host",
156
+ "consumed_by": "config/initializers/sentry.rb"
157
+ }
158
+ ```
159
+
160
+ `consumed_by:` may be a String or `Array<String>`; an Array is preserved as a JSON array.
161
+
162
+ ### Groups
163
+
164
+ Wrap related declarations in a `group "Label" do ... end` block to tag them with a category. Groups are pure metadata — they don't affect applicability, status, or evaluation order. Nested `group` blocks are supported; the innermost label propagates to enclosed entries. Calling `group` without a block raises `ArgumentError`.
165
+
166
+ ```ruby
167
+ StandardHealth::EnvSpec.define do
168
+ group "Singpass / MyInfo" do
169
+ required :MYINFO_CLIENT_ID
170
+ required :MYINFO_PRIVATE_JWKS, unless: -> { ENV["MYINFO_MOCK_MODE"].present? }
171
+ end
172
+
173
+ group "Database" do
174
+ required :DATABASE_URL, in: :deployed
175
+ end
176
+ end
177
+ ```
178
+
179
+ Audit rows for entries declared inside a `group` block carry a `group` key:
180
+
181
+ ```json
182
+ { "name": "MYINFO_CLIENT_ID", "level": "required", "status": "ok", "mode": "production", "group": "Singpass / MyInfo" }
183
+ ```
184
+
185
+ Entries declared outside any `group` block omit the `group` key entirely.
186
+
187
+ ### Backward compatibility
188
+
189
+ All v0.2.0 specs continue to produce identical audit output in v0.3.0. The new fields (`description`, `consumed_by`, `group`, `reason`) appear only when the corresponding feature is used; the new `:not_applicable` status only appears when a predicate suppresses an entry.
87
190
 
88
191
  ## Custom checks
89
192
 
@@ -125,8 +228,49 @@ StandardHealth.configure do |c|
125
228
  end
126
229
  ```
127
230
 
231
+ > **Note (Rails 7.1+):** the `only: :env` filter above raises `AbstractController::ActionNotFound` because `HealthController` (alive/ready) shares this parent and has no `:env` action. Use [Splitting auth between health and diagnostics](#splitting-auth-between-health-and-diagnostics) instead — that's why v0.2.0 added `diagnostics_parent_controller`.
232
+
128
233
  For a more granular setup, mount the engine inside an authenticated route block in your host app's `routes.rb`.
129
234
 
235
+ ### Splitting auth between health and diagnostics
236
+
237
+ The pattern above hits a snag on Rails 7.1+ when you want to protect *only* `/diagnostics/env`. Both `HealthController` and `DiagnosticsController` inherit from `parent_controller`, so a `before_action :authenticate, only: :env` on that single parent applies to both — and Rails raises `AbstractController::ActionNotFound` because `:env` doesn't exist on `HealthController`.
238
+
239
+ Pre-v0.2.0 the workaround was to disable the check on the host controller:
240
+
241
+ ```ruby
242
+ class StandardHealthHostController < ActionController::API
243
+ self.raise_on_missing_callback_actions = false # workaround
244
+ http_basic_authenticate_with(name: ..., password: ..., only: :env)
245
+ end
246
+ ```
247
+
248
+ From v0.2.0 onwards, point `diagnostics_parent_controller` at a separate base class instead. Only `DiagnosticsController` inherits from it, so the `only: :env` callback no longer leaks onto `HealthController`:
249
+
250
+ ```ruby
251
+ # app/controllers/health_base_controller.rb
252
+ class HealthBaseController < ActionController::API
253
+ end
254
+
255
+ # app/controllers/diagnostics_base_controller.rb
256
+ class DiagnosticsBaseController < ActionController::API
257
+ http_basic_authenticate_with(
258
+ name: ENV.fetch("HEALTH_USER"),
259
+ password: ENV.fetch("HEALTH_PASS")
260
+ )
261
+ end
262
+
263
+ # config/initializers/standard_health.rb
264
+ StandardHealth.configure do |c|
265
+ c.parent_controller = "HealthBaseController"
266
+ c.diagnostics_parent_controller = "DiagnosticsBaseController"
267
+ end
268
+ ```
269
+
270
+ Now `/health/alive` and `/health/ready` are unauthenticated (probe-friendly) while `/health/diagnostics/env` requires HTTP Basic — no `raise_on_missing_callback_actions` flag needed.
271
+
272
+ When `diagnostics_parent_controller` is unset, `DiagnosticsController` falls back to `parent_controller`, matching v0.1.0 behavior exactly.
273
+
130
274
  ## Status semantics
131
275
 
132
276
  `/ready` returns:
@@ -3,26 +3,16 @@
3
3
  module StandardHealth
4
4
  # Base controller for all StandardHealth endpoints.
5
5
  #
6
- # Lazily inherits from `StandardHealth.config.parent_controller` so host
6
+ # Lazily inherits from `StandardHealth.config.parent_controller` (resolved
7
+ # by `StandardHealth.parent_controller` in lib/standard_health.rb) so host
7
8
  # apps can wire their own auth/rate-limiting/before_actions in once and
8
- # have them apply to /alive, /ready, and /diagnostics/env. The default
9
- # is `ActionController::API` so the engine works in API-only host apps
9
+ # have them apply to /alive and /ready. The default is
10
+ # `ActionController::API` so the engine works in API-only host apps
10
11
  # without configuration.
11
12
  #
12
- # Resolution happens at the moment this class is first referenced, which
13
- # is request time by then the host app's controllers are loaded and
14
- # the constant resolves cleanly. Caching the resolved class on the
15
- # singleton avoids re-running `constantize` per request.
16
- def self.parent_controller
17
- @parent_controller ||= config.parent_controller.constantize
18
- end
19
-
20
- # Allow tests/host apps to bust the cached parent controller (e.g. when
21
- # toggling config between examples).
22
- def self.reset_parent_controller!
23
- @parent_controller = nil
24
- end
25
-
13
+ # `DiagnosticsController` has its own base class
14
+ # (`DiagnosticsApplicationController`) with its own resolver see
15
+ # `config.diagnostics_parent_controller`.
26
16
  class ApplicationController < parent_controller
27
17
  end
28
18
  end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StandardHealth
4
+ # Separate base class for `DiagnosticsController` so host apps can target
5
+ # diagnostics-only auth at this class via `diagnostics_parent_controller`.
6
+ # When `diagnostics_parent_controller` is unset, this resolves to the same
7
+ # class as `parent_controller`, so the inheritance chain is behaviorally
8
+ # identical to v0.1.0.
9
+ class DiagnosticsApplicationController < diagnostics_parent_controller
10
+ end
11
+ end
@@ -3,9 +3,15 @@
3
3
  module StandardHealth
4
4
  # Diagnostics endpoints. Output here is potentially sensitive (it
5
5
  # enumerates which env vars are missing), so host apps are responsible
6
- # for wrapping these routes with authentication via `parent_controller`
7
- # — typically a basic-auth `before_action` on `ApplicationController`.
8
- class DiagnosticsController < ApplicationController
6
+ # for wrapping these routes with authentication.
7
+ #
8
+ # Inherits from `DiagnosticsApplicationController`, which resolves to
9
+ # `config.diagnostics_parent_controller || config.parent_controller`.
10
+ # This lets host apps put auth on diagnostics only — e.g. an HTTP Basic
11
+ # `before_action :auth, only: :env` on a dedicated diagnostics parent —
12
+ # without that callback leaking onto `HealthController` and tripping
13
+ # Rails 7.1's `raise_on_missing_callback_actions`.
14
+ class DiagnosticsController < DiagnosticsApplicationController
9
15
  # Audits the configured EnvSpec against the current process ENV and
10
16
  # returns the result as JSON. When no EnvSpec is configured the
11
17
  # endpoint returns an empty audit rather than a 404 so callers don't
@@ -25,6 +25,18 @@ module StandardHealth
25
25
  # engine works in API-only host apps without configuration.
26
26
  attr_accessor :parent_controller
27
27
 
28
+ # Optional class name of a controller that ONLY `DiagnosticsController`
29
+ # should inherit from. When set, `HealthController` continues to use
30
+ # `parent_controller` while `DiagnosticsController` uses this one. Lets
31
+ # host apps put auth (e.g. HTTP Basic) on the diagnostics endpoint
32
+ # without needing to set `raise_on_missing_callback_actions = false`
33
+ # to suppress Rails 7.1's missing-action error caused by a single
34
+ # parent declaring `before_action :auth, only: :env` for both controllers.
35
+ #
36
+ # When unset (the default), `DiagnosticsController` falls back to
37
+ # `parent_controller` — fully backward-compatible with v0.1.0.
38
+ attr_accessor :diagnostics_parent_controller
39
+
28
40
  # An optional `StandardHealth::EnvSpec` instance describing required and
29
41
  # recommended environment variables for the host app. Audited via the
30
42
  # /diagnostics/env endpoint.
@@ -32,6 +44,7 @@ module StandardHealth
32
44
 
33
45
  def initialize
34
46
  @parent_controller = "ActionController::API"
47
+ @diagnostics_parent_controller = nil
35
48
  @env_spec = nil
36
49
  @checks = []
37
50
  end
@@ -6,32 +6,62 @@ module StandardHealth
6
6
  # Example:
7
7
  #
8
8
  # StandardHealth::EnvSpec.define do
9
+ # mode_alias :deployed, %w[staging preview production]
10
+ # mode_alias :live, %w[production]
11
+ #
9
12
  # required :SECRET_KEY_BASE
10
13
  # required :APP_ENVIRONMENT, in: %w[staging production]
11
14
  # recommended :SENTRY_DSN, description: "Error tracking DSN"
15
+ #
16
+ # group "Singpass / MyInfo" do
17
+ # required :MYINFO_CLIENT_ID
18
+ # required :MYINFO_PRIVATE_JWKS,
19
+ # in: :live,
20
+ # unless: -> { ENV["MYINFO_MOCK_MODE"].present? }
21
+ # end
12
22
  # end
13
23
  #
14
24
  # Each entry has:
15
25
  # - `name` (Symbol)
16
26
  # - `level` (:required | :recommended)
17
- # - `modes` (Array<String>, optional) — when set, the entry only applies
18
- # while `APP_ENVIRONMENT` matches one of these modes. Otherwise it
19
- # applies to every mode.
20
- # - `description` (String, optional) human-readable hint surfaced
27
+ # - `in:` (Array<String>, Symbol, or nil) — when an Array, the entry only
28
+ # applies while `APP_ENVIRONMENT` matches one of these modes. A Symbol
29
+ # is resolved against `mode_alias` declarations at audit time. When
30
+ # omitted, the entry applies to every mode.
31
+ # - `description:` (String, optional) — human-readable hint surfaced
21
32
  # verbatim by `/diagnostics/env`.
33
+ # - `consumed_by:` (String or Array<String>, optional) — pointer(s) to
34
+ # where the value is read in the host app. Surfaced verbatim.
35
+ # - `if:` / `unless:` (Proc, optional) — predicates evaluated at audit
36
+ # time. When `unless:` returns truthy or `if:` returns falsy the entry
37
+ # is reported with `status: :not_applicable`.
38
+ # - `group` (String, optional) — set implicitly by enclosing `group`
39
+ # block; surfaced verbatim.
22
40
  class EnvSpec
23
- Entry = Struct.new(:name, :level, :modes, :description, keyword_init: true) do
24
- # Whether this entry's audit applies under the given runtime mode.
25
- # Entries without a `modes:` constraint always apply.
26
- def applies_to?(mode)
27
- return true if modes.nil? || modes.empty?
28
- modes.include?(mode.to_s)
29
- end
30
- end
41
+ # Raised when `in:` references a Symbol that hasn't been declared via
42
+ # `mode_alias`.
43
+ class UnknownModeAlias < ArgumentError; end
44
+
45
+ # Internal entry record. `modes` may be an Array<String> or a Symbol
46
+ # alias that gets resolved at audit time.
47
+ Entry = Struct.new(
48
+ :name,
49
+ :level,
50
+ :modes,
51
+ :description,
52
+ :consumed_by,
53
+ :if_predicate,
54
+ :unless_predicate,
55
+ :group,
56
+ keyword_init: true
57
+ )
31
58
 
32
59
  # @return [Array<Entry>]
33
60
  attr_reader :entries
34
61
 
62
+ # @return [Hash{Symbol => Array<String>}]
63
+ attr_reader :mode_aliases
64
+
35
65
  # Build a spec via the DSL.
36
66
  def self.define(&block)
37
67
  new.tap { |spec| spec.instance_eval(&block) if block }
@@ -39,13 +69,42 @@ module StandardHealth
39
69
 
40
70
  def initialize
41
71
  @entries = []
72
+ @mode_aliases = {}
73
+ @group_stack = []
74
+ end
75
+
76
+ # Declare a mode alias usable in `in:`. Re-declaring overrides the
77
+ # previous value (last writer wins) so layered specs are easy to compose.
78
+ #
79
+ # @param name [Symbol]
80
+ # @param modes [Array<String>]
81
+ def mode_alias(name, modes)
82
+ @mode_aliases[name.to_sym] = Array(modes).map(&:to_s)
83
+ end
84
+
85
+ # Group subsequent declarations under a label. Pure metadata —
86
+ # propagated to audit rows as `group:` and otherwise inert. Nested
87
+ # `group` blocks are supported; the innermost label is the one that
88
+ # propagates to enclosed entries.
89
+ #
90
+ # @param label [String]
91
+ def group(label, &block)
92
+ raise ArgumentError, "group requires a block" unless block
93
+
94
+ @group_stack.push(label.to_s)
95
+ block.call
96
+ ensure
97
+ @group_stack.pop
42
98
  end
43
99
 
44
100
  # Declare a required env var.
45
101
  #
46
102
  # @param name [Symbol, String]
47
- # @param in [Array<String>, nil] limit applicability to these modes
103
+ # @param in [Array<String>, Symbol, nil] limit applicability to these modes
48
104
  # @param description [String, nil]
105
+ # @param consumed_by [String, Array<String>, nil]
106
+ # @param if [Proc, nil]
107
+ # @param unless [Proc, nil]
49
108
  def required(name, **opts)
50
109
  add(:required, name, **opts)
51
110
  end
@@ -60,41 +119,105 @@ module StandardHealth
60
119
  #
61
120
  # @param env_hash [Hash{String, Symbol => String}] e.g. ENV.to_h
62
121
  # @param mode [String, Symbol] current APP_ENVIRONMENT value
63
- # @return [Array<Hash>] one row per applicable entry, each shaped like:
64
- # { name:, level:, status: :ok | :missing | :should_set, mode: }
122
+ # @return [Array<Hash>] one row per applicable entry. Each row has at
123
+ # least `name`, `level`, `status`, `mode`. When an entry is suppressed
124
+ # by an `if:`/`unless:` predicate, `status` is `:not_applicable` and a
125
+ # `reason` field explains why. `description`, `consumed_by`, and
126
+ # `group` are included when set on the entry.
65
127
  def audit(env_hash, mode:)
66
128
  mode_str = mode.to_s
67
129
  env = stringify(env_hash)
68
130
 
69
131
  @entries.each_with_object([]) do |entry, out|
70
- next unless entry.applies_to?(mode_str)
71
-
72
- value = env[entry.name.to_s]
73
- status = classify(entry, value)
74
-
75
- row = {
76
- name: entry.name,
77
- level: entry.level,
78
- status: status,
79
- mode: mode_str
80
- }
81
- row[:description] = entry.description if entry.description
132
+ next unless mode_applies?(entry, mode_str)
133
+
134
+ row = base_row(entry, mode_str)
135
+
136
+ suppression = predicate_suppression(entry)
137
+ if suppression
138
+ row[:status] = :not_applicable
139
+ row[:reason] = suppression
140
+ else
141
+ value = env[entry.name.to_s]
142
+ row[:status] = classify(entry, value)
143
+ end
144
+
82
145
  out << row
83
146
  end
84
147
  end
85
148
 
86
149
  private
87
150
 
88
- def add(level, name, in: nil, description: nil)
89
- modes = binding.local_variable_get(:in)
151
+ def add(level, name, **opts)
152
+ modes_opt = opts[:in]
90
153
  @entries << Entry.new(
91
154
  name: name.to_sym,
92
155
  level: level,
93
- modes: modes ? Array(modes).map(&:to_s) : nil,
94
- description: description
156
+ modes: normalize_modes(modes_opt),
157
+ description: opts[:description],
158
+ consumed_by: normalize_consumed_by(opts[:consumed_by]),
159
+ if_predicate: opts[:if],
160
+ unless_predicate: opts[:unless],
161
+ group: @group_stack.last
95
162
  )
96
163
  end
97
164
 
165
+ def normalize_modes(modes_opt)
166
+ return nil if modes_opt.nil?
167
+ return modes_opt if modes_opt.is_a?(Symbol)
168
+ Array(modes_opt).map(&:to_s)
169
+ end
170
+
171
+ def normalize_consumed_by(value)
172
+ return nil if value.nil?
173
+ array = Array(value).map(&:to_s)
174
+ array.empty? ? nil : array
175
+ end
176
+
177
+ def mode_applies?(entry, mode_str)
178
+ modes = resolve_modes(entry.modes)
179
+ return true if modes.nil? || modes.empty?
180
+ modes.include?(mode_str)
181
+ end
182
+
183
+ def resolve_modes(modes)
184
+ return nil if modes.nil?
185
+ if modes.is_a?(Symbol)
186
+ unless @mode_aliases.key?(modes)
187
+ raise UnknownModeAlias, "Unknown mode alias: #{modes.inspect}"
188
+ end
189
+ @mode_aliases[modes]
190
+ else
191
+ modes
192
+ end
193
+ end
194
+
195
+ def predicate_suppression(entry)
196
+ if entry.unless_predicate && entry.unless_predicate.call
197
+ return "unless predicate matched"
198
+ end
199
+ if entry.if_predicate && !entry.if_predicate.call
200
+ return "if predicate did not match"
201
+ end
202
+ nil
203
+ end
204
+
205
+ # Builds the metadata-only portion of an audit row. The caller is
206
+ # responsible for setting `:status` (and optionally `:reason`) — keeping
207
+ # status off the base intentionally so a future code path that forgets
208
+ # to set it produces a missing-key error instead of a silent `nil`.
209
+ def base_row(entry, mode_str)
210
+ row = { name: entry.name, level: entry.level, mode: mode_str }
211
+ row[:description] = entry.description if entry.description
212
+ row[:consumed_by] = serialize_consumed_by(entry.consumed_by) if entry.consumed_by
213
+ row[:group] = entry.group if entry.group
214
+ row
215
+ end
216
+
217
+ def serialize_consumed_by(value)
218
+ value.length == 1 ? value.first : value
219
+ end
220
+
98
221
  def classify(entry, value)
99
222
  present = !value.nil? && !value.to_s.empty?
100
223
  return :ok if present
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module StandardHealth
4
- VERSION = "0.1.0"
4
+ VERSION = "0.3.0"
5
5
  end
@@ -32,5 +32,41 @@ module StandardHealth
32
32
  def reset_config!
33
33
  @config = Configuration.new
34
34
  end
35
+
36
+ # Resolves `config.parent_controller` to an actual class lazily — at
37
+ # the moment the engine's `ApplicationController` is first referenced
38
+ # (request time), by which point the host app's controllers are
39
+ # loaded. Caching avoids re-running `constantize` per request, and
40
+ # the name-mismatch reset lets tests swap config between examples.
41
+ def parent_controller
42
+ expected = config.parent_controller
43
+ if @parent_controller && @parent_controller.name != expected
44
+ @parent_controller = nil
45
+ end
46
+ @parent_controller ||= expected.constantize
47
+ end
48
+
49
+ def reset_parent_controller!
50
+ @parent_controller = nil
51
+ end
52
+
53
+ # Resolves the parent class for `DiagnosticsController` only. Falls
54
+ # back to `config.parent_controller` when no diagnostics-specific
55
+ # parent is configured, preserving v0.1.0 behavior. When set, lets
56
+ # host apps apply `before_action :auth, only: :env` to the diagnostics
57
+ # endpoint without that callback leaking onto `HealthController`
58
+ # (which would otherwise trip Rails 7.1's
59
+ # `raise_on_missing_callback_actions`).
60
+ def diagnostics_parent_controller
61
+ expected = config.diagnostics_parent_controller || config.parent_controller
62
+ if @diagnostics_parent_controller && @diagnostics_parent_controller.name != expected
63
+ @diagnostics_parent_controller = nil
64
+ end
65
+ @diagnostics_parent_controller ||= expected.constantize
66
+ end
67
+
68
+ def reset_diagnostics_parent_controller!
69
+ @diagnostics_parent_controller = nil
70
+ end
35
71
  end
36
72
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: standard_health
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jaryl Sim
@@ -37,6 +37,7 @@ files:
37
37
  - README.md
38
38
  - Rakefile
39
39
  - app/controllers/standard_health/application_controller.rb
40
+ - app/controllers/standard_health/diagnostics_application_controller.rb
40
41
  - app/controllers/standard_health/diagnostics_controller.rb
41
42
  - app/controllers/standard_health/health_controller.rb
42
43
  - config/routes.rb