Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 127 additions & 0 deletions json/parse.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
package json

import (
"bytes"
"encoding/json"
"fmt"
"os"
)

type Parser struct {
path *string

conv func(any) string
awaited map[string]bool
}

func New(path *string) *Parser {
return &Parser{path: path}
}

func (p *Parser) Type() string {
if p.path == nil || *p.path == "" {
return "json"
}
return fmt.Sprintf("json[%s]", *p.path)
}

func (p *Parser) Parse(keys map[string]bool, conv func(any) string) (found, unknown map[string]string, err error) {
found = make(map[string]string)
unknown = make(map[string]string)

if p.path == nil || *p.path == "" {
return found, unknown, nil
}

data, err := os.ReadFile(*p.path)
if err != nil {
if os.IsNotExist(err) {
return found, unknown, nil
}
return nil, nil, fmt.Errorf("read json file %q: %w", *p.path, err)
}

if len(data) == 0 {
return found, unknown, fmt.Errorf("json file %q is empty", *p.path)
}

p.conv = conv
p.awaited = keys

return p.parse(data)
}

func (p *Parser) parse(data []byte) (found, unknown map[string]string, err error) {
var settings any

decoder := json.NewDecoder(bytes.NewReader(data))
decoder.UseNumber()
Comment thread
FumingPower3925 marked this conversation as resolved.
if err = decoder.Decode(&settings); err != nil {
Comment thread
FumingPower3925 marked this conversation as resolved.
return nil, nil, fmt.Errorf("unmarshal json: %w", err)
}

settingsMap, ok := settings.(map[string]any)
if !ok {
return nil, nil, fmt.Errorf("json root is not an object")
}

found, unknown = p.flatten(settingsMap)

return found, unknown, nil
}

func (p *Parser) flatten(settings map[string]any) (found, unknown map[string]string) {
found, unknown = make(map[string]string), make(map[string]string)
p.flattenDFS(settings, "", found, unknown)
return found, unknown
}

func (p *Parser) flattenDFS(m map[string]any, prefix string, found, unknown map[string]string) {
Comment thread
chaindead marked this conversation as resolved.
for k, v := range m {

if v == nil {
continue
}

newKey := k
if prefix != "" {
newKey = prefix + "." + k
}

_, isAwaited := p.awaited[newKey]

if subMap, ok := v.(map[string]any); ok {
if isAwaited {
found[newKey] = p.conv(v)

} else {

isParentOfAwaited := false
for awaitedKey := range p.awaited {
if len(awaitedKey) > len(newKey) && awaitedKey[len(newKey)] == '.' && awaitedKey[:len(newKey)] == newKey {
isParentOfAwaited = true
break
}
}

if !isParentOfAwaited {
unknown[newKey] = p.conv(v)
}

p.flattenDFS(subMap, newKey, found, unknown)
}
} else if _, ok := v.([]any); ok {
if isAwaited {
found[newKey] = p.conv(v)
} else {
unknown[newKey] = p.conv(v)
}
} else {
if isAwaited {
found[newKey] = p.conv(v)
} else {
unknown[newKey] = p.conv(v)
}
}
}
}
189 changes: 189 additions & 0 deletions json/parse_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
package json_test
Comment thread
FumingPower3925 marked this conversation as resolved.

import (
"os"
"testing"

zfg "github.com/chaindead/zerocfg"
"github.com/chaindead/zerocfg/json"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestParse(t *testing.T) {
tests := []struct {
name string
input string
awaited map[string]bool
found map[string]string
unknown map[string]string
wantErr bool
}{
{
name: "simple key-value",
input: `{"str": "name", "int": 1, "bool": true}`,
awaited: map[string]bool{
"str": true,
},
found: map[string]string{
"str": `name`,
},
unknown: map[string]string{
"int": `1`,
"bool": `true`,
},
},
{
name: "nested",
input: `{
"database": {
"host": "localhost",
"port": 5432
}
}`,
awaited: map[string]bool{
"database.host": true,
},
found: map[string]string{
"database.host": `localhost`,
},
unknown: map[string]string{
"database.port": `5432`,
},
},
{
name: "array",
input: `{"tags": ["a", "b"]}`,
awaited: map[string]bool{
"tags": true,
},
found: map[string]string{
"tags": `["a","b"]`,
},
unknown: map[string]string{},
},
{
name: "map",
input: `{"options": {"k1": 1, "k2": "v2"}}`,
awaited: map[string]bool{
"options": true,
},
found: map[string]string{
"options": `{"k1":1,"k2":"v2"}`,
},
unknown: map[string]string{},
},
{
name: "null value is skipped",
input: `{"key1": "value1", "key2": null, "key3": 123}`,
awaited: map[string]bool{
"key1": true,
"key2": true,
"key3": true,
},
found: map[string]string{
"key1": `value1`,
"key3": `123`,
},
unknown: map[string]string{},
},
{
name: "awaited nested map skips inner unknown",
input: `{"db": {"host": "localhost", "port": 5432}}`,
awaited: map[string]bool{
"db": true,
},
found: map[string]string{
"db": `{"host":"localhost","port":5432}`,
},
unknown: map[string]string{},
},
{
name: "awaited inner map key identifies outer as known parent",
input: `{"db": {"host": "localhost", "port": 5432}}`,
awaited: map[string]bool{
"db.host": true,
},
found: map[string]string{
"db.host": `localhost`,
},
unknown: map[string]string{
"db.port": "5432",
},
},
{
name: "json root is not an object",
input: `[1, 2, 3]`,
wantErr: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.found == nil {
tt.found = map[string]string{}
}
if tt.unknown == nil {
tt.unknown = map[string]string{}
}

path := tempFile(t, tt.input)
p := json.New(&path)

found, unknown, err := p.Parse(tt.awaited, zfg.ToString)

if tt.wantErr {
assert.Error(t, err)
} else {
require.NoError(t, err)
assert.Equal(t, tt.found, found, "Found map mismatch")
assert.Equal(t, tt.unknown, unknown, "Unknown map mismatch")
}
})
}
}

func TestParse_Error(t *testing.T) {
path := tempFile(t, `{"invalid": "json",}`)
p := json.New(&path)

_, _, err := p.Parse(map[string]bool{}, zfg.ToString)
assert.Error(t, err)
assert.Contains(t, err.Error(), "unmarshal json")
}

func TestParse_EmptyInputError(t *testing.T) {
path := tempFile(t, ``)
p := json.New(&path)

_, _, err := p.Parse(map[string]bool{}, zfg.ToString)
assert.Error(t, err)
assert.Contains(t, err.Error(), "is empty")
}

func TestParse_FileNotExist(t *testing.T) {
nonExistentPath := "no_such_file.json"
p := json.New(&nonExistentPath)

found, unknown, err := p.Parse(map[string]bool{"some.key": true}, zfg.ToString)
require.NoError(t, err)
Comment thread
FumingPower3925 marked this conversation as resolved.
assert.Empty(t, found)
assert.Empty(t, unknown)
}

func tempFile(t *testing.T, data string) string {
f, err := os.CreateTemp("", "test-*.json")
require.NoError(t, err)
t.Cleanup(func() {
os.Remove(f.Name())
})

if data != "" {
_, err = f.WriteString(data)
require.NoError(t, err)
}

require.NoError(t, f.Close())

return f.Name()
}