From 34955ebf808c8ac2b6d0e0fd2368d9f8c5efad5e Mon Sep 17 00:00:00 2001 From: Martin Atkins Date: Tue, 1 Oct 2019 15:52:30 -0700 Subject: [PATCH] hclsimple: Simple one-shot parse/decode/evaluate API For programs that don't need fine control over the process of decoding a configuration, this allow a one-shot decode into a value of a Go struct type. --- doc.go | 33 +++++++++++ hclsimple/hclsimple.go | 108 ++++++++++++++++++++++++++++++++++++ hclsimple/hclsimple_test.go | 82 +++++++++++++++++++++++++++ hclsimple/testdata/test.hcl | 2 + 4 files changed, 225 insertions(+) create mode 100644 hclsimple/hclsimple.go create mode 100644 hclsimple/hclsimple_test.go create mode 100644 hclsimple/testdata/test.hcl diff --git a/doc.go b/doc.go index 01318c9..0d43fb2 100644 --- a/doc.go +++ b/doc.go @@ -1 +1,34 @@ +// Package hcl contains the main modelling types and general utility functions +// for HCL. +// +// For a simple entry point into HCL, see the package in the subdirectory +// "hclsimple", which has an opinionated function Decode that can decode HCL +// configurations in either native HCL syntax or JSON syntax into a Go struct +// type: +// +// package main +// +// import ( +// "log" +// "github.com/hashicorp/hcl/v2/hclsimple" +// ) +// +// type Config struct { +// LogLevel string `hcl:"log_level"` +// } +// +// func main() { +// var config Config +// err := hclsimple.DecodeFile("config.hcl", nil, &config) +// if err != nil { +// log.Fatalf("Failed to load configuration: %s", err) +// } +// log.Printf("Configuration is %#v", config) +// } +// +// If your application needs more control over the evaluation of the +// configuration, you can use the functions in the subdirectories hclparse, +// gohcl, hcldec, etc. Splitting the handling of configuration into multiple +// phases allows for advanced patterns such as allowing expressions in one +// part of the configuration to refer to data defined in another part. package hcl diff --git a/hclsimple/hclsimple.go b/hclsimple/hclsimple.go new file mode 100644 index 0000000..09bc4f7 --- /dev/null +++ b/hclsimple/hclsimple.go @@ -0,0 +1,108 @@ +// Package hclsimple is a higher-level entry point for loading HCL +// configuration files directly into Go struct values in a single step. +// +// This package is more opinionated than the rest of the HCL API. See the +// documentation for function Decode for more information. +package hclsimple + +import ( + "fmt" + "io/ioutil" + "os" + "path/filepath" + "strings" + + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/gohcl" + "github.com/hashicorp/hcl/v2/hclsyntax" + "github.com/hashicorp/hcl/v2/json" +) + +// Decode parses, decodes, and evaluates expressions in the given HCL source +// code, in a single step. +// +// The main HCL API is built to allow applications that need to decompose +// the processing steps into a pipeline, with different tasks done by +// different parts of the program: parsing the source code into an abstract +// representation, analysing the block structure, evaluating expressions, +// and then extracting the results into a form consumable by the rest of +// the program. +// +// This function does all of those steps in one call, going directly from +// source code to a populated Go struct value. +// +// The "filename" and "src" arguments describe the input configuration. The +// filename is used to add source location context to any returned error +// messages and its suffix will choose one of the two supported syntaxes: +// ".hcl" for native syntax, and ".json" for HCL JSON. The src must therefore +// contain a sequence of bytes that is valid for the selected syntax. +// +// The "ctx" argument provides variables and functions for use during +// expression evaluation. Applications that need no variables nor functions +// can just pass nil. +// +// The "target" argument must be a pointer to a value of a struct type, +// with struct tags as defined by the sibling package "gohcl". +// +// The return type is error but any non-nil error is guaranteed to be +// type-assertable to hcl.Diagnostics for applications that wish to access +// the full error details. +// +// This is a very opinionated function that is intended to serve the needs of +// applications that are just using HCL for simple configuration and don't +// need detailed control over the decoding process. Because this function is +// just wrapping functionality elsewhere, if it doesn't meet your needs then +// please consider copying it into your program and adapting it as needed. +func Decode(filename string, src []byte, ctx *hcl.EvalContext, target interface{}) error { + var file *hcl.File + var diags hcl.Diagnostics + + switch suffix := strings.ToLower(filepath.Ext(filename)); suffix { + case ".hcl": + file, diags = hclsyntax.ParseConfig(src, filename, hcl.Pos{Line: 1, Column: 1}) + case ".json": + file, diags = json.Parse(src, filename) + default: + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Unsupported file format", + Detail: fmt.Sprintf("Cannot read from %s: unrecognized file format suffix %q.", filename, suffix), + }) + return diags + } + if diags.HasErrors() { + return diags + } + + diags = gohcl.DecodeBody(file.Body, ctx, target) + if diags.HasErrors() { + return diags + } + return nil +} + +// DecodeFile is a wrapper around Decode that first reads the given filename +// from disk. See the Decode documentation for more information. +func DecodeFile(filename string, ctx *hcl.EvalContext, target interface{}) error { + src, err := ioutil.ReadFile(filename) + if err != nil { + if os.IsNotExist(err) { + return hcl.Diagnostics{ + { + Severity: hcl.DiagError, + Summary: "Configuration file not found", + Detail: fmt.Sprintf("The configuration file %s does not exist.", filename), + }, + } + } + return hcl.Diagnostics{ + { + Severity: hcl.DiagError, + Summary: "Failed to read configuration", + Detail: fmt.Sprintf("Can't read %s: %s.", filename, err), + }, + } + } + + return Decode(filename, src, ctx, target) +} diff --git a/hclsimple/hclsimple_test.go b/hclsimple/hclsimple_test.go new file mode 100644 index 0000000..b395804 --- /dev/null +++ b/hclsimple/hclsimple_test.go @@ -0,0 +1,82 @@ +package hclsimple_test + +import ( + "fmt" + "log" + "reflect" + "testing" + + "github.com/hashicorp/hcl/v2/hclsimple" +) + +func Example_nativeSyntax() { + type Config struct { + Foo string `hcl:"foo"` + Baz string `hcl:"baz"` + } + + const exampleConfig = ` + foo = "bar" + baz = "boop" + ` + + var config Config + err := hclsimple.Decode( + "example.hcl", []byte(exampleConfig), + nil, &config, + ) + if err != nil { + log.Fatalf("Failed to load configuration: %s", err) + } + fmt.Printf("Configuration is %v\n", config) + + // Output: + // Configuration is {bar boop} +} + +func Example_jsonSyntax() { + type Config struct { + Foo string `hcl:"foo"` + Baz string `hcl:"baz"` + } + + const exampleConfig = ` + { + "foo": "bar", + "baz": "boop" + } + ` + + var config Config + err := hclsimple.Decode( + "example.json", []byte(exampleConfig), + nil, &config, + ) + if err != nil { + log.Fatalf("Failed to load configuration: %s", err) + } + fmt.Printf("Configuration is %v\n", config) + + // Output: + // Configuration is {bar boop} +} + +func TestDecodeFile(t *testing.T) { + type Config struct { + Foo string `hcl:"foo"` + Baz string `hcl:"baz"` + } + + var got Config + err := hclsimple.DecodeFile("testdata/test.hcl", nil, &got) + if err != nil { + t.Fatalf("unexpected error(s): %s", err) + } + want := Config{ + Foo: "bar", + Baz: "boop", + } + if !reflect.DeepEqual(got, want) { + t.Errorf("wrong result\ngot: %#v\nwant: %#v", got, want) + } +} diff --git a/hclsimple/testdata/test.hcl b/hclsimple/testdata/test.hcl new file mode 100644 index 0000000..4383b2f --- /dev/null +++ b/hclsimple/testdata/test.hcl @@ -0,0 +1,2 @@ +foo = "bar" +baz = "boop"