diff --git a/README.md b/README.md index 4cf07e8..2af9fc9 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,7 @@ go run main.go | `min` | Minimum value (numbers, durations) | `min:"1024"` | | `max` | Maximum value (numbers, durations) | `max:"65535"` | | `pattern` | Regex pattern for strings | `pattern:"^[a-z]+$"` | +| `scheme` | Command separated list of schemes for `*url.URL` | `scheme:"http,https"` | | `required` | Must be present and non-empty | `required:"true"` | | `keyRequired` | Must be present (can be empty) | `keyRequired:"true"` | @@ -172,6 +173,11 @@ export MODEL_PARAMS='{"temperature":0.7,"max_tokens":1000}' 📚 **[JSON Guide](docs/json.md)** +## Troubleshooting + +If you see an error about parsing JSON when you are not expecting a JSON value, check that the type is recognized. +The JSON handling for struct types catches various types (such as `url.URL` before I added support for it) + ## Documentation - 📖 **[Documentation Index](docs/)** - Complete guides and reference diff --git a/custom_types.go b/custom_types.go index ee30a33..2ea7be2 100644 --- a/custom_types.go +++ b/custom_types.go @@ -16,6 +16,8 @@ type Wrapper[T any] = readpipeline.Wrapper[T] type TypedHandler[T any] = readpipeline.TypedHandler[T] +type Transform[T, U any] = customtypes.Transform[T, U] + func RegisterCustomType[T any](handler TypedHandler[T]) { readpipeline.RegisterType[T](handler) } @@ -39,16 +41,39 @@ func AddValidators[T any](baseHandler TypedHandler[T], customValidators ...Valid return baseHandler } +// AddDynamicValidation allows a TypedHandler to add validation (or other logic) to the process pipeline dependent +// on struct tags present on the target field. +// See the AddValidatorToPipeline function and the example/custom_tags example for more details. func AddDynamicValidation[T any](baseHandler TypedHandler[T], wrapper Wrapper[T]) TypedHandler[T] { return customtypes.AddWrapper(baseHandler, wrapper) } +// AddValidatorToPipeline adds a validator to a pipeline. This is used as part of pipeline building in the TypedHandler. +func AddValidatorToPipeline[T any](pipeline FieldProcessor[T], validator Validator[T]) FieldProcessor[T] { + return func(rawValue string) (T, error) { + value, err := pipeline(rawValue) + if err != nil { + return value, err + } + if err = validator(value); err != nil { + return value, err + } + return value, nil + } +} + func CastCustomType[T, U any](baseHandler TypedHandler[T]) TypedHandler[U] { - return customtypes.NewTransformer[T, U](baseHandler) + return customtypes.NewCastingTransformer[T, U](baseHandler) +} + +// TransformCustomType creates a TypedHandler that applies a Transform function to process data from a base handler. +func TransformCustomType[T, U any](baseHandler TypedHandler[T], transform Transform[T, U]) TypedHandler[U] { + return customtypes.NewTransformer(baseHandler, transform) } -func DefaultStringType() TypedHandler[string] { - return readpipeline.NewTypedStringHandler() +func DefaultStringType[T ~string]() TypedHandler[T] { + pipeline := readpipeline.NewTypedStringHandler() + return CastCustomType[string, T](pipeline) } func DefaultIntegerType[T ~int | ~int8 | ~int16 | ~int32 | ~int64]() TypedHandler[T] { diff --git a/custom_types_test.go b/custom_types_test.go index 122592a..5d2d23f 100644 --- a/custom_types_test.go +++ b/custom_types_test.go @@ -142,7 +142,7 @@ func TestAddDynamicValidation(t *testing.T) { Val string `key:"VAL" check:"true"` } - baseHandler := DefaultStringType() + baseHandler := DefaultStringType[string]() handler := AddDynamicValidation(baseHandler, func(tags reflect.StructTag, inputProcess FieldProcessor[string]) (FieldProcessor[string], error) { if tags.Get("check") == "true" { return func(rawValue string) (string, error) { @@ -196,7 +196,7 @@ func TestCastCustomType(t *testing.T) { func TestDefaultTypeHandlers(t *testing.T) { t.Run("String", func(t *testing.T) { - handler := DefaultStringType() + handler := DefaultStringType[string]() p, _ := handler.BuildPipeline("") val, _ := p("hello") if any(val).(string) != "hello" { diff --git a/envfile_keystore.go b/envfile_keystore.go new file mode 100644 index 0000000..32304c4 --- /dev/null +++ b/envfile_keystore.go @@ -0,0 +1,76 @@ +package goconfig + +import ( + "bufio" + "context" + "os" + "strings" +) + +// NewEnvFileKeyStore returns a KeyStore that reads values from a list of environment files. +// If no filenames are provided, it defaults to ".env". +// Files are processed in the order they are provided. If multiple files contain the same key, +// the first one encountered wins. +func NewEnvFileKeyStore(filenames ...string) KeyStore { + if len(filenames) == 0 { + filenames = []string{".env"} + } + + // Pre-load all files into a map + values := make(map[string]string) + for _, filename := range filenames { + fileValues, err := readEnvFile(filename) + if err != nil { + // If a file doesn't exist or can't be read, we just skip it as per typical .env behavior + continue + } + for k, v := range fileValues { + if _, exists := values[k]; !exists { + values[k] = v + } + } + } + + return func(ctx context.Context, key string) (string, bool, error) { + val, ok := values[key] + return val, ok, nil + } +} + +func readEnvFile(filename string) (map[string]string, error) { + file, err := os.Open(filename) + if err != nil { + return nil, err + } + defer file.Close() + + values := make(map[string]string) + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + // Skip empty lines and comments + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + // Split by first '=' + parts := strings.SplitN(line, "=", 2) + if len(parts) != 2 { + continue + } + + key := strings.TrimSpace(parts[0]) + value := strings.TrimSpace(parts[1]) + + // Remove quotes if present + if len(value) >= 2 { + if (value[0] == '"' && value[len(value)-1] == '"') || (value[0] == '\'' && value[len(value)-1] == '\'') { + value = value[1 : len(value)-1] + } + } + + values[key] = value + } + + return values, scanner.Err() +} diff --git a/envfile_keystore_test.go b/envfile_keystore_test.go new file mode 100644 index 0000000..9315904 --- /dev/null +++ b/envfile_keystore_test.go @@ -0,0 +1,93 @@ +package goconfig + +import ( + "context" + "os" + "testing" +) + +func TestNewEnvFileKeyStore(t *testing.T) { + // Create a temporary .env file + content := ` +PORT=9000 +DB_HOST=localhost +# This is a comment +EMPTY= +QUOTED="quoted value" +SINGLE_QUOTED='single quoted' +` + err := os.WriteFile(".env", []byte(content), 0644) + if err != nil { + t.Fatalf("Failed to create .env file: %v", err) + } + defer os.Remove(".env") + + t.Run("Default .env", func(t *testing.T) { + store := NewEnvFileKeyStore() + ctx := context.Background() + + tests := []struct { + key string + wantVal string + wantOk bool + }{ + {"PORT", "9000", true}, + {"DB_HOST", "localhost", true}, + {"EMPTY", "", true}, + {"QUOTED", "quoted value", true}, + {"SINGLE_QUOTED", "single quoted", true}, + {"MISSING", "", false}, + } + + for _, tt := range tests { + val, ok, err := store(ctx, tt.key) + if err != nil { + t.Errorf("Unexpected error for %s: %v", tt.key, err) + } + if ok != tt.wantOk { + t.Errorf("%s: got ok %v, want %v", tt.key, ok, tt.wantOk) + } + if val != tt.wantVal { + t.Errorf("%s: got val %q, want %q", tt.key, val, tt.wantVal) + } + } + }) + + t.Run("Specific files", func(t *testing.T) { + f1 := "test1.env" + f2 := "test2.env" + os.WriteFile(f1, []byte("KEY1=VAL1\nKEY2=VAL2_F1"), 0644) + os.WriteFile(f2, []byte("KEY2=VAL2_F2\nKEY3=VAL3"), 0644) + defer os.Remove(f1) + defer os.Remove(f2) + + store := NewEnvFileKeyStore(f1, f2) + ctx := context.Background() + + tests := []struct { + key string + wantVal string + wantOk bool + }{ + {"KEY1", "VAL1", true}, + {"KEY2", "VAL2_F1", true}, // First one wins in my implementation + {"KEY3", "VAL3", true}, + } + + for _, tt := range tests { + val, ok, _ := store(ctx, tt.key) + if ok != tt.wantOk || val != tt.wantVal { + t.Errorf("%s: got (%q, %v), want (%q, %v)", tt.key, val, ok, tt.wantVal, tt.wantOk) + } + } + }) + + t.Run("Non-existent file", func(t *testing.T) { + store := NewEnvFileKeyStore("nonexistent.env") + ctx := context.Background() + _, ok, _ := store(ctx, "ANY") + if ok { + t.Error("Expected ok=false for nonexistent file") + } + }) +} diff --git a/example/custom_tags/README.md b/example/custom_tags/README.md new file mode 100644 index 0000000..ec6e56a --- /dev/null +++ b/example/custom_tags/README.md @@ -0,0 +1,37 @@ +# Custom Tags + +This takes the [URL example](../custom_types/README.md) and reimplements the `SecureURL` type using a custom tag. This change +allows the `net.URL` type to be used throughout the config so avoiding the need to cast. This is a great improvement on +the URL sample. + +By the time this is released I will have added native support for `net.URL` into `goconfig` with the ability to use struct +tags to validate the scheme for `https`. This sample remains as a demonstration of how to use custom tags. + +This sample will fall back to `env.example` in the current working directory. + +## Testing + +If you run the sample from this working directory, it will output the values from `env.example`. + +You can demonstrate the custom Secure URL validation using + +```bash +export WHATSAPP_SERVER_URL=http://localhost:3000/ +go run . +``` + +You can demonstrate that explicitly blank values can override both defaults and later keystore (the `env.example` file) + +```bash +export WHATSAPP_SERVER_URL= +go run . +``` + +You can demonstrate that the `SecureURL` type is based on, but does not change, the `net.URL` type by providing a non-secure +URL for `MY_BASE_URL` + +```bash +export MY_BASE_URL=http://localhost:3000 +go run . +``` + diff --git a/example/custom_tags/config/config.go b/example/custom_tags/config/config.go new file mode 100644 index 0000000..c63a964 --- /dev/null +++ b/example/custom_tags/config/config.go @@ -0,0 +1,42 @@ +package config + +import ( + "context" + "net/url" + + "github.com/m0rjc/goconfig" +) + +type Config struct { + // MyBaseURL is the base URL for my web interface. This is used when sharing links in messages to direct the user + // to my web interface. + MyBaseURL *url.URL `key:"MY_BASE_URL" required:"false" default:"http://localhost:8080"` + // WhatsAppPhoneId is the phone number ID for my phone number + WhatsAppPhoneId string `key:"WHATSAPP_PHONE_ID" required:"true" pattern:"^[0-9]+$"` + // WhatsAppServerUrl is the URL of the WhatsApp Business API server. This uses the custom "secure" tag defined by this example + WhatsAppServerUrl *url.URL `key:"WHATSAPP_SERVER_URL" required:"true" secure:"true" default:"https://api.whatsapp.com"` + // WhatsAppAuthToken is the authentication token for the WhatsApp Business API. + WhatsAppAuthToken string `key:"WHATSAPP_AUTH_TOKEN" required:"true"` + // WhatsAppChallenge is the challenge token sent by the WhatsApp Business API. + WhatsAppChallenge string `key:"WHATSAPP_CHALLENGE"` + // ServerPort is the port on which the server will listen for incoming public requests. + ServerPort int `key:"SERVER_PORT" required:"true" default:"8080" min:"1024" max:"65535"` + // HealthPort is the port on which the server will listen for health checks. + // This can be forced off by setting the key to the empty string. + HealthPort *int `key:"HEALTH_PORT" default:"8081" min:"1024" max:"65535"` +} + +func LoadConfig() (*Config, error) { + var config Config + keystore := goconfig.CompositeStore( + fakeSecretsKeyStore, + goconfig.EnvironmentKeyStore, + goconfig.NewEnvFileKeyStore("env.example")) + + if err := goconfig.Load(context.Background(), &config, + goconfig.WithKeyStore(keystore), + goconfig.WithCustomType(newUrlCustomType())); err != nil { + return nil, err + } + return &config, nil +} diff --git a/example/custom_tags/config/fake_secrets_keystore.go b/example/custom_tags/config/fake_secrets_keystore.go new file mode 100644 index 0000000..ea873ee --- /dev/null +++ b/example/custom_tags/config/fake_secrets_keystore.go @@ -0,0 +1,15 @@ +package config + +import ( + "context" + "os" +) + +// fakeSecretsKeyStore is a fake keystore that reads secrets from environment variables. +// The environment variables must be prefixed with SECRET_. +// A production keystore could read secrets from a secure store, though I've just had Kubernetes inject them into +// the environment and used the normal keystore. +func fakeSecretsKeyStore(ctx context.Context, key string) (string, bool, error) { + value, ok := os.LookupEnv("SECRET_" + key) + return value, ok, nil +} diff --git a/example/custom_tags/config/url_types.go b/example/custom_tags/config/url_types.go new file mode 100644 index 0000000..073d5fd --- /dev/null +++ b/example/custom_tags/config/url_types.go @@ -0,0 +1,44 @@ +package config + +import ( + "errors" + "net/url" + "reflect" + + "github.com/m0rjc/goconfig" +) + +// ErrMustBeSecureUrl is returned when a URL does not use the HTTPS scheme, indicating it must be secure. +var ErrMustBeSecureUrl = errors.New("must be a secure URL") + +// typeUrlPtr is a custom type parser for URL values. +// Start with the base string type so that we gain the built-in regex validator +func newUrlCustomType() goconfig.TypedHandler[*url.URL] { + basicStringType := goconfig.DefaultStringType[string]() + typeUrlPtr := goconfig.TransformCustomType[string, *url.URL]( + basicStringType, + func(rawValue string) (*url.URL, error) { + value, err := url.ParseRequestURI(rawValue) + if err != nil { + return nil, err + } + return value, nil + }) + + // Each if these methods creates a new type handler by decorating the existing one. + // They do not modify the existing one. + typeUrlPtr = goconfig.AddDynamicValidation(typeUrlPtr, func(tags reflect.StructTag, pipeline goconfig.FieldProcessor[*url.URL]) (goconfig.FieldProcessor[*url.URL], error) { + secureTag := tags.Get("secure") + if secureTag == "true" { + pipeline = goconfig.AddValidatorToPipeline(pipeline, func(value *url.URL) error { + if value.Scheme != "https" { + return ErrMustBeSecureUrl + } + return nil + }) + } + return pipeline, nil + }) + + return typeUrlPtr +} diff --git a/example/custom_tags/env.example b/example/custom_tags/env.example new file mode 100644 index 0000000..c217c34 --- /dev/null +++ b/example/custom_tags/env.example @@ -0,0 +1,23 @@ +# Example environment values. +# This file will be read by the Git Action when testing the example. + +# My base URL is where the application would be visible to the outside world. This is used when sending links to users. +MY_BASE_URL=https://sample.org/ + +# WhatsApp Phone ID is the phone number ID of the WhatsApp Business account. +WHATSAPP_PHONE_ID=1234567890 + +# WhatsApp Server URL is the server URL for the WhatsApp Business API. +WHATSAPP_SERVER_URL=https://api.whatsapp.com/ + +# WhatsApp Token is the token for the WhatsApp Business API. +WHATSAPP_AUTH_TOKEN=some-top-secret-token + +# WhatsApp Challenge is the challenge for the WhatsApp Business API. +WHATSAPP_CHALLENGE=some-top-secret-challenge + +# Server Port is my server port inside my container. +SERVER_PORT=8080 + +# Health port is the port for the health check. +HEALTH_PORT=8081 diff --git a/example/custom_tags/main.go b/example/custom_tags/main.go new file mode 100644 index 0000000..da25f3a --- /dev/null +++ b/example/custom_tags/main.go @@ -0,0 +1,25 @@ +package main + +import ( + "fmt" + "log" + + config2 "github.com/m0rjc/goconfig/example/custom_types/config" +) + +func main() { + config, err := config2.LoadConfig() + if err != nil { + log.Fatal(err) + } + + // Given this is purely a demo, we don't have to worry about printing real secrets. + fmt.Printf(`The following configuration has been loaded: + Base URL: %s +WhatsApp Server URL: %s +WhatsApp Auth Token: %s + WhatsApp Challenge: %s + My Server Port: %d + Health Port: %d +`, config.MyBaseURL, config.WhatsAppServerUrl, config.WhatsAppAuthToken, config.WhatsAppChallenge, config.ServerPort, config.HealthPort) +} diff --git a/example/custom_types/README.md b/example/custom_types/README.md new file mode 100644 index 0000000..b57ec79 --- /dev/null +++ b/example/custom_types/README.md @@ -0,0 +1,55 @@ +# Custom URL Types + +This is how I registered types for URL and Secure URL in my wide-game-bot project. I've since added an URL type to this +library, though the code here still works. + +My example registers `*net.URL` and `*SecureURL` globally. That wasn't really necessary. I only have one config load +call and could have overridden them using options in the call to Load. If I wanted all URLs to be secure then I could +have used my Secure URL setup as the override for `*net.URL` at that level. + +My project has a large central config package which all the other components depend on. It's not very encapsulated +of them, but fine at the moment in a monolithic application like that. If I ever split the configuration, so that each package +exports its own configuration structure, I'll have to look at how best to set up this tool to use them. (I can imagine +a key prefix mechanism being useful and easy to implement). The monolithic configuration package does mean that all recognized +environment variables are listed in one place, and using `goconfig` has greatly reduced the noise in that file. + +This sample will fall back to `env.example` in the current working directory. + +## Testing + +If you run the sample from this working directory, it will output the values from `env.example`. + +You can demonstrate the custom Secure URL validation using + +```bash +export WHATSAPP_SERVER_URL=http://localhost:3000/ +go run . +``` + +You can demonstrate that explicitly blank values can override both defaults and later keystore (the `env.example` file) + +```bash +export WHATSAPP_SERVER_URL= +go run . +``` + +You can demonstrate that the `SecureURL` type is based on, but does not change, the `net.URL` type by providing a non-secure +URL for `MY_BASE_URL` + +```bash +export MY_BASE_URL=http://localhost:3000 +go run . +``` + +## Learning Points + +When I created my custom type `SecureURL` I lost all the methods that `net.URL` had. This came as a surprise. It's not +simple inheritance as in other languages. My sample provides a `String()` method that prints the URL in a format that +`net.URL` would print. By the time this is released I will have added native support for `net.URL` into `goconfig` +with the ability to use struct tags to validate the scheme for `https`. This will remove the need for `SecureURL`. + +I could have used a custom `Wrapper` to add a `secure:bool` tag to the `URL` type. +I will do this in another demonstration. +The simplest solution using `goconfig` as of `v0.3.0` would have been to use the existing `pattern` tag. + +See ../custom_tags \ No newline at end of file diff --git a/example/custom_types/config/config.go b/example/custom_types/config/config.go new file mode 100644 index 0000000..2b2ec9b --- /dev/null +++ b/example/custom_types/config/config.go @@ -0,0 +1,39 @@ +package config + +import ( + "context" + "net/url" + + "github.com/m0rjc/goconfig" +) + +type Config struct { + // MyBaseURL is the base URL for my web interface. This is used when sharing links in messages to direct the user + // to my web interface. + MyBaseURL *url.URL `key:"MY_BASE_URL" required:"false" default:"http://localhost:8080"` + // WhatsAppPhoneId is the phone number ID for my phone number + WhatsAppPhoneId string `key:"WHATSAPP_PHONE_ID" required:"true" pattern:"^[0-9]+$"` + // WhatsAppServerUrl is the URL of the WhatsApp Business API server. + WhatsAppServerUrl *SecureURL `key:"WHATSAPP_SERVER_URL" required:"true" default:"https://api.whatsapp.com"` + // WhatsAppAuthToken is the authentication token for the WhatsApp Business API. + WhatsAppAuthToken string `key:"WHATSAPP_AUTH_TOKEN" required:"true"` + // WhatsAppChallenge is the challenge token sent by the WhatsApp Business API. + WhatsAppChallenge string `key:"WHATSAPP_CHALLENGE"` + // ServerPort is the port on which the server will listen for incoming public requests. + ServerPort int `key:"SERVER_PORT" required:"true" default:"8080" min:"1024" max:"65535"` + // HealthPort is the port on which the server will listen for health checks. + // This can be forced off by setting the key to the empty string. + HealthPort *int `key:"HEALTH_PORT" default:"8081" min:"1024" max:"65535"` +} + +func LoadConfig() (*Config, error) { + var config Config + keystore := goconfig.CompositeStore( + fakeSecretsKeyStore, + goconfig.EnvironmentKeyStore, + goconfig.NewEnvFileKeyStore("env.example")) + if err := goconfig.Load(context.Background(), &config, goconfig.WithKeyStore(keystore)); err != nil { + return nil, err + } + return &config, nil +} diff --git a/example/custom_types/config/fake_secrets_keystore.go b/example/custom_types/config/fake_secrets_keystore.go new file mode 100644 index 0000000..ea873ee --- /dev/null +++ b/example/custom_types/config/fake_secrets_keystore.go @@ -0,0 +1,15 @@ +package config + +import ( + "context" + "os" +) + +// fakeSecretsKeyStore is a fake keystore that reads secrets from environment variables. +// The environment variables must be prefixed with SECRET_. +// A production keystore could read secrets from a secure store, though I've just had Kubernetes inject them into +// the environment and used the normal keystore. +func fakeSecretsKeyStore(ctx context.Context, key string) (string, bool, error) { + value, ok := os.LookupEnv("SECRET_" + key) + return value, ok, nil +} diff --git a/example/custom_types/config/init.go b/example/custom_types/config/init.go new file mode 100644 index 0000000..1b9668d --- /dev/null +++ b/example/custom_types/config/init.go @@ -0,0 +1,11 @@ +package config + +import ( + "github.com/m0rjc/goconfig" +) + +// init initializes and registers custom types for URL handling and validation, including secure HTTPS URL enforcement. +func init() { + goconfig.RegisterCustomType(typeUrlPtr) + goconfig.RegisterCustomType(typeSecureUrlPtr) +} diff --git a/example/custom_types/config/url_types.go b/example/custom_types/config/url_types.go new file mode 100644 index 0000000..5d70eb4 --- /dev/null +++ b/example/custom_types/config/url_types.go @@ -0,0 +1,46 @@ +package config + +import ( + "errors" + "net/url" + + "github.com/m0rjc/goconfig" +) + +// SecureURL is a custom type based on url.URL that represents a URL restricted to secure HTTPS schemes only. +type SecureURL url.URL + +// ErrMustBeSecureUrl is returned when a URL does not use the HTTPS scheme, indicating it must be secure. +var ErrMustBeSecureUrl = errors.New("must be a secure URL") + +// String returns the string representation of the SecureURL. +// This is required to allow printing +func (s *SecureURL) String() string { + if s == nil { + return "" + } + u := url.URL(*s) + return u.String() +} + +// typeUrlPtr is a custom type parser for URL values. +// Start with the base string type so that we gain the built-in regex validator +var typeUrlPtr = goconfig.TransformCustomType[string, *url.URL]( + goconfig.DefaultStringType[string](), + func(rawValue string) (*url.URL, error) { + value, err := url.ParseRequestURI(rawValue) + if err != nil { + return nil, err + } + return value, nil + }) + +// typeSecureUrlPtr adds a validator to ensure the URL is secure before casting it to SecureURL. +var typeSecureUrlPtr = goconfig.CastCustomType[*url.URL, *SecureURL]( + goconfig.AddValidators(typeUrlPtr, func(value *url.URL) error { + if value.Scheme != "https" { + return ErrMustBeSecureUrl + } + return nil + }), +) diff --git a/example/custom_types/env.example b/example/custom_types/env.example new file mode 100644 index 0000000..c217c34 --- /dev/null +++ b/example/custom_types/env.example @@ -0,0 +1,23 @@ +# Example environment values. +# This file will be read by the Git Action when testing the example. + +# My base URL is where the application would be visible to the outside world. This is used when sending links to users. +MY_BASE_URL=https://sample.org/ + +# WhatsApp Phone ID is the phone number ID of the WhatsApp Business account. +WHATSAPP_PHONE_ID=1234567890 + +# WhatsApp Server URL is the server URL for the WhatsApp Business API. +WHATSAPP_SERVER_URL=https://api.whatsapp.com/ + +# WhatsApp Token is the token for the WhatsApp Business API. +WHATSAPP_AUTH_TOKEN=some-top-secret-token + +# WhatsApp Challenge is the challenge for the WhatsApp Business API. +WHATSAPP_CHALLENGE=some-top-secret-challenge + +# Server Port is my server port inside my container. +SERVER_PORT=8080 + +# Health port is the port for the health check. +HEALTH_PORT=8081 diff --git a/example/custom_types/main.go b/example/custom_types/main.go new file mode 100644 index 0000000..da25f3a --- /dev/null +++ b/example/custom_types/main.go @@ -0,0 +1,25 @@ +package main + +import ( + "fmt" + "log" + + config2 "github.com/m0rjc/goconfig/example/custom_types/config" +) + +func main() { + config, err := config2.LoadConfig() + if err != nil { + log.Fatal(err) + } + + // Given this is purely a demo, we don't have to worry about printing real secrets. + fmt.Printf(`The following configuration has been loaded: + Base URL: %s +WhatsApp Server URL: %s +WhatsApp Auth Token: %s + WhatsApp Challenge: %s + My Server Port: %d + Health Port: %d +`, config.MyBaseURL, config.WhatsAppServerUrl, config.WhatsAppAuthToken, config.WhatsAppChallenge, config.ServerPort, config.HealthPort) +} diff --git a/example/simple/main.go b/example/simple/main.go index 3d7b231..8d978d4 100644 --- a/example/simple/main.go +++ b/example/simple/main.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "log" + "net/url" "time" "github.com/m0rjc/goconfig" @@ -11,8 +12,9 @@ import ( // WebhookConfig holds webhook-related configuration type WebhookConfig struct { - Path string `key:"WEBHOOK_PATH" default:"webhook"` - Timeout time.Duration `key:"WEBHOOK_TIMEOUT"` // No default here + ServerUrl *url.URL `key:"SERVER_URL" default:"http://localhost:8080" scheme:"http,https"` + Path string `key:"WEBHOOK_PATH" default:"webhook"` + Timeout time.Duration `key:"WEBHOOK_TIMEOUT"` // No default here } // AIConfig holds AI-related configuration (OpenAI, conversation state, etc.) @@ -38,6 +40,7 @@ func main() { // Print the loaded configuration fmt.Println("Configuration loaded successfully:") + fmt.Printf(" Webhook URL: %s\n", config.WebHook.ServerUrl) fmt.Printf(" AI.APIKey: %s\n", maskAPIKey(config.AI.APIKey)) fmt.Printf(" AI.Model: %s\n", config.AI.Model) fmt.Printf(" WebHook.Path: %s\n", config.WebHook.Path) diff --git a/internal/customtypes/transformer.go b/internal/customtypes/transformer.go index 75a54ac..42a1875 100644 --- a/internal/customtypes/transformer.go +++ b/internal/customtypes/transformer.go @@ -7,9 +7,13 @@ import ( "github.com/m0rjc/goconfig/internal/readpipeline" ) +// Transform is a function that transforms a value of type T to a value of type U. +type Transform[T, U any] func(T) (U, error) + +// transformer wraps a TypedHandler and applies a Transform function to the result. type transformer[T, U any] struct { Prior readpipeline.TypedHandler[T] - Cast func(T) (U, error) + Cast Transform[T, U] } func (t *transformer[T, U]) BuildPipeline(tags reflect.StructTag) (readpipeline.FieldProcessor[U], error) { @@ -40,7 +44,13 @@ func (b *badTransformer[T]) BuildPipeline(tags reflect.StructTag) (readpipeline. return nil, b.Err } -func NewTransformer[T, U any](handler readpipeline.TypedHandler[T]) readpipeline.TypedHandler[U] { +// NewTransformer creates a TypedHandler that applies a Transform function to the result of the prior handler. +func NewTransformer[T, U any](prior readpipeline.TypedHandler[T], transform Transform[T, U]) readpipeline.TypedHandler[U] { + return &transformer[T, U]{Prior: prior, Cast: transform} +} + +// NewCastingTransformer creates a TypedHandler that casts the result of the prior handler to the target type. +func NewCastingTransformer[T, U any](handler readpipeline.TypedHandler[T]) readpipeline.TypedHandler[U] { sourceType := reflect.TypeOf((*T)(nil)).Elem() newType := reflect.TypeOf((*U)(nil)).Elem() if !sourceType.ConvertibleTo(newType) { diff --git a/internal/customtypes/transformer_test.go b/internal/customtypes/transformer_test.go index e4a40d0..ae0eca5 100644 --- a/internal/customtypes/transformer_test.go +++ b/internal/customtypes/transformer_test.go @@ -14,7 +14,7 @@ func TestNewTransformer(t *testing.T) { }) t.Run("ValidConversion", func(t *testing.T) { - handler := NewTransformer[Source, Target](sourceHandler) + handler := NewCastingTransformer[Source, Target](sourceHandler) pipeline, err := handler.BuildPipeline("") if err != nil { t.Fatalf("BuildPipeline failed: %v", err) @@ -34,7 +34,7 @@ func TestNewTransformer(t *testing.T) { // Actually, int to string conversion is allowed in Go but let's use something that is definitely not convertible type Unrelated struct{ X int } - handler := NewTransformer[Source, Unrelated](sourceHandler) + handler := NewCastingTransformer[Source, Unrelated](sourceHandler) _, err := handler.BuildPipeline("") if err == nil { t.Error("expected error for incompatible types, got nil") @@ -45,7 +45,7 @@ func TestNewTransformer(t *testing.T) { errHandler := NewParser[Source](func(rawValue string) (Source, error) { return "", errors.New("upstream error") }) - handler := NewTransformer[Source, Target](errHandler) + handler := NewCastingTransformer[Source, Target](errHandler) pipeline, err := handler.BuildPipeline("") if err != nil { t.Fatalf("BuildPipeline failed: %v", err) diff --git a/internal/readpipeline/json_types.go b/internal/readpipeline/json_types.go index bd2d96c..05db76d 100644 --- a/internal/readpipeline/json_types.go +++ b/internal/readpipeline/json_types.go @@ -2,6 +2,7 @@ package readpipeline import ( "encoding/json" + "fmt" "reflect" ) @@ -12,7 +13,9 @@ func NewJsonPipelineBuilder(targetType reflect.Type) TypedHandler[any] { err := json.Unmarshal([]byte(rawValue), ptr) if err != nil { - return nil, err + // We arrive here quite often if the system has not recognized the type. + // Many types are structs under the covers. + return nil, fmt.Errorf("error parsing json: %w", err) } // Dereference the value to maintain consistency with the maxim "Pipelines always readpipeline values" diff --git a/internal/readpipeline/process.go b/internal/readpipeline/process.go index f60bf13..78aa105 100644 --- a/internal/readpipeline/process.go +++ b/internal/readpipeline/process.go @@ -11,15 +11,15 @@ import ( // The caller is responsible for assigning the value to the struct field, dealing with pointers as needed. func New(fieldType reflect.Type, tags reflect.StructTag, registry TypeRegistry) (FieldProcessor[any], error) { targetType := fieldType - isPointer := fieldType.Kind() == reflect.Ptr + handler := registry.HandlerFor(targetType) - if isPointer { - // Pointer writing is handled by the setFieldValue side of the readpipeline - // in config.go - targetType = targetType.Elem() + // If the target type is a pointer, try to find a handler for the underlying type + // Assignment back to the pointer will be handled by the caller's write pipeline + if handler == nil && fieldType.Kind() == reflect.Ptr { + targetType = fieldType.Elem() + handler = registry.HandlerFor(targetType) } - handler := registry.HandlerFor(targetType) if handler == nil { return nil, fmt.Errorf("no handler for type %s", targetType) } diff --git a/internal/readpipeline/typeregistry.go b/internal/readpipeline/typeregistry.go index 716cd6c..a9032f6 100644 --- a/internal/readpipeline/typeregistry.go +++ b/internal/readpipeline/typeregistry.go @@ -1,6 +1,7 @@ package readpipeline import ( + "net/url" "reflect" "time" ) @@ -9,7 +10,7 @@ import ( type HandlerFactory func(t reflect.Type) PipelineBuilder // TypedHandlerFactory is a function that returns a TypedHandler for a given type. -type TypedHandlerFactory[T any] func(t reflect.Type) TypedHandler[any] +type TypedHandlerFactory[T any] func(t reflect.Type) TypedHandler[T] type TypeRegistry interface { RegisterType(t reflect.Type, handler PipelineBuilder) @@ -62,6 +63,11 @@ func (r *rootTypeRegistry) RegisterType(t reflect.Type, handler PipelineBuilder) r.specialTypeHandlers[t] = handler } +// RegisterKind registers a factory method for a given Kind. +func (r *rootTypeRegistry) RegisterKind(kind reflect.Kind, factory HandlerFactory) { + r.kindHandlers[kind] = factory +} + // HandlerFor returns the PipelineBuilder for the given type, or nil if none is registered. func (r *rootTypeRegistry) HandlerFor(t reflect.Type) PipelineBuilder { // 1. Check for specific type overrides (The "Duration" check) @@ -95,31 +101,39 @@ func (a typedHandlerAdapter[T]) Build(tags reflect.StructTag) (FieldProcessor[an }, nil } -// WrapTypedHandler wraps a TypedHandler[T] as a PipelineBuilder. +// WrapTypedHandler wraps a TypedHandler[T] as a PipelineBuilder for use in the typeless registry. func WrapTypedHandler[T any](handler TypedHandler[T]) PipelineBuilder { return typedHandlerAdapter[T]{Handler: handler} } +// WrapKindHandler wraps a TypedHandlerFactory[T] as a HandlerFactory for use in the typeless registry. +func WrapKindHandler[T any](handler TypedHandlerFactory[T]) HandlerFactory { + return func(t reflect.Type) PipelineBuilder { + return WrapTypedHandler(handler(t)) + } +} + var rootRegistry = &rootTypeRegistry{ specialTypeHandlers: map[reflect.Type]PipelineBuilder{ reflect.TypeOf(time.Duration(0)): WrapTypedHandler(durationTypeHandler), + reflect.TypeOf((*url.URL)(nil)): WrapTypedHandler(NewUrlTypedHandler()), }, kindHandlers: map[reflect.Kind]HandlerFactory{ - reflect.Int: func(t reflect.Type) PipelineBuilder { return WrapTypedHandler(NewIntHandler(t)) }, - reflect.Int8: func(t reflect.Type) PipelineBuilder { return WrapTypedHandler(NewIntHandler(t)) }, - reflect.Int16: func(t reflect.Type) PipelineBuilder { return WrapTypedHandler(NewIntHandler(t)) }, - reflect.Int32: func(t reflect.Type) PipelineBuilder { return WrapTypedHandler(NewIntHandler(t)) }, - reflect.Int64: func(t reflect.Type) PipelineBuilder { return WrapTypedHandler(NewIntHandler(t)) }, - reflect.Uint: func(t reflect.Type) PipelineBuilder { return WrapTypedHandler(NewUintHandler(t)) }, - reflect.Uint8: func(t reflect.Type) PipelineBuilder { return WrapTypedHandler(NewUintHandler(t)) }, - reflect.Uint16: func(t reflect.Type) PipelineBuilder { return WrapTypedHandler(NewUintHandler(t)) }, - reflect.Uint32: func(t reflect.Type) PipelineBuilder { return WrapTypedHandler(NewUintHandler(t)) }, - reflect.Uint64: func(t reflect.Type) PipelineBuilder { return WrapTypedHandler(NewUintHandler(t)) }, - reflect.Struct: func(t reflect.Type) PipelineBuilder { return WrapTypedHandler(NewJsonPipelineBuilder(t)) }, - reflect.Map: func(t reflect.Type) PipelineBuilder { return WrapTypedHandler(NewJsonPipelineBuilder(t)) }, - reflect.String: func(t reflect.Type) PipelineBuilder { return WrapTypedHandler(NewStringHandler(t)) }, - reflect.Bool: func(t reflect.Type) PipelineBuilder { return WrapTypedHandler(NewBoolHandler(t)) }, - reflect.Float32: func(t reflect.Type) PipelineBuilder { return WrapTypedHandler(NewFloatHandler(t)) }, - reflect.Float64: func(t reflect.Type) PipelineBuilder { return WrapTypedHandler(NewFloatHandler(t)) }, + reflect.Int: WrapKindHandler(NewIntHandler), + reflect.Int8: WrapKindHandler(NewIntHandler), + reflect.Int16: WrapKindHandler(NewIntHandler), + reflect.Int32: WrapKindHandler(NewIntHandler), + reflect.Int64: WrapKindHandler(NewIntHandler), + reflect.Uint: WrapKindHandler(NewUintHandler), + reflect.Uint8: WrapKindHandler(NewUintHandler), + reflect.Uint16: WrapKindHandler(NewUintHandler), + reflect.Uint32: WrapKindHandler(NewUintHandler), + reflect.Uint64: WrapKindHandler(NewUintHandler), + reflect.Struct: WrapKindHandler(NewJsonPipelineBuilder), + reflect.Map: WrapKindHandler(NewJsonPipelineBuilder), + reflect.String: WrapKindHandler(NewStringHandler), + reflect.Bool: WrapKindHandler(NewBoolHandler), + reflect.Float32: WrapKindHandler(NewFloatHandler), + reflect.Float64: WrapKindHandler(NewFloatHandler), }, } diff --git a/internal/readpipeline/typeregistry_test.go b/internal/readpipeline/typeregistry_test.go index 65f2f49..02c5b33 100644 --- a/internal/readpipeline/typeregistry_test.go +++ b/internal/readpipeline/typeregistry_test.go @@ -2,6 +2,7 @@ package readpipeline import ( "errors" + "net/url" "reflect" "testing" "time" @@ -258,6 +259,7 @@ func TestDefaultHandlers(t *testing.T) { {"Float32", float32(0)}, {"Float64", float64(0)}, {"Duration", time.Duration(0)}, + {"URL", (*url.URL)(nil)}, {"Struct", struct{ X int }{}}, {"Map", map[string]int{}}, } diff --git a/internal/readpipeline/url_type.go b/internal/readpipeline/url_type.go new file mode 100644 index 0000000..66371de --- /dev/null +++ b/internal/readpipeline/url_type.go @@ -0,0 +1,48 @@ +package readpipeline + +import ( + "fmt" + "net/url" + "reflect" + "regexp" + "strings" +) + +func NewUrlTypedHandler() TypedHandler[*url.URL] { + return &typeHandlerImpl[*url.URL]{ + Parser: url.ParseRequestURI, + ValidationWrapper: wrapUrlPipeline, + } +} + +func wrapUrlPipeline(tags reflect.StructTag, pipeline FieldProcessor[*url.URL]) (FieldProcessor[*url.URL], error) { + patternTag := tags.Get("pattern") + if patternTag != "" { + pattern, err := regexp.Compile(patternTag) + if err != nil { + return nil, err + } + pipeline = Pipe(pipeline, func(value *url.URL) error { + if !pattern.MatchString(value.String()) { + return fmt.Errorf("does not match pattern %s", patternTag) + } + return nil + }) + } + + // scheme is a command separated list of acceptable schemes, for example `http,https` or `imaps` + schemeTag := tags.Get("scheme") + if schemeTag != "" { + schemes := strings.Split(schemeTag, ",") + pipeline = Pipe(pipeline, func(value *url.URL) error { + for _, scheme := range schemes { + if scheme == value.Scheme { + return nil + } + } + return fmt.Errorf("scheme must be one of %s", strings.Join(schemes, ", ")) + }) + } + + return pipeline, nil +} diff --git a/internal/readpipeline/url_type_test.go b/internal/readpipeline/url_type_test.go new file mode 100644 index 0000000..5f58e33 --- /dev/null +++ b/internal/readpipeline/url_type_test.go @@ -0,0 +1,115 @@ +package readpipeline + +import ( + "net/url" + "reflect" + "testing" +) + +func TestUrlTypedHandler(t *testing.T) { + handler := NewUrlTypedHandler() + if handler == nil { + t.Fatal("NewUrlTypedHandler returned nil") + } + + t.Run("ValidURL", func(t *testing.T) { + pipeline, err := handler.BuildPipeline("") + if err != nil { + t.Fatalf("BuildPipeline failed: %v", err) + } + + var u *url.URL + u, err = pipeline("http://example.com/path?q=1") + if err != nil { + t.Fatalf("pipeline failed: %v", err) + } + + if u.String() != "http://example.com/path?q=1" { + t.Errorf("expected http://example.com/path?q=1, got %s", u.String()) + } + }) + + t.Run("InvalidURL", func(t *testing.T) { + pipeline, err := handler.BuildPipeline("") + if err != nil { + t.Fatalf("BuildPipeline failed: %v", err) + } + + _, err = pipeline("not a url") + if err == nil { + t.Error("expected error for invalid URL, got nil") + } + }) + + t.Run("PatternValidation", func(t *testing.T) { + tags := reflect.StructTag(`pattern:"^https://.*"`) + pipeline, err := handler.BuildPipeline(tags) + if err != nil { + t.Fatalf("BuildPipeline failed: %v", err) + } + + _, err = pipeline("https://example.com") + if err != nil { + t.Errorf("expected success for https://example.com, got %v", err) + } + + _, err = pipeline("http://example.com") + if err == nil { + t.Error("expected error for http://example.com (not matching pattern), got nil") + } + }) + + t.Run("InvalidPattern", func(t *testing.T) { + tags := reflect.StructTag(`pattern:"["`) + _, err := handler.BuildPipeline(tags) + if err == nil { + t.Error("expected error for invalid pattern, got nil") + } + }) + + t.Run("SchemeValidation", func(t *testing.T) { + tags := reflect.StructTag(`scheme:"https,mailto"`) + pipeline, err := handler.BuildPipeline(tags) + if err != nil { + t.Fatalf("BuildPipeline failed: %v", err) + } + + _, err = pipeline("https://example.com") + if err != nil { + t.Errorf("expected success for https, got %v", err) + } + + _, err = pipeline("mailto:user@example.com") + if err != nil { + t.Errorf("expected success for mailto, got %v", err) + } + + _, err = pipeline("http://example.com") + if err == nil { + t.Error("expected error for http, got nil") + } + }) + + t.Run("CombinedValidation", func(t *testing.T) { + tags := reflect.StructTag(`scheme:"https" pattern:".*example\\.com.*"`) + pipeline, err := handler.BuildPipeline(tags) + if err != nil { + t.Fatalf("BuildPipeline failed: %v", err) + } + + _, err = pipeline("https://example.com/test") + if err != nil { + t.Errorf("expected success, got %v", err) + } + + _, err = pipeline("https://other.com") + if err == nil { + t.Error("expected pattern error, got nil") + } + + _, err = pipeline("http://example.com") + if err == nil { + t.Error("expected scheme error, got nil") + } + }) +}