Skip to content

Commit b3f9c99

Browse files
committed
refactor: improve nested path resolution and add boolean schema support
The settings loader now handles nested environment variables more robustly, addressing cases where underscores are used as delimiters for fields that contain underscores. A new prefix-based matching strategy in the decoder accurately resolves these paths by sorting fields by length. The JSON schema resolver is updated to support boolean schemas. This allows true or false to be used as definitions and ensures they are resolved correctly. When merging metadata into a boolean schema, the resolver now wraps the elements in an allOf structure to preserve schema validity. Further changes include: - Addition of a comprehensive settings_loader.exs example. - Validation that the :input option in Settings.load/2 is a map. - Graceful handling of non-map values during definition extraction. - New tests for boolean schema resolution and underscore delimiters. - Support for both definitions and $defs in the reference resolver.
1 parent 62a3123 commit b3f9c99

8 files changed

Lines changed: 376 additions & 30 deletions

File tree

examples/README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ bash examples/run_all.sh --fail-fast
6767
| `readme_examples.exs` | Verifies README snippets by executing them as a consistency check | `mix run` |
6868
| `root_schema.exs` | Root-level validation for non-map data (arrays, unions, primitives) | `mix run` |
6969
| `runtime_schema.exs` | Dynamic schema creation and validation at runtime | `elixir` |
70+
| `settings_loader.exs` | End-to-end environment settings loading: prefixes, overrides, nested exploded keys, and error handling | `mix run` |
7071
| `type_adapter.exs` | TypeAdapter-based runtime validation, coercion, dumping, and batch use | `elixir` |
7172
| `wrapper_models.exs` | Wrapper model patterns for single-field validation and reuse | `elixir` |
7273

@@ -89,7 +90,8 @@ bash examples/run_all.sh --fail-fast
8990
15. `field_metadata_dspy.exs`
9091
16. `conditional_recursive_validation.exs`
9192
17. `advanced_config.exs`
92-
18. `readme_examples.exs`
93+
18. `settings_loader.exs`
94+
19. `readme_examples.exs`
9395

9496
## Notes
9597

