-
Notifications
You must be signed in to change notification settings - Fork 36
feat(cmd): add dotnet appsettings.json export formats #243
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -24,6 +24,7 @@ const ( | |
| FormatCSV string = "csv" | ||
| FormatYaml string = "yaml" | ||
| FormatDotEnvExport string = "dotenv-export" | ||
| FormatAppSettings string = "appsettings" | ||
| ) | ||
|
|
||
| // exportCmd represents the export command | ||
|
|
@@ -231,6 +232,8 @@ func getDefaultFilename(format string) string { | |
| switch strings.ToLower(format) { | ||
| case FormatJson: | ||
| return "secrets.json" | ||
| case FormatAppSettings: | ||
| return "appsettings.json" | ||
| case FormatCSV: | ||
| return "secrets.csv" | ||
| case FormatYaml: | ||
|
|
@@ -247,7 +250,7 @@ func getDefaultFilename(format string) string { | |
| // getDefaultExtension returns the default file extension based on the format | ||
| func getDefaultExtension(format string) string { | ||
| switch strings.ToLower(format) { | ||
| case FormatJson: | ||
| case FormatJson, FormatAppSettings: | ||
| return ".json" | ||
| case FormatCSV: | ||
| return ".csv" | ||
|
|
@@ -266,7 +269,7 @@ func init() { | |
| RootCmd.AddCommand(exportCmd) | ||
| exportCmd.Flags().StringP("env", "e", "dev", "Set the environment (dev, prod, etc.) from which your secrets should be pulled from") | ||
| exportCmd.Flags().Bool("expand", true, "Parse shell parameter expansions in your secrets") | ||
| exportCmd.Flags().StringP("format", "f", "dotenv", "Set the format of the output file (dotenv, json, csv)") | ||
| exportCmd.Flags().StringP("format", "f", "dotenv", "Set the format of the output file (dotenv, json, csv, yaml, appsettings)") | ||
| exportCmd.Flags().Bool("secret-overriding", true, "Prioritizes personal secrets, if any, with the same name over shared secrets") | ||
| exportCmd.Flags().Bool("include-imports", true, "Imported linked secrets") | ||
| exportCmd.Flags().String("token", "", "Fetch secrets using service token or machine identity access token") | ||
|
|
@@ -290,8 +293,10 @@ func formatEnvs(envs []models.SingleEnvironmentVariable, format string) (string, | |
| return formatAsCSV(envs), nil | ||
| case FormatYaml: | ||
| return formatAsYaml(envs) | ||
| case FormatAppSettings: | ||
| return formatAsAppSettingsJson(envs), nil | ||
| default: | ||
| return "", fmt.Errorf("invalid format type: %s. Available format types are [%s]", format, []string{FormatDotenv, FormatJson, FormatCSV, FormatYaml, FormatDotEnvExport}) | ||
| return "", fmt.Errorf("invalid format type: %s. Available format types are [%s]", format, []string{FormatDotenv, FormatJson, FormatCSV, FormatYaml, FormatDotEnvExport, FormatAppSettings}) | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -350,6 +355,89 @@ func formatAsJson(envs []models.SingleEnvironmentVariable) string { | |
| return string(json) | ||
| } | ||
|
|
||
| // Attempts to parse a string value as JSON (object, array, number, boolean, null) | ||
| // falls back to the raw string on error. | ||
| func parseSecretValue(val string) interface{} { | ||
| trimmed := strings.TrimSpace(val) | ||
| var parsed interface{} | ||
| err := json.Unmarshal([]byte(trimmed), &parsed) | ||
| if err != nil { | ||
| return val | ||
| } | ||
| if parsed == nil && trimmed != "null" { | ||
| return val | ||
| } | ||
| return parsed | ||
| } | ||
|
|
||
| // Recursively merges two maps | ||
| func mergeMaps(dest, src map[string]interface{}) { | ||
| for k, v := range src { | ||
| destVal, exists := dest[k] | ||
| if !exists { | ||
| dest[k] = v | ||
| continue | ||
| } | ||
|
|
||
| destMap, destIsMap := destVal.(map[string]interface{}) | ||
| srcMap, srcIsMap := v.(map[string]interface{}) | ||
| if destIsMap && srcIsMap { | ||
| mergeMaps(destMap, srcMap) | ||
| } else { | ||
| dest[k] = v | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // Formats environment variables into a nested JSON object structure compatible with ASP.NET Core appsettings.json | ||
| func formatAsAppSettingsJson(envs []models.SingleEnvironmentVariable) string { | ||
| result := make(map[string]interface{}) | ||
|
|
||
| for _, env := range envs { | ||
| val := parseSecretValue(env.Value) | ||
|
|
||
| normalizedKey := strings.ReplaceAll(env.Key, "__", ":") | ||
| parts := strings.Split(normalizedKey, ":") | ||
|
|
||
| current := result | ||
| for i, part := range parts { | ||
| if i == len(parts)-1 { | ||
| existingVal, exists := current[part] | ||
| existingMap, existingIsMap := existingVal.(map[string]interface{}) | ||
| newMap, newIsMap := val.(map[string]interface{}) | ||
| if exists && existingIsMap && newIsMap { | ||
| mergeMaps(existingMap, newMap) | ||
| } else { | ||
| current[part] = val | ||
|
Comment on lines
+396
to
+411
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When two secrets produce conflicting key paths — one as a scalar value and another using that same key as a non-leaf prefix — one silently overwrites the other with no warning logged. Because |
||
| } | ||
| } else { | ||
| next, exists := current[part] | ||
| if !exists { | ||
| nextMap := make(map[string]interface{}) | ||
| current[part] = nextMap | ||
| current = nextMap | ||
| } else { | ||
| nextMap, ok := next.(map[string]interface{}) | ||
| if !ok { | ||
| nextMap = make(map[string]interface{}) | ||
| current[part] = nextMap | ||
| current = nextMap | ||
| } else { | ||
| current = nextMap | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| jsonBytes, err := json.MarshalIndent(result, "", " ") | ||
| if err != nil { | ||
| log.Err(err).Msgf("Unable to marshal environment variables to appsettings JSON") | ||
| return "" | ||
| } | ||
| return string(jsonBytes) | ||
| } | ||
|
|
||
| func escapeNewLinesIfRequired(env models.SingleEnvironmentVariable) string { | ||
| if env.IsMultilineEncodingEnabled() && strings.ContainsRune(env.Value, '\n') { | ||
| return strings.ReplaceAll(env.Value, "\n", "\\n") | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -77,3 +77,116 @@ func TestFormatAsYaml(t *testing.T) { | |
| }) | ||
| } | ||
| } | ||
|
|
||
| func TestFormatAsAppSettingsJson(t *testing.T) { | ||
| tests := []struct { | ||
| name string | ||
| input []models.SingleEnvironmentVariable | ||
| expected string | ||
| }{ | ||
| { | ||
| name: "Empty input", | ||
| input: []models.SingleEnvironmentVariable{}, | ||
| expected: "{}\n", | ||
| }, | ||
| { | ||
| name: "Flat primitive types parsing", | ||
| input: []models.SingleEnvironmentVariable{ | ||
| {Key: "PORT", Value: "8080"}, | ||
| {Key: "DEBUG", Value: "true"}, | ||
| {Key: "NAME", Value: "MyApplication"}, | ||
| {Key: "NULL_VAL", Value: "null"}, | ||
| }, | ||
| expected: `{ | ||
| "DEBUG": true, | ||
| "NAME": "MyApplication", | ||
| "NULL_VAL": null, | ||
| "PORT": 8080 | ||
| }`, | ||
| }, | ||
| { | ||
| name: "Nested keys with colons", | ||
| input: []models.SingleEnvironmentVariable{ | ||
| {Key: "AWS:Region", Value: "us-east-1"}, | ||
| {Key: "ConnectionStrings:Default", Value: "Server=myServerAddress;Database=myDataBase;"}, | ||
| }, | ||
| expected: `{ | ||
| "AWS": { | ||
| "Region": "us-east-1" | ||
|
Comment on lines
+84
to
+115
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The test suite covers merging two maps and merging a JSON-object value with an explicit nested key, but there is no test for the scenario where a plain scalar key ( |
||
| }, | ||
| "ConnectionStrings": { | ||
| "Default": "Server=myServerAddress;Database=myDataBase;" | ||
| } | ||
| }`, | ||
| }, | ||
| { | ||
| name: "Nested keys with double underscores", | ||
| input: []models.SingleEnvironmentVariable{ | ||
| {Key: "AWS__Region", Value: "us-east-2"}, | ||
| {Key: "ConnectionStrings__Default", Value: "Server=otherServer;"}, | ||
| }, | ||
| expected: `{ | ||
| "AWS": { | ||
| "Region": "us-east-2" | ||
| }, | ||
| "ConnectionStrings": { | ||
| "Default": "Server=otherServer;" | ||
| } | ||
| }`, | ||
| }, | ||
| { | ||
| name: "Values as JSON objects/arrays", | ||
| input: []models.SingleEnvironmentVariable{ | ||
| {Key: "AWS", Value: `{"Region":"us-west-2","Version":"v2"}`}, | ||
| {Key: "TAGS", Value: `["production","web"]`}, | ||
| }, | ||
| expected: `{ | ||
| "AWS": { | ||
| "Region": "us-west-2", | ||
| "Version": "v2" | ||
| }, | ||
| "TAGS": [ | ||
| "production", | ||
| "web" | ||
| ] | ||
| }`, | ||
| }, | ||
| { | ||
| name: "Deep nesting and merging objects", | ||
| input: []models.SingleEnvironmentVariable{ | ||
| {Key: "AWS:Credentials:AccessKey", Value: "AKIAIOSFODNN7EXAMPLE"}, | ||
| {Key: "AWS:Credentials:SecretKey", Value: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"}, | ||
| {Key: "AWS:Region", Value: "us-east-1"}, | ||
| }, | ||
| expected: `{ | ||
| "AWS": { | ||
| "Credentials": { | ||
| "AccessKey": "AKIAIOSFODNN7EXAMPLE", | ||
| "SecretKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" | ||
| }, | ||
| "Region": "us-east-1" | ||
| } | ||
| }`, | ||
| }, | ||
| { | ||
| name: "Merging parsed JSON objects and explicit nested keys in any order", | ||
| input: []models.SingleEnvironmentVariable{ | ||
| {Key: "AWS", Value: `{"Region":"us-east-1"}`}, | ||
| {Key: "AWS:SecretKey", Value: "abc"}, | ||
| }, | ||
| expected: `{ | ||
| "AWS": { | ||
| "Region": "us-east-1", | ||
| "SecretKey": "abc" | ||
| } | ||
| }`, | ||
| }, | ||
| } | ||
|
|
||
| for _, tt := range tests { | ||
| t.Run(tt.name, func(t *testing.T) { | ||
| result := formatAsAppSettingsJson(tt.input) | ||
| assert.JSONEq(t, tt.expected, result) | ||
| }) | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
parseSecretValuefunction parses"null"correctly but the nil check is subtly redundant.json.Unmarshalsetsparsed = nilonly when the input is the JSON literal"null", so the guardtrimmed != "null"can never fire — if unmarshal succeeded andparsed == nil,trimmedmust equal"null". The double-negative condition adds no protection and can be removed.