Skip to content

Commit 9f82470

Browse files
JAORMXclaude
andcommitted
Add generic config field framework
Introduce a declarative framework for managing configuration fields that reduces boilerplate and ensures consistency across all config operations. Key features: - ConfigFieldSpec: Define fields with validation, getters, setters once - Field registry: Thread-safe registration and lookup of config fields - Generic operations: SetConfigField, GetConfigField, UnsetConfigField - Validation helpers: Reusable validators for common patterns Built-in fields registered: - ca-cert: CA certificate path with format validation - registry-url: Registry URL with HTTPS enforcement and insecure flag - registry-file: Local registry file with JSON validation Benefits: - Add new fields with ~30 lines instead of 100+ lines per field - Consistent error messages and validation patterns - Easy maintenance through centralized field definitions - Comprehensive test coverage with parallel execution This framework provides the foundation for migrating OTEL configuration and other future config fields, significantly reducing code duplication. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent b159c21 commit 9f82470

File tree

5 files changed

+1462
-0
lines changed

5 files changed

+1462
-0
lines changed

pkg/config/doc.go

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
// Package config provides configuration management for ToolHive, including a
2+
// generic framework for easily adding new configuration fields.
3+
//
4+
// # Architecture
5+
//
6+
// The package uses a Provider pattern to abstract configuration storage:
7+
// - DefaultProvider: Uses XDG config directories (~/.config/toolhive/config.yaml)
8+
// - PathProvider: Uses a specific file path (useful for testing)
9+
// - KubernetesProvider: No-op implementation for Kubernetes environments
10+
//
11+
// # Generic Config Field Framework
12+
//
13+
// The framework allows you to define config fields declaratively with minimal
14+
// boilerplate. Fields are registered once with validation, getters, setters,
15+
// and unseters.
16+
//
17+
// # Adding a New Config Field
18+
//
19+
// Step 1: Add your field to the Config struct:
20+
//
21+
// type Config struct {
22+
// // ... existing fields ...
23+
// MyNewField string `yaml:"my_new_field,omitempty"`
24+
// }
25+
//
26+
// Step 2: Register the field using a helper constructor:
27+
//
28+
// func init() {
29+
// // For simple string fields:
30+
// config.RegisterStringField("my-field",
31+
// func(cfg *Config) *string { return &cfg.MyNewField },
32+
// validateMyField) // Optional validator
33+
//
34+
// // For boolean fields:
35+
// config.RegisterBoolField("my-bool-field",
36+
// func(cfg *Config) *bool { return &cfg.MyBoolField },
37+
// nil) // nil = no validation
38+
//
39+
// // For float fields:
40+
// config.RegisterFloatField("my-float-field",
41+
// func(cfg *Config) *float64 { return &cfg.MyFloatField },
42+
// 0.0, // zero value
43+
// validateMyFloat)
44+
//
45+
// // For string slice fields (comma-separated):
46+
// config.RegisterStringSliceField("my-list-field",
47+
// func(cfg *Config) *[]string { return &cfg.MyListField },
48+
// nil)
49+
// }
50+
//
51+
// Step 3: Use the field through the generic framework:
52+
//
53+
// provider := config.NewDefaultProvider()
54+
//
55+
// // Set a value
56+
// err := config.SetConfigField(provider, "my-field", "some-value")
57+
//
58+
// // Get a value
59+
// value, isSet, err := config.GetConfigField(provider, "my-field")
60+
//
61+
// // Unset a value
62+
// err := config.UnsetConfigField(provider, "my-field")
63+
//
64+
// # Advanced: Custom Field Registration
65+
//
66+
// For fields with complex logic, use RegisterConfigField directly:
67+
//
68+
// config.RegisterConfigField(config.ConfigFieldSpec{
69+
// Name: "my-complex-field",
70+
// SetValidator: func(_ Provider, value string) error {
71+
// // Custom validation logic
72+
// return nil
73+
// },
74+
// Setter: func(cfg *Config, value string) {
75+
// // Custom setter logic
76+
// },
77+
// Getter: func(cfg *Config) string {
78+
// // Custom getter logic
79+
// return ""
80+
// },
81+
// Unsetter: func(cfg *Config) {
82+
// // Custom unsetter logic
83+
// },
84+
// })
85+
//
86+
// # Validation Helpers
87+
//
88+
// The package provides common validation functions:
89+
// - validateFilePath: Validates file exists and returns cleaned path
90+
// - validateFileExists: Checks if file exists
91+
// - validateJSONFile: Validates file is JSON format
92+
// - validateURLScheme: Validates URL scheme (http/https)
93+
// - makeAbsolutePath: Converts relative to absolute path
94+
//
95+
// Use these in your validator function for consistent error messages.
96+
//
97+
// # Built-in Fields
98+
//
99+
// The following fields are currently registered:
100+
// - ca-cert: Path to a CA certificate file for TLS validation
101+
// - registry-url: URL of the MCP server registry (HTTP/HTTPS)
102+
// - registry-file: Path to a local JSON file containing the registry
103+
// - otel-endpoint: OpenTelemetry OTLP endpoint
104+
// - otel-sampling-rate: Trace sampling rate (0.0-1.0)
105+
// - otel-env-vars: Environment variables for telemetry
106+
// - otel-metrics-enabled: Enable metrics export
107+
// - otel-tracing-enabled: Enable tracing export
108+
// - otel-insecure: Use insecure connection
109+
// - otel-enable-prometheus-metrics-path: Enable Prometheus endpoint
110+
package config

