feat: values updaters

This commit is contained in:
wpetit 2022-05-10 22:31:17 +02:00
parent 08476e5346
commit 0e7e955a58
16 changed files with 415 additions and 155 deletions

View File

@ -30,17 +30,59 @@ It will download `frmd` to your current directory.
Formidable uses URLs to define how to handle schemas/defaults/values. Formidable uses URLs to define how to handle schemas/defaults/values.
For example, to edit a web available schema (in YAML), defaults from `stdin` (in JSON) and values from the local file system (in HCL): For example, to edit a schema (in YAML) from an HTTPS server, while readig default values from `stdin` (in JSON) and using effective values from the local file system (in HCL), outputing updates to `stdout`:
```shell ```bash
echo '{}' | frmd \ echo '{}' | frmd \
edit edit
--schema https://example.com/my-schema.yml \ --schema 'https://example.com/my-schema.yml' \
--defaults stdin://local?format=json \ --defaults 'stdin://local?format=json' \
--values file:///my/file/absolute/path.hcl --values 'file:///my/file/absolute/path.hcl' \
--output 'stdout://local?format=json'
``` ```
The `?format=<json|yaml|hcl>` query variable allows to specify the file format when no file extension is available (for example when reading from `stdin`). ### Available loaders
#### `stdin://`
> TODO: Write doc + example
#### `http://` and `https://`
> TODO: Write doc + example
#### `file://`
> TODO: Write doc + example
### Available formats
#### JSON
- URL Query: `?format=json`
- File extension: `.json`
#### YAML
- URL Query: `?format=yaml`
- File extension: `.yaml` or `.yml`
#### HCL
- URL Query: `?format=hcl`
- File extension: `.hcl`
### Available outputs
#### `stdout://` (default)
> TODO: Write doc + example
#### `file://`
> TODO: Write doc + example
#### `exec://`
> TODO: Write doc + example
## Licence ## Licence

View File

@ -4,7 +4,6 @@ import (
"bytes" "bytes"
"io" "io"
"net/url" "net/url"
"os"
encjson "encoding/json" encjson "encoding/json"
@ -15,6 +14,9 @@ import (
"forge.cadoles.com/wpetit/formidable/internal/data/scheme/file" "forge.cadoles.com/wpetit/formidable/internal/data/scheme/file"
"forge.cadoles.com/wpetit/formidable/internal/data/scheme/http" "forge.cadoles.com/wpetit/formidable/internal/data/scheme/http"
"forge.cadoles.com/wpetit/formidable/internal/data/scheme/stdin" "forge.cadoles.com/wpetit/formidable/internal/data/scheme/stdin"
"forge.cadoles.com/wpetit/formidable/internal/data/updater/exec"
fileUpdater "forge.cadoles.com/wpetit/formidable/internal/data/updater/file"
"forge.cadoles.com/wpetit/formidable/internal/data/updater/stdout"
"forge.cadoles.com/wpetit/formidable/internal/def" "forge.cadoles.com/wpetit/formidable/internal/def"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/santhosh-tekuri/jsonschema/v5" "github.com/santhosh-tekuri/jsonschema/v5"
@ -50,8 +52,8 @@ func commonFlags() []cli.Flag {
&cli.StringFlag{ &cli.StringFlag{
Name: "output", Name: "output",
Aliases: []string{"o", "out"}, Aliases: []string{"o", "out"},
Value: "-", Value: "stdout://local?format=json",
Usage: "Output modified values to `output_file` (or '-' for stdout, the default)", Usage: "Output modified values to specified URL",
}, },
} }
} }
@ -147,29 +149,39 @@ func loadSchema(ctx *cli.Context) (*jsonschema.Schema, error) {
return schema, nil return schema, nil
} }
const OutputStdout = "-" func outputValues(ctx *cli.Context, values interface{}) error {
outputFlag := ctx.String("output")
type noopWriteCloser struct { url, err := url.Parse(outputFlag)
io.Writer
}
func (c *noopWriteCloser) Close() error {
return nil
}
func outputWriter(ctx *cli.Context) (io.WriteCloser, error) {
output := ctx.String("output")
if output == OutputStdout {
return &noopWriteCloser{ctx.App.Writer}, nil
}
file, err := os.OpenFile(output, os.O_WRONLY|os.O_CREATE, 0o644)
if err != nil { if err != nil {
return nil, errors.WithStack(err) return errors.WithStack(err)
} }
return file, nil encoder := newEncoder()
reader, err := encoder.Encode(url, values)
if err != nil {
return errors.WithStack(err)
}
updater := newUpdater()
writer, err := updater.Update(url)
if err != nil {
return errors.WithStack(err)
}
defer func() {
if err := writer.Close(); err != nil {
panic(errors.WithStack(err))
}
}()
if _, err := io.Copy(writer, reader); err != nil && !errors.Is(err, io.EOF) {
return errors.WithStack(err)
}
return nil
} }
func newLoader() *data.Loader { func newLoader() *data.Loader {
@ -187,3 +199,18 @@ func newDecoder() *data.Decoder {
yaml.NewDecoderHandler(), yaml.NewDecoderHandler(),
) )
} }
func newUpdater() *data.Updater {
return data.NewUpdater(
stdout.NewUpdaterHandler(),
fileUpdater.NewUpdaterHandler(),
exec.NewUpdaterHandler(),
)
}
func newEncoder() *data.Encoder {
return data.NewEncoder(
json.NewEncoderHandler(),
yaml.NewEncoderHandler(),
)
}

