From 68fd5ac24c6641f6d75eb179b2202bf5b9f00473 Mon Sep 17 00:00:00 2001 From: Stavros Date: Fri, 12 Sep 2025 12:54:44 +0300 Subject: [PATCH] feat: add env decoder --- internal/utils/decoders/env_decoder.go | 136 ++++++++++++++++++++ internal/utils/decoders/env_decoder_test.go | 60 +++++++++ 2 files changed, 196 insertions(+) create mode 100644 internal/utils/decoders/env_decoder.go create mode 100644 internal/utils/decoders/env_decoder_test.go diff --git a/internal/utils/decoders/env_decoder.go b/internal/utils/decoders/env_decoder.go new file mode 100644 index 0000000..467c68b --- /dev/null +++ b/internal/utils/decoders/env_decoder.go @@ -0,0 +1,136 @@ +package decoders + +import ( + "fmt" + "slices" + "sort" + "strings" + "tinyauth/internal/config" + "tinyauth/internal/utils" + + "github.com/traefik/paerser/parser" +) + +func DecodeEnv(env map[string]string) (config.Providers, error) { + normalized := normalizeEnv(env, "tinyauth") + + node, err := decodeEnvsToNode(normalized, "tinyauth", "tinyauth_providers") + + if err != nil { + return config.Providers{}, err + } + + var providers config.Providers + + metaOpts := parser.MetadataOpts{TagName: "env", AllowSliceAsStruct: true} + + err = parser.AddMetadata(&providers, node, metaOpts) + + if err != nil { + return config.Providers{}, err + } + + err = parser.Fill(&providers, node, parser.FillerOpts{AllowSliceAsStruct: true}) + + if err != nil { + return config.Providers{}, err + } + + return providers, nil +} + +func decodeEnvsToNode(env map[string]string, rootName string, filters ...string) (*parser.Node, error) { + sorted := sortEnvKeys(env, filters) + + var node *parser.Node + + for i, k := range sorted { + split := strings.SplitN(k, "_", 4) + + if split[0] != rootName { + return nil, fmt.Errorf("invalid env root %s", split[0]) + } + + if slices.Contains(split, "") { + return nil, fmt.Errorf("invalid element: %s", k) + } + + if i == 0 { + node = &parser.Node{} + } + + decodeEnvToNode(node, split, env[k]) + } + + return node, nil +} + +func decodeEnvToNode(root *parser.Node, path []string, value string) { + if len(root.Name) == 0 { + root.Name = path[0] + } + + if !(len(path) > 1) { + root.Value = value + return + } + + if n := containsEnvNode(root.Children, path[1]); n != nil { + decodeEnvToNode(n, path[1:], value) + return + } + + child := &parser.Node{Name: path[1]} + decodeEnvToNode(child, path[1:], value) + root.Children = append(root.Children, child) +} + +func containsEnvNode(node []*parser.Node, name string) *parser.Node { + for _, n := range node { + if strings.EqualFold(n.Name, name) { + return n + } + } + return nil +} + +func sortEnvKeys(env map[string]string, filters []string) []string { + var sorted []string + + for k := range env { + if len(filters) == 0 { + sorted = append(sorted, k) + continue + } + + for _, f := range filters { + if strings.HasPrefix(k, f) { + sorted = append(sorted, k) + break + } + } + } + + sort.Strings(sorted) + return sorted +} + +// normalizeEnv converts env vars from PROVIDERS_CLIENT1_CLIENT_ID to tinyauth_providers_client_clientId +func normalizeEnv(env map[string]string, rootName string) map[string]string { + n := make(map[string]string) + for k, v := range env { + fk := strings.ToLower(k) + fks := strings.SplitN(fk, "_", 3) + fkb := "" + for i, s := range strings.Split(fks[len(fks)-1], "_") { + if i == 0 { + fkb += s + continue + } + fkb += utils.Capitalize(s) + } + fk = rootName + "_" + strings.Join(fks[:len(fks)-1], "_") + "_" + fkb + n[fk] = v + } + return n +} diff --git a/internal/utils/decoders/env_decoder_test.go b/internal/utils/decoders/env_decoder_test.go new file mode 100644 index 0000000..2233241 --- /dev/null +++ b/internal/utils/decoders/env_decoder_test.go @@ -0,0 +1,60 @@ +package decoders_test + +import ( + "testing" + "tinyauth/internal/config" + "tinyauth/internal/utils/decoders" + + "gotest.tools/v3/assert" +) + +func TestDecodeEnv(t *testing.T) { + // Variables + expected := config.Providers{ + Providers: map[string]config.OAuthServiceConfig{ + "client1": { + ClientID: "client1-id", + ClientSecret: "client1-secret", + Scopes: []string{"client1-scope1", "client1-scope2"}, + RedirectURL: "client1-redirect-url", + AuthURL: "client1-auth-url", + UserinfoURL: "client1-user-info-url", + Name: "Client1", + InsecureSkipVerify: false, + }, + "client2": { + ClientID: "client2-id", + ClientSecret: "client2-secret", + Scopes: []string{"client2-scope1", "client2-scope2"}, + RedirectURL: "client2-redirect-url", + AuthURL: "client2-auth-url", + UserinfoURL: "client2-user-info-url", + Name: "My Awesome Client2", + InsecureSkipVerify: false, + }, + }, + } + test := map[string]string{ + "PROVIDERS_CLIENT1_CLIENT_ID": "client1-id", + "PROVIDERS_CLIENT1_CLIENT_SECRET": "client1-secret", + "PROVIDERS_CLIENT1_SCOPES": "client1-scope1,client1-scope2", + "PROVIDERS_CLIENT1_REDIRECT_URL": "client1-redirect-url", + "PROVIDERS_CLIENT1_AUTH_URL": "client1-auth-url", + "PROVIDERS_CLIENT1_USER_INFO_URL": "client1-user-info-url", + "PROVIDERS_CLIENT1_NAME": "Client1", + "PROVIDERS_CLIENT1_INSECURE_SKIP_VERIFY": "false", + "PROVIDERS_CLIENT2_CLIENT_ID": "client2-id", + "PROVIDERS_CLIENT2_CLIENT_SECRET": "client2-secret", + "PROVIDERS_CLIENT2_SCOPES": "client2-scope1,client2-scope2", + "PROVIDERS_CLIENT2_REDIRECT_URL": "client2-redirect-url", + "PROVIDERS_CLIENT2_AUTH_URL": "client2-auth-url", + "PROVIDERS_CLIENT2_USER_INFO_URL": "client2-user-info-url", + "PROVIDERS_CLIENT2_NAME": "My Awesome Client2", + "PROVIDERS_CLIENT2_INSECURE_SKIP_VERIFY": "false", + } + + // Test + res, err := decoders.DecodeEnv(test) + assert.NilError(t, err) + assert.DeepEqual(t, expected, res) +}