From 0e7e955a587fcafd558ddbc7bc9541134c90d847 Mon Sep 17 00:00:00 2001 From: William Petit Date: Tue, 10 May 2022 22:31:17 +0200 Subject: [PATCH] feat: values updaters --- README.md | 54 ++++++++- internal/command/common.go | 71 +++++++---- internal/command/delete.go | 14 +-- internal/command/edit.go | 7 ++ internal/command/get.go | 14 +-- internal/command/set.go | 24 +--- internal/data/format/json/encoder_handler.go | 40 +++++++ internal/data/format/yaml/encoder_handler.go | 38 ++++++ internal/data/updater.go | 38 ++++++ internal/data/updater/exec/updater_handler.go | 57 +++++++++ internal/data/updater/file/updater_handler.go | 33 ++++++ .../data/updater/stdout/updater_handler.go | 23 ++++ internal/server/option.go | 7 ++ internal/server/{route/form.go => route.go} | 110 +++++++++--------- internal/server/route/handler.go | 23 ---- internal/server/server.go | 17 +-- 16 files changed, 415 insertions(+), 155 deletions(-) create mode 100644 internal/data/format/json/encoder_handler.go create mode 100644 internal/data/format/yaml/encoder_handler.go create mode 100644 internal/data/updater.go create mode 100644 internal/data/updater/exec/updater_handler.go create mode 100644 internal/data/updater/file/updater_handler.go create mode 100644 internal/data/updater/stdout/updater_handler.go rename internal/server/{route/form.go => route.go} (66%) delete mode 100644 internal/server/route/handler.go diff --git a/README.md b/README.md index a1166ea..6f47b7e 100644 --- a/README.md +++ b/README.md @@ -30,17 +30,59 @@ It will download `frmd` to your current directory. 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 \ edit - --schema https://example.com/my-schema.yml \ - --defaults stdin://local?format=json \ - --values file:///my/file/absolute/path.hcl + --schema 'https://example.com/my-schema.yml' \ + --defaults 'stdin://local?format=json' \ + --values 'file:///my/file/absolute/path.hcl' \ + --output 'stdout://local?format=json' ``` -The `?format=` 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 diff --git a/internal/command/common.go b/internal/command/common.go index f729796..feb4bbb 100644 --- a/internal/command/common.go +++ b/internal/command/common.go @@ -4,7 +4,6 @@ import ( "bytes" "io" "net/url" - "os" 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/http" "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" "github.com/pkg/errors" "github.com/santhosh-tekuri/jsonschema/v5" @@ -50,8 +52,8 @@ func commonFlags() []cli.Flag { &cli.StringFlag{ Name: "output", Aliases: []string{"o", "out"}, - Value: "-", - Usage: "Output modified values to `output_file` (or '-' for stdout, the default)", + Value: "stdout://local?format=json", + Usage: "Output modified values to specified URL", }, } } @@ -147,29 +149,39 @@ func loadSchema(ctx *cli.Context) (*jsonschema.Schema, error) { return schema, nil } -const OutputStdout = "-" +func outputValues(ctx *cli.Context, values interface{}) error { + outputFlag := ctx.String("output") -type noopWriteCloser struct { - 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) + url, err := url.Parse(outputFlag) 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 { @@ -187,3 +199,18 @@ func newDecoder() *data.Decoder { 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(), + ) +} diff --git a/internal/command/delete.go b/internal/command/delete.go index 9dc9ac0..3419798 100644 --- a/internal/command/delete.go +++ b/internal/command/delete.go @@ -1,7 +1,6 @@ package command import ( - "encoding/json" "fmt" "os" @@ -49,17 +48,8 @@ func Delete() *cli.Command { return errors.Wrap(err, "could not validate resulting json") } - output, err := outputWriter(ctx) - if err != nil { - 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") + if err := outputValues(ctx, updatedValues); err != nil { + return errors.Wrap(err, "could not output updated values") } return nil diff --git a/internal/command/edit.go b/internal/command/edit.go index ca0f470..726fc73 100644 --- a/internal/command/edit.go +++ b/internal/command/edit.go @@ -52,6 +52,13 @@ func Edit() *cli.Command { server.WithSchema(schema), server.WithValues(values), 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) diff --git a/internal/command/get.go b/internal/command/get.go index 098a6a0..cb966fd 100644 --- a/internal/command/get.go +++ b/internal/command/get.go @@ -1,7 +1,6 @@ package command import ( - "encoding/json" "fmt" "os" @@ -50,17 +49,8 @@ func Get() *cli.Command { return errors.Wrapf(err, "could not get value from pointer '%v'", rawPointer) } - output, err := outputWriter(ctx) - if err != nil { - 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") + if err := outputValues(ctx, value); err != nil { + return errors.Wrap(err, "could not output updated values") } return nil diff --git a/internal/command/set.go b/internal/command/set.go index d9e7a53..7806f09 100644 --- a/internal/command/set.go +++ b/internal/command/set.go @@ -1,7 +1,6 @@ package command import ( - "encoding/json" "fmt" "os" @@ -44,23 +43,17 @@ func Set() *cli.Command { 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{} force := ctx.Bool("force") if force { - updatedValues, err = pointer.Force(values, value) + updatedValues, err = pointer.Force(values, rawValue) if err != nil { return errors.Wrapf(err, "could not force value '%v' to pointer '%v'", rawValue, rawPointer) } } else { - updatedValues, err = pointer.Set(values, value) + updatedValues, err = pointer.Set(values, rawValue) if err != nil { 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") } - output, err := outputWriter(ctx) - if err != nil { - 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") + if err := outputValues(ctx, updatedValues); err != nil { + return errors.Wrap(err, "could not output updated values") } return nil diff --git a/internal/data/format/json/encoder_handler.go b/internal/data/format/json/encoder_handler.go new file mode 100644 index 0000000..a9200e4 --- /dev/null +++ b/internal/data/format/json/encoder_handler.go @@ -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{} +} diff --git a/internal/data/format/yaml/encoder_handler.go b/internal/data/format/yaml/encoder_handler.go new file mode 100644 index 0000000..809b11e --- /dev/null +++ b/internal/data/format/yaml/encoder_handler.go @@ -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{} +} diff --git a/internal/data/updater.go b/internal/data/updater.go new file mode 100644 index 0000000..6287749 --- /dev/null +++ b/internal/data/updater.go @@ -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} +} diff --git a/internal/data/updater/exec/updater_handler.go b/internal/data/updater/exec/updater_handler.go new file mode 100644 index 0000000..f46213b --- /dev/null +++ b/internal/data/updater/exec/updater_handler.go @@ -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{} +} diff --git a/internal/data/updater/file/updater_handler.go b/internal/data/updater/file/updater_handler.go new file mode 100644 index 0000000..4817dd8 --- /dev/null +++ b/internal/data/updater/file/updater_handler.go @@ -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{} +} diff --git a/internal/data/updater/stdout/updater_handler.go b/internal/data/updater/stdout/updater_handler.go new file mode 100644 index 0000000..9c5a973 --- /dev/null +++ b/internal/data/updater/stdout/updater_handler.go @@ -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{} +} diff --git a/internal/server/option.go b/internal/server/option.go index 3d72412..343cf6c 100644 --- a/internal/server/option.go +++ b/internal/server/option.go @@ -11,6 +11,7 @@ type Option struct { Schema *jsonschema.Schema Values interface{} Defaults interface{} + OnUpdate OnUpdateFunc } type OptionFunc func(*Option) @@ -47,3 +48,9 @@ func WithDefaults(defaults interface{}) OptionFunc { opt.Defaults = defaults } } + +func WithOnUpdate(onUpdate OnUpdateFunc) OptionFunc { + return func(opt *Option) { + opt.OnUpdate = onUpdate + } +} diff --git a/internal/server/route/form.go b/internal/server/route.go similarity index 66% rename from internal/server/route/form.go rename to internal/server/route.go index 90473b0..131743d 100644 --- a/internal/server/route/form.go +++ b/internal/server/route.go @@ -1,4 +1,4 @@ -package route +package server import ( "net/http" @@ -12,68 +12,72 @@ import ( "github.com/santhosh-tekuri/jsonschema/v5" ) -func createRenderFormHandlerFunc(schema *jsonschema.Schema, defaults, values interface{}) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - data := &template.FormItemData{ - Parent: nil, - Schema: schema, - Property: "", - Defaults: defaults, - Values: values, +func (s *Server) serveFormReq(w http.ResponseWriter, r *http.Request) { + data := &template.FormItemData{ + Parent: nil, + Schema: s.schema, + Property: "", + Defaults: s.defaults, + Values: s.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 { - validationErr, ok := err.(*jsonschema.ValidationError) - if !ok { - panic(errors.Wrap(err, "could not validate values")) - } + data.Error = validationErr + } - 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 { - return func(w http.ResponseWriter, r *http.Request) { - data := &template.FormItemData{ - Parent: nil, - Schema: schema, - Property: "", - Defaults: defaults, - Values: values, - } +func (s *Server) handleFormReq(w http.ResponseWriter, r *http.Request) { + data := &template.FormItemData{ + Parent: nil, + Schema: s.schema, + Property: "", + Defaults: s.defaults, + Values: s.values, + } - if err := r.ParseForm(); err != nil { - panic(errors.WithStack(err)) - } else { - values, err = handleForm(r.Form, schema, values) - if err != nil { - panic(errors.WithStack(err)) - } + var values interface{} - data.Values = values - } - - if err := 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 { - data.SuccessMessage = "Data updated." - } - - if err := template.Exec("index.html.tmpl", w, data); err != nil { + if err := r.ParseForm(); err != nil { + panic(errors.WithStack(err)) + } else { + values, err = handleForm(r.Form, s.schema, values) + if err != nil { 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)) } } diff --git a/internal/server/route/handler.go b/internal/server/route/handler.go deleted file mode 100644 index b30d08d..0000000 --- a/internal/server/route/handler.go +++ /dev/null @@ -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 -} diff --git a/internal/server/server.go b/internal/server/server.go index a874570..9fe61ae 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -7,8 +7,8 @@ import ( "net" "net/http" - "forge.cadoles.com/wpetit/formidable/internal/server/route" "forge.cadoles.com/wpetit/formidable/internal/server/template" + "github.com/go-chi/chi" "github.com/pkg/errors" "github.com/santhosh-tekuri/jsonschema/v5" ) @@ -19,8 +19,11 @@ type Server struct { schema *jsonschema.Schema defaults interface{} values interface{} + onUpdate OnUpdateFunc } +type OnUpdateFunc func(values interface{}) error + func (s *Server) Start(ctx context.Context) (<-chan net.Addr, <-chan error) { errs := make(chan error) 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() assetsHandler := http.FileServer(http.FS(assets)) - handler, err := route.NewHandler(s.schema, s.defaults, s.values, assetsHandler) - if err != nil { - errs <- errors.WithStack(err) + router := chi.NewRouter() - return - } + router.Get("/", s.serveFormReq) + router.Post("/", s.handleFormReq) + router.Handle("/assets/*", assetsHandler) 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) } @@ -99,5 +101,6 @@ func New(funcs ...OptionFunc) *Server { schema: opt.Schema, defaults: opt.Defaults, values: opt.Values, + onUpdate: opt.OnUpdate, } }