View File

@ -1,7 +1,6 @@
package command package command
import ( import (
"encoding/json"
"fmt" "fmt"
"os" "os"
@ -49,17 +48,8 @@ func Delete() *cli.Command {
return errors.Wrap(err, "could not validate resulting json") return errors.Wrap(err, "could not validate resulting json")
} }
output, err := outputWriter(ctx) if err := outputValues(ctx, updatedValues); err != nil {
if err != nil { return errors.Wrap(err, "could not output updated values")
return errors.Wrap(err, "could not create output writer")
}
encoder := json.NewEncoder(output)
encoder.SetIndent("", " ")
if err := encoder.Encode(updatedValues); err != nil {
return errors.Wrap(err, "could not write to output")
} }
return nil return nil

View File

@ -52,6 +52,13 @@ func Edit() *cli.Command {
server.WithSchema(schema), server.WithSchema(schema),
server.WithValues(values), server.WithValues(values),
server.WithDefaults(defaults), server.WithDefaults(defaults),
server.WithOnUpdate(func(values interface{}) error {
if err := outputValues(ctx, values); err != nil {
return errors.Wrap(err, "could not output updated values")
}
return nil
}),
) )
addrs, srvErrs := srv.Start(srvCtx) addrs, srvErrs := srv.Start(srvCtx)

View File