examples/settings_loader.exs

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
# Comprehensive Exdantic.Settings example
2+
# Run with: mix run examples/settings_loader.exs
3+
4+
Mix.Task.run("compile")
5+
6+
defmodule SettingsLoaderExample do
7+
alias Exdantic.Settings
8+
9+
defmodule DatabaseConfig do
10+
use Exdantic
11+
12+
schema do
13+
field(:host, :string, required: true)
14+
field(:pool_size, :integer, default: 5)
15+
end
16+
end
17+
18+
defmodule AppConfig do
19+
use Exdantic, define_struct: true
20+
21+
schema "Settings schema for a sample service" do
22+
field(:name, :string, default: "exdantic-service")
23+
field(:port, :integer, default: 4000)
24+
field(:enabled, :boolean, default: true)
25+
field(:mode, :atom, default: :safe)
26+
field(:database, DatabaseConfig, required: true)
27+
field(:tags, {:array, :string}, default: [])
28+
field(:database_url, :string, required: true, extra: %{"env" => "DATABASE_URL"})
29+
end
30+
end
31+
32+
def run do
33+
IO.puts("=== Exdantic.Settings Comprehensive Example ===")
34+
IO.puts("")
35+
36+
successful_load_demo()
37+
input_precedence_demo()
38+
empty_string_behavior_demo()
39+
case_collision_demo()
40+
invalid_json_demo()
41+
from_system_env_demo()
42+
end
43+
44+
defp successful_load_demo do
45+
IO.puts("1) Successful load with prefixing, nested merge, and absolute override")
46+
env = baseline_env()
47+
48+
{:ok, settings} =
49+
Settings.load(AppConfig,
50+
env: env,
51+
env_prefix: "APP_",
52+
env_nested_delimiter: "__",
53+
allow_atoms: :existing,
54+
ignore_empty: true
55+
)
56+
57+
IO.inspect(settings, label: "Loaded settings")
58+
IO.puts("Derived key APP_DATABASE_URL is ignored because DATABASE_URL override wins.")
59+
IO.puts("")
60+
end
61+
62+
defp input_precedence_demo do
63+
IO.puts("2) Explicit input has higher precedence than env values")
64+
env = baseline_env()
65+
66+
{:ok, settings} =
67+
Settings.load(AppConfig,
68+
env: env,
69+
env_prefix: "APP_",
70+
allow_atoms: :existing,
71+
input: %{port: 4200, database: %{pool_size: 20}}
72+
)
73+
74+
IO.inspect(settings, label: "Merged settings")
75+
IO.puts("")
76+
end
77+
78+
defp empty_string_behavior_demo do
79+
IO.puts("3) ignore_empty controls whether empty strings are treated as absent")
80+
81+
env =
82+
baseline_env()
83+
|> Map.put("APP_NAME", "")
84+
85+
{:ok, ignored_empty} =
86+
Settings.load(AppConfig,
87+
env: env,
88+
env_prefix: "APP_",
89+
allow_atoms: :existing,
90+
ignore_empty: true
91+
)
92+
93+
{:ok, kept_empty} =
94+
Settings.load(AppConfig,
95+
env: env,
96+
env_prefix: "APP_",
97+
allow_atoms: :existing,
98+
ignore_empty: false
99+
)
100+
101+
IO.inspect(ignored_empty.name, label: "ignore_empty: true")
102+
IO.inspect(kept_empty.name, label: "ignore_empty: false")
103+
IO.puts("")
104+
end
105+
106+
defp case_collision_demo do
107+
IO.puts("4) Case-insensitive collisions are rejected")
108+
109+
env =
110+
baseline_env()
111+
|> Map.merge(%{"app_port" => "1111", "APP_PORT" => "2222"})
112+
113+
case Settings.load(AppConfig, env: env, env_prefix: "APP_", allow_atoms: :existing) do
114+
{:ok, _} ->
115+
IO.puts("Unexpected success")
116+
117+
{:error, errors} ->
118+
IO.inspect(errors, label: "Collision errors")
119+
end
120+
121+
IO.puts("")
122+
end
123+
124+
defp invalid_json_demo do
125+
IO.puts("5) Structured fields require JSON values")
126+
127+
env =
128+
baseline_env()
129+
|> Map.put("APP_TAGS", "not-json")
130+
131+
case Settings.load(AppConfig, env: env, env_prefix: "APP_", allow_atoms: :existing) do
132+
{:ok, _} ->
133+
IO.puts("Unexpected success")
134+
135+
{:error, errors} ->
136+
IO.inspect(errors, label: "JSON decode errors")
137+
end
138+
139+
IO.puts("")
140+
end
141+
142+
defp from_system_env_demo do
143+
IO.puts("6) from_system_env/2 usage")
144+
145+
env_vars = %{
146+
"APP_DATABASE" => ~s({"host":"db.system","pool_size":7}),
147+
"APP_PORT" => "4300",
148+
"APP_MODE" => "safe",
149+
"DATABASE_URL" => "ecto://system@localhost/system_db"
150+
}
151+
152+
previous = Enum.into(env_vars, %{}, fn {k, _} -> {k, System.get_env(k)} end)
153+
154+
Enum.each(env_vars, fn {k, v} -> System.put_env(k, v) end)
155+
156+
try do
157+
{:ok, settings} =
158+
Settings.from_system_env(AppConfig,
159+
env_prefix: "APP_",
160+
allow_atoms: :existing
161+
)
162+
163+
IO.inspect(settings, label: "System env settings")
164+
after
165+
Enum.each(previous, fn
166+
{k, nil} -> System.delete_env(k)
167+
{k, v} -> System.put_env(k, v)
168+
end)
169+
end
170+
171+
IO.puts("")
172+
end
173+
174+
defp baseline_env do
175+
%{
176+
"APP_PORT" => "4100",
177+
"APP_ENABLED" => "0",
178+
"APP_MODE" => "safe",
179+
"APP_DATABASE" => ~s({"host":"db.local","pool_size":5}),
180+
"APP_DATABASE__POOL_SIZE" => "12",
181+
"APP_TAGS" => ~s(["settings","example"]),
182+
"APP_DATABASE_URL" => "ecto://derived@localhost/from_prefix",
183+
"DATABASE_URL" => "ecto://override@localhost/main_db"
184+
}
185+
end
186+
end
187+
188+
SettingsLoaderExample.run()

