Skip to content

Commit 7ccebcf

Browse files
committed
feat: implement protocol-based configuration system with snippets support
- Add Caddy.Caddyfile protocol for type-safe configuration rendering - Create Caddy.Config.Snippet module for reusable config blocks with argument placeholders - Create Caddy.Config.Import module for snippet and file imports - Create Caddy.Config.Global module for global Caddy configuration - Create Caddy.Config.Site module with NixOS-inspired fluent API builder - Update Caddy.Config struct to use snippets instead of additional field - Implement protocol rendering with backward compatibility for legacy formats - Add snippet management functions to ConfigProvider (set_snippet, get_snippet, remove_snippet) - Fix Application startup to use Caddy.Supervisor directly - Add comprehensive test coverage (141 tests, all passing) - Include example files demonstrating protocol usage and builder pattern This implements the protocol-based design approach approved by the user with focus on ease of use and testing difficulty.
1 parent 5c13f4e commit 7ccebcf

19 files changed

+2161
-47
lines changed

examples/README.md

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
# Protocol-Based Config Examples
2+
3+
This directory contains examples demonstrating the new protocol-based configuration system.
4+
5+
## Running the Examples
6+
7+
```bash
8+
# Protocol demonstration
9+
elixir -r lib/caddy/caddyfile.ex -r lib/caddy/config/snippet.ex -r lib/caddy/config/import.ex -r lib/caddy/config/global.ex -r lib/caddy/config/site.ex examples/protocol_demo.exs
10+
11+
# Builder pattern demonstration
12+
elixir -r lib/caddy/caddyfile.ex -r lib/caddy/config/snippet.ex -r lib/caddy/config/import.ex -r lib/caddy/config/site.ex examples/builder_demo.exs
13+
14+
# Testing benefits demonstration
15+
elixir -r lib/caddy/caddyfile.ex -r lib/caddy/config/snippet.ex -r lib/caddy/config/import.ex -r lib/caddy/config/global.ex -r lib/caddy/config/site.ex examples/testing_demo.exs
16+
```
17+
18+
## What's Demonstrated
19+
20+
### protocol_demo.exs
21+
- **Snippet creation** with argument placeholders (`{args[0]}`)
22+
- **Import directives** (with and without args)
23+
- **Global configuration** blocks
24+
- **Site configuration** with all features
25+
- **Complete Caddyfile** generation
26+
- Your specific **log-zone** snippet requirement
27+
28+
### builder_demo.exs
29+
- **Fluent API** (method chaining)
30+
- **Step-by-step building**
31+
- **Conditional configuration**
32+
- **Building from data** (Enum.map)
33+
- **Composing with Enum.reduce**
34+
- **Custom helper functions**
35+
36+
### testing_demo.exs
37+
- **Pure function testing** (no I/O needed)
38+
- **Performance** (100 iterations in ~1ms)
39+
- **Clear assertions**
40+
- **Struct inspection**
41+
- **Property-based testing** examples
42+
- **Test factories**
43+
44+
## Key Benefits
45+
46+
### ✅ Ease of Use
47+
- Type-safe structs with clear fields
48+
- Builder pattern for fluent API
49+
- Self-documenting code
50+
- Great IDE support
51+
52+
### ✅ Testing
53+
- Pure functions - no I/O needed
54+
- Blazing fast tests
55+
- Easy to test each component
56+
- Simple test factories
57+
- No mocking required
58+
59+
### ✅ Protocol-Based
60+
- Elegant and extensible
61+
- Users can implement custom types
62+
- Clean separation of data and rendering
63+
- NixOS-inspired declarative structure
64+
65+
## Next Steps
66+
67+
Once we complete the implementation, you'll be able to use this like:
68+
69+
```elixir
70+
# In your code
71+
alias Caddy.Config.{Site, Snippet}
72+
73+
# Define reusable snippets
74+
ConfigProvider.add_snippet("log-zone", """
75+
log {
76+
format json
77+
output file /srv/logs/{args[0]}/{args[1]}/access.log {
78+
roll_size 50mb
79+
roll_keep 5
80+
roll_keep_for 720h
81+
}
82+
}
83+
""")
84+
85+
# Create sites
86+
site = Site.new("example.com")
87+
|> Site.import_snippet("log-zone", ["app", "production"])
88+
|> Site.reverse_proxy("localhost:3000")
89+
90+
ConfigProvider.set_site("example", site)
91+
92+
# Generate Caddyfile
93+
config = ConfigProvider.get_config()
94+
caddyfile = Caddy.Caddyfile.to_caddyfile(config)
95+
```
96+
97+
## Implementation Status
98+
99+
✅ Phase 1: Protocol Foundation (Complete)
100+
✅ Phase 2: Core Structs (Complete)
101+
✅ Phase 3: Site Configuration (Complete)
102+
⏳ Phase 4-9: Integration with existing code (In Progress)

