Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
150 changes: 150 additions & 0 deletions documentation/how_to/build-extensions-with-builders.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
<!--
SPDX-FileCopyrightText: 2025 spark contributors <https://github.com/ash-project/spark/graphs.contributors>

SPDX-License-Identifier: MIT
-->

# Building Extensions with the Builder API

This guide shows how to use the builder modules to define a DSL extension
programmatically. This is useful when you want to generate similar DSLs,
share schema fragments, or keep DSL construction in code instead of raw structs.

## Example: notifications extension

Define a small DSL with a single `notifications` section and a `notification`
entity. The schema uses `Field.new/2` options for types, defaults, docs, and
nested keys.

### Inline approach

For simple extensions, inline the builders directly in the `use` statement:

```elixir
defmodule MyApp.Notifications.Notification do
defstruct [:name, :type, :target, :metadata, :__identifier__, :__spark_metadata__]
end

defmodule MyApp.Notifications.Dsl do
alias Spark.Builder.{Entity, Field, Section}

use Spark.Dsl.Extension,
sections: [
Section.new(:notifications)
|> Section.describe("Notification configuration")
|> Section.entities([
Entity.new(:notification, MyApp.Notifications.Notification)
|> Entity.describe("Defines a notification delivery")
|> Entity.args([:name, :type])
|> Entity.schema([
Field.new(:name, type: :atom, required?: true, doc: "Notification name"),
Field.new(:type,
type: {:one_of, [:email, :slack]},
required?: true,
doc: "Notification type"
),
Field.new(:target, type: :string, doc: "Delivery target"),
Field.new(:metadata,
type: :keyword_list,
keys: [
priority: [type: :integer, default: 0, doc: "Priority level"]
],
doc: "Optional metadata"
)
])
|> Entity.identifier(:name)
|> Entity.build!()
])
|> Section.build!()
]

use Spark.Dsl, default_extensions: [extensions: __MODULE__]
end
```

### Helper module approach

In more complex cases, consider extracting builders into a separate module. This keeps
the DSL module clean and makes builders reusable:

```elixir
defmodule MyApp.Notifications.Notification do
defstruct [:name, :type, :target, :metadata, :__identifier__, :__spark_metadata__]
end

defmodule MyApp.Notifications.Dsl.Builder do
alias Spark.Builder.{Entity, Field, Section}

def notification_entity do
Entity.new(:notification, MyApp.Notifications.Notification)
|> Entity.describe("Defines a notification delivery")
|> Entity.args([:name, :type])
|> Entity.schema([
Field.new(:name, type: :atom, required?: true, doc: "Notification name"),
Field.new(:type,
type: {:one_of, [:email, :slack]},
required?: true,
doc: "Notification type"
),
Field.new(:target, type: :string, doc: "Delivery target"),
Field.new(:metadata,
type: :keyword_list,
keys: [
priority: [type: :integer, default: 0, doc: "Priority level"]
],
doc: "Optional metadata"
)
])
|> Entity.identifier(:name)
|> Entity.build!()
end

def notifications_section do
Section.new(:notifications)
|> Section.describe("Notification configuration")
|> Section.entities([notification_entity()])
|> Section.build!()
end
end

defmodule MyApp.Notifications.Dsl do
alias MyApp.Notifications.Dsl.Builder

use Spark.Dsl.Extension, sections: [Builder.notifications_section()]
use Spark.Dsl, default_extensions: [extensions: __MODULE__]
end
```

## Using the DSL

```elixir
defmodule MyApp.Config do
use MyApp.Notifications.Dsl

notifications do
notification :ops, :email do
target "[email protected]"
metadata priority: 1
end
end
end
```

## Introspection helpers

You can expose a small info module that wraps `Spark.Dsl.Extension` helpers.

```elixir
defmodule MyApp.Notifications.Info do
def notifications(module) do
Spark.Dsl.Extension.get_entities(module, [:notifications])
end
end
```

## Notes

- Types are passed as atoms or tuples (for example `{:one_of, [:email, :slack]}`).
- Use `Field.new/2` options to set `:required?`, `:default`, `:keys`, and docs.
- The builder modules are `Spark.Builder.Field`, `Spark.Builder.Entity`, and
`Spark.Builder.Section`.
2 changes: 2 additions & 0 deletions documentation/how_to/writing-extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ Writing extensions generally involves three main components.

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.

If you want to build those structs programmatically, see [Building Extensions with the Builder API](build-extensions-with-builders.md).

## Transformers

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
Expand Down
Loading
Loading