pkg/config/fields.go

Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
package config
2+
3+
import (
4+
"fmt"
5+
"strconv"
6+
"strings"
7+
"sync"
8+
)
9+
10+
// ConfigFieldSpec defines the specification for a generic config field.
11+
// It encapsulates all the logic needed to set, get, unset, and validate a config field.
12+
type ConfigFieldSpec struct {
13+
// Name is the unique identifier for the field (e.g., "ca-cert", "registry-url")
14+
Name string
15+
16+
// SetValidator validates the value before setting it.
17+
// Returns an error if the value is invalid.
18+
// This is called before Setter.
19+
SetValidator func(provider Provider, value string) error
20+
21+
// Setter sets the value in the Config struct.
22+
// It receives the config to modify and the validated value.
23+
Setter func(cfg *Config, value string)
24+
25+
// Getter retrieves the current value from the Config struct.
26+
// Returns the current value as a string.
27+
Getter func(cfg *Config) string
28+
29+
// Unsetter clears the field in the Config struct.
30+
// It resets the field to its default/empty state.
31+
Unsetter func(cfg *Config)
32+
}
33+
34+
// fieldRegistry stores all registered config field specifications
35+
var fieldRegistry = make(map[string]ConfigFieldSpec)
36+
37+
// registryMutex protects concurrent access to the field registry
38+
var registryMutex sync.RWMutex
39+
40+
// RegisterConfigField registers a new config field specification.
41+
// This function is typically called during package initialization.
42+
// Panics if a field with the same name is already registered.
43+
func RegisterConfigField(spec ConfigFieldSpec) {
44+
registryMutex.Lock()
45+
defer registryMutex.Unlock()
46+
47+
if spec.Name == "" {
48+
panic("config field name cannot be empty")
49+
}
50+
51+
if _, exists := fieldRegistry[spec.Name]; exists {
52+
panic(fmt.Sprintf("config field %q is already registered", spec.Name))
53+
}
54+
55+
// Validate required fields
56+
if spec.Setter == nil {
57+
panic(fmt.Sprintf("config field %q must have a Setter", spec.Name))
58+
}
59+
if spec.Getter == nil {
60+
panic(fmt.Sprintf("config field %q must have a Getter", spec.Name))
61+
}
62+
if spec.Unsetter == nil {
63+
panic(fmt.Sprintf("config field %q must have an Unsetter", spec.Name))
64+
}
65+
66+
fieldRegistry[spec.Name] = spec
67+
}
68+
69+
// GetConfigFieldSpec retrieves a registered config field specification by name.
70+
// Returns the field spec and true if found, or an empty spec and false if not found.
71+
func GetConfigFieldSpec(fieldName string) (ConfigFieldSpec, bool) {
72+
registryMutex.RLock()
73+
defer registryMutex.RUnlock()
74+
75+
spec, exists := fieldRegistry[fieldName]
76+
return spec, exists
77+
}
78+
79+
// ListConfigFields returns a list of all registered config field names.
80+
func ListConfigFields() []string {
81+
registryMutex.RLock()
82+
defer registryMutex.RUnlock()
83+
84+
fields := make([]string, 0, len(fieldRegistry))
85+
for name := range fieldRegistry {
86+
fields = append(fields, name)
87+
}
88+
return fields
89+
}
90+
91+
// SetConfigField sets a config field value using the generic framework.
92+
// It looks up the field spec, validates the value, and updates the config.
93+
// Returns an error if the field is not registered, validation fails, or update fails.
94+
func SetConfigField(provider Provider, fieldName, value string) error {
95+
spec, exists := GetConfigFieldSpec(fieldName)
96+
if !exists {
97+
return fmt.Errorf("unknown config field: %q", fieldName)
98+
}
99+
100+
// Run custom validation if provided
101+
if spec.SetValidator != nil {
102+
if err := spec.SetValidator(provider, value); err != nil {
103+
return err
104+
}
105+
}
106+
107+
// Update the config
108+
err := provider.UpdateConfig(func(cfg *Config) {
109+
spec.Setter(cfg, value)
110+
})
111+
if err != nil {
112+
return fmt.Errorf("failed to update configuration: %w", err)
113+
}
114+
115+
return nil
116+
}
117+
118+
// GetConfigField retrieves a config field value using the generic framework.
119+
// It looks up the field spec and returns the current value.
120+
// Returns the value, whether it's set (non-empty), and any error.
121+
func GetConfigField(provider Provider, fieldName string) (value string, isSet bool, err error) {
122+
spec, exists := GetConfigFieldSpec(fieldName)
123+
if !exists {
124+
return "", false, fmt.Errorf("unknown config field: %q", fieldName)
125+
}
126+
127+
cfg := provider.GetConfig()
128+
value = spec.Getter(cfg)
129+
isSet = value != ""
130+
131+
return value, isSet, nil
132+
}
133+
134+
// UnsetConfigField clears a config field using the generic framework.
135+
// It looks up the field spec and resets the field to its default state.
136+
// Returns an error if the field is not registered or update fails.
137+
func UnsetConfigField(provider Provider, fieldName string) error {
138+
spec, exists := GetConfigFieldSpec(fieldName)
139+
if !exists {
140+
return fmt.Errorf("unknown config field: %q", fieldName)
141+
}
142+
143+
// Update the config
144+
err := provider.UpdateConfig(func(cfg *Config) {
145+
spec.Unsetter(cfg)
146+
})
147+
if err != nil {
148+
return fmt.Errorf("failed to update configuration: %w", err)
149+
}
150+
151+
return nil
152+
}
153+
154+
// Helper constructors for common field types
155+
156+
// RegisterStringField registers a simple string config field with optional validation.
157+
// The fieldGetter returns a pointer to the string field in the config struct.
158+
func RegisterStringField(
159+
name string,
160+
fieldGetter func(*Config) *string,
161+
validator func(Provider, string) error,
162+
) {
163+
RegisterConfigField(ConfigFieldSpec{
164+
Name: name,
165+
SetValidator: validator,
166+
Setter: func(cfg *Config, value string) {
167+
*fieldGetter(cfg) = value
168+
},
169+
Getter: func(cfg *Config) string {
170+
return *fieldGetter(cfg)
171+
},
172+
Unsetter: func(cfg *Config) {
173+
*fieldGetter(cfg) = ""
174+
},
175+
})
176+
}
177+
178+
// RegisterBoolField registers a boolean config field with automatic string conversion.
179+
// The fieldGetter returns a pointer to the bool field in the config struct.
180+
func RegisterBoolField(
181+
name string,
182+
fieldGetter func(*Config) *bool,
183+
validator func(Provider, string) error,
184+
) {
185+
RegisterConfigField(ConfigFieldSpec{
186+
Name: name,
187+
SetValidator: validator,
188+
Setter: func(cfg *Config, value string) {
189+
enabled, _ := strconv.ParseBool(value) // Already validated
190+
*fieldGetter(cfg) = enabled
191+
},
192+
Getter: func(cfg *Config) string {
193+
return strconv.FormatBool(*fieldGetter(cfg))
194+
},
195+
Unsetter: func(cfg *Config) {
196+
*fieldGetter(cfg) = false
197+
},
198+
})
199+
}
200+
201+
// RegisterFloatField registers a float64 config field with automatic string conversion.
202+
// The fieldGetter returns a pointer to the float64 field in the config struct.
203+
// The zeroValue parameter specifies what value indicates "unset" (typically 0.0).
204+
func RegisterFloatField(
205+
name string,
206+
fieldGetter func(*Config) *float64,
207+
zeroValue float64,
208+
validator func(Provider, string) error,
209+
) {
210+
RegisterConfigField(ConfigFieldSpec{
211+
Name: name,
212+
SetValidator: validator,
213+
Setter: func(cfg *Config, value string) {
214+
floatVal, _ := strconv.ParseFloat(value, 64) // Already validated
215+
*fieldGetter(cfg) = floatVal
216+
},
217+
Getter: func(cfg *Config) string {
218+
val := *fieldGetter(cfg)
219+
if val == zeroValue {
220+
return ""
221+
}
222+
return strconv.FormatFloat(val, 'f', -1, 64)
223+
},
224+
Unsetter: func(cfg *Config) {
225+
*fieldGetter(cfg) = zeroValue
226+
},
227+
})
228+
}
229+
230+
// RegisterStringSliceField registers a string slice config field with comma-separated string conversion.
231+
// The fieldGetter returns a pointer to the []string field in the config struct.
232+
func RegisterStringSliceField(
233+
name string,
234+
fieldGetter func(*Config) *[]string,
235+
validator func(Provider, string) error,
236+
) {
237+
RegisterConfigField(ConfigFieldSpec{
238+
Name: name,
239+
SetValidator: validator,
240+
Setter: func(cfg *Config, value string) {
241+
vars := strings.Split(value, ",")
242+
// Trim whitespace from each item
243+
for i, item := range vars {
244+
vars[i] = strings.TrimSpace(item)
245+
}
246+
*fieldGetter(cfg) = vars
247+
},
248+
Getter: func(cfg *Config) string {
249+
slice := *fieldGetter(cfg)
250+
if len(slice) == 0 {
251+
return ""
252+
}
253+
return strings.Join(slice, ",")
254+
},
255+
Unsetter: func(cfg *Config) {
256+
*fieldGetter(cfg) = nil
257+
},
258+
})
259+
}

0 commit comments

Comments
 (0)