Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -271,13 +271,35 @@ limits:
min: 1
```

### JSON Source (via URL)

The JSON parser loads configuration from a URL, supporting `file://`, `http://`, and `https://` schemes. Schemeless paths are treated as local files.

First, define a URL option:

```go
var jsonConfigURL = zfg.URL("json.config", "file:///path/to/your/config.json", "URL for JSON configuration")
// Or for an optional remote config:
// var remoteJsonConfigURL = zfg.URL("json.remote", "", "Optional remote JSON config URL (http/https)")
```

**Example Go config:**

```go
zfg.Str("group.option", "", "hierarchical usage")
zfg.Ints("numbers", nil, "slice of server configs")
zfg.Map("limits", nil, "map of limits")
```
Then, add it to `zerocfg.Parse`:

```go
err := zerocfg.Parse(
// ... other providers
json.New(jsonConfigURL),
)
```

If the `json.config` URL option is not provided (or its default is empty), the JSON provider will be skipped without error.

## Advanced Usage

Expand Down
150 changes: 150 additions & 0 deletions json/parse.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
package json

import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
)

type Parser struct {
sourceURL **url.URL

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

func New(sourceURL **url.URL) *Parser {
return &Parser{sourceURL: sourceURL}
}

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

func fetchJSONData(u *url.URL) ([]byte, error) {
if u == nil {
return nil, fmt.Errorf("source URL is nil")
}

var reader io.ReadCloser
var err error

switch u.Scheme {
case "http", "https":
resp, httpErr := http.Get(u.String())
if httpErr != nil {
return nil, fmt.Errorf("http get failed for %s: %w", u.String(), httpErr)
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
return nil, fmt.Errorf("http status %s for %s", resp.Status, u.String())
}
reader = resp.Body
case "file":
path := u.Path
if u.Host != "" && (os.PathSeparator == '\\' && strings.HasPrefix(path, "/")) {
path = "//" + u.Host + path
} else if u.Host != "" {
path = u.Host + path
}
reader, err = os.Open(path)
if err != nil {
return nil, fmt.Errorf("file open failed for %s: %w", path, err)
}
default:
if u.Scheme == "" && u.Path != "" {
reader, err = os.Open(u.Path)
if err != nil {
return nil, fmt.Errorf("local file open failed for %s: %w", u.Path, err)
}
} else {
return nil, fmt.Errorf("unsupported URL scheme: %q for %s", u.Scheme, u.String())
}
}
defer reader.Close()

data, err := io.ReadAll(reader)
if err != nil {
return nil, fmt.Errorf("read failed for %s: %w", u.String(), err)
}
return data, nil
}

func (p *Parser) Parse(keys map[string]bool, conv func(any) string) (found, unknown map[string]string, err error) {
if p.sourceURL == nil || *(p.sourceURL) == nil {
return make(map[string]string), make(map[string]string), nil
}

currentURL := *(p.sourceURL)

data, err := fetchJSONData(currentURL)
if err != nil {
return nil, nil, fmt.Errorf("fetch json data from %s: %w", currentURL.String(), err)
}

p.conv = conv
p.awaited = keys

return p.parse(data)
}

func (p *Parser) parse(data []byte) (found, unknown map[string]string, err error) {
if len(data) == 0 {
return nil, nil, fmt.Errorf("unmarshal json: empty json input")
}
var settings any

decoder := json.NewDecoder(bytes.NewReader(data))
decoder.UseNumber()

if err = decoder.Decode(&settings); err != nil {
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) {
for k, v := range m {
if v == nil {
continue
}

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

if p.awaited[newKey] {
found[newKey] = p.conv(v)
continue
}

if subMap, ok := v.(map[string]any); ok {
p.flattenDFS(subMap, newKey, found, unknown)
continue
}

unknown[newKey] = p.conv(v)
}
}
Loading