disposita 0.1.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 +7 -0
- data/CHANGELOG.md +35 -0
- data/LICENSE.txt +21 -0
- data/README.md +322 -0
- data/lib/disposita/configuration.rb +209 -0
- data/lib/disposita/error.rb +46 -0
- data/lib/disposita/formats/yaml.rb +67 -0
- data/lib/disposita/internal/deep_merge.rb +35 -0
- data/lib/disposita/internal/definition.rb +42 -0
- data/lib/disposita/internal/hash_tools.rb +100 -0
- data/lib/disposita/internal/schema_builder.rb +97 -0
- data/lib/disposita/internal/type_adapter.rb +70 -0
- data/lib/disposita/paths.rb +99 -0
- data/lib/disposita/schema.rb +312 -0
- data/lib/disposita/source.rb +79 -0
- data/lib/disposita/sources/environment.rb +55 -0
- data/lib/disposita/sources/file.rb +112 -0
- data/lib/disposita/sources/hash.rb +29 -0
- data/lib/disposita/types.rb +152 -0
- data/lib/disposita/version.rb +6 -0
- data/lib/disposita.rb +85 -0
- metadata +65 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 2fb4585036294fbe8e0879b28e7a2dcba238402e3719cdd4fabacf016af8f1ee
|
|
4
|
+
data.tar.gz: 6761b686729f6fb6042fb8e204c990bad8f7839197b2a9251bd2eb4b07b4c785
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 99b4d980ac2044614b82a938f44edb69f63128ffa4e0edb86b2c3179e7905941d8de0daa5631c57e36a3a0dee8b6a9af2655e1d550c997503e86c266bb17cff3
|
|
7
|
+
data.tar.gz: 3c6c118173f448fc1b830c584f90d5dab96a641c6bfd3ab1b6ad28364ff3eb6ae28b85875b5c87055989d5a6fbcf07b7d73ce97cfb1810f9696f48953bdb6103
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to Disposita will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project follows Semantic Versioning.
|
|
6
|
+
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- Load YARD correctly and keep Markdown out of Ruby source parsing; validate documentation in CI.
|
|
12
|
+
- Redact secret defaults in schema metadata and preserve caller-owned path arrays.
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
- Complete namespace/helper documentation and regression coverage for source contracts, safe YAML and failed atomic writes.
|
|
17
|
+
|
|
18
|
+
## [0.1.0] - 2026-09-04
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- Consumer-owned schema DSL with nested namespaces.
|
|
23
|
+
- Typed settings with conservative coercion and custom validation.
|
|
24
|
+
- Defaults, required/optional settings and strict unknown-setting detection.
|
|
25
|
+
- Boolean, enum and typed-array helpers for configuration-oriented types.
|
|
26
|
+
- Immutable resolved configuration with dot access and detached hash export.
|
|
27
|
+
- Explicit layered resolution and source provenance.
|
|
28
|
+
- Environment source with explicit names or generated prefixes.
|
|
29
|
+
- In-memory source for runtime overrides and tests.
|
|
30
|
+
- Safe YAML loading and serialization.
|
|
31
|
+
- Atomic YAML file persistence with explicit writable targets.
|
|
32
|
+
- Secret metadata, diagnostic redaction and opt-in secret file persistence.
|
|
33
|
+
- Cross-platform user configuration path helpers.
|
|
34
|
+
- Schema introspection and schema version checks.
|
|
35
|
+
- Structured Disposita error hierarchy.
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Rubcraft
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
# Disposita
|
|
2
|
+
|
|
3
|
+
Declarative, typed and layered configuration infrastructure for Ruby applications, gems and CLIs.
|
|
4
|
+
|
|
5
|
+
Disposita provides the mechanics of configuration while leaving ownership and meaning with the consumer. It does not know what `:ssh`, `production` or a timeout mean to your application; it only knows how those values are declared, loaded, coerced, validated, layered and persisted.
|
|
6
|
+
|
|
7
|
+
## Why Disposita?
|
|
8
|
+
|
|
9
|
+
Configuration tends to grow into repeated infrastructure: parsers, defaults, environment variables, per-user paths, project overrides, validation, persistence and diagnostics. Disposita centralizes that infrastructure without making a toolkit or framework the owner of every consumer's configuration.
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
Add to your Gemfile:
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
gem "disposita"
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Then run `bundle install`.
|
|
20
|
+
|
|
21
|
+
## Define a schema
|
|
22
|
+
|
|
23
|
+
```ruby
|
|
24
|
+
require "disposita"
|
|
25
|
+
|
|
26
|
+
SCMConfig = Disposita.define(:scm, version: 1) do
|
|
27
|
+
namespace :git do
|
|
28
|
+
setting :default_remote,
|
|
29
|
+
type: String,
|
|
30
|
+
default: "origin",
|
|
31
|
+
description: "Default Git remote"
|
|
32
|
+
|
|
33
|
+
setting :transport,
|
|
34
|
+
type: Disposita::Types.enum(:ssh, :https),
|
|
35
|
+
default: :ssh,
|
|
36
|
+
env: "SCM_GIT_TRANSPORT"
|
|
37
|
+
|
|
38
|
+
setting :timeout,
|
|
39
|
+
type: Integer,
|
|
40
|
+
default: 30
|
|
41
|
+
|
|
42
|
+
setting :token,
|
|
43
|
+
type: String,
|
|
44
|
+
secret: true,
|
|
45
|
+
optional: true
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
`Disposita.define` returns a schema object. It does not register global mutable state and it performs no filesystem or environment reads by itself.
|
|
51
|
+
|
|
52
|
+
### Setting options
|
|
53
|
+
|
|
54
|
+
Within `Disposita.define`, `namespace(name) { ... }` groups settings; namespaces can nest.
|
|
55
|
+
`setting(name, type:, ...) { |value| ... }` declares a leaf. Its optional validator runs after coercion and must return a truthy value.
|
|
56
|
+
|
|
57
|
+
| Option | Default | Meaning |
|
|
58
|
+
| --- | --- | --- |
|
|
59
|
+
| `type:` | Required | Ruby class, a built-in type helper, or an object implementing `valid?` and optionally `coerce`. Otherwise validation uses `===`. |
|
|
60
|
+
| `default:` | Absent | Value used when no source provides one. An explicit `nil` still counts as a default and must satisfy the declared type. |
|
|
61
|
+
| `required:` | `false` | Reject resolution if the setting is absent. Cannot be combined with a default or `optional: true`. |
|
|
62
|
+
| `optional:` | `false` | Explicitly documents that absence is allowed; settings are already optional unless required. |
|
|
63
|
+
| `env:` | `nil` | Explicit variable name, taking precedence over generated names. |
|
|
64
|
+
| `secret:` | `false` | Redact diagnostics and deny ordinary file persistence. |
|
|
65
|
+
| `description:` | `nil` | Consumer-facing text returned by schema introspection. |
|
|
66
|
+
| `coerce:` | `true` | Convert raw input before checking semantic validation. Set false for strict values. |
|
|
67
|
+
|
|
68
|
+
`Schema#load` accepts no arguments for defaults-only resolution. `Schema#resolve` requires an explicit source or array; use `resolve([])` for defaults alone.
|
|
69
|
+
|
|
70
|
+
## Resolve layers
|
|
71
|
+
|
|
72
|
+
```ruby
|
|
73
|
+
global = Disposita::Sources::File.new(
|
|
74
|
+
File.join(Disposita::Paths.user_config("scm"), "config.yml"),
|
|
75
|
+
name: :global
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
project = Disposita::Sources::File.new(
|
|
79
|
+
".scm.yml",
|
|
80
|
+
name: :project
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
config = SCMConfig.load(
|
|
84
|
+
sources: [global, project],
|
|
85
|
+
env: ENV,
|
|
86
|
+
env_prefix: "SCM",
|
|
87
|
+
overrides: { git: { timeout: 10 } }
|
|
88
|
+
)
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Precedence is explicit and follows source order. Schema defaults are always the lowest layer; runtime overrides supplied to `load` are the highest layer.
|
|
92
|
+
|
|
93
|
+
```text
|
|
94
|
+
defaults < global < project < environment < runtime
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Typed access
|
|
98
|
+
|
|
99
|
+
```ruby
|
|
100
|
+
config.git.default_remote # => "origin"
|
|
101
|
+
config.git.transport # => :ssh
|
|
102
|
+
config.git.timeout # => 10
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Resolved configuration is immutable. `to_h` returns a detached copy for interoperability.
|
|
106
|
+
|
|
107
|
+
## Coercion
|
|
108
|
+
|
|
109
|
+
Disposita performs conservative coercion when a setting allows it (the default):
|
|
110
|
+
|
|
111
|
+
```text
|
|
112
|
+
"5432" -> Integer
|
|
113
|
+
"1.5" -> Float
|
|
114
|
+
"ssh" -> Symbol / enum value
|
|
115
|
+
"false" -> Boolean
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Use `coerce: false` to require an already-typed value.
|
|
119
|
+
|
|
120
|
+
```ruby
|
|
121
|
+
setting :strict_port, type: Integer, coerce: false
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## Validation
|
|
125
|
+
|
|
126
|
+
A setting can add consumer-owned semantic validation:
|
|
127
|
+
|
|
128
|
+
```ruby
|
|
129
|
+
setting :timeout, type: Integer, default: 30 do |value|
|
|
130
|
+
value.positive?
|
|
131
|
+
end
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Disposita runs the rule; the consumer defines what the rule means.
|
|
135
|
+
|
|
136
|
+
## Environment variables
|
|
137
|
+
|
|
138
|
+
A setting may name its environment variable explicitly:
|
|
139
|
+
|
|
140
|
+
```ruby
|
|
141
|
+
setting :transport, type: Symbol, env: "SCM_GIT_TRANSPORT"
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Or a source may generate names from a prefix and setting path:
|
|
145
|
+
|
|
146
|
+
```ruby
|
|
147
|
+
Disposita::Sources::Environment.new(prefix: "SCM")
|
|
148
|
+
# git.transport -> SCM_GIT_TRANSPORT
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
## Safe YAML
|
|
152
|
+
|
|
153
|
+
The bundled file source uses `Psych.safe_load` and disables Ruby-object deserialization and YAML aliases. Symbols are serialized as strings and coerced back according to the schema.
|
|
154
|
+
|
|
155
|
+
```yaml
|
|
156
|
+
version: 1
|
|
157
|
+
git:
|
|
158
|
+
transport: ssh
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Disposita intentionally ships YAML only in 0.1.0. The format boundary is isolated so JSON or TOML can be added without changing schema ownership or resolution semantics.
|
|
162
|
+
|
|
163
|
+
## Explicit writes
|
|
164
|
+
|
|
165
|
+
Reading may combine many layers. Writing always targets one explicit writable source.
|
|
166
|
+
|
|
167
|
+
```ruby
|
|
168
|
+
project = Disposita::Sources::File.new(".scm.yml", name: :project)
|
|
169
|
+
|
|
170
|
+
SCMConfig.write(project, git: { transport: :https })
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
Writes are validated and performed atomically through a temporary file followed by rename.
|
|
174
|
+
|
|
175
|
+
Defaults are not written automatically: consumers persist only the overrides they choose.
|
|
176
|
+
|
|
177
|
+
## Secrets
|
|
178
|
+
|
|
179
|
+
A setting can be marked as sensitive:
|
|
180
|
+
|
|
181
|
+
```ruby
|
|
182
|
+
setting :token, type: String, secret: true
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
The actual value remains available to application code:
|
|
186
|
+
|
|
187
|
+
```ruby
|
|
188
|
+
config.git.token
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
But diagnostics redact it:
|
|
192
|
+
|
|
193
|
+
```ruby
|
|
194
|
+
config.inspect
|
|
195
|
+
# => #<Disposita::Configuration git=#<... token=[REDACTED]>>
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
Normal file sources reject secret persistence by default. A consumer must opt in explicitly:
|
|
199
|
+
|
|
200
|
+
```ruby
|
|
201
|
+
private_store = Disposita::Sources::File.new(
|
|
202
|
+
"~/.config/scm/private.yml",
|
|
203
|
+
name: :private,
|
|
204
|
+
allow_secrets: true
|
|
205
|
+
)
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
Secret-aware behavior is not encryption. Disposita 0.1.0 deliberately does not implement cryptographic storage, key management, Vault, KMS or OS keychains.
|
|
209
|
+
|
|
210
|
+
## Provenance
|
|
211
|
+
|
|
212
|
+
Disposita tracks which layer supplied the winning value:
|
|
213
|
+
|
|
214
|
+
```ruby
|
|
215
|
+
config.source_of("git.transport")
|
|
216
|
+
# => :project
|
|
217
|
+
|
|
218
|
+
config.explain("git.transport")
|
|
219
|
+
# => { path: "git.transport", value: :https, source: :project }
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
Secret values are redacted from `explain`.
|
|
223
|
+
|
|
224
|
+
## Paths
|
|
225
|
+
|
|
226
|
+
User-level configuration paths follow platform conventions:
|
|
227
|
+
|
|
228
|
+
- Linux: `$XDG_CONFIG_HOME/<app>` or `~/.config/<app>`
|
|
229
|
+
- macOS: `~/Library/Application Support/<app>`
|
|
230
|
+
- Windows: `%APPDATA%\<app>` (falling back to `%LOCALAPPDATA%`)
|
|
231
|
+
|
|
232
|
+
Project paths remain consumer-owned:
|
|
233
|
+
|
|
234
|
+
```ruby
|
|
235
|
+
Disposita::Paths.project(Dir.pwd, ".rubcraft/scm.yml")
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
Disposita never imposes `.rubcraft`, `.disposita`, or another project directory.
|
|
239
|
+
|
|
240
|
+
## Schema introspection
|
|
241
|
+
|
|
242
|
+
```ruby
|
|
243
|
+
SCMConfig.describe("git.transport")
|
|
244
|
+
# => {
|
|
245
|
+
# path: "git.transport",
|
|
246
|
+
# type: "enum(:ssh, :https)",
|
|
247
|
+
# default: :ssh,
|
|
248
|
+
# has_default: true,
|
|
249
|
+
# required: false,
|
|
250
|
+
# secret: false,
|
|
251
|
+
# env: "SCM_GIT_TRANSPORT",
|
|
252
|
+
# description: nil
|
|
253
|
+
# }
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
This metadata is intended to support future CLI help, documentation and UI tooling without coupling Disposita to a particular CLI framework. Secret defaults appear as `[REDACTED]`, while `has_default` still indicates whether a default exists. Direct configuration access and `to_h` return actual values.
|
|
257
|
+
|
|
258
|
+
Prefer `describe` for diagnostic tooling. The lower-level `setting` and `settings` methods expose internal definition objects, including actual defaults; they are not safe logging representations.
|
|
259
|
+
|
|
260
|
+
## Schema versions
|
|
261
|
+
|
|
262
|
+
Each schema has a version and file sources may persist it. Disposita rejects configuration produced by a newer schema version. Automatic migrations are intentionally deferred beyond 0.1.0 so the migration contract can be designed without freezing a premature API.
|
|
263
|
+
|
|
264
|
+
## Ownership model
|
|
265
|
+
|
|
266
|
+
Disposita owns:
|
|
267
|
+
|
|
268
|
+
- schema infrastructure
|
|
269
|
+
- type checking and configuration-oriented coercion
|
|
270
|
+
- loading and persistence primitives
|
|
271
|
+
- layering and provenance
|
|
272
|
+
- conventional user paths
|
|
273
|
+
- validation mechanics
|
|
274
|
+
- secret-aware diagnostics
|
|
275
|
+
|
|
276
|
+
Consumers own:
|
|
277
|
+
|
|
278
|
+
- the schema itself
|
|
279
|
+
- project file locations
|
|
280
|
+
- layer policy and precedence
|
|
281
|
+
- what each setting means
|
|
282
|
+
- semantic validation rules
|
|
283
|
+
- whether and where secrets may be persisted
|
|
284
|
+
|
|
285
|
+
A library such as SCM can remain configuration-agnostic while `SCM CLI`, Rubcraft Toolkit, or another application defines its own Disposita schema around SCM.
|
|
286
|
+
|
|
287
|
+
## Non-goals for 0.1.0
|
|
288
|
+
|
|
289
|
+
Disposita does not aim to be a general-purpose type system, secret manager, encryption framework, Rails settings singleton, command-line parser or business-rule engine.
|
|
290
|
+
|
|
291
|
+
## Development
|
|
292
|
+
|
|
293
|
+
```bash
|
|
294
|
+
bundle install
|
|
295
|
+
bundle exec rspec
|
|
296
|
+
bundle exec rubocop
|
|
297
|
+
bundle exec rake
|
|
298
|
+
COVERAGE=true bundle exec rspec
|
|
299
|
+
bundle exec rake yard
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
The test suite is organized by public behavior and subsystem, with integration-style schema specs separated from source/format/path specs.
|
|
303
|
+
|
|
304
|
+
## License
|
|
305
|
+
|
|
306
|
+
MIT.
|
|
307
|
+
|
|
308
|
+
## API documentation
|
|
309
|
+
|
|
310
|
+
Disposita's public API is documented with YARD comments that explain not only
|
|
311
|
+
method signatures, but also ownership, precedence, persistence safety and common
|
|
312
|
+
usage patterns. Generate the local documentation with:
|
|
313
|
+
|
|
314
|
+
```sh
|
|
315
|
+
bundle exec rake yard
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
The generated site is written to `doc/`. CI generates it with warnings treated as failures. Internal implementation objects are
|
|
319
|
+
marked with `@api private`; applications should build against the documented
|
|
320
|
+
public objects such as `Disposita`, `Disposita::Schema`,
|
|
321
|
+
`Disposita::Configuration`, `Disposita::Source`, `Disposita::Sources::*`,
|
|
322
|
+
`Disposita::Types` and `Disposita::Paths`.
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Disposita
|
|
4
|
+
# Immutable, typed configuration produced after all sources are resolved.
|
|
5
|
+
#
|
|
6
|
+
# Configuration is the object an application normally consumes at runtime.
|
|
7
|
+
# Values can be accessed through dot notation or brackets, but mutation is
|
|
8
|
+
# intentionally unsupported. {#to_h} returns a detached copy when a mutable
|
|
9
|
+
# representation is needed for interoperability.
|
|
10
|
+
#
|
|
11
|
+
# Every selected value retains provenance, allowing diagnostic tooling to
|
|
12
|
+
# explain whether it came from a default, global file, project file, ENV or a
|
|
13
|
+
# runtime override. Settings marked +secret: true+ are redacted from
|
|
14
|
+
# diagnostic representations such as {#inspect} and {#explain}.
|
|
15
|
+
#
|
|
16
|
+
# @example
|
|
17
|
+
# config.server.port # => 3000
|
|
18
|
+
# config[:server][:port] # => 3000
|
|
19
|
+
# config.source_of("server.port") # => :default
|
|
20
|
+
#
|
|
21
|
+
# @see Disposita::Schema#resolve
|
|
22
|
+
class Configuration
|
|
23
|
+
# Read-only namespace node used to provide ergonomic nested access.
|
|
24
|
+
#
|
|
25
|
+
# Node deliberately wraps hashes instead of exposing them directly so the
|
|
26
|
+
# resolved configuration remains immutable and secret-aware diagnostics can
|
|
27
|
+
# be delegated back to the owning Configuration.
|
|
28
|
+
class Node
|
|
29
|
+
# @param data [Hash] subtree represented by this node.
|
|
30
|
+
# @param root [Disposita::Configuration] owning root configuration.
|
|
31
|
+
# @param path [Array<Symbol>] absolute path represented by this node.
|
|
32
|
+
def initialize(data, root:, path: [])
|
|
33
|
+
@data = data
|
|
34
|
+
@root = root
|
|
35
|
+
@path = path
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Reads a child setting or namespace by key.
|
|
39
|
+
#
|
|
40
|
+
# @param key [String, Symbol] immediate child name.
|
|
41
|
+
# @return [Object, Disposita::Configuration::Node] scalar value or nested
|
|
42
|
+
# read-only node.
|
|
43
|
+
# @raise [KeyError] when the child is not present in the resolved data.
|
|
44
|
+
def [](key)
|
|
45
|
+
value_for(key)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Returns a detached mutable Hash representation of this subtree.
|
|
49
|
+
#
|
|
50
|
+
# Changing the returned object never mutates the resolved configuration.
|
|
51
|
+
# This method returns actual secret values and should therefore not be
|
|
52
|
+
# treated as a safe logging representation.
|
|
53
|
+
#
|
|
54
|
+
# @return [Hash]
|
|
55
|
+
def to_h
|
|
56
|
+
Internal::HashTools.deep_dup(@data)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Returns a secret-aware diagnostic representation.
|
|
60
|
+
#
|
|
61
|
+
# @return [String] representation with secret settings replaced by
|
|
62
|
+
# +[REDACTED]+.
|
|
63
|
+
def inspect
|
|
64
|
+
@root.__send__(:inspect_node, @data, @path)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Resolves dot access to a setting or namespace; unknown calls use Ruby lookup.
|
|
68
|
+
# @param name [Symbol] requested setting name.
|
|
69
|
+
# @param arguments [Array<Object>] arguments supplied by the caller.
|
|
70
|
+
# @return [Object] resolved setting or namespace.
|
|
71
|
+
# @raise [NoMethodError] for unknown names or calls with arguments.
|
|
72
|
+
def method_missing(name, *arguments)
|
|
73
|
+
return super unless arguments.empty? && @data.key?(name)
|
|
74
|
+
|
|
75
|
+
value_for(name)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def respond_to_missing?(name, include_private = false)
|
|
79
|
+
@data.key?(name) || super
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def value_for(key)
|
|
85
|
+
key = key.to_sym
|
|
86
|
+
raise KeyError, "unknown configuration key: #{(@path + [key]).join('.')}" unless @data.key?(key)
|
|
87
|
+
|
|
88
|
+
value = @data[key]
|
|
89
|
+
value.is_a?(Hash) ? self.class.new(value, root: @root, path: @path + [key]) : value
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# @return [Disposita::Schema] schema that produced this configuration.
|
|
94
|
+
attr_reader :schema
|
|
95
|
+
|
|
96
|
+
# Builds an immutable resolved configuration.
|
|
97
|
+
#
|
|
98
|
+
# Applications normally receive instances from {Disposita::Schema#resolve}
|
|
99
|
+
# or {Disposita::Schema#load} rather than constructing them directly.
|
|
100
|
+
#
|
|
101
|
+
# @param schema [Disposita::Schema] owning schema.
|
|
102
|
+
# @param data [Hash] fully coerced and validated values.
|
|
103
|
+
# @param provenance [Hash{Array<Symbol> => Symbol}] selected source for each
|
|
104
|
+
# resolved leaf setting.
|
|
105
|
+
def initialize(schema:, data:, provenance:)
|
|
106
|
+
@schema = schema
|
|
107
|
+
@data = Internal::HashTools.deep_freeze(data)
|
|
108
|
+
@provenance = provenance.freeze
|
|
109
|
+
@root = Node.new(@data, root: self)
|
|
110
|
+
freeze
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Reads a top-level setting or namespace.
|
|
114
|
+
#
|
|
115
|
+
# @param key [String, Symbol] top-level key.
|
|
116
|
+
# @return [Object, Disposita::Configuration::Node]
|
|
117
|
+
# @raise [KeyError] when the key is absent.
|
|
118
|
+
def [](key) = @root[key]
|
|
119
|
+
|
|
120
|
+
# Reports which source supplied the effective value for a setting.
|
|
121
|
+
#
|
|
122
|
+
# @param path [String, Array<String, Symbol>, Symbol] setting path.
|
|
123
|
+
# @return [Symbol, nil] source name, +:default+ for schema defaults, or +nil+
|
|
124
|
+
# when no provenance entry exists.
|
|
125
|
+
# @example
|
|
126
|
+
# config.source_of("git.transport") # => :project
|
|
127
|
+
def source_of(path)
|
|
128
|
+
@provenance[normalize_path(path)]
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Explains one effective setting without exposing secret values.
|
|
132
|
+
#
|
|
133
|
+
# This small stable structure is intended for diagnostics and CLI commands
|
|
134
|
+
# such as +config explain+. A secret setting reports +[REDACTED]+ even though
|
|
135
|
+
# direct application access still returns its real value.
|
|
136
|
+
#
|
|
137
|
+
# @param path [String, Array<String, Symbol>, Symbol] setting path.
|
|
138
|
+
# @return [Hash] frozen Hash containing +:path+, +:value+ and +:source+.
|
|
139
|
+
# @raise [KeyError] if the path is not declared by the schema.
|
|
140
|
+
def explain(path)
|
|
141
|
+
normalized = normalize_path(path)
|
|
142
|
+
setting = schema.setting(normalized)
|
|
143
|
+
raise KeyError, "unknown configuration key: #{normalized.join('.')}" unless setting
|
|
144
|
+
|
|
145
|
+
{
|
|
146
|
+
path: normalized.join("."),
|
|
147
|
+
value: setting.secret? ? "[REDACTED]" : Internal::HashTools.get(@data, normalized),
|
|
148
|
+
source: @provenance[normalized]
|
|
149
|
+
}.freeze
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Returns a detached mutable Hash containing resolved values.
|
|
153
|
+
#
|
|
154
|
+
# Unlike {#inspect}, this is a data export API and therefore includes actual
|
|
155
|
+
# secret values. Callers are responsible for avoiding accidental logging or
|
|
156
|
+
# persistence of the returned Hash.
|
|
157
|
+
#
|
|
158
|
+
# @return [Hash]
|
|
159
|
+
def to_h
|
|
160
|
+
Internal::HashTools.deep_dup(@data)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Returns a secret-aware diagnostic representation of the configuration.
|
|
164
|
+
#
|
|
165
|
+
# @return [String]
|
|
166
|
+
def inspect = inspect_node(@data, [])
|
|
167
|
+
|
|
168
|
+
# Resolves dot access to a setting or namespace; unknown calls use Ruby lookup.
|
|
169
|
+
# @param name [Symbol] requested setting name.
|
|
170
|
+
# @param arguments [Array<Object>] arguments supplied by the caller.
|
|
171
|
+
# @return [Object] resolved setting or namespace.
|
|
172
|
+
# @raise [NoMethodError] for unknown names or calls with arguments.
|
|
173
|
+
def method_missing(name, *arguments)
|
|
174
|
+
return super unless arguments.empty? && @root.respond_to?(name)
|
|
175
|
+
|
|
176
|
+
@root.public_send(name)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def respond_to_missing?(name, include_private = false)
|
|
180
|
+
@root.respond_to?(name) || super
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
private
|
|
184
|
+
|
|
185
|
+
def normalize_path(path)
|
|
186
|
+
case path
|
|
187
|
+
when String then path.split(".").map!(&:to_sym)
|
|
188
|
+
when Array then path.map(&:to_sym)
|
|
189
|
+
else Array(path).map!(&:to_sym)
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def inspect_node(data, prefix)
|
|
194
|
+
body = data.map do |key, value|
|
|
195
|
+
path = prefix + [key]
|
|
196
|
+
setting = schema.setting(path)
|
|
197
|
+
rendered = if setting&.secret?
|
|
198
|
+
"[REDACTED]"
|
|
199
|
+
elsif value.is_a?(Hash)
|
|
200
|
+
inspect_node(value, path)
|
|
201
|
+
else
|
|
202
|
+
value.inspect
|
|
203
|
+
end
|
|
204
|
+
"#{key}=#{rendered}"
|
|
205
|
+
end.join(" ")
|
|
206
|
+
"#<#{self.class.name} #{body}>"
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Disposita
|
|
4
|
+
# Base class for errors raised by Disposita.
|
|
5
|
+
#
|
|
6
|
+
# Consumers may rescue this class when they want one boundary around all
|
|
7
|
+
# configuration failures, or rescue a narrower subclass to render specific
|
|
8
|
+
# diagnostics in a CLI or application.
|
|
9
|
+
class Error < StandardError; end
|
|
10
|
+
|
|
11
|
+
# Raised when the consumer declares an invalid schema.
|
|
12
|
+
class SchemaError < Error; end
|
|
13
|
+
|
|
14
|
+
# Base class for failures discovered while validating configuration values.
|
|
15
|
+
class ValidationError < Error; end
|
|
16
|
+
|
|
17
|
+
# Raised when a raw value cannot be converted to its declared schema type.
|
|
18
|
+
class CoercionError < ValidationError; end
|
|
19
|
+
|
|
20
|
+
# Raised when a required setting remains absent after all layers are resolved.
|
|
21
|
+
class MissingSettingError < ValidationError; end
|
|
22
|
+
|
|
23
|
+
# Raised when a source contains a setting not declared by the schema.
|
|
24
|
+
class UnknownSettingError < ValidationError; end
|
|
25
|
+
|
|
26
|
+
# Raised when serialized configuration cannot be decoded safely.
|
|
27
|
+
class ParseError < Error; end
|
|
28
|
+
|
|
29
|
+
# Raised when a source cannot provide its configuration data.
|
|
30
|
+
class LoadError < Error; end
|
|
31
|
+
|
|
32
|
+
# Raised when configuration cannot be persisted to the requested source.
|
|
33
|
+
class SaveError < Error; end
|
|
34
|
+
|
|
35
|
+
# Raised when persisted configuration is newer than the runtime schema.
|
|
36
|
+
class VersionError < Error; end
|
|
37
|
+
|
|
38
|
+
# Raised when a conventional or project path cannot be resolved safely.
|
|
39
|
+
class PathError < Error; end
|
|
40
|
+
|
|
41
|
+
# Raised when secret data would be written to a source that forbids secrets.
|
|
42
|
+
#
|
|
43
|
+
# This protects against accidental persistence only. It does not imply that a
|
|
44
|
+
# source allowing secrets provides encryption or a secret-management system.
|
|
45
|
+
class UnsafeSecretPersistenceError < SaveError; end
|
|
46
|
+
end
|