lib/exdantic/json_schema/resolver.ex

Lines changed: 55 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ defmodule Exdantic.JsonSchema.Resolver do
77
LLM providers (OpenAI, Anthropic, etc.).
88
"""
99

10-
@type schema :: map()
10+
@type schema :: map() | boolean()
1111
@type resolver_context :: %{
1212
definitions: %{optional(String.t()) => schema()},
1313
max_depth: non_neg_integer(),
@@ -56,7 +56,10 @@ defmodule Exdantic.JsonSchema.Resolver do
5656
}
5757
"""
5858
@spec resolve_references(schema(), resolution_options()) :: schema()
59-
def resolve_references(schema, opts \\ []) do
59+
def resolve_references(schema, opts \\ [])
60+
def resolve_references(schema, _opts) when is_boolean(schema), do: schema
61+
62+
def resolve_references(schema, opts) when is_map(schema) do
6063
max_depth = Keyword.get(opts, :max_depth, 10)
6164
preserve_titles = Keyword.get(opts, :preserve_titles, true)
6265
preserve_descriptions = Keyword.get(opts, :preserve_descriptions, true)
@@ -118,7 +121,10 @@ defmodule Exdantic.JsonSchema.Resolver do
118121
}
119122
"""
120123
@spec flatten_schema(schema(), keyword()) :: schema()
121-
def flatten_schema(schema, opts \\ []) do
124+
def flatten_schema(schema, opts \\ [])
125+
def flatten_schema(schema, _opts) when is_boolean(schema), do: schema
126+
127+
def flatten_schema(schema, opts) when is_map(schema) do
122128
max_depth = Keyword.get(opts, :max_depth, 5)
123129
inline_simple_refs = Keyword.get(opts, :inline_simple_refs, true)
124130
preserve_complex_refs = Keyword.get(opts, :preserve_complex_refs, false)
@@ -206,10 +212,11 @@ defmodule Exdantic.JsonSchema.Resolver do
206212

207213
# Private helper functions
208214

209-
@spec extract_definitions(schema()) :: map()
210-
defp extract_definitions(schema) do
211-
Map.get(schema, "definitions", %{})
212-
|> Map.merge(Map.get(schema, "$defs", %{}))
215+
@spec extract_definitions(map()) :: map()
216+
defp extract_definitions(schema) when is_map(schema) do
217+
definitions = schema |> Map.get("definitions", %{}) |> ensure_map()
218+
defs = schema |> Map.get("$defs", %{}) |> ensure_map()
219+
Map.merge(definitions, defs)
213220
end
214221

215222
@spec resolve_schema_part(schema(), resolver_context(), non_neg_integer()) :: schema()
@@ -289,7 +296,7 @@ defmodule Exdantic.JsonSchema.Resolver do
289296
nil ->
290297
{:error, "Definition not found: #{def_name}"}
291298

292-
definition when is_map(definition) ->
299+
definition when is_map(definition) or is_boolean(definition) ->
293300
if Map.has_key?(context.visited, def_name) do
294301
{:error, "Circular reference detected: #{def_name}"}
295302
else
@@ -311,12 +318,45 @@ defmodule Exdantic.JsonSchema.Resolver do
311318
{:error, "Unsupported reference format: #{ref}"}
312319
end
313320

314-
defp merge_schema_properties(resolved, additional_props, context) do
321+
defp merge_schema_properties(resolved, additional_props, context) when is_map(resolved) do
315322
resolved
316323
|> merge_if_preserve_titles(additional_props, context)
317324
|> merge_if_preserve_descriptions(additional_props, context)
318325
end
319326

327+
defp merge_schema_properties(resolved, additional_props, context) do
328+
metadata = build_preserved_metadata(additional_props, context)
329+
330+
if map_size(metadata) == 0 do
331+
resolved
332+
else
333+
%{"allOf" => [resolved, metadata]}
334+
end
335+
end
336+
337+
defp build_preserved_metadata(additional_props, context) do
338+
metadata = %{}
339+
340+
metadata =
341+
if context.preserve_titles do
342+
case Map.fetch(additional_props, "title") do
343+
{:ok, title} -> Map.put(metadata, "title", title)
344+
:error -> metadata
345+
end
346+
else
347+
metadata
348+
end
349+
350+
if context.preserve_descriptions do
351+
case Map.fetch(additional_props, "description") do
352+
{:ok, description} -> Map.put(metadata, "description", description)
353+
:error -> metadata
354+
end
355+
else
356+
metadata
357+
end
358+
end
359+
320360
defp merge_if_preserve_titles(schema, additional_props, context) do
321361
if context.preserve_titles and Map.has_key?(additional_props, "title") do
322362
Map.put(schema, "title", additional_props["title"])
@@ -334,12 +374,17 @@ defmodule Exdantic.JsonSchema.Resolver do
334374
end
335375

336376
@spec remove_definitions(schema()) :: schema()
337-
defp remove_definitions(schema) do
377+
defp remove_definitions(schema) when is_boolean(schema), do: schema
378+
379+
defp remove_definitions(schema) when is_map(schema) do
338380
schema
339381
|> Map.delete("definitions")
340382
|> Map.delete("$defs")
341383
end
342384

385+
defp ensure_map(value) when is_map(value), do: value
386+
defp ensure_map(_value), do: %{}
387+
343388
@spec inline_simple_types(schema(), boolean()) :: schema()
344389
defp inline_simple_types(
345390
%{"type" => "object", "properties" => properties} = schema,

lib/exdantic/settings.ex

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,15 +34,14 @@ defmodule Exdantic.Settings do
3434
{:ok, map() | struct()} | {:error, [Exdantic.Error.t()]}
3535
def load(schema_module, opts \\ []) when is_atom(schema_module) do
3636
with :ok <- ensure_schema_module(schema_module),
37+
{:ok, input} <- resolve_input(opts),
3738
{:ok, env_map} <-
3839
Env.normalize_env_map(
3940
resolve_env(opts),
4041
Keyword.get(opts, :case_sensitive, false)
4142
),
4243
{:ok, env_values} <-
4344
Loader.load_env_values(schema_module, env_map, opts) do
44-
input = Keyword.get(opts, :input, %{})
45-
4645
merged =
4746
env_values
4847
|> DeepMerge.deep_merge(input)
@@ -73,6 +72,23 @@ defmodule Exdantic.Settings do
7372
end
7473
end
7574

75+
defp resolve_input(opts) do
76+
case Keyword.get(opts, :input, %{}) do
77+
input when is_map(input) ->
78+
{:ok, input}
79+
80+
other ->
81+
{:error,
82+
[
83+
Exdantic.Error.new(
84+
[],
85+
:type,
86+
"expected :input to be a map, got #{inspect(other)}"
87+
)
88+
]}
89+
end
90+
end
91+
7692
defp ensure_schema_module(schema_module) do
7793
if Code.ensure_loaded?(schema_module) and function_exported?(schema_module, :__schema__, 1) do
7894
:ok

0 commit comments

Comments
 (0)