Skip to content

Commit 488ba55

Browse files
authored
feat: Builder API for Sections and Entities (#253)
1 parent 75eb6e6 commit 488ba55

File tree

13 files changed

+2217
-7
lines changed

13 files changed

+2217
-7
lines changed
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
<!--
2+
SPDX-FileCopyrightText: 2025 spark contributors <https://github.com/ash-project/spark/graphs.contributors>
3+
4+
SPDX-License-Identifier: MIT
5+
-->
6+
7+
# Building Extensions with the Builder API
8+
9+
This guide shows how to use the builder modules to define a DSL extension
10+
programmatically. This is useful when you want to generate similar DSLs,
11+
share schema fragments, or keep DSL construction in code instead of raw structs.
12+
13+
## Example: notifications extension
14+
15+
Define a small DSL with a single `notifications` section and a `notification`
16+
entity. The schema uses `Field.new/3` with `(name, type, opts)` for types, defaults, docs, and
17+
nested keys.
18+
19+
### Inline approach
20+
21+
For simple extensions, inline the builders directly in the `use` statement:
22+
23+
```elixir
24+
defmodule MyApp.Notifications.Notification do
25+
defstruct [:name, :type, :target, :metadata, :__identifier__, :__spark_metadata__]
26+
end
27+
28+
defmodule MyApp.Notifications.Dsl do
29+
alias Spark.Builder.{Entity, Field, Section}
30+
31+
use Spark.Dsl.Extension,
32+
sections: [
33+
Section.new(:notifications,
34+
describe: "Notification configuration",
35+
entities: [
36+
Entity.new(:notification, MyApp.Notifications.Notification,
37+
describe: "Defines a notification delivery",
38+
args: [:name, :type],
39+
schema: [
40+
Field.new(:name, :atom, required: true, doc: "Notification name"),
41+
Field.new(:type, {:one_of, [:email, :slack]},
42+
required: true,
43+
doc: "Notification type"
44+
),
45+
Field.new(:target, :string, doc: "Delivery target"),
46+
Field.new(:metadata, :keyword_list,
47+
keys: [
48+
priority: [type: :integer, default: 0, doc: "Priority level"]
49+
],
50+
doc: "Optional metadata"
51+
)
52+
],
53+
identifier: :name
54+
)
55+
|> Entity.build!()
56+
]
57+
)
58+
|> Section.build!()
59+
]
60+
61+
use Spark.Dsl, default_extensions: [extensions: __MODULE__]
62+
end
63+
```
64+
65+
### Helper module approach
66+
67+
In more complex cases, consider extracting builders into a separate module. This keeps
68+
the DSL module clean and makes builders reusable:
69+
70+
```elixir
71+
defmodule MyApp.Notifications.Notification do
72+
defstruct [:name, :type, :target, :metadata, :__identifier__, :__spark_metadata__]
73+
end
74+
75+
defmodule MyApp.Notifications.Dsl.Builder do
76+
alias Spark.Builder.{Entity, Field, Section}
77+
78+
def notification_entity do
79+
Entity.new(:notification, MyApp.Notifications.Notification,
80+
describe: "Defines a notification delivery",
81+
args: [:name, :type],
82+
schema: [
83+
Field.new(:name, :atom, required: true, doc: "Notification name"),
84+
Field.new(:type, {:one_of, [:email, :slack]},
85+
required: true,
86+
doc: "Notification type"
87+
),
88+
Field.new(:target, :string, doc: "Delivery target"),
89+
Field.new(:metadata, :keyword_list,
90+
keys: [
91+
priority: [type: :integer, default: 0, doc: "Priority level"]
92+
],
93+
doc: "Optional metadata"
94+
)
95+
],
96+
identifier: :name
97+
)
98+
|> Entity.build!()
99+
end
100+
101+
def notifications_section do
102+
Section.new(:notifications,
103+
describe: "Notification configuration",
104+
entities: [notification_entity()]
105+
)
106+
|> Section.build!()
107+
end
108+
end
109+
110+
defmodule MyApp.Notifications.Dsl do
111+
alias MyApp.Notifications.Dsl.Builder
112+
113+
use Spark.Dsl.Extension, sections: [Builder.notifications_section()]
114+
use Spark.Dsl, default_extensions: [extensions: __MODULE__]
115+
end
116+
```
117+
118+
## Using the DSL
119+
120+
```elixir
121+
defmodule MyApp.Config do
122+
use MyApp.Notifications.Dsl
123+
124+
notifications do
125+
notification :ops, :email do
126+
target "ops@example.com"
127+
metadata priority: 1
128+
end
129+
end
130+
end
131+
```
132+
133+
## Introspection helpers
134+
135+
You can expose a small info module that wraps `Spark.Dsl.Extension` helpers.
136+
137+
```elixir
138+
defmodule MyApp.Notifications.Info do
139+
def notifications(module) do
140+
Spark.Dsl.Extension.get_entities(module, [:notifications])
141+
end
142+
end
143+
```
144+
145+
## Notes
146+
147+
- Types are passed as atoms or tuples (for example `{:one_of, [:email, :slack]}`).
148+
- Use `Field.new/3` with `(name, type, opts)` to set `:required`, `:default`, `:keys`, and docs.
149+
- The builder modules are `Spark.Builder.Field`, `Spark.Builder.Entity`, and
150+
`Spark.Builder.Section`.

documentation/how_to/writing-extensions.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ Writing extensions generally involves three main components.
1313

1414
The DSL is declared as a series of `Spark.Dsl.Section`, which can contain `Spark.Dsl.Entity` and further `Spark.Dsl.Section` structs. See `Spark.Dsl.Section` and `Spark.Dsl.Entity` for more information.
1515

16+
If you want to build those structs programmatically, see [Building Extensions with the Builder API](build-extensions-with-builders.md).
17+
1618
## Transformers
1719

1820
Extension writing gets a bit more complicated when you get into the world of transformers, but this is also where a lot of the power is. Each transformer can declare other transformers it must go before or after, and then is given the opportunity to modify the entirety of the DSL it is extending up to that point. This allows extensions to make rich modifications to the structure in question. See `Spark.Dsl.Transformer` for more information

0 commit comments

Comments
 (0)