examples/builder_demo.exs

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
# Builder Pattern Demo
2+
# This shows how easy it is to build complex configurations
3+
4+
alias Caddy.Caddyfile
5+
alias Caddy.Config.{Snippet, Site}
6+
7+
IO.puts("\n" <> IO.ANSI.cyan() <> "=== Fluent Builder API Demo ===" <> IO.ANSI.reset() <> "\n")
8+
9+
# ============================================================================
10+
# Example 1: Building a Site Step-by-Step
11+
# ============================================================================
12+
IO.puts(IO.ANSI.yellow() <> "Example 1: Step-by-Step Site Building" <> IO.ANSI.reset())
13+
14+
site = Site.new("myapp.com")
15+
16+
IO.puts("\nStep 1: Just hostname")
17+
IO.puts(IO.ANSI.green() <> Caddyfile.to_caddyfile(site) <> IO.ANSI.reset())
18+
19+
site = site |> Site.listen(":443")
20+
IO.puts("\nStep 2: Add listen address")
21+
IO.puts(IO.ANSI.green() <> Caddyfile.to_caddyfile(site) <> IO.ANSI.reset())
22+
23+
site = site |> Site.add_alias("www.myapp.com")
24+
IO.puts("\nStep 3: Add alias")
25+
IO.puts(IO.ANSI.green() <> Caddyfile.to_caddyfile(site) <> IO.ANSI.reset())
26+
27+
site = site |> Site.reverse_proxy("localhost:3000")
28+
IO.puts("\nStep 4: Add reverse proxy")
29+
IO.puts(IO.ANSI.green() <> Caddyfile.to_caddyfile(site) <> IO.ANSI.reset())
30+
31+
# ============================================================================
32+
# Example 2: Chaining Everything
33+
# ============================================================================
34+
IO.puts("\n" <> IO.ANSI.yellow() <> "Example 2: Chained Builder (One Expression)" <> IO.ANSI.reset())
35+
36+
chained_site =
37+
Site.new("api.example.com")
38+
|> Site.listen(":8080")
39+
|> Site.add_alias(["api.example.org", "api.backup.com"])
40+
|> Site.tls(:internal)
41+
|> Site.import_snippet("log-zone", ["api", "staging"])
42+
|> Site.import_snippet("cors", ["*"])
43+
|> Site.add_directive("encode gzip")
44+
|> Site.add_directive({"header", "X-API-Version 2.0"})
45+
|> Site.reverse_proxy("localhost:4000")
46+
47+
IO.puts("\nOne chained expression produces:")
48+
IO.puts(IO.ANSI.green())
49+
IO.puts(Caddyfile.to_caddyfile(chained_site))
50+
IO.puts(IO.ANSI.reset())
51+
52+
# ============================================================================
53+
# Example 3: Conditional Building
54+
# ============================================================================
55+
IO.puts(IO.ANSI.yellow() <> "\nExample 3: Conditional Configuration" <> IO.ANSI.reset())
56+
57+
env = :production # Could be :development or :production
58+
59+
base_site = Site.new("conditional.com")
60+
|> Site.reverse_proxy("localhost:3000")
61+
62+
site_with_env = if env == :production do
63+
base_site
64+
|> Site.listen(":443")
65+
|> Site.tls(:auto)
66+
|> Site.import_snippet("log-zone", ["app", "prod"])
67+
|> Site.add_directive("encode gzip")
68+
else
69+
base_site
70+
|> Site.listen(":8080")
71+
|> Site.tls(:off)
72+
end
73+
74+
IO.puts("\nProduction config:")
75+
IO.puts(IO.ANSI.green())
76+
IO.puts(Caddyfile.to_caddyfile(site_with_env))
77+
IO.puts(IO.ANSI.reset())
78+
79+
# ============================================================================
80+
# Example 4: Building from Data
81+
# ============================================================================
82+
IO.puts(IO.ANSI.yellow() <> "\nExample 4: Building Sites from Data" <> IO.ANSI.reset())
83+
84+
# Imagine this comes from a database or config file
85+
services = [
86+
%{name: "web", port: 3000, aliases: ["www.example.com"]},
87+
%{name: "api", port: 4000, aliases: []},
88+
%{name: "admin", port: 5000, aliases: ["admin.example.com"]}
89+
]
90+
91+
sites = Enum.map(services, fn service ->
92+
Site.new("#{service.name}.example.com")
93+
|> Site.add_alias(service.aliases)
94+
|> Site.import_snippet("log-zone", [service.name, "production"])
95+
|> Site.reverse_proxy("localhost:#{service.port}")
96+
end)
97+
98+
IO.puts("\nGenerated #{length(sites)} sites from data:")
99+
Enum.each(sites, fn site ->
100+
IO.puts("\n" <> IO.ANSI.green() <> Caddyfile.to_caddyfile(site) <> IO.ANSI.reset())
101+
end)
102+
103+
# ============================================================================
104+
# Example 5: Composing Multiple Directives
105+
# ============================================================================
106+
IO.puts("\n" <> IO.ANSI.yellow() <> "Example 5: Adding Multiple Directives" <> IO.ANSI.reset())
107+
108+
security_directives = [
109+
{"header", "X-Content-Type-Options nosniff"},
110+
{"header", "X-Frame-Options DENY"},
111+
{"header", "X-XSS-Protection \"1; mode=block\""},
112+
"encode gzip",
113+
"encode zstd"
114+
]
115+
116+
secure_site = Enum.reduce(security_directives, Site.new("secure.example.com"), fn directive, site ->
117+
Site.add_directive(site, directive)
118+
end)
119+
|> Site.reverse_proxy("localhost:3000")
120+
121+
IO.puts("\nSite with multiple security directives:")
122+
IO.puts(IO.ANSI.green())
123+
IO.puts(Caddyfile.to_caddyfile(secure_site))
124+
IO.puts(IO.ANSI.reset())
125+
126+
# ============================================================================
127+
# Example 6: Helper Function Pattern
128+
# ============================================================================
129+
IO.puts(IO.ANSI.yellow() <> "\nExample 6: Custom Helper Functions" <> IO.ANSI.reset())
130+
131+
# Define your own helpers
132+
defmodule SiteHelpers do
133+
alias Caddy.Config.Site
134+
135+
def with_standard_security(site) do
136+
site
137+
|> Site.add_directive({"header", "X-Content-Type-Options nosniff"})
138+
|> Site.add_directive({"header", "X-Frame-Options DENY"})
139+
|> Site.add_directive("encode gzip")
140+
end
141+
142+
def with_logging(site, app_name, env) do
143+
site
144+
|> Site.import_snippet("log-zone", [app_name, env])
145+
end
146+
147+
def with_cors(site, origin) do
148+
site
149+
|> Site.import_snippet("cors", [origin])
150+
end
151+
end
152+
153+
helper_site = Site.new("helper-example.com")
154+
|> SiteHelpers.with_standard_security()
155+
|> SiteHelpers.with_logging("myapp", "production")
156+
|> SiteHelpers.with_cors("https://example.com")
157+
|> Site.reverse_proxy("localhost:3000")
158+
159+
IO.puts("\nSite built with custom helpers:")
160+
IO.puts(IO.ANSI.green())
161+
IO.puts(Caddyfile.to_caddyfile(helper_site))
162+
IO.puts(IO.ANSI.reset())
163+
164+
IO.puts("\n" <> IO.ANSI.cyan() <> "=== Demo Complete ===" <> IO.ANSI.reset())
165+
IO.puts("\nKey Patterns Demonstrated:")
166+
IO.puts("✓ Step-by-step building")
167+
IO.puts("✓ Method chaining (fluent API)")
168+
IO.puts("✓ Conditional configuration")
169+
IO.puts("✓ Building from data (Enum.map)")
170+
IO.puts("✓ Composing with Enum.reduce")
171+
IO.puts("✓ Custom helper functions")
172+
IO.puts("\nAll using pure, testable functions! 🎉")

0 commit comments

Comments
 (0)