From ada7f18e360322c81f5ea4ef61bc3288728e647a Mon Sep 17 00:00:00 2001 From: William Petit Date: Tue, 22 Mar 2022 09:21:55 +0100 Subject: [PATCH] Initial commit --- .env.dist | 0 .gitignore | 2 + Makefile | 31 +++ cmd/frmd/main.go | 86 +++++++ go.mod | 44 ++++ go.sum | 88 +++++++ internal/command/common.go | 156 ++++++++++++ internal/command/delete.go | 68 +++++ internal/command/edit.go | 98 ++++++++ internal/command/get.go | 69 ++++++ internal/command/root.go | 12 + internal/command/set.go | 95 +++++++ internal/def/schema.go | 12 + internal/jsonpointer/delete.go | 73 ++++++ internal/jsonpointer/delete_test.go | 95 +++++++ internal/jsonpointer/error.go | 9 + internal/jsonpointer/force.go | 111 +++++++++ internal/jsonpointer/get.go | 60 +++++ internal/jsonpointer/get_test.go | 140 +++++++++++ internal/jsonpointer/pointer.go | 92 +++++++ internal/jsonpointer/set.go | 67 +++++ internal/jsonpointer/set_test.go | 102 ++++++++ internal/jsonpointer/testdata/ietf.json | 12 + internal/jsonpointer/testdata/set/basic.json | 1 + internal/jsonpointer/testdata/set/nested.json | 8 + internal/server/option.go | 49 ++++ internal/server/route/form.go | 161 ++++++++++++ internal/server/route/handler.go | 19 ++ internal/server/server.go | 98 ++++++++ .../server/template/blocks/form.html.tmpl | 19 ++ .../template/blocks/form_input.html.tmpl | 10 + .../blocks/form_input_array.html.tmpl | 31 +++ .../blocks/form_input_boolean.html.tmpl | 12 + .../blocks/form_input_integer.html.tmpl | 3 + .../template/blocks/form_input_null.html.tmpl | 1 + .../blocks/form_input_number.html.tmpl | 5 + .../blocks/form_input_object.html.tmpl | 5 + .../blocks/form_input_string.html.tmpl | 5 + .../template/blocks/form_item.html.tmpl | 11 + .../server/template/blocks/form_row.html.tmpl | 23 ++ .../server/template/blocks/head.html.tmpl | 10 + .../server/template/layouts/index.html.tmpl | 6 + internal/server/template/template.go | 233 ++++++++++++++++++ misc/schema/card.json | 106 ++++++++ misc/schema/filesystem.json | 136 ++++++++++ modd.conf | 12 + schema.json | 39 +++ script/release | 104 ++++++++ values.json | 6 + 49 files changed, 2635 insertions(+) create mode 100644 .env.dist create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 cmd/frmd/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/command/common.go create mode 100644 internal/command/delete.go create mode 100644 internal/command/edit.go create mode 100644 internal/command/get.go create mode 100644 internal/command/root.go create mode 100644 internal/command/set.go create mode 100644 internal/def/schema.go create mode 100644 internal/jsonpointer/delete.go create mode 100644 internal/jsonpointer/delete_test.go create mode 100644 internal/jsonpointer/error.go create mode 100644 internal/jsonpointer/force.go create mode 100644 internal/jsonpointer/get.go create mode 100644 internal/jsonpointer/get_test.go create mode 100644 internal/jsonpointer/pointer.go create mode 100644 internal/jsonpointer/set.go create mode 100644 internal/jsonpointer/set_test.go create mode 100644 internal/jsonpointer/testdata/ietf.json create mode 100644 internal/jsonpointer/testdata/set/basic.json create mode 100644 internal/jsonpointer/testdata/set/nested.json create mode 100644 internal/server/option.go create mode 100644 internal/server/route/form.go create mode 100644 internal/server/route/handler.go create mode 100644 internal/server/server.go create mode 100644 internal/server/template/blocks/form.html.tmpl create mode 100644 internal/server/template/blocks/form_input.html.tmpl create mode 100644 internal/server/template/blocks/form_input_array.html.tmpl create mode 100644 internal/server/template/blocks/form_input_boolean.html.tmpl create mode 100644 internal/server/template/blocks/form_input_integer.html.tmpl create mode 100644 internal/server/template/blocks/form_input_null.html.tmpl create mode 100644 internal/server/template/blocks/form_input_number.html.tmpl create mode 100644 internal/server/template/blocks/form_input_object.html.tmpl create mode 100644 internal/server/template/blocks/form_input_string.html.tmpl create mode 100644 internal/server/template/blocks/form_item.html.tmpl create mode 100644 internal/server/template/blocks/form_row.html.tmpl create mode 100644 internal/server/template/blocks/head.html.tmpl create mode 100644 internal/server/template/layouts/index.html.tmpl create mode 100644 internal/server/template/template.go create mode 100644 misc/schema/card.json create mode 100644 misc/schema/filesystem.json create mode 100644 modd.conf create mode 100644 schema.json create mode 100755 script/release create mode 100644 values.json diff --git a/.env.dist b/.env.dist new file mode 100644 index 0000000..e69de29 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..43b7f71 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/.env +/bin \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..c4a5556 --- /dev/null +++ b/Makefile @@ -0,0 +1,31 @@ +.DEFAULT_GOAL := help +LINT_ARGS ?= --timeout 5m +FRMD_CMD ?= +SHELL = /bin/bash + +.PHONY: help +help: ## Display this help + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' + +watch: ## Watching updated files - live reload + go run -mod=readonly github.com/cortesi/modd/cmd/modd@latest + +test: .env ## Executing tests + ( set -o allexport && source .env && set +o allexport && go test -v -race -count=1 $(GOTEST_ARGS) ./... ) + +lint: ## Lint sources code + golangci-lint run --enable-all $(LINT_ARGS) + +build: build-frmd ## Build artefacts + +build-frmd: ## Build executable + CGO_ENABLED=0 go build -v -o ./bin/frmd ./cmd/frmd + +.env: + cp .env.dist .env + +deps: + +.PHONY: release +release: + ./misc/script/release \ No newline at end of file diff --git a/cmd/frmd/main.go b/cmd/frmd/main.go new file mode 100644 index 0000000..ab00904 --- /dev/null +++ b/cmd/frmd/main.go @@ -0,0 +1,86 @@ +package main + +import ( + "context" + "fmt" + "os" + "sort" + + "forge.cadoles.com/wpetit/formidable/internal/command" + "github.com/pkg/errors" + "github.com/urfave/cli/v2" +) + +// nolint: gochecknoglobals +var ( + GitRef = "unknown" + ProjectVersion = "unknown" + BuildDate = "unknown" +) + +func main() { + ctx := context.Background() + + app := &cli.App{ + Name: "frmd", + Usage: "JSON Schema based cli forms", + Commands: command.Root(), + Before: func(ctx *cli.Context) error { + workdir := ctx.String("workdir") + // Switch to new working directory if defined + if workdir != "" { + if err := os.Chdir(workdir); err != nil { + return errors.Wrap(err, "could not change working directory") + } + } + + if err := ctx.Set("projectVersion", ProjectVersion); err != nil { + return errors.WithStack(err) + } + + if err := ctx.Set("gitRef", GitRef); err != nil { + return errors.WithStack(err) + } + + if err := ctx.Set("buildDate", BuildDate); err != nil { + return errors.WithStack(err) + } + + return nil + }, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "workdir", + Value: "", + Usage: "The working directory", + }, + &cli.StringFlag{ + Name: "projectVersion", + Value: "", + Hidden: true, + }, + &cli.StringFlag{ + Name: "gitRef", + Value: "", + Hidden: true, + }, + &cli.StringFlag{ + Name: "buildDate", + Value: "", + Hidden: true, + }, + }, + } + + app.ExitErrHandler = func(ctx *cli.Context, err error) { + fmt.Printf("%+v", err) + } + + sort.Sort(cli.FlagsByName(app.Flags)) + sort.Sort(cli.CommandsByName(app.Commands)) + + err := app.RunContext(ctx, os.Args) + if err != nil { + panic(errors.WithStack(err)) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..affae50 --- /dev/null +++ b/go.mod @@ -0,0 +1,44 @@ +module forge.cadoles.com/wpetit/formidable + +go 1.17 + +require ( + github.com/pkg/errors v0.9.1 + github.com/urfave/cli/v2 v2.4.0 +) + +require ( + github.com/Masterminds/goutils v1.1.1 // indirect + github.com/Masterminds/semver/v3 v3.1.1 // indirect + github.com/Masterminds/sprig/v3 v3.2.2 // indirect + github.com/google/uuid v1.1.1 // indirect + github.com/huandu/xstrings v1.3.1 // indirect + github.com/imdario/mergo v0.3.11 // indirect + github.com/mitchellh/copystructure v1.0.0 // indirect + github.com/mitchellh/reflectwalk v1.0.0 // indirect + github.com/shopspring/decimal v1.2.0 // indirect + github.com/spf13/cast v1.3.1 // indirect + golang.org/x/crypto v0.0.0-20200414173820-0848c9571904 // indirect +) + +require ( + github.com/awesome-gocui/gocui v1.1.0 // indirect + github.com/cpuguy83/go-md2man/v2 v2.0.1 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/gdamore/encoding v1.0.0 // indirect + github.com/gdamore/tcell/v2 v2.4.1-0.20210905002822-f057f0a857a1 // indirect + github.com/go-chi/chi v1.5.4 + github.com/jroimartin/gocui v0.5.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-runewidth v0.0.13 // indirect + github.com/nsf/termbox-go v1.1.1 // indirect + github.com/rivo/tview v0.0.0-20220307222120-9994674d60a8 // indirect + github.com/rivo/uniseg v0.2.0 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/santhosh-tekuri/jsonschema/v5 v5.0.0 // indirect + github.com/skanehira/gocui-component v0.0.0-20190406233618-9b1c71353c96 // indirect + github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect + golang.org/x/sys v0.0.0-20210309074719-68d13333faf2 // indirect + golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d // indirect + golang.org/x/text v0.3.6 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..d0155e9 --- /dev/null +++ b/go.sum @@ -0,0 +1,88 @@ +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= +github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= +github.com/Masterminds/semver/v3 v3.1.1 h1:hLg3sBzpNErnxhQtUy/mmLR2I9foDujNK030IGemrRc= +github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs= +github.com/Masterminds/sprig/v3 v3.2.2 h1:17jRggJu518dr3QaafizSXOjKYp94wKfABxUmyxvxX8= +github.com/Masterminds/sprig/v3 v3.2.2/go.mod h1:UoaO7Yp8KlPnJIYWTFkMaqPUYKTfGFPhxNuwnnxkKlk= +github.com/awesome-gocui/gocui v1.1.0 h1:db2j7yFEoHZjpQFeE2xqiatS8bm1lO3THeLwE6MzOII= +github.com/awesome-gocui/gocui v1.1.0/go.mod h1:M2BXkrp7PR97CKnPRT7Rk0+rtswChPtksw/vRAESGpg= +github.com/cpuguy83/go-md2man/v2 v2.0.1 h1:r/myEWzV9lfsM1tFLgDyu0atFtJ1fXn261LKYj/3DxU= +github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko= +github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg= +github.com/gdamore/tcell/v2 v2.4.0/go.mod h1:cTTuF84Dlj/RqmaCIV5p4w8uG1zWdk0SF6oBpwHp4fU= +github.com/gdamore/tcell/v2 v2.4.1-0.20210905002822-f057f0a857a1 h1:QqwPZCwh/k1uYqq6uXSb9TRDhTkfQbO80v8zhnIe5zM= +github.com/gdamore/tcell/v2 v2.4.1-0.20210905002822-f057f0a857a1/go.mod h1:Az6Jt+M5idSED2YPGtwnfJV0kXohgdCBPmHGSYc1r04= +github.com/go-chi/chi v1.5.4 h1:QHdzF2szwjqVV4wmByUnTcsbIg7UGaQ0tPF2t5GcAIs= +github.com/go-chi/chi v1.5.4/go.mod h1:uaf8YgoFazUOkPBG7fxPftUylNumIev9awIWOENIuEg= +github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY= +github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/huandu/xstrings v1.3.1 h1:4jgBlKK6tLKFvO8u5pmYjG91cqytmDCDvGh7ECVFfFs= +github.com/huandu/xstrings v1.3.1/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= +github.com/imdario/mergo v0.3.11 h1:3tnifQM4i+fbajXKBHXWEH+KvNHqojZ778UH75j3bGA= +github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= +github.com/jroimartin/gocui v0.5.0 h1:DCZc97zY9dMnHXJSJLLmx9VqiEnAj0yh0eTNpuEtG/4= +github.com/jroimartin/gocui v0.5.0/go.mod h1:l7Hz8DoYoL6NoYnlnaX6XCNR62G7J5FfSW5jEogzaxE= +github.com/lucasb-eyer/go-colorful v1.0.3/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU= +github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mitchellh/copystructure v1.0.0 h1:Laisrj+bAB6b/yJwB5Bt3ITZhGJdqmxquMKeZ+mmkFQ= +github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw= +github.com/mitchellh/reflectwalk v1.0.0 h1:9D+8oIskB4VJBN5SFlmc27fSlIBZaov1Wpk/IfikLNY= +github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/nsf/termbox-go v1.1.1 h1:nksUPLCb73Q++DwbYUBEglYBRPZyoXJdrj5L+TkjyZY= +github.com/nsf/termbox-go v1.1.1/go.mod h1:T0cTdVuOwf7pHQNtfhnEbzHbcNyCEcVU4YPpouCbVxo= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/tview v0.0.0-20220307222120-9994674d60a8 h1:xe+mmCnDN82KhC010l3NfYlA8ZbOuzbXAzSYBa6wbMc= +github.com/rivo/tview v0.0.0-20220307222120-9994674d60a8/go.mod h1:WIfMkQNY+oq/mWwtsjOYHIZBuwthioY2srOmljJkTnk= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/santhosh-tekuri/jsonschema/v5 v5.0.0 h1:TToq11gyfNlrMFZiYujSekIsPd9AmsA2Bj/iv+s4JHE= +github.com/santhosh-tekuri/jsonschema/v5 v5.0.0/go.mod h1:FKdcjfQW6rpZSnxxUvEA5H/cDPdvJ/SZJQLWWXWGrZ0= +github.com/shopspring/decimal v1.2.0 h1:abSATXmQEYyShuxI4/vyW3tV1MrKAJzCZ/0zLUXYbsQ= +github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= +github.com/skanehira/gocui-component v0.0.0-20190406233618-9b1c71353c96 h1:iNHvqWqQWIQ0wdWkcHW2sQhvRaVlCrnFmUftk7LcbrE= +github.com/skanehira/gocui-component v0.0.0-20190406233618-9b1c71353c96/go.mod h1:uhDvc/srGKwvK9bGt4zlfTiywMLL7ngz44Yp2nQwTjE= +github.com/spf13/cast v1.3.1 h1:nFm6S0SMdyzrzcmThSipiEubIDy8WEXKNZ0UOgiRpng= +github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/urfave/cli/v2 v2.4.0 h1:m2pxjjDFgDxSPtO8WSdbndj17Wu2y8vOT86wE/tjr+I= +github.com/urfave/cli/v2 v2.4.0/go.mod h1:NX9W0zmTvedE5oDoOMs2RTC8RvdK98NTYZE5LbaEYPg= +github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo= +github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200414173820-0848c9571904 h1:bXoxMPcSLOq08zI3/c5dEBT6lE4eh+jOh886GHrn6V8= +golang.org/x/crypto v0.0.0-20200414173820-0848c9571904/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210309074719-68d13333faf2 h1:46ULzRKLh1CwgRq2dC5SlBzEqqNCi8rreOZnNrbqcIY= +golang.org/x/sys v0.0.0-20210309074719-68d13333faf2/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d h1:SZxvLBoTP5yHO3Frd4z4vrF+DBX9vMVanchswa69toE= +golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6 h1:aRYxNxv6iGQlyVaZmk6ZgYEDa+Jg18DxebPSrd6bg1M= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/internal/command/common.go b/internal/command/common.go new file mode 100644 index 0000000..c0e10a1 --- /dev/null +++ b/internal/command/common.go @@ -0,0 +1,156 @@ +package command + +import ( + "encoding/json" + "io" + "os" + "strings" + + "forge.cadoles.com/wpetit/formidable/internal/def" + "github.com/pkg/errors" + "github.com/santhosh-tekuri/jsonschema/v5" + "github.com/urfave/cli/v2" +) + +const ( + filePathPrefix = "@" +) + +func commonFlags() []cli.Flag { + return []cli.Flag{ + &cli.StringFlag{ + Name: "defaults", + Aliases: []string{"d"}, + Usage: "Default values as JSON or file path prefixed by '@'", + Value: "{}", + }, + &cli.StringFlag{ + Name: "values", + Aliases: []string{"v"}, + Usage: "Current values as JSON or file path prefixed by '@'", + Value: "{}", + }, + &cli.StringFlag{ + Name: "schema", + Aliases: []string{"s"}, + Usage: "Use `schema_file` as schema", + TakesFile: true, + }, + &cli.StringFlag{ + Name: "output", + Aliases: []string{"o", "out"}, + Value: "-", + Usage: "Output modified values to `output_file` (or '-' for stdout, the default)", + }, + } +} + +func loadJSONFlag(ctx *cli.Context, flagName string) (interface{}, error) { + flagValue := ctx.String(flagName) + + if flagValue == "" { + return nil, nil + } + + if !strings.HasPrefix(flagValue, filePathPrefix) { + var value interface{} + + if err := json.Unmarshal([]byte(flagValue), &value); err != nil { + return nil, errors.WithStack(err) + } + + return value, nil + } + + flagValue = strings.TrimPrefix(flagValue, filePathPrefix) + + file, err := os.Open(flagValue) + if err != nil { + return nil, errors.WithStack(err) + } + + defer func() { + if err := file.Close(); err != nil { + panic(errors.WithStack(err)) + } + }() + + reader := json.NewDecoder(file) + + var values interface{} + + if err := reader.Decode(&values); err != nil { + return nil, errors.WithStack(err) + } + + return values, nil +} + +func loadValues(ctx *cli.Context) (interface{}, error) { + values, err := loadJSONFlag(ctx, "values") + if err != nil { + return nil, errors.WithStack(err) + } + + return values, nil +} + +func loadDefaults(ctx *cli.Context) (interface{}, error) { + values, err := loadJSONFlag(ctx, "defaults") + if err != nil { + return nil, errors.WithStack(err) + } + + return values, nil +} + +func loadSchema(ctx *cli.Context) (*jsonschema.Schema, error) { + schemaFlag := ctx.String("schema") + + compiler := jsonschema.NewCompiler() + + compiler.ExtractAnnotations = true + compiler.AssertFormat = true + compiler.AssertContent = true + + var ( + schema *jsonschema.Schema + err error + ) + + if schemaFlag == "" { + schema = def.Schema + } else { + schema, err = compiler.Compile(schemaFlag) + } + if err != nil { + return nil, errors.WithStack(err) + } + + return schema, nil +} + +const OutputStdout = "-" + +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) + if err != nil { + return nil, errors.WithStack(err) + } + + return file, nil +} diff --git a/internal/command/delete.go b/internal/command/delete.go new file mode 100644 index 0000000..9dc9ac0 --- /dev/null +++ b/internal/command/delete.go @@ -0,0 +1,68 @@ +package command + +import ( + "encoding/json" + "fmt" + "os" + + "forge.cadoles.com/wpetit/formidable/internal/jsonpointer" + "github.com/pkg/errors" + "github.com/santhosh-tekuri/jsonschema/v5" + _ "github.com/santhosh-tekuri/jsonschema/v5/httploader" + "github.com/urfave/cli/v2" +) + +func Delete() *cli.Command { + return &cli.Command{ + Name: "delete", + Usage: "Delete value at specific path", + Flags: commonFlags(), + Action: func(ctx *cli.Context) error { + schema, err := loadSchema(ctx) + if err != nil { + return errors.Wrap(err, "could not load schema") + } + + values, err := loadValues(ctx) + if err != nil { + return errors.Wrap(err, "could not load values") + } + + rawPointer := ctx.Args().Get(0) + + pointer := jsonpointer.New(rawPointer) + + var updatedValues interface{} + + updatedValues, err = pointer.Delete(values) + if err != nil { + return errors.Wrapf(err, "could not delete pointer '%v'", rawPointer) + } + + if err := schema.Validate(updatedValues); err != nil { + if _, ok := err.(*jsonschema.ValidationError); ok { + fmt.Printf("%#v\n", err) + + os.Exit(1) + } + + 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") + } + + return nil + }, + } +} diff --git a/internal/command/edit.go b/internal/command/edit.go new file mode 100644 index 0000000..ca0f470 --- /dev/null +++ b/internal/command/edit.go @@ -0,0 +1,98 @@ +package command + +import ( + "context" + "fmt" + "log" + "os" + "os/exec" + "strings" + + "forge.cadoles.com/wpetit/formidable/internal/server" + "github.com/pkg/errors" + _ "github.com/santhosh-tekuri/jsonschema/v5/httploader" + "github.com/urfave/cli/v2" +) + +func Edit() *cli.Command { + flags := commonFlags() + + flags = append(flags, &cli.StringFlag{ + Name: "browser", + EnvVars: []string{"FORMIDABLE_BROWSER"}, + Value: "w3m", + }) + + return &cli.Command{ + Name: "edit", + Usage: "Display a form for given schema and values", + Flags: flags, + Action: func(ctx *cli.Context) error { + browser := ctx.String("browser") + + schema, err := loadSchema(ctx) + if err != nil { + return errors.Wrap(err, "could not load schema") + } + + values, err := loadValues(ctx) + if err != nil { + return errors.Wrap(err, "could not load values") + } + + defaults, err := loadDefaults(ctx) + if err != nil { + return errors.Wrap(err, "could not load defaults") + } + + srvCtx, srvCancel := context.WithCancel(ctx.Context) + defer srvCancel() + + srv := server.New( + server.WithSchema(schema), + server.WithValues(values), + server.WithDefaults(defaults), + ) + + addrs, srvErrs := srv.Start(srvCtx) + + url := fmt.Sprintf("http://%s", (<-addrs).String()) + url = strings.Replace(url, "0.0.0.0", "127.0.0.1", 1) + + log.Printf("listening on %s", url) + + cmdErrs := make(chan error) + cmdCtx, cmdCancel := context.WithCancel(ctx.Context) + defer cmdCancel() + + go func() { + defer func() { + close(cmdErrs) + }() + + cmd := exec.CommandContext(cmdCtx, browser, url) + + cmd.Stdin = os.Stdin + cmd.Stderr = os.Stderr + cmd.Stdout = os.Stdout + cmd.Env = os.Environ() + + if err := cmd.Run(); err != nil { + cmdErrs <- errors.WithStack(err) + } + }() + + select { + case err := <-cmdErrs: + srvCancel() + + return errors.WithStack(err) + + case err := <-srvErrs: + cmdCancel() + + return errors.WithStack(err) + } + }, + } +} diff --git a/internal/command/get.go b/internal/command/get.go new file mode 100644 index 0000000..098a6a0 --- /dev/null +++ b/internal/command/get.go @@ -0,0 +1,69 @@ +package command + +import ( + "encoding/json" + "fmt" + "os" + + "forge.cadoles.com/wpetit/formidable/internal/jsonpointer" + "github.com/pkg/errors" + "github.com/santhosh-tekuri/jsonschema/v5" + _ "github.com/santhosh-tekuri/jsonschema/v5/httploader" + "github.com/urfave/cli/v2" +) + +func Get() *cli.Command { + flags := []cli.Flag{} + + flags = append(flags, commonFlags()...) + + return &cli.Command{ + Name: "get", + Usage: "Get value at specific path", + Flags: flags, + Action: func(ctx *cli.Context) error { + schema, err := loadSchema(ctx) + if err != nil { + return errors.Wrap(err, "could not load schema") + } + + values, err := loadValues(ctx) + if err != nil { + return errors.Wrap(err, "could not load values") + } + + if err := schema.Validate(values); err != nil { + if _, ok := err.(*jsonschema.ValidationError); ok { + fmt.Printf("%#v\n", err) + + os.Exit(1) + } + + return errors.Wrap(err, "could not validate resulting json") + } + + rawPointer := ctx.Args().Get(0) + pointer := jsonpointer.New(rawPointer) + + value, err := pointer.Get(values) + if err != nil { + 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") + } + + return nil + }, + } +} diff --git a/internal/command/root.go b/internal/command/root.go new file mode 100644 index 0000000..0cafced --- /dev/null +++ b/internal/command/root.go @@ -0,0 +1,12 @@ +package command + +import "github.com/urfave/cli/v2" + +func Root() []*cli.Command { + return []*cli.Command{ + Edit(), + Set(), + Get(), + Delete(), + } +} diff --git a/internal/command/set.go b/internal/command/set.go new file mode 100644 index 0000000..d9e7a53 --- /dev/null +++ b/internal/command/set.go @@ -0,0 +1,95 @@ +package command + +import ( + "encoding/json" + "fmt" + "os" + + "forge.cadoles.com/wpetit/formidable/internal/jsonpointer" + "github.com/pkg/errors" + "github.com/santhosh-tekuri/jsonschema/v5" + _ "github.com/santhosh-tekuri/jsonschema/v5/httploader" + "github.com/urfave/cli/v2" +) + +func Set() *cli.Command { + flags := []cli.Flag{ + &cli.BoolFlag{ + Name: "force", + Aliases: []string{"f"}, + Usage: "Force data tree creation", + Value: false, + }, + } + + flags = append(flags, commonFlags()...) + + return &cli.Command{ + Name: "set", + Usage: "Set value at specific path", + Flags: flags, + Action: func(ctx *cli.Context) error { + schema, err := loadSchema(ctx) + if err != nil { + return errors.Wrap(err, "could not load schema") + } + + values, err := loadValues(ctx) + if err != nil { + return errors.Wrap(err, "could not load values") + } + + rawPointer := ctx.Args().Get(0) + rawValue := ctx.Args().Get(1) + + 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) + if err != nil { + return errors.Wrapf(err, "could not force value '%v' to pointer '%v'", rawValue, rawPointer) + } + } else { + updatedValues, err = pointer.Set(values, value) + if err != nil { + return errors.Wrapf(err, "could not set value '%v' to pointer '%v'", rawValue, rawPointer) + } + } + + if err := schema.Validate(updatedValues); err != nil { + if _, ok := err.(*jsonschema.ValidationError); ok { + fmt.Printf("%#v\n", err) + + os.Exit(1) + } + + 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") + } + + return nil + }, + } +} diff --git a/internal/def/schema.go b/internal/def/schema.go new file mode 100644 index 0000000..3dbfcbf --- /dev/null +++ b/internal/def/schema.go @@ -0,0 +1,12 @@ +package def + +import "github.com/santhosh-tekuri/jsonschema/v5" + +const rawSchema = ` +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Formidable default schema", + "type": ["null", "boolean", "object", "array", "number", "string"] +}` + +var Schema = jsonschema.MustCompileString("", rawSchema) diff --git a/internal/jsonpointer/delete.go b/internal/jsonpointer/delete.go new file mode 100644 index 0000000..36130f0 --- /dev/null +++ b/internal/jsonpointer/delete.go @@ -0,0 +1,73 @@ +package jsonpointer + +import ( + "strconv" + + "github.com/pkg/errors" +) + +func del(doc interface{}, tokens []string) (interface{}, error) { + currentToken := tokens[0] + + switch typedDoc := doc.(type) { + + case map[string]interface{}: + nestedDoc, exists := typedDoc[currentToken] + if !exists { + return doc, nil + } + + if len(tokens) == 1 { + delete(typedDoc, currentToken) + + return typedDoc, nil + } + + nestedDoc, err := del(nestedDoc, tokens[1:]) + if err != nil { + return nil, errors.WithStack(err) + } + + typedDoc[currentToken] = nestedDoc + + return typedDoc, nil + + case []interface{}: + var ( + index uint64 + nestedDoc interface{} + err error + ) + + if currentToken == NonExistentMemberToken { + index = uint64(len(typedDoc) - 1) + } else { + index, err = strconv.ParseUint(currentToken, 10, 64) + if err != nil { + return nil, errors.WithStack(err) + } + + if len(typedDoc) <= int(index) { + return typedDoc, nil + } + } + + if len(tokens) == 1 { + typedDoc = append(typedDoc[:index], typedDoc[index+1:]...) + + return typedDoc, nil + } + + nestedDoc, err = del(nestedDoc, tokens[1:]) + if err != nil { + return nil, errors.WithStack(err) + } + + typedDoc[index] = nestedDoc + + return typedDoc, nil + + default: + return typedDoc, nil + } +} diff --git a/internal/jsonpointer/delete_test.go b/internal/jsonpointer/delete_test.go new file mode 100644 index 0000000..7ba235e --- /dev/null +++ b/internal/jsonpointer/delete_test.go @@ -0,0 +1,95 @@ +package jsonpointer + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "reflect" + "strings" + "testing" + + "github.com/pkg/errors" +) + +type pointerDeleteTestCase struct { + DocPath string + Pointer string + ExpectedRawDocument string +} + +func TestPointerDelete(t *testing.T) { + t.Parallel() + + testCases := []pointerDeleteTestCase{ + { + DocPath: "./testdata/set/basic.json", + Pointer: "/foo", + ExpectedRawDocument: `{}`, + }, + { + DocPath: "./testdata/set/nested.json", + Pointer: "/nestedObject/foo/1", + ExpectedRawDocument: ` + { + "nestedObject": { + "foo": [ + "bar" + ] + } + }`, + }, + { + DocPath: "./testdata/set/nested.json", + Pointer: "/nestedObject/foo/-", + ExpectedRawDocument: ` + { + "nestedObject": { + "foo": [ + "bar" + ] + } + }`, + }, + } + + for i, tc := range testCases { + func(index int, tc pointerDeleteTestCase) { + t.Run(fmt.Sprintf("#%d: '%s'", i, tc.Pointer), func(t *testing.T) { + t.Parallel() + + baseRawDocument, err := ioutil.ReadFile(tc.DocPath) + if err != nil { + t.Fatal(errors.WithStack(err)) + } + + var baseDoc interface{} + + if err := json.Unmarshal([]byte(baseRawDocument), &baseDoc); err != nil { + t.Fatal(errors.WithStack(err)) + } + + pointer := New(tc.Pointer) + + updatedDoc, err := pointer.Delete(baseDoc) + if err != nil { + t.Fatal(errors.WithStack(err)) + } + + rawDoc, err := json.MarshalIndent(updatedDoc, "", " ") + if err != nil { + t.Fatal(errors.WithStack(err)) + } + + var expectedDoc interface{} + + if err := json.Unmarshal([]byte(tc.ExpectedRawDocument), &expectedDoc); err != nil { + t.Fatal(errors.WithStack(err)) + } + + if !reflect.DeepEqual(expectedDoc, updatedDoc) { + t.Errorf("Delete pointer '%s': expected document \n'%s', got \n'%s'", tc.Pointer, strings.TrimSpace(tc.ExpectedRawDocument), rawDoc) + } + }) + }(i, tc) + } +} diff --git a/internal/jsonpointer/error.go b/internal/jsonpointer/error.go new file mode 100644 index 0000000..b18b2d6 --- /dev/null +++ b/internal/jsonpointer/error.go @@ -0,0 +1,9 @@ +package jsonpointer + +import "errors" + +var ( + ErrNotFound = errors.New("not found") + ErrUnexpectedType = errors.New("unexpected type") + ErrOutOfBounds = errors.New("out of bounds") +) diff --git a/internal/jsonpointer/force.go b/internal/jsonpointer/force.go new file mode 100644 index 0000000..7f1cc7b --- /dev/null +++ b/internal/jsonpointer/force.go @@ -0,0 +1,111 @@ +package jsonpointer + +import ( + "strconv" + + "github.com/pkg/errors" +) + +func force(doc interface{}, tokens []string, value interface{}) (interface{}, error) { + if len(tokens) == 0 { + return value, nil + } + + currentToken := tokens[0] + + switch typedDoc := doc.(type) { + + case map[string]interface{}: + nestedDoc, exists := typedDoc[currentToken] + if !exists { + if len(tokens) == 1 { + typedDoc[currentToken] = value + + return typedDoc, nil + } + + nextToken := tokens[1] + if isArrayIndexToken(nextToken) { + nestedDoc = make([]interface{}, 0) + } else { + nestedDoc = make(map[string]interface{}) + } + } + + nestedDoc, err := force(nestedDoc, tokens[1:], value) + if err != nil { + return nil, errors.WithStack(err) + } + + typedDoc[currentToken] = nestedDoc + + return typedDoc, nil + + case []interface{}: + var ( + index uint64 + nestedDoc interface{} + err error + ) + + if currentToken == NonExistentMemberToken { + typedDoc = append(typedDoc, value) + index = uint64(len(typedDoc) - 1) + } else { + index, err = strconv.ParseUint(currentToken, 10, 64) + if err != nil { + return nil, errors.WithStack(err) + } + + if len(typedDoc) <= int(index) { + for i := len(typedDoc); i <= int(index); i++ { + typedDoc = append(typedDoc, nil) + } + } + + nestedDoc = typedDoc[index] + } + + nestedDoc, err = force(nestedDoc, tokens[1:], value) + if err != nil { + return nil, errors.WithStack(err) + } + + typedDoc[index] = nestedDoc + + return typedDoc, nil + + default: + overrideDoc := map[string]interface{}{} + overrideDoc[currentToken] = value + + var nestedDoc interface{} + + if len(tokens) > 1 && isArrayIndexToken(tokens[1]) { + nestedDoc = make([]interface{}, 0) + } else { + nestedDoc = make(map[string]interface{}) + } + + nestedDoc, err := force(nestedDoc, tokens[1:], value) + if err != nil { + return nil, errors.WithStack(err) + } + + overrideDoc[currentToken] = nestedDoc + + return overrideDoc, nil + } +} + +func isArrayIndexToken(token string) bool { + if token == NonExistentMemberToken { + return true + } + + if _, err := strconv.ParseUint(token, 10, 64); err != nil { + return false + } + + return true +} diff --git a/internal/jsonpointer/get.go b/internal/jsonpointer/get.go new file mode 100644 index 0000000..3ae6c1c --- /dev/null +++ b/internal/jsonpointer/get.go @@ -0,0 +1,60 @@ +package jsonpointer + +import ( + "strconv" + + "github.com/pkg/errors" +) + +func get(doc interface{}, tokens []string) (interface{}, error) { + if len(tokens) == 0 { + return doc, nil + } + + currentToken := tokens[0] + + if doc == nil { + return nil, errors.Wrapf(ErrNotFound, "pointer '%s' not found on document", tokensToString(tokens)) + } + + switch typedDoc := doc.(type) { + case map[string]interface{}: + value, exists := typedDoc[currentToken] + if !exists { + return nil, errors.Wrapf(ErrNotFound, "pointer '%s' not found on document", tokensToString(tokens)) + } + + value, err := get(value, tokens[1:]) + if err != nil { + return nil, errors.WithStack(err) + } + + return value, nil + + case []interface{}: + if currentToken == NonExistentMemberToken { + return nil, errors.WithStack(ErrOutOfBounds) + } + + index, err := strconv.ParseUint(currentToken, 10, 64) + if err != nil { + return nil, errors.WithStack(err) + } + + if len(typedDoc) <= int(index) { + return nil, errors.WithStack(ErrOutOfBounds) + } + + value := typedDoc[index] + + value, err = get(value, tokens[1:]) + if err != nil { + return nil, errors.WithStack(err) + } + + return value, nil + + default: + return nil, errors.Wrapf(ErrUnexpectedType, "unexpected type '%T'", typedDoc) + } +} diff --git a/internal/jsonpointer/get_test.go b/internal/jsonpointer/get_test.go new file mode 100644 index 0000000..65f8b97 --- /dev/null +++ b/internal/jsonpointer/get_test.go @@ -0,0 +1,140 @@ +package jsonpointer + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "reflect" + "testing" + + "github.com/pkg/errors" +) + +type pointerGetTestCase struct { + Document interface{} + Pointer string + ExpectedRawValue string +} + +func TestPointerGet(t *testing.T) { + t.Parallel() + + ietfRawDocument, err := ioutil.ReadFile("./testdata/ietf.json") + if err != nil { + t.Fatal(errors.WithStack(err)) + } + + var ietfDoc interface{} + + if err := json.Unmarshal([]byte(ietfRawDocument), &ietfDoc); err != nil { + t.Fatal(errors.WithStack(err)) + } + + // IETF tests cases + // From https://datatracker.ietf.org/doc/html/rfc6901 + // + // "" // the whole document + // "/foo" ["bar", "baz"] + // "/foo/0" "bar" + // "/" 0 + // "/a~1b" 1 + // "/c%d" 2 + // "/e^f" 3 + // "/g|h" 4 + // "/i\\j" 5 + // "/k\"l" 6 + // "/ " 7 + // "/m~0n" 8 + testCases := []pointerGetTestCase{ + { + Document: ietfDoc, + Pointer: "", + ExpectedRawValue: string(ietfRawDocument), + }, + { + Document: ietfDoc, + Pointer: "/foo", + ExpectedRawValue: "[\"bar\", \"baz\"]", + }, + { + Document: ietfDoc, + Pointer: "/foo/0", + ExpectedRawValue: "\"bar\"", + }, + { + Document: ietfDoc, + Pointer: `/`, + ExpectedRawValue: `0`, + }, + { + Document: ietfDoc, + Pointer: "/a~1b", + ExpectedRawValue: "1", + }, + { + Document: ietfDoc, + Pointer: "/c%d", + ExpectedRawValue: "2", + }, + { + Document: ietfDoc, + Pointer: "/e^f", + ExpectedRawValue: "3", + }, + { + Document: ietfDoc, + Pointer: "/g|h", + ExpectedRawValue: "4", + }, + { + Document: ietfDoc, + Pointer: "/i\\j", + ExpectedRawValue: "5", + }, + { + Document: ietfDoc, + Pointer: "/k\"l", + ExpectedRawValue: "6", + }, + { + Document: ietfDoc, + Pointer: "/ ", + ExpectedRawValue: "7", + }, + { + Document: ietfDoc, + Pointer: "/m~0n", + ExpectedRawValue: "8", + }, + } + + for i, tc := range testCases { + func(index int, tc pointerGetTestCase) { + t.Run(fmt.Sprintf("#%d: '%s'", i, tc.Pointer), func(t *testing.T) { + t.Parallel() + + pointer := New(tc.Pointer) + + value, err := pointer.Get(tc.Document) + if err != nil { + t.Fatal(errors.WithStack(err)) + } + + rawValue, err := json.Marshal(value) + if err != nil { + t.Fatal(errors.WithStack(err)) + } + + var expectedValue interface{} + + if err := json.Unmarshal([]byte(tc.ExpectedRawValue), &expectedValue); err != nil { + t.Fatal(errors.WithStack(err)) + } + + if !reflect.DeepEqual(expectedValue, value) { + t.Errorf("Pointer '%s': expected value '%s', got '%s'", tc.Pointer, tc.ExpectedRawValue, rawValue) + } + }) + }(i, tc) + } +} diff --git a/internal/jsonpointer/pointer.go b/internal/jsonpointer/pointer.go new file mode 100644 index 0000000..917b88e --- /dev/null +++ b/internal/jsonpointer/pointer.go @@ -0,0 +1,92 @@ +package jsonpointer + +import ( + "strings" + + "github.com/pkg/errors" +) + +const ( + TokenSeparator = "/" + NonExistentMemberToken = "-" +) + +type Pointer struct { + tokens []string +} + +func (p *Pointer) Get(doc interface{}) (interface{}, error) { + value, err := get(doc, p.tokens) + if err != nil { + return nil, errors.WithStack(err) + } + + return value, nil +} + +func (p *Pointer) Set(doc interface{}, value interface{}) (interface{}, error) { + doc, err := set(doc, p.tokens, value) + if err != nil { + return nil, errors.WithStack(err) + } + + return doc, nil +} + +func (p *Pointer) Force(doc interface{}, value interface{}) (interface{}, error) { + doc, err := force(doc, p.tokens, value) + if err != nil { + return nil, errors.WithStack(err) + } + + return doc, nil +} + +func (p *Pointer) Delete(doc interface{}) (interface{}, error) { + doc, err := del(doc, p.tokens) + if err != nil { + return nil, errors.WithStack(err) + } + + return doc, nil +} + +func New(raw string) *Pointer { + tokens := decodeTokens(raw) + + return &Pointer{tokens} +} + +func tokensToString(tokens []string) string { + escapedTokens := make([]string, 0) + + for _, t := range tokens { + escapedTokens = append(escapedTokens, escapeToken(t)) + } + + return TokenSeparator + strings.Join(escapedTokens, TokenSeparator) +} + +func escapeToken(token string) string { + token = strings.ReplaceAll(token, "/", "~1") + token = strings.ReplaceAll(token, "~", "~0") + + return token +} + +func unescapeToken(token string) string { + token = strings.ReplaceAll(token, "~1", "/") + token = strings.ReplaceAll(token, "~0", "~") + + return token +} + +func decodeTokens(raw string) []string { + tokens := strings.Split(raw, TokenSeparator) + + for i, t := range tokens { + tokens[i] = unescapeToken(t) + } + + return tokens[1:] +} diff --git a/internal/jsonpointer/set.go b/internal/jsonpointer/set.go new file mode 100644 index 0000000..9b739ab --- /dev/null +++ b/internal/jsonpointer/set.go @@ -0,0 +1,67 @@ +package jsonpointer + +import ( + "strconv" + + "github.com/pkg/errors" +) + +func set(doc interface{}, tokens []string, value interface{}) (interface{}, error) { + if len(tokens) == 0 { + return value, nil + } + + currentToken := tokens[0] + + switch typedDoc := doc.(type) { + case map[string]interface{}: + nestedDoc, exists := typedDoc[currentToken] + if !exists { + return nil, errors.Wrapf(ErrNotFound, "pointer '%s' not found on document", tokensToString(tokens)) + } + + nestedDoc, err := set(nestedDoc, tokens[1:], value) + if err != nil { + return nil, errors.WithStack(err) + } + + typedDoc[currentToken] = nestedDoc + + return typedDoc, nil + + case []interface{}: + var ( + index uint64 + nestedDoc interface{} + err error + ) + + if currentToken == NonExistentMemberToken { + typedDoc = append(typedDoc, value) + index = uint64(len(typedDoc) - 1) + } else { + index, err = strconv.ParseUint(currentToken, 10, 64) + if err != nil { + return nil, errors.WithStack(err) + } + + if len(typedDoc) <= int(index) { + return nil, errors.WithStack(ErrOutOfBounds) + } + + nestedDoc = typedDoc[index] + } + + nestedDoc, err = set(nestedDoc, tokens[1:], value) + if err != nil { + return nil, errors.WithStack(err) + } + + typedDoc[index] = nestedDoc + + return typedDoc, nil + + default: + return nil, errors.Wrapf(ErrUnexpectedType, "unexpected type '%T'", typedDoc) + } +} diff --git a/internal/jsonpointer/set_test.go b/internal/jsonpointer/set_test.go new file mode 100644 index 0000000..9043cbb --- /dev/null +++ b/internal/jsonpointer/set_test.go @@ -0,0 +1,102 @@ +package jsonpointer + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "reflect" + "strings" + "testing" + + "github.com/pkg/errors" +) + +type pointerSetTestCase struct { + DocPath string + Pointer string + Value interface{} + ExpectedRawDocument string +} + +func TestPointerSet(t *testing.T) { + t.Parallel() + + testCases := []pointerSetTestCase{ + { + DocPath: "./testdata/set/basic.json", + Pointer: "/foo", + Value: "bar", + ExpectedRawDocument: `{"foo":"bar"}`, + }, + { + DocPath: "./testdata/set/nested.json", + Pointer: "/nestedObject/foo/1", + Value: "test", + ExpectedRawDocument: ` + { + "nestedObject": { + "foo": [ + "bar", + "test" + ] + } + }`, + }, + { + DocPath: "./testdata/set/nested.json", + Pointer: "/nestedObject/foo/-", + Value: "baz", + ExpectedRawDocument: ` + { + "nestedObject": { + "foo": [ + "bar", + 0, + "baz" + ] + } + }`, + }, + } + + for i, tc := range testCases { + func(index int, tc pointerSetTestCase) { + t.Run(fmt.Sprintf("#%d: '%s'", i, tc.Pointer), func(t *testing.T) { + t.Parallel() + + baseRawDocument, err := ioutil.ReadFile(tc.DocPath) + if err != nil { + t.Fatal(errors.WithStack(err)) + } + + var baseDoc interface{} + + if err := json.Unmarshal([]byte(baseRawDocument), &baseDoc); err != nil { + t.Fatal(errors.WithStack(err)) + } + + pointer := New(tc.Pointer) + + updatedDoc, err := pointer.Set(baseDoc, tc.Value) + if err != nil { + t.Fatal(errors.WithStack(err)) + } + + rawDoc, err := json.MarshalIndent(updatedDoc, "", " ") + if err != nil { + t.Fatal(errors.WithStack(err)) + } + + var expectedDoc interface{} + + if err := json.Unmarshal([]byte(tc.ExpectedRawDocument), &expectedDoc); err != nil { + t.Fatal(errors.WithStack(err)) + } + + if !reflect.DeepEqual(expectedDoc, updatedDoc) { + t.Errorf("Set pointer '%s' -> '%v': expected document '%s', got '%s'", tc.Pointer, tc.Value, strings.TrimSpace(tc.ExpectedRawDocument), rawDoc) + } + }) + }(i, tc) + } +} diff --git a/internal/jsonpointer/testdata/ietf.json b/internal/jsonpointer/testdata/ietf.json new file mode 100644 index 0000000..d3c201f --- /dev/null +++ b/internal/jsonpointer/testdata/ietf.json @@ -0,0 +1,12 @@ +{ + "foo": ["bar", "baz"], + "": 0, + "a/b": 1, + "c%d": 2, + "e^f": 3, + "g|h": 4, + "i\\j": 5, + "k\"l": 6, + " ": 7, + "m~n": 8 +} \ No newline at end of file diff --git a/internal/jsonpointer/testdata/set/basic.json b/internal/jsonpointer/testdata/set/basic.json new file mode 100644 index 0000000..1907071 --- /dev/null +++ b/internal/jsonpointer/testdata/set/basic.json @@ -0,0 +1 @@ +{"foo":null} \ No newline at end of file diff --git a/internal/jsonpointer/testdata/set/nested.json b/internal/jsonpointer/testdata/set/nested.json new file mode 100644 index 0000000..ee92d8f --- /dev/null +++ b/internal/jsonpointer/testdata/set/nested.json @@ -0,0 +1,8 @@ +{ + "nestedObject": { + "foo": [ + "bar", + 0 + ] + } +} \ No newline at end of file diff --git a/internal/server/option.go b/internal/server/option.go new file mode 100644 index 0000000..3d72412 --- /dev/null +++ b/internal/server/option.go @@ -0,0 +1,49 @@ +package server + +import ( + "forge.cadoles.com/wpetit/formidable/internal/def" + "github.com/santhosh-tekuri/jsonschema/v5" +) + +type Option struct { + Host string + Port uint + Schema *jsonschema.Schema + Values interface{} + Defaults interface{} +} + +type OptionFunc func(*Option) + +func defaultOption() *Option { + return &Option{ + Host: "", + Port: 0, + Schema: def.Schema, + } +} + +func WithAddress(host string, port uint) OptionFunc { + return func(opt *Option) { + opt.Host = host + opt.Port = port + } +} + +func WithSchema(schema *jsonschema.Schema) OptionFunc { + return func(opt *Option) { + opt.Schema = schema + } +} + +func WithValues(values interface{}) OptionFunc { + return func(opt *Option) { + opt.Values = values + } +} + +func WithDefaults(defaults interface{}) OptionFunc { + return func(opt *Option) { + opt.Defaults = defaults + } +} diff --git a/internal/server/route/form.go b/internal/server/route/form.go new file mode 100644 index 0000000..f1882b8 --- /dev/null +++ b/internal/server/route/form.go @@ -0,0 +1,161 @@ +package route + +import ( + "net/http" + "net/url" + "strings" + + "forge.cadoles.com/wpetit/formidable/internal/jsonpointer" + "forge.cadoles.com/wpetit/formidable/internal/server/template" + "github.com/pkg/errors" + "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, + } + + 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 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, + } + + 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)) + } + + 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 err := template.Exec("index.html.tmpl", w, data); err != nil { + panic(errors.WithStack(err)) + } + } +} + +func handleForm(form url.Values, schema *jsonschema.Schema, values interface{}) (interface{}, error) { + pendingDeletes := make([]string, 0) + + var err error + + for name, fieldValues := range form { + if name == "submit" { + continue + } + + prefix, property, err := parseFieldName(name) + if err != nil { + return nil, errors.WithStack(err) + } + + switch prefix { + case "bool": + booVal, err := parseBoolean(fieldValues[0]) + if err != nil { + return nil, errors.Wrapf(err, "could not parse boolean field '%s'", property) + } + + pointer := jsonpointer.New(property) + + values, err = pointer.Force(values, booVal) + if err != nil { + return nil, errors.Wrapf(err, "could not set property '%s' with value '%v'", property, fieldValues[0]) + } + + case "add": + pointer := jsonpointer.New(property) + + values, err = pointer.Force(values, nil) + if err != nil { + return nil, errors.Wrapf(err, "could not add item '%s'", property) + } + + case "del": + // Mark property for deletion pass + pendingDeletes = append(pendingDeletes, property) + + default: + pointer := jsonpointer.New(property) + + values, err = pointer.Force(values, fieldValues[0]) + if err != nil { + return nil, errors.Wrapf(err, "could not set property '%s' with value '%v'", property, fieldValues[0]) + } + } + } + + for _, property := range pendingDeletes { + pointer := jsonpointer.New(property) + + values, err = pointer.Delete(values) + if err != nil { + return nil, errors.Wrapf(err, "could not delete property '%s'", property) + } + } + + return values, nil +} + +func parseBoolean(value string) (bool, error) { + switch value { + case "yes": + return true, nil + case "no": + return false, nil + default: + return false, errors.Errorf("unexpected boolean value '%s'", value) + } +} + +func parseFieldName(name string) (string, string, error) { + tokens := strings.SplitN(name, ":", 2) + + if len(tokens) == 1 { + return "", tokens[0], nil + } + + if len(tokens) == 2 { + return tokens[0], tokens[1], nil + } + + return "", "", errors.Errorf("unexpected field name '%s'", name) +} diff --git a/internal/server/route/handler.go b/internal/server/route/handler.go new file mode 100644 index 0000000..c90db87 --- /dev/null +++ b/internal/server/route/handler.go @@ -0,0 +1,19 @@ +package route + +import ( + "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{}) (*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)) + + return router, nil +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..b398614 --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,98 @@ +package server + +import ( + "context" + "fmt" + "log" + "net" + "net/http" + + "forge.cadoles.com/wpetit/formidable/internal/server/route" + "forge.cadoles.com/wpetit/formidable/internal/server/template" + "github.com/pkg/errors" + "github.com/santhosh-tekuri/jsonschema/v5" +) + +type Server struct { + host string + port uint + schema *jsonschema.Schema + defaults interface{} + values interface{} +} + +func (s *Server) Start(ctx context.Context) (<-chan net.Addr, <-chan error) { + errs := make(chan error) + addrs := make(chan net.Addr) + + go s.run(ctx, addrs, errs) + + return addrs, errs +} + +func (s *Server) run(parentCtx context.Context, addrs chan net.Addr, errs chan error) { + ctx, cancel := context.WithCancel(parentCtx) + defer cancel() + + listener, err := net.Listen("tcp", fmt.Sprintf("%s:%d", s.host, s.port)) + if err != nil { + errs <- errors.WithStack(err) + + return + } + + addrs <- listener.Addr() + + defer func() { + if err := listener.Close(); err != nil && !errors.Is(err, net.ErrClosed) { + errs <- errors.WithStack(err) + } + + close(errs) + close(addrs) + }() + + go func() { + <-ctx.Done() + + if err := listener.Close(); err != nil && !errors.Is(err, net.ErrClosed) { + log.Printf("%+v", errors.WithStack(err)) + } + }() + + if err := template.Load(); err != nil { + errs <- errors.WithStack(err) + + return + } + + handler, err := route.NewHandler(s.schema, s.defaults, s.values) + if err != nil { + errs <- errors.WithStack(err) + + return + } + + log.Println("http server listening") + + if err := http.Serve(listener, handler); err != nil && !errors.Is(err, net.ErrClosed) { + errs <- errors.WithStack(err) + } + + log.Println("http server exiting") +} + +func New(funcs ...OptionFunc) *Server { + opt := defaultOption() + for _, fn := range funcs { + fn(opt) + } + + return &Server{ + host: opt.Host, + port: opt.Port, + schema: opt.Schema, + defaults: opt.Defaults, + values: opt.Values, + } +} diff --git a/internal/server/template/blocks/form.html.tmpl b/internal/server/template/blocks/form.html.tmpl new file mode 100644 index 0000000..1334b5d --- /dev/null +++ b/internal/server/template/blocks/form.html.tmpl @@ -0,0 +1,19 @@ +{{define "form"}} +
+ + + + + + + +
+ +
+
+ {{ .Schema.Title }} + {{ .Schema.Description }} +
+ {{template "form_item" .}} +
+{{end}} \ No newline at end of file diff --git a/internal/server/template/blocks/form_input.html.tmpl b/internal/server/template/blocks/form_input.html.tmpl new file mode 100644 index 0000000..c5cf60c --- /dev/null +++ b/internal/server/template/blocks/form_input.html.tmpl @@ -0,0 +1,10 @@ +{{define "form_input"}} + {{ if .Schema.Types }} + {{ $root := . }} + {{range .Schema.Types}} + {{ $inputBlock := printf "%s_%s" "form_input" . }} + {{ include $inputBlock $root }} + {{end}} + {{ else }} + {{ end }} +{{end}} \ No newline at end of file diff --git a/internal/server/template/blocks/form_input_array.html.tmpl b/internal/server/template/blocks/form_input_array.html.tmpl new file mode 100644 index 0000000..309ebd2 --- /dev/null +++ b/internal/server/template/blocks/form_input_array.html.tmpl @@ -0,0 +1,31 @@ +{{ define "form_input_array" }} + {{ $root := . }} + {{ $fullProperty := getFullProperty .Parent .Property }} + {{ $values := getValue .Defaults .Values $fullProperty }} + + + {{ range $index, $value := $values }} + {{ $itemFullProperty := printf "%s/%d" $fullProperty $index }} + {{ $itemProperty := printf "%d" $index }} + {{ $itemSchema := getItemSchema $root.Schema }} + {{ $formItemData := formItemData $root $itemProperty $itemSchema }} + + + {{ template "form_row" $formItemData }} + + + + + {{end}} + + + + + +
+ +
+
+ +
+{{ end }} \ No newline at end of file diff --git a/internal/server/template/blocks/form_input_boolean.html.tmpl b/internal/server/template/blocks/form_input_boolean.html.tmpl new file mode 100644 index 0000000..04e2540 --- /dev/null +++ b/internal/server/template/blocks/form_input_boolean.html.tmpl @@ -0,0 +1,12 @@ +{{define "form_input_boolean"}} +{{ $fullProperty := getFullProperty .Parent .Property }} +{{ $checked := getValue .Defaults .Values $fullProperty }} + + +{{end}} \ No newline at end of file diff --git a/internal/server/template/blocks/form_input_integer.html.tmpl b/internal/server/template/blocks/form_input_integer.html.tmpl new file mode 100644 index 0000000..458f831 --- /dev/null +++ b/internal/server/template/blocks/form_input_integer.html.tmpl @@ -0,0 +1,3 @@ +{{define "form_input_integer"}} +{{template "form_input_number" .}} +{{end}} \ No newline at end of file diff --git a/internal/server/template/blocks/form_input_null.html.tmpl b/internal/server/template/blocks/form_input_null.html.tmpl new file mode 100644 index 0000000..d61a84d --- /dev/null +++ b/internal/server/template/blocks/form_input_null.html.tmpl @@ -0,0 +1 @@ +{{define "form_input_null"}}{{end}} \ No newline at end of file diff --git a/internal/server/template/blocks/form_input_number.html.tmpl b/internal/server/template/blocks/form_input_number.html.tmpl new file mode 100644 index 0000000..dbbb0a1 --- /dev/null +++ b/internal/server/template/blocks/form_input_number.html.tmpl @@ -0,0 +1,5 @@ +{{define "form_input_number"}} +{{ $fullProperty := getFullProperty .Parent .Property }} +{{ $value := getValue .Defaults .Values $fullProperty }} + +{{end}} \ No newline at end of file diff --git a/internal/server/template/blocks/form_input_object.html.tmpl b/internal/server/template/blocks/form_input_object.html.tmpl new file mode 100644 index 0000000..27a058a --- /dev/null +++ b/internal/server/template/blocks/form_input_object.html.tmpl @@ -0,0 +1,5 @@ +{{define "form_input_object"}} +
+{{ $formItemData := formItemData . "" .Schema }} +{{template "form_item" $formItemData}} +{{end}} \ No newline at end of file diff --git a/internal/server/template/blocks/form_input_string.html.tmpl b/internal/server/template/blocks/form_input_string.html.tmpl new file mode 100644 index 0000000..19b5df4 --- /dev/null +++ b/internal/server/template/blocks/form_input_string.html.tmpl @@ -0,0 +1,5 @@ +{{define "form_input_string"}} +{{ $fullProperty := getFullProperty .Parent .Property }} +{{ $value := getValue .Defaults .Values $fullProperty }} + +{{end}} \ No newline at end of file diff --git a/internal/server/template/blocks/form_item.html.tmpl b/internal/server/template/blocks/form_item.html.tmpl new file mode 100644 index 0000000..6eb24b2 --- /dev/null +++ b/internal/server/template/blocks/form_item.html.tmpl @@ -0,0 +1,11 @@ +{{define "form_item"}} + + + {{ $root := .}} + {{ range $property, $schema := .Schema.Properties}} + {{ $formItemData := formItemData $root $property $schema }} + {{template "form_row" $formItemData}} + {{end}} + +
+{{end}} \ No newline at end of file diff --git a/internal/server/template/blocks/form_row.html.tmpl b/internal/server/template/blocks/form_row.html.tmpl new file mode 100644 index 0000000..3803a62 --- /dev/null +++ b/internal/server/template/blocks/form_row.html.tmpl @@ -0,0 +1,23 @@ +{{define "form_row"}} +{{ $fullProperty := getFullProperty .Parent .Property }} + + + + + + {{template "form_input" .}} + + + {{ $err := getPropertyError .Error $fullProperty }} + {{if $err}} + {{ $err.Message }} + {{end}} + + +{{end}} \ No newline at end of file diff --git a/internal/server/template/blocks/head.html.tmpl b/internal/server/template/blocks/head.html.tmpl new file mode 100644 index 0000000..06dd3b3 --- /dev/null +++ b/internal/server/template/blocks/head.html.tmpl @@ -0,0 +1,10 @@ +{{define "head"}} + + Formidable + + +{{end}} \ No newline at end of file diff --git a/internal/server/template/layouts/index.html.tmpl b/internal/server/template/layouts/index.html.tmpl new file mode 100644 index 0000000..738b0eb --- /dev/null +++ b/internal/server/template/layouts/index.html.tmpl @@ -0,0 +1,6 @@ + + {{ template "head" . }} + + {{ template "form" . }} + + \ No newline at end of file diff --git a/internal/server/template/template.go b/internal/server/template/template.go new file mode 100644 index 0000000..9d3b636 --- /dev/null +++ b/internal/server/template/template.go @@ -0,0 +1,233 @@ +package template + +import ( + "bytes" + "embed" + "fmt" + "html/template" + "io" + "io/fs" + "strings" + + "forge.cadoles.com/wpetit/formidable/internal/jsonpointer" + "github.com/Masterminds/sprig/v3" + "github.com/davecgh/go-spew/spew" + "github.com/pkg/errors" + "github.com/santhosh-tekuri/jsonschema/v5" +) + +var ( + //go:embed layouts/* blocks/* + files embed.FS + layouts map[string]*template.Template + blocks map[string]string +) + +func Load() error { + if blocks == nil { + blocks = make(map[string]string) + } + + blockFiles, err := fs.ReadDir(files, "blocks") + if err != nil { + return errors.WithStack(err) + } + + for _, f := range blockFiles { + templateData, err := fs.ReadFile(files, "blocks/"+f.Name()) + if err != nil { + return errors.WithStack(err) + } + + blocks[f.Name()] = string(templateData) + } + + layoutFiles, err := fs.ReadDir(files, "layouts") + if err != nil { + return errors.WithStack(err) + } + + for _, f := range layoutFiles { + templateData, err := fs.ReadFile(files, "layouts/"+f.Name()) + if err != nil { + return errors.WithStack(err) + } + + if err := loadLayout(f.Name(), string(templateData)); err != nil { + return errors.WithStack(err) + } + } + + return nil +} + +func loadLayout(name string, rawTemplate string) error { + if layouts == nil { + layouts = make(map[string]*template.Template) + } + + tmpl := template.New(name) + funcMap := mergeHelpers( + sprig.FuncMap(), + customHelpers(tmpl), + ) + + tmpl.Funcs(funcMap) + + for blockName, b := range blocks { + if _, err := tmpl.Parse(b); err != nil { + return errors.Wrapf(err, "could not parse template block '%s'", blockName) + } + } + + tmpl, err := tmpl.Parse(rawTemplate) + if err != nil { + return errors.Wrapf(err, "could not parse template '%s'", name) + } + + layouts[name] = tmpl + + return nil +} + +func Exec(name string, w io.Writer, data interface{}) error { + tmpl, exists := layouts[name] + if !exists { + return errors.Errorf("could not find template '%s'", name) + } + + if err := tmpl.Execute(w, data); err != nil { + return errors.WithStack(err) + } + + return nil +} + +func mergeHelpers(helpers ...template.FuncMap) template.FuncMap { + merged := template.FuncMap{} + + for _, help := range helpers { + for name, fn := range help { + merged[name] = fn + } + } + + return merged +} + +type FormItemData struct { + Parent *FormItemData + Schema *jsonschema.Schema + Property string + Error *jsonschema.ValidationError + Values interface{} + Defaults interface{} +} + +func customHelpers(tpl *template.Template) template.FuncMap { + return template.FuncMap{ + "formItemData": func(parent *FormItemData, property string, schema *jsonschema.Schema) *FormItemData { + return &FormItemData{ + Parent: parent, + Property: property, + Schema: schema, + Defaults: parent.Defaults, + Values: parent.Values, + Error: parent.Error, + } + }, + "dump": func(data interface{}) string { + spew.Dump(data) + + return "" + }, + "include": func(name string, data interface{}) (template.HTML, error) { + buf := bytes.NewBuffer([]byte{}) + + if err := tpl.ExecuteTemplate(buf, name, data); err != nil { + return "", errors.WithStack(err) + } + + return template.HTML(buf.String()), nil + }, + "getFullProperty": func(parent *FormItemData, property string) string { + fullProperty := property + for { + fullProperty = fmt.Sprintf("%s/%s", parent.Property, strings.TrimPrefix(fullProperty, "/")) + parent = parent.Parent + if parent == nil { + break + } + } + + return fullProperty + }, + "getValue": func(defaults, values interface{}, path string) (interface{}, error) { + if defaults == nil { + defaults = make(map[string]interface{}) + } + + if values == nil { + values = make(map[string]interface{}) + } + + pointer := jsonpointer.New(path) + + val, err := pointer.Get(values) + if err != nil && !errors.Is(err, jsonpointer.ErrNotFound) { + return nil, errors.WithStack(err) + } + + if errors.Is(err, jsonpointer.ErrNotFound) { + val, err = pointer.Get(defaults) + if err != nil && !errors.Is(err, jsonpointer.ErrNotFound) { + return nil, errors.WithStack(err) + } + } + + return val, nil + }, + "getItemSchema": func(arraySchema *jsonschema.Schema) (*jsonschema.Schema, error) { + itemSchema := arraySchema.Items + if itemSchema == nil { + itemSchema = arraySchema.Items2020 + } + + if itemSchema == nil { + return nil, errors.New("item schema not found") + } + + switch schema := itemSchema.(type) { + case *jsonschema.Schema: + return schema, nil + case []*jsonschema.Schema: + if len(schema) > 0 { + return schema[0], nil + } + + return nil, errors.New("no item schema found") + default: + return nil, errors.Errorf("unexpected schema type '%T'", schema) + } + }, + "getPropertyError": findPropertyValidationError, + } +} + +func findPropertyValidationError(err *jsonschema.ValidationError, property string) *jsonschema.ValidationError { + if err == nil { + return nil + } + + if property == err.InstanceLocation { + return err + } + + for _, cause := range err.Causes { + if err := findPropertyValidationError(cause, property); err != nil { + return err + } + } + + return nil +} diff --git a/misc/schema/card.json b/misc/schema/card.json new file mode 100644 index 0000000..3953176 --- /dev/null +++ b/misc/schema/card.json @@ -0,0 +1,106 @@ +{ + "$id": "https://example.com/card.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "A representation of a person, company, organization, or place", + "type": "object", + "required": [ + "familyName", + "givenName" + ], + "properties": { + "fn": { + "description": "Formatted Name", + "type": "string" + }, + "familyName": { + "type": "string" + }, + "givenName": { + "type": "string" + }, + "additionalName": { + "type": "array", + "items": { + "type": "string" + } + }, + "honorificPrefix": { + "type": "array", + "items": { + "type": "string" + } + }, + "honorificSuffix": { + "type": "array", + "items": { + "type": "string" + } + }, + "nickname": { + "type": "string" + }, + "url": { + "type": "string" + }, + "email": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "value": { + "type": "string" + } + } + }, + "tel": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "value": { + "type": "string" + } + } + }, + "adr": { + "$ref": "https://json-schema.org/learn/examples/address.schema.json" + }, + "geo": { + "$ref": "https://json-schema.org/learn/examples/geographical-location.schema.json" + }, + "tz": { + "type": "string" + }, + "photo": { + "type": "string" + }, + "logo": { + "type": "string" + }, + "sound": { + "type": "string" + }, + "bday": { + "type": "string" + }, + "title": { + "type": "string" + }, + "role": { + "type": "string" + }, + "org": { + "type": "object", + "properties": { + "organizationName": { + "type": "string" + }, + "organizationUnit": { + "type": "string" + } + } + } + } +} \ No newline at end of file diff --git a/misc/schema/filesystem.json b/misc/schema/filesystem.json new file mode 100644 index 0000000..d9b8b10 --- /dev/null +++ b/misc/schema/filesystem.json @@ -0,0 +1,136 @@ +{ + "$id": "https://example.com/entry-schema", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "JSON Schema for an fstab entry", + "type": "object", + "required": [ + "storage" + ], + "properties": { + "storage": { + "type": "object", + "oneOf": [ + { + "$ref": "#/$defs/diskDevice" + }, + { + "$ref": "#/$defs/diskUUID" + }, + { + "$ref": "#/$defs/nfs" + }, + { + "$ref": "#/$defs/tmpfs" + } + ] + }, + "fstype": { + "enum": [ + "ext3", + "ext4", + "btrfs" + ] + }, + "options": { + "type": "array", + "minItems": 1, + "items": { + "type": "string" + }, + "uniqueItems": true + }, + "readonly": { + "type": "boolean" + } + }, + "$defs": { + "diskDevice": { + "properties": { + "type": { + "enum": [ + "disk" + ] + }, + "device": { + "type": "string", + "pattern": "^/dev/[^/]+(/[^/]+)*$" + } + }, + "required": [ + "type", + "device" + ], + "additionalProperties": false + }, + "diskUUID": { + "properties": { + "type": { + "enum": [ + "disk" + ] + }, + "label": { + "type": "string", + "pattern": "^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$" + } + }, + "required": [ + "type", + "label" + ], + "additionalProperties": false + }, + "nfs": { + "properties": { + "type": { + "enum": [ + "nfs" + ] + }, + "remotePath": { + "type": "string", + "pattern": "^(/[^/]+)+$" + }, + "server": { + "type": "string", + "oneOf": [ + { + "format": "hostname" + }, + { + "format": "ipv4" + }, + { + "format": "ipv6" + } + ] + } + }, + "required": [ + "type", + "server", + "remotePath" + ], + "additionalProperties": false + }, + "tmpfs": { + "properties": { + "type": { + "enum": [ + "tmpfs" + ] + }, + "sizeInMB": { + "type": "integer", + "minimum": 16, + "maximum": 512 + } + }, + "required": [ + "type", + "sizeInMB" + ], + "additionalProperties": false + } + } +} \ No newline at end of file diff --git a/modd.conf b/modd.conf new file mode 100644 index 0000000..c37874a --- /dev/null +++ b/modd.conf @@ -0,0 +1,12 @@ +**/*.go +misc/pipeline/dist/*.js +misc/schemas/*.json +**/testdata/**/* +internal/server/template/blocks/*.tmpl +internal/server/template/layouts/*.tmpl +modd.conf +.env { + prep: make build-frmd + prep: [ -e .env ] || ( cp .env.dist .env ) + prep: make test +} \ No newline at end of file diff --git a/schema.json b/schema.json new file mode 100644 index 0000000..3cc5a0e --- /dev/null +++ b/schema.json @@ -0,0 +1,39 @@ +{ + "$id": "https://example.com/custom.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "Test", + "type": "object", + "required": [ + "foo" + ], + "properties": { + "foo": { + "description": "Ça fait des trucs", + "type": "object", + "properties": { + "bar": { + "type": "string", + "minLength": 5 + }, + "enabled": { + "type": "boolean" + }, + "myItems": { + "type": "array", + "items": { + "type": "object", + "properties": { + "stringProp": { + "type": "string", + "minLength": 10 + }, + "numericProp": { + "type": "integer" + } + } + } + } + } + } + } +} \ No newline at end of file diff --git a/script/release b/script/release new file mode 100755 index 0000000..639bf21 --- /dev/null +++ b/script/release @@ -0,0 +1,104 @@ +#!/bin/bash + +set -xeo pipefail + +OS_TARGETS=(linux) +ARCH_TARGETS=${ARCH_TARGETS:-amd64} +ARM_TARGETS=${ARM_TARGETS:-5 6 7} + +DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null && pwd )" +PROJECT_DIR="$DIR/../.." + +function build { + local name=$1 + local srcdir=$2 + local os=$3 + local arch=$4 + + local dirname="$name-$os-$arch$GOARM" + local destdir="$PROJECT_DIR/release/$dirname" + + rm -rf "$destdir" + mkdir -p "$destdir" + + echo "building $dirname..." + + CGO_ENABLED=0 GOOS="$os" GOARCH="$arch" go build \ + -ldflags="-s -w -X 'main.GitRef=$(current_commit_ref)' -X 'main.ProjectVersion=$(current_version)' -X 'main.BuildDate=$(current_date)'" \ + -gcflags=-trimpath="${PWD}" \ + -asmflags=-trimpath="${PWD}" \ + -o "$destdir/bin/$name" \ + "$srcdir" + + if [ ! -z "$(which upx)" ]; then + upx --brute -9 "$destdir/bin/$name" + fi +} + +function current_date { + date '+%Y-%m-%d %H:%M' +} + +function current_commit_ref { + git log -n 1 --pretty="format:%h" +} + +function current_version { + local latest_tag=$(git describe --abbrev=0 | rev | cut -d '/' -f 1 | rev) + echo ${latest_tag:-0.0.0} +} + +function copy { + local name=$1 + local os=$2 + local arch=$3 + local src=$4 + local dest=$5 + + local dirname="$name-$os-$arch$GOARM" + local destdir="$PROJECT_DIR/release/$dirname" + + echo "copying '$src' to '$destdir/$dest'..." + + mkdir -p "$(dirname $destdir/$dest)" + + cp -rfL $src "$destdir/$dest" +} + +function compress { + local name=$1 + local os=$2 + local arch=$3 + + local dirname="$name-$os-$arch$GOARM" + local destdir="$PROJECT_DIR/release/$dirname" + + echo "compressing $dirname..." + tar -czf "$destdir.tar.gz" -C "$destdir/../" "$dirname" +} + +function release_frmd { + local os=$1 + local arch=$2 + + build 'frmd' "$PROJECT_DIR/cmd/frmd" $os $arch + copy 'frmd' $os $arch "$PROJECT_DIR/README.md" "README.md" + + compress 'frmd' $os $arch +} + +function main { + for os in ${OS_TARGETS[@]}; do + for arch in ${ARCH_TARGETS[@]}; do + if [ "$arch" == "arm" ]; then + for arm_target in $ARM_TARGETS; do + GOARM=$arm_target release_frmd $os $arch + done + else + release_frmd $os $arch + fi + done + done +} + +main \ No newline at end of file diff --git a/values.json b/values.json new file mode 100644 index 0000000..132b1ff --- /dev/null +++ b/values.json @@ -0,0 +1,6 @@ +{ + "foo": { + "bar": "toto", + "enabled": true + } +}