azureappconfiguration/azureappconfiguration.go (206 lines of code) (raw):
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
// Package azureappconfiguration provides a client for Azure App Configuration, enabling Go applications
// to manage application settings with Microsoft's Azure App Configuration service.
//
// The azureappconfiguration package allows loading configuration data from Azure App Configuration in a structured way,
// with support for automatic Key Vault reference resolution, hierarchical configuration construction,
// and strongly typed configuration binding.
//
// For more information about Azure App Configuration, see:
// https://learn.microsoft.com/en-us/azure/azure-app-configuration/
package azureappconfiguration
import (
"context"
"encoding/json"
"fmt"
"log"
"regexp"
"strings"
"sync"
"github.com/Azure/AppConfiguration-GoProvider/azureappconfiguration/internal/tree"
decoder "github.com/go-viper/mapstructure/v2"
"golang.org/x/sync/errgroup"
)
// An AzureAppConfiguration is a configuration provider that stores and manages settings sourced from Azure App Configuration.
type AzureAppConfiguration struct {
keyValues map[string]any
kvSelectors []Selector
trimPrefixes []string
clientManager *configurationClientManager
resolver *keyVaultReferenceResolver
}
// Load initializes a new AzureAppConfiguration instance and loads the configuration data from
// Azure App Configuration service.
//
// Parameters:
// - ctx: The context for the operation.
// - authentication: Authentication options for connecting to the Azure App Configuration service
// - options: Configuration options to customize behavior, such as key filters and prefix trimming
//
// Returns:
// - A configured AzureAppConfiguration instance that provides access to the loaded configuration data
// - An error if the operation fails, such as authentication errors or connectivity issues
func Load(ctx context.Context, authentication AuthenticationOptions, options *Options) (*AzureAppConfiguration, error) {
if err := verifyAuthenticationOptions(authentication); err != nil {
return nil, err
}
if options == nil {
options = &Options{}
}
clientManager, err := newConfigurationClientManager(authentication, options.ClientOptions)
if err != nil {
return nil, err
}
azappcfg := new(AzureAppConfiguration)
azappcfg.keyValues = make(map[string]any)
azappcfg.kvSelectors = deduplicateSelectors(options.Selectors)
azappcfg.trimPrefixes = options.TrimKeyPrefixes
azappcfg.clientManager = clientManager
azappcfg.resolver = &keyVaultReferenceResolver{
clients: sync.Map{},
secretResolver: options.KeyVaultOptions.SecretResolver,
credential: options.KeyVaultOptions.Credential,
}
if err := azappcfg.load(ctx); err != nil {
return nil, err
}
return azappcfg, nil
}
// Unmarshal parses the configuration and stores the result in the value pointed to v. It builds a hierarchical configuration structure based on key separators.
// It supports converting values to appropriate target types.
//
// Fields in the target struct are matched with configuration keys using the field name by default.
// For custom field mapping, use json struct tags.
//
// Parameters:
// - v: A pointer to the struct to populate with configuration values
// - options: Optional parameters (e,g, separator) for controlling the unmarshalling behavior
//
// Returns:
// - An error if unmarshalling fails due to type conversion issues or invalid configuration
func (azappcfg *AzureAppConfiguration) Unmarshal(v any, options *ConstructionOptions) error {
if options == nil || options.Separator == "" {
options = &ConstructionOptions{
Separator: defaultSeparator,
}
} else {
err := verifySeparator(options.Separator)
if err != nil {
return err
}
}
config := &decoder.DecoderConfig{
Result: v,
WeaklyTypedInput: true,
TagName: "json",
DecodeHook: decoder.ComposeDecodeHookFunc(
decoder.StringToTimeDurationHookFunc(),
decoder.StringToSliceHookFunc(","),
),
}
decoder, err := decoder.NewDecoder(config)
if err != nil {
return err
}
return decoder.Decode(azappcfg.constructHierarchicalMap(options.Separator))
}
// GetBytes returns the configuration as a JSON byte array with hierarchical structure.
// This method is particularly useful for integrating with "encoding/json" package or third-party configuration packages like Viper or Koanf.
//
// Parameters:
// - options: Optional parameters for controlling JSON construction, particularly the key separator
//
// Returns:
// - A byte array containing the JSON representation of the configuration
// - An error if JSON marshalling fails or if an invalid separator is specified
func (azappcfg *AzureAppConfiguration) GetBytes(options *ConstructionOptions) ([]byte, error) {
if options == nil || options.Separator == "" {
options = &ConstructionOptions{
Separator: defaultSeparator,
}
} else {
err := verifySeparator(options.Separator)
if err != nil {
return nil, err
}
}
return json.Marshal(azappcfg.constructHierarchicalMap(options.Separator))
}
func (azappcfg *AzureAppConfiguration) load(ctx context.Context) error {
keyValuesClient := &selectorSettingsClient{
selectors: azappcfg.kvSelectors,
client: azappcfg.clientManager.staticClient.client,
}
return azappcfg.loadKeyValues(ctx, keyValuesClient)
}
func (azappcfg *AzureAppConfiguration) loadKeyValues(ctx context.Context, settingsClient settingsClient) error {
settingsResponse, err := settingsClient.getSettings(ctx)
if err != nil {
return err
}
kvSettings := make(map[string]any, len(settingsResponse.settings))
keyVaultRefs := make(map[string]string)
for _, setting := range settingsResponse.settings {
if setting.Key == nil {
continue
}
trimmedKey := azappcfg.trimPrefix(*setting.Key)
if len(trimmedKey) == 0 {
log.Printf("Key of the setting '%s' is trimmed to the empty string, just ignore it", *setting.Key)
continue
}
if setting.ContentType == nil || setting.Value == nil {
kvSettings[trimmedKey] = setting.Value
continue
}
switch *setting.ContentType {
case featureFlagContentType:
continue // ignore feature flag while getting key value settings
case secretReferenceContentType:
keyVaultRefs[trimmedKey] = *setting.Value
default:
if isJsonContentType(setting.ContentType) {
var v any
if err := json.Unmarshal([]byte(*setting.Value), &v); err != nil {
log.Printf("Failed to unmarshal JSON value: key=%s, error=%s", *setting.Key, err.Error())
continue
}
kvSettings[trimmedKey] = v
} else {
kvSettings[trimmedKey] = setting.Value
}
}
}
var eg errgroup.Group
resolvedSecrets := sync.Map{}
if len(keyVaultRefs) > 0 {
if azappcfg.resolver.credential == nil && azappcfg.resolver.secretResolver == nil {
return fmt.Errorf("no Key Vault credential or SecretResolver configured")
}
for key, kvRef := range keyVaultRefs {
key, kvRef := key, kvRef
eg.Go(func() error {
resolvedSecret, err := azappcfg.resolver.resolveSecret(ctx, kvRef)
if err != nil {
return fmt.Errorf("fail to resolve the Key Vault reference '%s': %s", key, err.Error())
}
resolvedSecrets.Store(key, resolvedSecret)
return nil
})
}
if err := eg.Wait(); err != nil {
return err
}
}
resolvedSecrets.Range(func(key, value interface{}) bool {
kvSettings[key.(string)] = value.(string)
return true
})
azappcfg.keyValues = kvSettings
return nil
}
func (azappcfg *AzureAppConfiguration) trimPrefix(key string) string {
result := key
for _, prefix := range azappcfg.trimPrefixes {
if strings.HasPrefix(result, prefix) {
result = result[len(prefix):]
break
}
}
return result
}
func isJsonContentType(contentType *string) bool {
if contentType == nil {
return false
}
contentTypeStr := strings.ToLower(strings.Trim(*contentType, " "))
matched, _ := regexp.MatchString("^application\\/(?:[^\\/]+\\+)?json(;.*)?$", contentTypeStr)
return matched
}
func deduplicateSelectors(selectors []Selector) []Selector {
// If no selectors provided, return the default selector
if len(selectors) == 0 {
return []Selector{
{
KeyFilter: wildCard,
LabelFilter: defaultLabel,
},
}
}
// Create a map to track unique selectors
seen := make(map[Selector]struct{})
var result []Selector
// Process the selectors in reverse order to maintain the behavior
// where later duplicates take precedence over earlier ones
for i := len(selectors) - 1; i >= 0; i-- {
// Normalize empty label filter
if selectors[i].LabelFilter == "" {
selectors[i].LabelFilter = defaultLabel
}
// Check if we've seen this selector before
if _, exists := seen[selectors[i]]; !exists {
seen[selectors[i]] = struct{}{}
result = append(result, selectors[i])
}
}
// Reverse the result to maintain the original order
reverse(result)
return result
}
// constructHierarchicalMap converts a flat map with delimited keys to a hierarchical structure
func (azappcfg *AzureAppConfiguration) constructHierarchicalMap(separator string) map[string]any {
tree := &tree.Tree{}
for k, v := range azappcfg.keyValues {
tree.Insert(strings.Split(k, separator), v)
}
return tree.Build()
}