@ -1,7 +1,6 @@
package command package command
import ( import (
"encoding/json"
"fmt" "fmt"
"os" "os"
@ -50,17 +49,8 @@ func Get() *cli.Command {
return errors.Wrapf(err, "could not get value from pointer '%v'", rawPointer) return errors.Wrapf(err, "could not get value from pointer '%v'", rawPointer)
} }
output, err := outputWriter(ctx) if err := outputValues(ctx, value); err != nil {
if err != nil { return errors.Wrap(err, "could not output updated values")
return errors.Wrap(err, "could not create output writer")
}
encoder := json.NewEncoder(output)
encoder.SetIndent("", " ")
if err := encoder.Encode(value); err != nil {
return errors.Wrap(err, "could not write to output")
} }
return nil return nil

View File

@ -1,7 +1,6 @@
package command package command
import ( import (
"encoding/json"
"fmt" "fmt"
"os" "os"
@ -44,23 +43,17 @@ func Set() *cli.Command {
pointer := jsonpointer.New(rawPointer) pointer := jsonpointer.New(rawPointer)
var value interface{}
if err := json.Unmarshal([]byte(rawValue), &value); err != nil {
return errors.Wrapf(err, "could not parse json '%s'", rawValue)
}
var updatedValues interface{} var updatedValues interface{}
force := ctx.Bool("force") force := ctx.Bool("force")
if force { if force {
updatedValues, err = pointer.Force(values, value) updatedValues, err = pointer.Force(values, rawValue)
if err != nil { if err != nil {
return errors.Wrapf(err, "could not force value '%v' to pointer '%v'", rawValue, rawPointer) return errors.Wrapf(err, "could not force value '%v' to pointer '%v'", rawValue, rawPointer)
} }
} else { } else {
updatedValues, err = pointer.Set(values, value) updatedValues, err = pointer.Set(values, rawValue)
if err != nil { if err != nil {
return errors.Wrapf(err, "could not set value '%v' to pointer '%v'", rawValue, rawPointer) return errors.Wrapf(err, "could not set value '%v' to pointer '%v'", rawValue, rawPointer)
} }
@ -76,17 +69,8 @@ func Set() *cli.Command {
return errors.Wrap(err, "could not validate resulting json") return errors.Wrap(err, "could not validate resulting json")
} }
output, err := outputWriter(ctx) if err := outputValues(ctx, updatedValues); err != nil {
if err != nil { return errors.Wrap(err, "could not output updated values")
return errors.Wrap(err, "could not create output writer")
}
encoder := json.NewEncoder(output)
encoder.SetIndent("", " ")
if err := encoder.Encode(updatedValues); err != nil {
return errors.Wrap(err, "could not write to output")
} }
return nil return nil

View File

@ -0,0 +1,40 @@
package json
import (
"bytes"
"encoding/json"
"io"
"net/url"
"path"
"path/filepath"
"forge.cadoles.com/wpetit/formidable/internal/data/format"
"github.com/pkg/errors"
)
type EncoderHandler struct{}
func (d *EncoderHandler) Match(url *url.URL) bool {
ext := filepath.Ext(path.Join(url.Host, url.Path))
return ext == ExtensionJSON ||
format.MatchURLQueryFormat(url, FormatJSON)
}
func (d *EncoderHandler) Encode(url *url.URL, data interface{}) (io.Reader, error) {
var buf bytes.Buffer
encoder := json.NewEncoder(&buf)
encoder.SetIndent("", " ")
if err := encoder.Encode(data); err != nil {
return nil, errors.WithStack(err)
}
return &buf, nil
}
func NewEncoderHandler() *EncoderHandler {
return &EncoderHandler{}
}

View File

@ -0,0 +1,38 @@
package yaml
import (
"bytes"
"io"
"net/url"
"path"
"path/filepath"
"forge.cadoles.com/wpetit/formidable/internal/data/format"
"github.com/pkg/errors"
"gopkg.in/yaml.v3"
)
type EncoderHandler struct{}
func (d *EncoderHandler) Match(url *url.URL) bool {
ext := filepath.Ext(path.Join(url.Host, url.Path))
return ExtensionYAML.MatchString(ext) ||
format.MatchURLQueryFormat(url, FormatYAML)
}
func (d *EncoderHandler) Encode(url *url.URL, data interface{}) (io.Reader, error) {
var buf bytes.Buffer
encoder := yaml.NewEncoder(&buf)
if err := encoder.Encode(data); err != nil {
return nil, errors.WithStack(err)
}
return &buf, nil
}
func NewEncoderHandler() *EncoderHandler {
return &EncoderHandler{}
}

38
internal/data/updater.go Normal file
View File

@ -0,0 +1,38 @@
package data
import (
"io"
"net/url"
"github.com/pkg/errors"
)
type UpdaterHandler interface {
URLMatcher
Update(url *url.URL) (io.WriteCloser, error)
}
type Updater struct {
handlers []UpdaterHandler
}
func (u *Updater) Update(url *url.URL) (io.WriteCloser, error) {
for _, h := range u.handlers {
if !h.Match(url) {
continue
}
wr, err := h.Update(url)
if err != nil {
return nil, errors.WithStack(err)
}
return wr, nil
}
return nil, errors.Wrapf(ErrHandlerNotFound, "could not find matching handler for url '%s'", url.String())
}
func NewUpdater(handlers ...UpdaterHandler) *Updater {
return &Updater{handlers}
}

View File

@ -0,0 +1,57 @@
package exec
import (
"io"
"net/url"
"os"
"os/exec"
"path/filepath"
"github.com/pkg/errors"
)
const SchemeExec = "exec"
type UpdaterHandler struct{}
func (h *UpdaterHandler) Match(url *url.URL) bool {
return url.Scheme == SchemeExec
}
func (u *UpdaterHandler) Update(url *url.URL) (io.WriteCloser, error) {
path := filepath.Join(url.Host, url.Path)
absPath, err := filepath.Abs(path)
if err != nil {
return nil, errors.WithStack(err)
}
cmd := exec.Command(absPath)
if url.Query().Get("env") == "yes" {
cmd.Env = os.Environ()
}
if url.Query().Get("stdout") == "yes" {
cmd.Stdout = os.Stdout
}
if url.Query().Get("stderr") == "yes" {
cmd.Stderr = os.Stderr
}
writer, err := cmd.StdinPipe()
if err != nil {
return nil, errors.WithStack(err)
}
if err := cmd.Start(); err != nil {
panic(errors.WithStack(err))
}
return writer, nil
}
func NewUpdaterHandler() *UpdaterHandler {
return &UpdaterHandler{}
}

View File

@ -0,0 +1,33 @@
package file
import (
"io"
"net/url"
"os"
"path/filepath"
"github.com/pkg/errors"
)
const SchemeFile = "file"
type UpdaterHandler struct{}
func (h *UpdaterHandler) Match(url *url.URL) bool {
return url.Scheme == SchemeFile
}
func (u *UpdaterHandler) Update(url *url.URL) (io.WriteCloser, error) {
name := filepath.Join(url.Host, url.Path)
file, err := os.Create(name)
if err != nil {
return nil, errors.Wrapf(err, "could not open file '%s'", name)
}
return file, nil
}
func NewUpdaterHandler() *UpdaterHandler {
return &UpdaterHandler{}
}

View File

@ -0,0 +1,23 @@
package stdout
import (
"io"
"net/url"
"os"
)
const SchemeStdout = "stdout"
type UpdaterHandler struct{}
func (h *UpdaterHandler) Match(url *url.URL) bool {
return url.Scheme == SchemeStdout
}
func (u *UpdaterHandler) Update(url *url.URL) (io.WriteCloser, error) {
return os.Stdout, nil
}
func NewUpdaterHandler() *UpdaterHandler {
return &UpdaterHandler{}
}

View File

@ -11,6 +11,7 @@ type Option struct {
Schema *jsonschema.Schema Schema *jsonschema.Schema
Values interface{} Values interface{}
Defaults interface{} Defaults interface{}
OnUpdate OnUpdateFunc
} }
type OptionFunc func(*Option) type OptionFunc func(*Option)
@ -47,3 +48,9 @@ func WithDefaults(defaults interface{}) OptionFunc {
opt.Defaults = defaults opt.Defaults = defaults
} }
} }
func WithOnUpdate(onUpdate OnUpdateFunc) OptionFunc {
return func(opt *Option) {
opt.OnUpdate = onUpdate
}
}

View File

@ -1,4 +1,4 @@
package route package server
import ( import (
"net/http" "net/http"
@ -12,68 +12,72 @@ import (
"github.com/santhosh-tekuri/jsonschema/v5" "github.com/santhosh-tekuri/jsonschema/v5"
) )
func createRenderFormHandlerFunc(schema *jsonschema.Schema, defaults, values interface{}) http.HandlerFunc { func (s *Server) serveFormReq(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) { data := &template.FormItemData{
data := &template.FormItemData{ Parent: nil,
Parent: nil, Schema: s.schema,
Schema: schema, Property: "",
Property: "", Defaults: s.defaults,
Defaults: defaults, Values: s.values,
Values: values, }
if err := s.schema.Validate(data.Values); err != nil {
validationErr, ok := err.(*jsonschema.ValidationError)
if !ok {
panic(errors.Wrap(err, "could not validate values"))
} }
if err := schema.Validate(data.Values); err != nil { data.Error = validationErr
validationErr, ok := err.(*jsonschema.ValidationError) }
if !ok {
panic(errors.Wrap(err, "could not validate values"))
}
data.Error = validationErr if err := template.Exec("index.html.tmpl", w, data); err != nil {
} panic(errors.WithStack(err))
if err := template.Exec("index.html.tmpl", w, data); err != nil {
panic(errors.WithStack(err))
}
} }
} }
func createHandleFormHandlerFunc(schema *jsonschema.Schema, defaults, values interface{}) http.HandlerFunc { func (s *Server) handleFormReq(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) { data := &template.FormItemData{
data := &template.FormItemData{ Parent: nil,
Parent: nil, Schema: s.schema,
Schema: schema, Property: "",
Property: "", Defaults: s.defaults,
Defaults: defaults, Values: s.values,
Values: values, }
}
if err := r.ParseForm(); err != nil { var values interface{}
panic(errors.WithStack(err))
} else {
values, err = handleForm(r.Form, schema, values)
if err != nil {
panic(errors.WithStack(err))
}
data.Values = values if err := r.ParseForm(); err != nil {
} panic(errors.WithStack(err))
} else {
if err := schema.Validate(data.Values); err != nil { values, err = handleForm(r.Form, s.schema, values)
validationErr, ok := err.(*jsonschema.ValidationError) if err != nil {
if !ok {
panic(errors.Wrap(err, "could not validate values"))
}
data.Error = validationErr
}
if data.Error == nil {
data.SuccessMessage = "Data updated."
}
if err := template.Exec("index.html.tmpl", w, data); err != nil {
panic(errors.WithStack(err)) panic(errors.WithStack(err))
} }
data.Values = values
}
if err := s.schema.Validate(data.Values); err != nil {
validationErr, ok := err.(*jsonschema.ValidationError)
if !ok {
panic(errors.Wrap(err, "could not validate values"))
}
data.Error = validationErr
}
if data.Error == nil {
if s.onUpdate != nil {
if err := s.onUpdate(data.Values); err != nil {
panic(errors.Wrap(err, "could not update values"))
}
}
data.SuccessMessage = "Data updated."
}
if err := template.Exec("index.html.tmpl", w, data); err != nil {
panic(errors.WithStack(err))
} }
} }

