feat(config): 配置加载与 schema 校验(.env/env 注入 + 反射解码 + 校验)
This commit is contained in:
1 parent
0236010136
commit
efed5f05b1
5 files changed
+551
No files matched your search
@@ -0,0 +1,137 @@
|
|||||||
|
// Package config loads, defaults, and validates the Cyrene v2 configuration
|
||||||
|
// from environment variables and an optional .env file (development only).
|
||||||
|
//
|
||||||
|
// Every setting is sourced from an environment variable (no hard-coded values
|
||||||
|
// in code). Nested groups map to struct fields tagged with `env:"NAME"`.
|
||||||
|
// Missing required values cause Load/MustLoad to return an error so startup
|
||||||
|
// can fail fast.
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config is the root configuration tree.
|
||||||
|
type Config struct {
|
||||||
|
Env string `env:"ENV" default:"development"`
|
||||||
|
LogLevel string `env:"LOG_LEVEL" default:"debug"`
|
||||||
|
Timezone string `env:"TZ" default:"Asia/Shanghai"`
|
||||||
|
DataDir string `env:"DATA_DIR" default:"./data"`
|
||||||
|
AllowedOrigins []string `env:"ALLOWED_ORIGINS" default:"http://localhost:5173,http://localhost:3000"`
|
||||||
|
|
||||||
|
Server ServerConfig
|
||||||
|
Auth AuthConfig
|
||||||
|
DB DBConfig
|
||||||
|
LLM LLMConfig
|
||||||
|
Embedding EmbeddingConfig
|
||||||
|
IoT IoTConfig
|
||||||
|
Voice VoiceConfig
|
||||||
|
Background BackgroundConfig
|
||||||
|
WSHub WSHubConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServerConfig holds the single gateway listener settings.
|
||||||
|
type ServerConfig struct {
|
||||||
|
Host string `env:"HOST" default:"0.0.0.0"`
|
||||||
|
Port int `env:"PORT" default:"8080"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthConfig holds authentication and admin credential settings.
|
||||||
|
type AuthConfig struct {
|
||||||
|
JWTSecret string `env:"JWT_SECRET" validate:"required,min=32"`
|
||||||
|
AdminUser string `env:"ADMIN_USERNAME" default:"admin"`
|
||||||
|
AdminPass string `env:"ADMIN_PASSWORD" default:"admin12345"`
|
||||||
|
RegistrationEnabled bool `env:"REGISTRATION_ENABLED" default:"true"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DBConfig holds database connection settings. SQLite is the default; PGURL is
|
||||||
|
// the optional PostgreSQL+pgvector enhancement.
|
||||||
|
type DBConfig struct {
|
||||||
|
SQLitePath string `env:"DB_SQLITE_PATH" default:"./data/cyrene.db"`
|
||||||
|
PGURL string `env:"DB_PG_URL"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LLMConfig holds the multi-supplier LLM adapter settings.
|
||||||
|
type LLMConfig struct {
|
||||||
|
BaseURL string `env:"LLM_BASE_URL"`
|
||||||
|
APIKey string `env:"LLM_API_KEY"`
|
||||||
|
Model string `env:"LLM_MODEL"`
|
||||||
|
Fallback string `env:"LLM_FALLBACK_MODEL"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// EmbeddingConfig holds the optional semantic embedding API settings. When
|
||||||
|
// unset, the runtime falls back to built-in n-gram/FTS vectors.
|
||||||
|
type EmbeddingConfig struct {
|
||||||
|
APIURL string `env:"EMBEDDING_API_URL"`
|
||||||
|
APIKey string `env:"EMBEDDING_API_KEY"`
|
||||||
|
Model string `env:"EMBEDDING_MODEL"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// IoTConfig selects the IoT device provider.
|
||||||
|
type IoTConfig struct {
|
||||||
|
Provider string `env:"IOT_PROVIDER" default:"mock"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// VoiceConfig holds TTS/STT provider settings.
|
||||||
|
type VoiceConfig struct {
|
||||||
|
TTSProvider string `env:"TTS_PROVIDER" default:"edge-tts"`
|
||||||
|
STTProvider string `env:"STT_PROVIDER" default:"dashscope"`
|
||||||
|
DashscopeAPIKey string `env:"DASHSCOPE_API_KEY"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BackgroundConfig holds the autonomous thinking settings.
|
||||||
|
type BackgroundConfig struct {
|
||||||
|
Enabled bool `env:"ENABLE_BACKGROUND_THINKING" default:"true"`
|
||||||
|
IntervalSec int `env:"THINK_INTERVAL_SEC" default:"300"`
|
||||||
|
SilenceTimeout int `env:"THINK_SILENCE_TIMEOUT_SEC" default:"120"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WSHubConfig holds the WebSocket hub limits.
|
||||||
|
type WSHubConfig struct {
|
||||||
|
MaxConnections int `env:"WS_MAX_CONNECTIONS" default:"1000"`
|
||||||
|
SessionIdleTimeout int `env:"SESSION_IDLE_TIMEOUT_MIN" default:"30"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load reads configuration, merging an optional .env file (development only)
|
||||||
|
// with process environment variables. Process environment variables always take
|
||||||
|
// precedence over .env values, which take precedence over code defaults.
|
||||||
|
//
|
||||||
|
// Passing an empty envPath skips .env entirely. A missing .env file is not an
|
||||||
|
// error; only missing required values are.
|
||||||
|
func Load(envPath string) (*Config, error) {
|
||||||
|
fromEnv := map[string]string{}
|
||||||
|
if envPath != "" {
|
||||||
|
m, err := parseDotEnv(envPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("config: read %s: %w", envPath, err)
|
||||||
|
}
|
||||||
|
fromEnv = m
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := &Config{}
|
||||||
|
if err := decodeValue(reflect.ValueOf(cfg), fromEnv); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if errs := validateStruct(reflect.ValueOf(cfg).Elem()); len(errs) > 0 {
|
||||||
|
parts := make([]string, 0, len(errs))
|
||||||
|
for _, e := range errs {
|
||||||
|
parts = append(parts, e.Error())
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("config: validation failed: %s", strings.Join(parts, "; "))
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MustLoad is Load that panics on error. It is intended for startup, matching
|
||||||
|
// the "schema validation fails at boot" rule.
|
||||||
|
func MustLoad(envPath string) *Config {
|
||||||
|
cfg, err := Load(envPath)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func writeEnvFile(t *testing.T, content string) string {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, ".env")
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||||
|
t.Fatalf("write env file: %v", err)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoad_DefaultsWhenUnset(t *testing.T) {
|
||||||
|
t.Setenv("JWT_SECRET", strings.Repeat("s", 40))
|
||||||
|
|
||||||
|
cfg, err := Load("")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load() error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Env != "development" {
|
||||||
|
t.Errorf("Env = %q, want development", cfg.Env)
|
||||||
|
}
|
||||||
|
if cfg.Server.Host != "0.0.0.0" || cfg.Server.Port != 8080 {
|
||||||
|
t.Errorf("Server = %+v, want 0.0.0.0:8080", cfg.Server)
|
||||||
|
}
|
||||||
|
if cfg.LogLevel != "debug" {
|
||||||
|
t.Errorf("LogLevel = %q, want debug", cfg.LogLevel)
|
||||||
|
}
|
||||||
|
if cfg.DB.SQLitePath != "./data/cyrene.db" {
|
||||||
|
t.Errorf("SQLitePath = %q, want ./data/cyrene.db", cfg.DB.SQLitePath)
|
||||||
|
}
|
||||||
|
want := []string{"http://localhost:5173", "http://localhost:3000"}
|
||||||
|
if !reflect.DeepEqual(cfg.AllowedOrigins, want) {
|
||||||
|
t.Errorf("AllowedOrigins = %v, want %v", cfg.AllowedOrigins, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoad_EnvOverridesDefault(t *testing.T) {
|
||||||
|
t.Setenv("JWT_SECRET", strings.Repeat("s", 40))
|
||||||
|
t.Setenv("PORT", "9090")
|
||||||
|
t.Setenv("LOG_LEVEL", "warn")
|
||||||
|
t.Setenv("IOT_PROVIDER", "none")
|
||||||
|
|
||||||
|
cfg, err := Load("")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load() error: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.Server.Port != 9090 {
|
||||||
|
t.Errorf("Port = %d, want 9090", cfg.Server.Port)
|
||||||
|
}
|
||||||
|
if cfg.LogLevel != "warn" {
|
||||||
|
t.Errorf("LogLevel = %q, want warn", cfg.LogLevel)
|
||||||
|
}
|
||||||
|
if cfg.IoT.Provider != "none" {
|
||||||
|
t.Errorf("IoT.Provider = %q, want none", cfg.IoT.Provider)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoad_DotEnvThenProcessEnv(t *testing.T) {
|
||||||
|
path := writeEnvFile(t, `PORT=7000
|
||||||
|
LOG_LEVEL=error
|
||||||
|
# comment line
|
||||||
|
IOT_PROVIDER=none
|
||||||
|
`)
|
||||||
|
// Process env overrides .env for PORT but leaves LOG_LEVEL from .env.
|
||||||
|
t.Setenv("JWT_SECRET", strings.Repeat("s", 40))
|
||||||
|
t.Setenv("PORT", "8000")
|
||||||
|
|
||||||
|
cfg, err := Load(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load(%q) error: %v", path, err)
|
||||||
|
}
|
||||||
|
if cfg.Server.Port != 8000 {
|
||||||
|
t.Errorf("Port = %d, want 8000 (process env wins)", cfg.Server.Port)
|
||||||
|
}
|
||||||
|
if cfg.LogLevel != "error" {
|
||||||
|
t.Errorf("LogLevel = %q, want error from .env", cfg.LogLevel)
|
||||||
|
}
|
||||||
|
if cfg.IoT.Provider != "none" {
|
||||||
|
t.Errorf("IoT.Provider = %q, want none from .env", cfg.IoT.Provider)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoad_MissingRequiredFails(t *testing.T) {
|
||||||
|
// Do not set JWT_SECRET.
|
||||||
|
if _, err := Load(""); err == nil {
|
||||||
|
t.Fatal("expected error when JWT_SECRET missing, got nil")
|
||||||
|
} else if !strings.Contains(err.Error(), "JWT_SECRET") {
|
||||||
|
t.Errorf("error should mention JWT_SECRET, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoad_ValidationMinLength(t *testing.T) {
|
||||||
|
t.Setenv("JWT_SECRET", "short")
|
||||||
|
if _, err := Load(""); err == nil {
|
||||||
|
t.Fatal("expected error for JWT_SECRET shorter than 32, got nil")
|
||||||
|
} else if !strings.Contains(err.Error(), "at least 32") {
|
||||||
|
t.Errorf("error should mention min length, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoad_InvalidPortFails(t *testing.T) {
|
||||||
|
t.Setenv("JWT_SECRET", strings.Repeat("s", 40))
|
||||||
|
t.Setenv("PORT", "not-a-number")
|
||||||
|
if _, err := Load(""); err == nil {
|
||||||
|
t.Fatal("expected error for invalid PORT, got nil")
|
||||||
|
} else if !strings.Contains(err.Error(), "PORT") {
|
||||||
|
t.Errorf("error should mention PORT, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoad_MissingDotEnvIsTolerated(t *testing.T) {
|
||||||
|
t.Setenv("JWT_SECRET", strings.Repeat("s", 40))
|
||||||
|
// A non-existent .env path must not error.
|
||||||
|
cfg, err := Load(filepath.Join(t.TempDir(), "does-not-exist.env"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load(nonexistent) error: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.Server.Port != 8080 {
|
||||||
|
t.Errorf("Port = %d, want default 8080", cfg.Server.Port)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseDotEnv_QuotedAndExport(t *testing.T) {
|
||||||
|
path := writeEnvFile(t, `export FOO=bar
|
||||||
|
BAZ='quoted value'
|
||||||
|
EMPTY=
|
||||||
|
`)
|
||||||
|
m, err := parseDotEnv(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseDotEnv error: %v", err)
|
||||||
|
}
|
||||||
|
if m["FOO"] != "bar" {
|
||||||
|
t.Errorf("FOO = %q, want bar", m["FOO"])
|
||||||
|
}
|
||||||
|
if m["BAZ"] != "quoted value" {
|
||||||
|
t.Errorf("BAZ = %q, want 'quoted value'", m["BAZ"])
|
||||||
|
}
|
||||||
|
if v, ok := m["EMPTY"]; !ok || v != "" {
|
||||||
|
t.Errorf("EMPTY = %q (present=%v), want empty", v, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"reflect"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// decodeValue populates the struct pointed to by v. Precedence, highest first:
|
||||||
|
// process environment variable, the parsed .env file, then the default tag.
|
||||||
|
func decodeValue(v reflect.Value, fromEnv map[string]string) error {
|
||||||
|
if v.Kind() != reflect.Ptr || v.Elem().Kind() != reflect.Struct {
|
||||||
|
return fmt.Errorf("config: decode expects a pointer to a struct")
|
||||||
|
}
|
||||||
|
return decodeStruct(v.Elem(), fromEnv)
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeStruct(sv reflect.Value, fromEnv map[string]string) error {
|
||||||
|
st := sv.Type()
|
||||||
|
for i := 0; i < st.NumField(); i++ {
|
||||||
|
field := st.Field(i)
|
||||||
|
if !field.IsExported() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fv := sv.Field(i)
|
||||||
|
|
||||||
|
envTag, hasEnv := field.Tag.Lookup("env")
|
||||||
|
if !hasEnv {
|
||||||
|
// Untagged struct fields are nested config groups.
|
||||||
|
if fv.Kind() == reflect.Struct {
|
||||||
|
if err := decodeStruct(fv, fromEnv); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
name := envTagName(envTag)
|
||||||
|
raw := lookupRaw(name, field.Tag.Get("default"), fromEnv)
|
||||||
|
if err := setField(fv, raw); err != nil {
|
||||||
|
return fmt.Errorf("config: %s: %w", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// envTagName extracts the variable name from an env tag (dropping any
|
||||||
|
// comma-separated options that may be added later).
|
||||||
|
func envTagName(tag string) string {
|
||||||
|
name, _, _ := strings.Cut(tag, ",")
|
||||||
|
return strings.TrimSpace(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// lookupRaw returns the effective raw value for key: process env, then .env,
|
||||||
|
// then default.
|
||||||
|
func lookupRaw(key, def string, fromEnv map[string]string) string {
|
||||||
|
if v, ok := os.LookupEnv(key); ok {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
if v, ok := fromEnv[key]; ok {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
func setField(fv reflect.Value, raw string) error {
|
||||||
|
switch fv.Kind() {
|
||||||
|
case reflect.String:
|
||||||
|
fv.SetString(raw)
|
||||||
|
return nil
|
||||||
|
case reflect.Bool:
|
||||||
|
return setBool(fv, raw)
|
||||||
|
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||||
|
return setInt(fv, raw)
|
||||||
|
case reflect.Slice:
|
||||||
|
return setStringSlice(fv, raw)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unsupported kind %s", fv.Kind())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setBool(fv reflect.Value, raw string) error {
|
||||||
|
if raw == "" {
|
||||||
|
fv.SetBool(false)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
b, err := strconv.ParseBool(strings.ToLower(raw))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid boolean %q", raw)
|
||||||
|
}
|
||||||
|
fv.SetBool(b)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func setInt(fv reflect.Value, raw string) error {
|
||||||
|
if raw == "" {
|
||||||
|
fv.SetInt(0)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
n, err := strconv.ParseInt(raw, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid integer %q", raw)
|
||||||
|
}
|
||||||
|
fv.SetInt(n)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func setStringSlice(fv reflect.Value, raw string) error {
|
||||||
|
if fv.Type().Elem().Kind() != reflect.String {
|
||||||
|
return fmt.Errorf("unsupported slice element kind %s", fv.Type().Elem().Kind())
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(raw) == "" {
|
||||||
|
fv.Set(reflect.MakeSlice(fv.Type(), 0, 0))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
parts := strings.Split(raw, ",")
|
||||||
|
out := make([]string, 0, len(parts))
|
||||||
|
for _, p := range parts {
|
||||||
|
if t := strings.TrimSpace(p); t != "" {
|
||||||
|
out = append(out, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fv.Set(reflect.ValueOf(out))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// parseDotEnv loads a KEY=VALUE .env file into a map. It is tolerant by design:
|
||||||
|
// a missing file yields an empty map and no error, because .env is a
|
||||||
|
// development-only convenience. Values may be single- or double-quoted and may
|
||||||
|
// carry an optional `export` prefix. Lines starting with `#` and blank lines
|
||||||
|
// are ignored.
|
||||||
|
func parseDotEnv(path string) (map[string]string, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return map[string]string{}, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
env := make(map[string]string)
|
||||||
|
for _, raw := range strings.Split(string(data), "\n") {
|
||||||
|
line := strings.TrimSpace(raw)
|
||||||
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
line = strings.TrimPrefix(line, "export ")
|
||||||
|
key, val, ok := strings.Cut(line, "=")
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key = strings.TrimSpace(key)
|
||||||
|
val = strings.TrimSpace(val)
|
||||||
|
val = strings.Trim(val, `"'`)
|
||||||
|
env[key] = val
|
||||||
|
}
|
||||||
|
return env, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// validateStruct returns a list of human-readable validation errors found while
|
||||||
|
// walking the struct tree. Validation is driven by the `validate` struct tag,
|
||||||
|
// which holds comma-separated rules. Supported rules: required, min=N.
|
||||||
|
func validateStruct(sv reflect.Value) []error {
|
||||||
|
var errs []error
|
||||||
|
st := sv.Type()
|
||||||
|
for i := 0; i < st.NumField(); i++ {
|
||||||
|
field := st.Field(i)
|
||||||
|
if !field.IsExported() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fv := sv.Field(i)
|
||||||
|
|
||||||
|
if rules := field.Tag.Get("validate"); rules != "" {
|
||||||
|
name := envTagName(field.Tag.Get("env"))
|
||||||
|
if name == "" {
|
||||||
|
name = field.Name
|
||||||
|
}
|
||||||
|
errs = append(errs, applyRules(name, rules, fv)...)
|
||||||
|
}
|
||||||
|
if fv.Kind() == reflect.Struct {
|
||||||
|
errs = append(errs, validateStruct(fv)...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errs
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyRules(name, rules string, fv reflect.Value) []error {
|
||||||
|
var errs []error
|
||||||
|
for _, rule := range strings.Split(rules, ",") {
|
||||||
|
rule = strings.TrimSpace(rule)
|
||||||
|
if rule == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ruleName, arg, _ := strings.Cut(rule, "=")
|
||||||
|
switch ruleName {
|
||||||
|
case "required":
|
||||||
|
if isZero(fv) {
|
||||||
|
errs = append(errs, fmt.Errorf("%s is required", name))
|
||||||
|
}
|
||||||
|
case "min":
|
||||||
|
if err := applyMin(name, arg, fv); err != nil {
|
||||||
|
errs = append(errs, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errs
|
||||||
|
}
|
||||||
|
|
||||||
|
func isZero(fv reflect.Value) bool {
|
||||||
|
switch fv.Kind() {
|
||||||
|
case reflect.String:
|
||||||
|
return fv.String() == ""
|
||||||
|
case reflect.Bool:
|
||||||
|
return !fv.Bool()
|
||||||
|
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||||
|
return fv.Int() == 0
|
||||||
|
default:
|
||||||
|
return fv.IsZero()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyMin(name, arg string, fv reflect.Value) error {
|
||||||
|
if fv.Kind() != reflect.String {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
min, err := parseIntArg(arg)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if len(fv.String()) < min {
|
||||||
|
return fmt.Errorf("%s must be at least %d characters", name, min)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseIntArg(arg string) (int, error) {
|
||||||
|
if arg == "" {
|
||||||
|
return 0, fmt.Errorf("missing min argument")
|
||||||
|
}
|
||||||
|
n := 0
|
||||||
|
for _, c := range arg {
|
||||||
|
if c < '0' || c > '9' {
|
||||||
|
return 0, fmt.Errorf("invalid min argument %q", arg)
|
||||||
|
}
|
||||||
|
n = n*10 + int(c-'0')
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
Reference in new issue
Block a user