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.
@@ -0,0 +1,312 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Disposita
4
+ # Describes a consumer-owned configuration domain and resolves its values.
5
+ #
6
+ # A Schema contains structure and rules, not application semantics. It knows
7
+ # that a setting is an Integer, required, secret or sourced from a particular
8
+ # environment variable; the consumer remains responsible for deciding what
9
+ # that value means to the application.
10
+ #
11
+ # Schemas are immutable after construction. Resolution is explicit and
12
+ # ordered: later sources override earlier sources, while schema defaults form
13
+ # the lowest-precedence layer. Environment and runtime overrides may be added
14
+ # conveniently through {#load}.
15
+ #
16
+ # @example Resolve ordered layers
17
+ # schema = Disposita.define(:app) do
18
+ # setting :port, type: Integer, default: 3000
19
+ # end
20
+ #
21
+ # global = Disposita::Sources::Hash.new({ port: 4000 }, name: :global)
22
+ # project = Disposita::Sources::Hash.new({ port: 5000 }, name: :project)
23
+ #
24
+ # schema.resolve([global, project]).port # => 5000
25
+ #
26
+ # @see Disposita.define
27
+ # @see Disposita::Configuration
28
+ class Schema
29
+ # @return [Symbol] logical name of the configuration domain.
30
+ attr_reader :name
31
+
32
+ # @return [Integer] consumer-owned persisted schema version.
33
+ attr_reader :version
34
+
35
+ # @return [Array<Disposita::Internal::SettingDefinition>] declared settings.
36
+ attr_reader :settings
37
+
38
+ # Builds a schema from already validated definitions.
39
+ #
40
+ # Consumers normally create schemas with {Disposita.define}; this
41
+ # initializer is public primarily to keep Schema as a normal Ruby object.
42
+ #
43
+ # @param name [String, Symbol] logical domain name.
44
+ # @param version [Integer, #to_int] persisted schema version.
45
+ # @param settings [Array<Disposita::Internal::SettingDefinition>] settings
46
+ # created by the schema DSL.
47
+ # @return [Disposita::Schema]
48
+ # @raise [ArgumentError, TypeError] if +version+ cannot be converted to an
49
+ # Integer.
50
+ def initialize(name:, version:, settings:)
51
+ @name = name.to_sym
52
+ @version = Integer(version)
53
+ @settings = settings.freeze
54
+ @settings_by_path = settings.to_h { |setting| [setting.path, setting] }.freeze
55
+ freeze
56
+ end
57
+
58
+ # Finds a setting definition by dotted, array or scalar path.
59
+ #
60
+ # This is the lowest-level introspection method. Most user-facing tooling
61
+ # should prefer {#describe}, which returns a stable metadata Hash instead of
62
+ # exposing the internal definition object.
63
+ #
64
+ # @param path [String, Array<String, Symbol>, Symbol] setting path,
65
+ # for example +"git.transport"+ or +[:git, :transport]+.
66
+ # @return [Disposita::Internal::SettingDefinition, nil] definition when the
67
+ # path exists, otherwise +nil+.
68
+ def setting(path)
69
+ @settings_by_path[normalize_path(path)]
70
+ end
71
+
72
+ # Returns user-oriented metadata for one declared setting.
73
+ #
74
+ # The returned Hash is suitable for CLI help, documentation generators and
75
+ # configuration editors. Secret values are never included; only metadata
76
+ # such as whether a setting is secret is exposed. Secret defaults are
77
+ # replaced by +[REDACTED]+; absent defaults remain +nil+.
78
+ #
79
+ # @param path [String, Array<String, Symbol>, Symbol] setting path.
80
+ # @return [Hash, nil] frozen metadata Hash, or +nil+ for an unknown path.
81
+ # @example
82
+ # schema.describe("git.transport")
83
+ # # => {
84
+ # # path: "git.transport",
85
+ # # type: "enum(:ssh, :https)",
86
+ # # default: :ssh,
87
+ # # has_default: true,
88
+ # # required: false,
89
+ # # secret: false,
90
+ # # env: nil,
91
+ # # description: "Preferred Git transport"
92
+ # # }
93
+ def describe(path)
94
+ item = setting(path)
95
+ return unless item
96
+
97
+ {
98
+ path: item.key,
99
+ type: Internal::TypeAdapter.describe(item.type),
100
+ default: if item.default?
101
+ item.secret? ? "[REDACTED]" : item.default
102
+ end,
103
+ has_default: item.default?,
104
+ required: item.required?,
105
+ secret: item.secret?,
106
+ env: item.env,
107
+ description: item.description
108
+ }.freeze
109
+ end
110
+
111
+ # Resolves configuration using convenient optional ENV and runtime layers.
112
+ #
113
+ # Sources supplied in +sources+ are applied in order. If +env+ is provided,
114
+ # an Environment source is appended after them. If +overrides+ is provided,
115
+ # an in-memory runtime source is appended last, giving runtime values the
116
+ # highest precedence.
117
+ #
118
+ # No global ENV lookup happens unless +env+ is explicitly provided. Passing
119
+ # +ENV+ is therefore an application decision rather than a side effect of
120
+ # loading Disposita.
121
+ #
122
+ # @param sources [Array<Disposita::Source>] ordered low-to-high precedence
123
+ # configuration sources.
124
+ # @param env [Hash, nil] environment-like Hash. When present, declared +env+
125
+ # names or +env_prefix+ derived names are read from it.
126
+ # @param env_prefix [String, nil] prefix used to derive names such as
127
+ # +APP_SERVER_PORT+ from the setting path +server.port+.
128
+ # @param overrides [Hash, nil] highest-precedence runtime values. These are
129
+ # never persisted automatically.
130
+ # @return [Disposita::Configuration] immutable typed configuration.
131
+ # @raise [Disposita::ValidationError] if a value cannot be validated.
132
+ # @raise [Disposita::UnknownSettingError] if a source contains undeclared
133
+ # settings.
134
+ # @raise [Disposita::VersionError] if persisted data declares a newer schema
135
+ # version than this runtime understands.
136
+ # @example
137
+ # config = schema.load(
138
+ # sources: [project_source],
139
+ # env: ENV,
140
+ # env_prefix: "MY_APP",
141
+ # overrides: { server: { port: 9292 } }
142
+ # )
143
+ def load(sources: [], env: nil, env_prefix: nil, overrides: nil)
144
+ effective_sources = Array(sources).dup
145
+ effective_sources << Sources::Environment.new(env: env, prefix: env_prefix) if env
146
+ effective_sources << Sources::Hash.new(overrides, name: :runtime) if overrides
147
+ resolve(effective_sources)
148
+ end
149
+
150
+ # Resolves an explicit ordered list of sources.
151
+ #
152
+ # Defaults are applied first. Each source then contributes a partial layer;
153
+ # hashes are deep-merged, while arrays and scalar values replace lower
154
+ # precedence values. The final representation is coerced and validated only
155
+ # after all layers have been combined.
156
+ #
157
+ # @param sources [Array<Disposita::Source>, Disposita::Source] source or
158
+ # ordered sources from lowest to highest precedence.
159
+ # @return [Disposita::Configuration] immutable resolved configuration with
160
+ # provenance information for each selected value.
161
+ # @raise [Disposita::MissingSettingError] when a required setting remains
162
+ # absent after all layers are resolved.
163
+ # @raise [Disposita::CoercionError] when a value cannot be coerced to its
164
+ # declared type.
165
+ # @raise [Disposita::UnknownSettingError] when input contains an undeclared
166
+ # setting.
167
+ # @raise [Disposita::VersionError] when a source is newer than the schema.
168
+ def resolve(sources)
169
+ data = defaults
170
+ provenance = default_provenance
171
+
172
+ Array(sources).each do |source|
173
+ raw = source.read(self)
174
+ validate_version!(raw)
175
+ normalized = normalize_layer(raw)
176
+ reject_unknown!(normalized)
177
+ data = Internal::DeepMerge.call(data, normalized)
178
+ mark_provenance!(provenance, normalized, source.name)
179
+ end
180
+
181
+ resolved = coerce_and_validate(data)
182
+ Configuration.new(schema: self, data: resolved, provenance: provenance)
183
+ end
184
+
185
+ # Validates and writes explicit partial data to one writable source.
186
+ #
187
+ # Writes deliberately do not serialize a fully resolved Configuration: ENV,
188
+ # runtime overrides and defaults could otherwise leak into an unrelated
189
+ # file. Callers must choose both the target source and the values to persist.
190
+ # The current schema version is added automatically.
191
+ #
192
+ # @param source [Disposita::Source] explicit writable target.
193
+ # @param data [Hash] partial configuration values to persist.
194
+ # @return [Object] whatever the source returns from +#write+; file sources
195
+ # return their path.
196
+ # @raise [Disposita::SaveError] if the source is read-only or persistence
197
+ # fails.
198
+ # @raise [Disposita::ValidationError] if supplied values are invalid.
199
+ # @raise [Disposita::UnsafeSecretPersistenceError] if the target source
200
+ # refuses secret values.
201
+ # @example
202
+ # file = Disposita::Sources::File.new(".app.yml", name: :project)
203
+ # schema.write(file, server: { port: 9292 })
204
+ def write(source, data)
205
+ raise SaveError, "source #{source.name.inspect} is read-only" unless source.writable?
206
+
207
+ normalized = Internal::HashTools.symbolize(data)
208
+ reject_unknown!(normalized)
209
+ validated = validate_partial(normalized)
210
+ payload = { version: version }
211
+ payload = Internal::DeepMerge.call(payload, validated)
212
+ source.write(self, payload)
213
+ end
214
+
215
+ private
216
+
217
+ def normalize_path(path)
218
+ case path
219
+ when String then path.split(".").map!(&:to_sym)
220
+ when Array then path.map(&:to_sym)
221
+ else Array(path).map!(&:to_sym)
222
+ end
223
+ end
224
+
225
+ def defaults
226
+ settings.each_with_object({}) do |setting, result|
227
+ next unless setting.default?
228
+
229
+ Internal::HashTools.set(result, setting.path, Internal::HashTools.deep_dup(setting.default))
230
+ end
231
+ end
232
+
233
+ def default_provenance
234
+ settings.each_with_object({}) do |setting, result|
235
+ result[setting.path] = :default if setting.default?
236
+ end
237
+ end
238
+
239
+ def normalize_layer(raw)
240
+ raw = Internal::HashTools.symbolize(raw)
241
+ raw.except(:version)
242
+ end
243
+
244
+ def validate_version!(raw)
245
+ raw = Internal::HashTools.symbolize(raw)
246
+ persisted = raw[:version]
247
+ return unless persisted
248
+
249
+ persisted = Integer(persisted)
250
+ return if persisted <= version
251
+
252
+ raise VersionError, "configuration version #{persisted} is newer than schema version #{version}"
253
+ rescue ArgumentError, TypeError
254
+ raise VersionError, "configuration version must be an integer"
255
+ end
256
+
257
+ def reject_unknown!(data)
258
+ known = settings.map(&:path)
259
+ Internal::HashTools.flatten_keys(data).each do |path|
260
+ next if known.include?(path)
261
+
262
+ raise UnknownSettingError, "unknown setting: #{path.join('.')}"
263
+ end
264
+ end
265
+
266
+ def mark_provenance!(provenance, layer, source_name)
267
+ Internal::HashTools.flatten_keys(layer).each { |path| provenance[path] = source_name }
268
+ end
269
+
270
+ def coerce_and_validate(data)
271
+ settings.each_with_object({}) do |setting, result|
272
+ value = Internal::HashTools.get(data, setting.path)
273
+ if value.equal?(Internal::UNDEFINED)
274
+ raise MissingSettingError, "missing required setting: #{setting.key}" if setting.required?
275
+
276
+ next
277
+ end
278
+
279
+ Internal::HashTools.set(result, setting.path, validate_value(setting, value))
280
+ end
281
+ end
282
+
283
+ def validate_partial(data)
284
+ settings.each_with_object({}) do |setting, result|
285
+ value = Internal::HashTools.get(data, setting.path)
286
+ next if value.equal?(Internal::UNDEFINED)
287
+
288
+ Internal::HashTools.set(result, setting.path, validate_value(setting, value))
289
+ end
290
+ end
291
+
292
+ def coerce_value(setting, value)
293
+ return value if Internal::TypeAdapter.valid?(setting.type, value)
294
+ return Internal::TypeAdapter.coerce(setting.type, value) if setting.coerce?
295
+
296
+ expected = Internal::TypeAdapter.describe(setting.type)
297
+ raise ValidationError, "#{setting.key} expected #{expected}, got #{value.class}"
298
+ end
299
+
300
+ def validate_value(setting, value)
301
+ resolved = coerce_value(setting, value)
302
+
303
+ if setting.validator && !setting.validator.call(resolved)
304
+ raise ValidationError, "validation failed for #{setting.key}"
305
+ end
306
+
307
+ resolved
308
+ rescue CoercionError => e
309
+ raise CoercionError, "#{setting.key}: #{e.message}"
310
+ end
311
+ end
312
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Disposita
4
+ # Base contract for one configuration source/layer.
5
+ #
6
+ # A source contributes partial raw configuration data to a Schema. Sources are
7
+ # intentionally small: they know how to read one layer and, optionally, how
8
+ # to persist explicit data. Ordering and precedence remain the responsibility
9
+ # of {Disposita::Schema#resolve}.
10
+ #
11
+ # Custom integrations can subclass Source to read from another system without
12
+ # teaching Disposita about that system. A remote secret manager, database or
13
+ # organization-specific store can therefore participate like any built-in
14
+ # source.
15
+ #
16
+ # @example Implement a read-only custom source
17
+ # class DatabaseSource < Disposita::Source
18
+ # def read(_schema)
19
+ # { feature: { enabled: true } }
20
+ # end
21
+ # end
22
+ #
23
+ # source = DatabaseSource.new(name: :database)
24
+ # config = schema.resolve([source])
25
+ #
26
+ # @see Disposita::Sources::Hash
27
+ # @see Disposita::Sources::Environment
28
+ # @see Disposita::Sources::File
29
+ class Source
30
+ # @return [Symbol] stable name used for provenance diagnostics.
31
+ attr_reader :name
32
+
33
+ # @param name [String, Symbol] source name reported by
34
+ # {Disposita::Configuration#source_of}.
35
+ def initialize(name:)
36
+ @name = name.to_sym
37
+ end
38
+
39
+ # Reads this source's partial configuration layer.
40
+ #
41
+ # Subclasses must implement this method and should return only raw scalar,
42
+ # Array and Hash values. Schema coercion and validation happen later.
43
+ #
44
+ # @param _schema [Disposita::Schema] schema being resolved; custom sources
45
+ # may use it for introspection.
46
+ # @return [Hash] partial configuration data.
47
+ # @raise [NotImplementedError] in the base implementation.
48
+ def read(_schema)
49
+ raise NotImplementedError
50
+ end
51
+
52
+ # Indicates whether {#write} is supported.
53
+ #
54
+ # @return [Boolean] +false+ by default.
55
+ def writable? = false
56
+
57
+ # Indicates whether this source permits settings marked +secret: true+.
58
+ #
59
+ # Sources should default to the safer behavior and opt in only when their
60
+ # storage characteristics are appropriate for secret material.
61
+ #
62
+ # @return [Boolean] +false+ by default.
63
+ def allows_secrets? = false
64
+
65
+ # Persists explicit validated data to this source.
66
+ #
67
+ # Subclasses that return +true+ from {#writable?} must implement this method.
68
+ # Resolved configuration is never written implicitly; callers choose the
69
+ # target source and payload through {Disposita::Schema#write}.
70
+ #
71
+ # @param _schema [Disposita::Schema] schema performing the write.
72
+ # @param _data [Hash] validated partial payload including schema version.
73
+ # @return [Object]
74
+ # @raise [Disposita::SaveError] in the base implementation.
75
+ def write(_schema, _data)
76
+ raise SaveError, "source #{name.inspect} is read-only"
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Disposita
4
+ module Sources
5
+ # Read-only source that maps environment variables to declared settings.
6
+ #
7
+ # Per-setting +env:+ names take precedence. When a prefix is supplied,
8
+ # settings without an explicit name derive one from their full schema path;
9
+ # for example +server.port+ with prefix +MY_APP+ becomes
10
+ # +MY_APP_SERVER_PORT+.
11
+ #
12
+ # Environment values remain raw strings here. Type coercion belongs to the
13
+ # Schema so every source follows the same validation rules.
14
+ #
15
+ # @example
16
+ # source = Disposita::Sources::Environment.new(
17
+ # env: { "APP_SERVER_PORT" => "9292" },
18
+ # prefix: "APP"
19
+ # )
20
+ # schema.resolve([source]).server.port # => 9292
21
+ class Environment < Source
22
+ # @param env [Hash] environment-like mapping; defaults to the process ENV.
23
+ # Pass a Hash to isolate resolution from the process environment.
24
+ # @param prefix [String, nil] optional generated-variable prefix.
25
+ # @param name [String, Symbol] provenance name.
26
+ def initialize(env: ENV, prefix: nil, name: :environment)
27
+ super(name: name)
28
+ @env = env
29
+ @prefix = prefix
30
+ end
31
+
32
+ # Builds a partial configuration layer from variables present in +env+.
33
+ #
34
+ # @param schema [Disposita::Schema] schema whose setting metadata is used
35
+ # to determine variable names.
36
+ # @return [Hash] nested raw configuration values.
37
+ def read(schema)
38
+ schema.settings.each_with_object({}) do |setting, data|
39
+ env_name = setting.env || generated_name(setting)
40
+ next unless env_name && @env.key?(env_name)
41
+
42
+ Internal::HashTools.set(data, setting.path, @env.fetch(env_name))
43
+ end
44
+ end
45
+
46
+ private
47
+
48
+ def generated_name(setting)
49
+ return unless @prefix
50
+
51
+ ([@prefix] + setting.path).join("_").upcase
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "tempfile"
5
+
6
+ module Disposita
7
+ # Built-in layers for files, environment variables and in-memory values.
8
+ # Pass sources to Schema#resolve in increasing order of precedence.
9
+ module Sources
10
+ # File-backed configuration source with atomic persistence.
11
+ #
12
+ # YAML is the default format, but any codec implementing +load(String)+ and
13
+ # +dump(Hash)+ can be supplied. Missing files behave as empty layers, which
14
+ # makes optional global/project configuration straightforward.
15
+ #
16
+ # Secret persistence is denied by default because project configuration is
17
+ # frequently committed to source control. A caller may explicitly opt in
18
+ # with +allow_secrets: true+ for a trusted local target; such files are
19
+ # written with mode +0600+ where supported. This is a persistence policy,
20
+ # not encryption.
21
+ #
22
+ # Writes use a temporary file in the destination directory and rename it
23
+ # into place after flushing and fsyncing, reducing the chance of partially
24
+ # written configuration.
25
+ #
26
+ # @example Read and write a project file
27
+ # source = Disposita::Sources::File.new(".app.yml", name: :project)
28
+ # config = schema.resolve([source])
29
+ # schema.write(source, server: { port: 9292 })
30
+ class File < Source
31
+ # @return [String] expanded backing file path.
32
+ attr_reader :path
33
+
34
+ # @param path [String] configuration file path.
35
+ # @param name [String, Symbol] provenance name.
36
+ # @param format [Object] codec implementing +load+ and +dump+.
37
+ # @param allow_secrets [Boolean] whether secret settings may be written.
38
+ def initialize(path, name: :file, format: Formats::YAML, allow_secrets: false)
39
+ super(name: name)
40
+ @path = ::File.expand_path(path)
41
+ @format = format
42
+ @allow_secrets = allow_secrets
43
+ end
44
+
45
+ # Reads and decodes the file.
46
+ #
47
+ # @param _schema [Disposita::Schema] unused by the built-in file source.
48
+ # @return [Hash] normalized partial configuration; empty if file is absent.
49
+ # @raise [Disposita::ParseError] when the configured codec rejects content.
50
+ # @raise [Disposita::LoadError] when the file cannot be read.
51
+ def read(_schema)
52
+ return {} unless ::File.exist?(path)
53
+
54
+ Internal::HashTools.symbolize(@format.load(::File.read(path)))
55
+ rescue ParseError
56
+ raise
57
+ rescue SystemCallError => e
58
+ raise LoadError, "cannot read #{path}: #{e.message}"
59
+ end
60
+
61
+ # @return [Boolean] always +true+ for a File source.
62
+ def writable? = true
63
+
64
+ # @return [Boolean] whether secret settings were explicitly allowed.
65
+ def allows_secrets? = @allow_secrets
66
+
67
+ # Atomically persists validated configuration data.
68
+ #
69
+ # Prefer calling {Disposita::Schema#write}; it validates types, unknown
70
+ # keys and schema version before delegating here.
71
+ #
72
+ # @param schema [Disposita::Schema] schema used to identify secret fields.
73
+ # @param data [Hash] validated payload.
74
+ # @return [String] backing file path.
75
+ # @raise [Disposita::UnsafeSecretPersistenceError] when secret data is
76
+ # present and +allow_secrets+ is false.
77
+ # @raise [Disposita::SaveError] when filesystem persistence fails.
78
+ def write(schema, data)
79
+ reject_secrets!(schema, data) unless allows_secrets?
80
+ replace_file(@format.dump(data))
81
+ path
82
+ rescue UnsafeSecretPersistenceError
83
+ raise
84
+ rescue SystemCallError => e
85
+ raise SaveError, "cannot write #{path}: #{e.message}"
86
+ end
87
+
88
+ private
89
+
90
+ def replace_file(content)
91
+ directory = ::File.dirname(path)
92
+ FileUtils.mkdir_p(directory)
93
+ Tempfile.create([".disposita", ".tmp"], directory) do |temporary|
94
+ temporary.write(content)
95
+ temporary.flush
96
+ temporary.fsync
97
+ ::File.chmod(0o600, temporary.path) if allows_secrets?
98
+ ::File.rename(temporary.path, path)
99
+ end
100
+ end
101
+
102
+ def reject_secrets!(schema, data)
103
+ schema.settings.select(&:secret?).each do |setting|
104
+ next if Internal::HashTools.get(data, setting.path).equal?(Internal::UNDEFINED)
105
+
106
+ raise UnsafeSecretPersistenceError,
107
+ "refusing to persist secret setting #{setting.key.inspect} to #{path}"
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Disposita
4
+ module Sources
5
+ # Simple in-memory source for runtime overrides, tests and adapters.
6
+ #
7
+ # Keys are normalized to Symbols recursively when the source is created.
8
+ # Because the source is read-only, it is ideal for command-line arguments or
9
+ # values supplied programmatically at the highest-precedence layer.
10
+ #
11
+ # @example
12
+ # source = Disposita::Sources::Hash.new(
13
+ # { server: { port: 9292 } },
14
+ # name: :runtime
15
+ # )
16
+ class Hash < Source
17
+ # @param data [Hash] partial configuration layer.
18
+ # @param name [String, Symbol] provenance name.
19
+ def initialize(data, name: :memory)
20
+ super(name: name)
21
+ @data = Internal::HashTools.symbolize(data)
22
+ end
23
+
24
+ # @param _schema [Disposita::Schema] unused; included for Source contract.
25
+ # @return [Hash] normalized in-memory layer.
26
+ def read(_schema) = @data
27
+ end
28
+ end
29
+ end