View File

@ -1,23 +0,0 @@
package route
import (
"net/http"
"github.com/go-chi/chi"
"github.com/go-chi/chi/middleware"
"github.com/santhosh-tekuri/jsonschema/v5"
)
func NewHandler(schema *jsonschema.Schema, defaults, values interface{}, assetsHandler http.Handler) (*chi.Mux, error) {
router := chi.NewRouter()
router.Use(middleware.RequestID)
// router.Use(middleware.Logger)
router.Get("/", createRenderFormHandlerFunc(schema, defaults, values))
router.Post("/", createHandleFormHandlerFunc(schema, defaults, values))
router.Handle("/assets/*", assetsHandler)
return router, nil
}

View File

@ -7,8 +7,8 @@ import (
"net" "net"
"net/http" "net/http"
"forge.cadoles.com/wpetit/formidable/internal/server/route"
"forge.cadoles.com/wpetit/formidable/internal/server/template" "forge.cadoles.com/wpetit/formidable/internal/server/template"
"github.com/go-chi/chi"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/santhosh-tekuri/jsonschema/v5" "github.com/santhosh-tekuri/jsonschema/v5"
) )
@ -19,8 +19,11 @@ type Server struct {
schema *jsonschema.Schema schema *jsonschema.Schema
defaults interface{} defaults interface{}
values interface{} values interface{}
onUpdate OnUpdateFunc
} }
type OnUpdateFunc func(values interface{}) error
func (s *Server) Start(ctx context.Context) (<-chan net.Addr, <-chan error) { func (s *Server) Start(ctx context.Context) (<-chan net.Addr, <-chan error) {
errs := make(chan error) errs := make(chan error)
addrs := make(chan net.Addr) addrs := make(chan net.Addr)
@ -71,16 +74,15 @@ func (s *Server) run(parentCtx context.Context, addrs chan net.Addr, errs chan e
assets := getEmbeddedAssets() assets := getEmbeddedAssets()
assetsHandler := http.FileServer(http.FS(assets)) assetsHandler := http.FileServer(http.FS(assets))
handler, err := route.NewHandler(s.schema, s.defaults, s.values, assetsHandler) router := chi.NewRouter()
if err != nil {
errs <- errors.WithStack(err)
return router.Get("/", s.serveFormReq)
} router.Post("/", s.handleFormReq)
router.Handle("/assets/*", assetsHandler)
log.Println("http server listening") log.Println("http server listening")
if err := http.Serve(listener, handler); err != nil && !errors.Is(err, net.ErrClosed) { if err := http.Serve(listener, router); err != nil && !errors.Is(err, net.ErrClosed) {
errs <- errors.WithStack(err) errs <- errors.WithStack(err)
} }
@ -99,5 +101,6 @@ func New(funcs ...OptionFunc) *Server {
schema: opt.Schema, schema: opt.Schema,
defaults: opt.Defaults, defaults: opt.Defaults,
values: opt.Values, values: opt.Values,
onUpdate: opt.OnUpdate,
} }
} }