Initial commit

This commit is contained in:
2022-03-22 09:21:55 +01:00
commit ada7f18e36
49 changed files with 2635 additions and 0 deletions

156
internal/command/common.go Normal file
View File

@ -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
}

View File

@ -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
},
}
}

98
internal/command/edit.go Normal file
View File

@ -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)
}
},
}
}

69
internal/command/get.go Normal file
View File

@ -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
},
}
}

12
internal/command/root.go Normal file
View File

@ -0,0 +1,12 @@
package command
import "github.com/urfave/cli/v2"
func Root() []*cli.Command {
return []*cli.Command{
Edit(),
Set(),
Get(),
Delete(),
}
}

95
internal/command/set.go Normal file
View File

@ -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
},
}
}

12
internal/def/schema.go Normal file
View File

@ -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)

View File

@ -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
}
}

View File

@ -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)
}
}

View File

@ -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")
)

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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:]
}

View File

@ -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)
}
}

View File

@ -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)
}
}

12
internal/jsonpointer/testdata/ietf.json vendored Normal file
View File

@ -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
}

View File

@ -0,0 +1 @@
{"foo":null}

View File

@ -0,0 +1,8 @@
{
"nestedObject": {
"foo": [
"bar",
0
]
}
}

49
internal/server/option.go Normal file
View File

@ -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
}
}

View File

@ -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)
}

View File

@ -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
}

98
internal/server/server.go Normal file
View File

@ -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,
}
}

View File

@ -0,0 +1,19 @@
{{define "form"}}
<form method="post">
<table width="100%">
<tbody>
<tr>
<td align="left" nowrap=""></td>
<td align="right" nowrap="">
<input type="submit" name="submit" value="Enregistrer" />
</td>
</tr>
</tbody>
</table>
<hr />
<strong>{{ .Schema.Title }}</strong>
<em>{{ .Schema.Description }}</em>
<hr />
{{template "form_item" .}}
</form>
{{end}}

View File

@ -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}}

View File

@ -0,0 +1,31 @@
{{ define "form_input_array" }}
{{ $root := . }}
{{ $fullProperty := getFullProperty .Parent .Property }}
{{ $values := getValue .Defaults .Values $fullProperty }}
<table width="100%">
<tbody>
{{ range $index, $value := $values }}
{{ $itemFullProperty := printf "%s/%d" $fullProperty $index }}
{{ $itemProperty := printf "%d" $index }}
{{ $itemSchema := getItemSchema $root.Schema }}
{{ $formItemData := formItemData $root $itemProperty $itemSchema }}
<tr>
{{ template "form_row" $formItemData }}
</tr>
<tr>
<td colspan="3">
<input type="submit" name="del:{{ $fullProperty }}/{{$index}}" value="Supprimer" />
<hr />
</td>
</tr>
{{end}}
<tr>
<td colspan="2"></td>
<td align="right">
<input type="submit" name="add:{{ $fullProperty }}/-" value="Ajouter" />
</td>
</tr>
</tbody>
</table>
{{ end }}

View File

@ -0,0 +1,12 @@
{{define "form_input_boolean"}}
{{ $fullProperty := getFullProperty .Parent .Property }}
{{ $checked := getValue .Defaults .Values $fullProperty }}
<label for="yes:{{ $fullProperty }}">
Yes
<input type="radio" id="yes:{{ $fullProperty }}" name="bool:{{ $fullProperty }}" value="yes" {{if $checked}}checked="yes"{{end}} />
</label>
<label for="no:{{ $fullProperty }}">
No
<input type="radio" id="no:{{ $fullProperty }}" name="bool:{{ $fullProperty }}" value="no" {{if not $checked}}checked{{end}} />
</label>
{{end}}

View File

@ -0,0 +1,3 @@
{{define "form_input_integer"}}
{{template "form_input_number" .}}
{{end}}

View File

@ -0,0 +1 @@
{{define "form_input_null"}}{{end}}

View File

@ -0,0 +1,5 @@
{{define "form_input_number"}}
{{ $fullProperty := getFullProperty .Parent .Property }}
{{ $value := getValue .Defaults .Values $fullProperty }}
<input type="number" name="{{ $fullProperty }}" value="{{ $value }}" />
{{end}}

View File

@ -0,0 +1,5 @@
{{define "form_input_object"}}
<br />
{{ $formItemData := formItemData . "" .Schema }}
{{template "form_item" $formItemData}}
{{end}}

View File

@ -0,0 +1,5 @@
{{define "form_input_string"}}
{{ $fullProperty := getFullProperty .Parent .Property }}
{{ $value := getValue .Defaults .Values $fullProperty }}
<input type="text" name="{{ $fullProperty }}" id="{{ $fullProperty }}" value="{{ $value }}" />
{{end}}

View File

@ -0,0 +1,11 @@
{{define "form_item"}}
<table width="100%">
<tbody>
{{ $root := .}}
{{ range $property, $schema := .Schema.Properties}}
{{ $formItemData := formItemData $root $property $schema }}
{{template "form_row" $formItemData}}
{{end}}
</tbody>
</table>
{{end}}

View File

@ -0,0 +1,23 @@
{{define "form_row"}}
{{ $fullProperty := getFullProperty .Parent .Property }}
<tr>
<td align="left" nowrap="">
<label for="{{ $fullProperty }}">
<strong>
{{ if .Schema.Title }}{{ .Schema.Title }}{{ else }}{{ .Property }}{{ end }}
</strong>
<br />
<span>{{ .Schema.Description }}</span>
</label>
</td>
<td align="left" nowrap="">
{{template "form_input" .}}
</td>
<td>
{{ $err := getPropertyError .Error $fullProperty }}
{{if $err}}
<em>{{ $err.Message }}</em>
{{end}}
</td>
</tr>
{{end}}

View File

@ -0,0 +1,10 @@
{{define "head"}}
<head>
<title>Formidable</title>
<style>
body {
padding: 10px;
}
</style>
</head>
{{end}}

View File

@ -0,0 +1,6 @@
<html>
{{ template "head" . }}
<body>
{{ template "form" . }}
</body>
</html>

View File

@ -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
}