snapshot of https://github.com/ory/kratos.git (master, 2026-09-15; blobs > 131072B omitted)
Some checks failed
CI / Run tests and lints (push) Failing after 29s
CI / Build CLI docs (push) Has been skipped
CI / Run Playwright end-to-end tests (mysql) (push) Failing after 3s
CI / Run Playwright end-to-end tests (postgres) (push) Failing after 2s
CI / Run Playwright end-to-end tests (sqlite) (push) Failing after 3s
CodeQL / Analyze (go) (push) Failing after 4s
CodeQL / Analyze (javascript) (push) Failing after 3s
Docker Image Scanners / scanners (push) Failing after 4s
Synchronize Issue Labels / Synchronize Issue Labels (push) Has been skipped
CI / Run end-to-end tests (postgres) (push) Failing after 19s
CI / Run end-to-end tests (sqlite) (push) Failing after 2s
CI / Run Playwright end-to-end tests (cockroach) (push) Failing after 2s
CI / Draft newsletter (push) Has been skipped
CI / Generate release (push) Has been skipped
CI / Pending approval Slack notification (push) Has been skipped
CI / Send newsletter (push) Has been skipped
Some checks failed
CI / Run tests and lints (push) Failing after 29s
CI / Build CLI docs (push) Has been skipped
CI / Run Playwright end-to-end tests (mysql) (push) Failing after 3s
CI / Run Playwright end-to-end tests (postgres) (push) Failing after 2s
CI / Run Playwright end-to-end tests (sqlite) (push) Failing after 3s
CodeQL / Analyze (go) (push) Failing after 4s
CodeQL / Analyze (javascript) (push) Failing after 3s
Docker Image Scanners / scanners (push) Failing after 4s
Synchronize Issue Labels / Synchronize Issue Labels (push) Has been skipped
CI / Run end-to-end tests (postgres) (push) Failing after 19s
CI / Run end-to-end tests (sqlite) (push) Failing after 2s
CI / Run Playwright end-to-end tests (cockroach) (push) Failing after 2s
CI / Draft newsletter (push) Has been skipped
CI / Generate release (push) Has been skipped
CI / Pending approval Slack notification (push) Has been skipped
CI / Send newsletter (push) Has been skipped
This commit is contained in:
394
ui/container/container.go
Normal file
394
ui/container/container.go
Normal file
@@ -0,0 +1,394 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package container
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/ory/kratos/ui/node"
|
||||
"github.com/ory/x/sqlxx"
|
||||
|
||||
"github.com/ory/jsonschema/v3"
|
||||
|
||||
"github.com/ory/x/decoderx"
|
||||
"github.com/ory/x/jsonschemax"
|
||||
"github.com/ory/x/jsonx"
|
||||
|
||||
"github.com/ory/kratos/schema"
|
||||
"github.com/ory/kratos/text"
|
||||
)
|
||||
|
||||
var (
|
||||
_ ErrorParser = (*Container)(nil)
|
||||
_ ValueSetter = (*Container)(nil)
|
||||
_ Resetter = (*Container)(nil)
|
||||
_ CSRFSetter = (*Container)(nil)
|
||||
_ NodeGetter = (*Container)(nil)
|
||||
)
|
||||
|
||||
// Container represents a HTML Form. The container can work with both HTTP Form and JSON requests
|
||||
//
|
||||
// swagger:model uiContainer
|
||||
type Container struct {
|
||||
// Action should be used as the form action URL `<form action="{{ .Action }}" method="post">`.
|
||||
//
|
||||
// required: true
|
||||
Action string `json:"action" faker:"url"`
|
||||
|
||||
// Method is the form method (e.g. POST)
|
||||
//
|
||||
// required: true
|
||||
Method string `json:"method" faker:"http_method"`
|
||||
|
||||
// Nodes contains the form's nodes
|
||||
//
|
||||
// The form's nodes can be input fields, text, images, and other UI elements.
|
||||
//
|
||||
// required: true
|
||||
Nodes node.Nodes `json:"nodes"`
|
||||
|
||||
// Messages contains all global form messages and errors.
|
||||
Messages text.Messages `json:"messages,omitempty"`
|
||||
}
|
||||
|
||||
// New returns an empty container.
|
||||
func New(action string) *Container {
|
||||
return &Container{
|
||||
Action: action,
|
||||
Method: "POST",
|
||||
Nodes: node.Nodes{},
|
||||
}
|
||||
}
|
||||
|
||||
// NewFromHTTPRequest creates a new Container and populates fields by parsing the HTTP Request body.
|
||||
// A jsonSchemaRef needs to be added to allow HTTP Form Post Body parsing.
|
||||
func NewFromHTTPRequest(r *http.Request, group node.UiNodeGroup, action string, compiler decoderx.HTTPDecoderOption) (*Container, error) {
|
||||
c := New(action)
|
||||
raw := json.RawMessage(`{}`)
|
||||
if err := decoderx.Decode(r, &raw, compiler); err != nil {
|
||||
if err := c.ParseError(group, err); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
c.UpdateNodeValuesFromJSON(raw, "", group)
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// NewFromJSON creates a UI Container based on the provided JSON struct.
|
||||
func NewFromJSON(action string, group node.UiNodeGroup, raw json.RawMessage, prefix string) *Container {
|
||||
c := New(action)
|
||||
c.UpdateNodeValuesFromJSON(raw, prefix, group)
|
||||
return c
|
||||
}
|
||||
|
||||
// NewFromStruct creates a UI Container based on serialized contents of the provided struct.
|
||||
func NewFromStruct(action string, group node.UiNodeGroup, v interface{}, prefix string) (*Container, error) {
|
||||
c := New(action)
|
||||
data, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.UpdateNodeValuesFromJSON(data, prefix, group)
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// NewFromJSONSchema creates a new Container and populates the fields
|
||||
// using the provided JSON Schema. When compiler is nil, disallowRefs toggles
|
||||
// the `$ref` scheme restriction on the default compiler; otherwise the
|
||||
// caller is responsible for constructing a compiler with the desired
|
||||
// restriction.
|
||||
func NewFromJSONSchema(ctx context.Context, action string, group node.UiNodeGroup, jsonSchemaRef, prefix string, compiler *jsonschema.Compiler, disallowRefs bool) (*Container, error) {
|
||||
c := New(action)
|
||||
nodes, err := NodesFromJSONSchema(ctx, group, jsonSchemaRef, prefix, compiler, disallowRefs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
c.Nodes = nodes
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func NodesFromJSONSchema(ctx context.Context, group node.UiNodeGroup, jsonSchemaRef, prefix string, compiler *jsonschema.Compiler, disallowRefs bool) (node.Nodes, error) {
|
||||
if compiler == nil {
|
||||
// When the caller doesn't supply a compiler, we build a default one
|
||||
// ourselves. Apply the caller's disallowRefs preference so the default
|
||||
// doesn't silently downgrade to legacy behavior.
|
||||
var err error
|
||||
compiler, err = schema.NewCompilerWithURL(ctx, jsonSchemaRef, disallowRefs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
paths, err := jsonschemax.ListPaths(ctx, jsonSchemaRef, compiler)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
nodes := node.Nodes{}
|
||||
for _, value := range paths {
|
||||
if value.TypeHint == jsonschemax.JSON {
|
||||
continue
|
||||
}
|
||||
|
||||
name := addPrefix(value.Name, prefix, ".")
|
||||
nodes = append(nodes, node.NewInputFieldFromSchema(name, group, value))
|
||||
}
|
||||
|
||||
return nodes, nil
|
||||
}
|
||||
|
||||
func (c *Container) GetNodes() *node.Nodes {
|
||||
return &c.Nodes
|
||||
}
|
||||
|
||||
func (c *Container) SortNodes(ctx context.Context, opts ...node.SortOption) error {
|
||||
return c.Nodes.SortBySchema(ctx, opts...)
|
||||
}
|
||||
|
||||
// ResetMessages resets the container's own and its node's messages.
|
||||
func (c *Container) ResetMessages(exclude ...string) {
|
||||
c.Messages = nil
|
||||
for k, n := range c.Nodes {
|
||||
if !slices.Contains(exclude, n.ID()) {
|
||||
n.Messages = nil
|
||||
}
|
||||
c.Nodes[k] = n
|
||||
}
|
||||
}
|
||||
|
||||
// Reset resets the container's errors as well as each field's value and errors.
|
||||
func (c *Container) Reset(exclude ...string) {
|
||||
c.Messages = nil
|
||||
c.Nodes.Reset(exclude...)
|
||||
}
|
||||
|
||||
// ParseError type asserts the given error and sets the container's errors or a
|
||||
// field's errors and if the error is not something to be handled by the
|
||||
// formUI Container, the error is returned.
|
||||
//
|
||||
// This method DOES NOT touch the values of the node values/names, only its errors.
|
||||
func (c *Container) ParseError(group node.UiNodeGroup, err error) error {
|
||||
if e := richError(nil); errors.As(err, &e) {
|
||||
switch e.ID() {
|
||||
case text.ErrIDIdentityDisabled:
|
||||
c.AddMessage(group, text.NewErrorValidationIdentityDisabled())
|
||||
return nil
|
||||
default:
|
||||
if e.StatusCode() == http.StatusBadRequest {
|
||||
c.AddMessage(group, text.NewValidationErrorGeneric(e.Reason()))
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return err
|
||||
} else if e := new(schema.ValidationError); errors.As(err, &e) {
|
||||
pointer, _ := jsonschemax.JSONPointerToDotNotation(e.InstancePtr)
|
||||
for i := range e.Messages {
|
||||
c.AddMessage(group, &e.Messages[i], pointer)
|
||||
}
|
||||
return nil
|
||||
} else if e := new(jsonschema.ValidationError); errors.As(err, &e) {
|
||||
switch ctx := e.Context.(type) {
|
||||
case *jsonschema.ValidationErrorContextRequired:
|
||||
for _, required := range ctx.Missing {
|
||||
// The pointer can be ignored because if there is an error, we'll just use
|
||||
// the empty field (global error).
|
||||
pointer, _ := jsonschemax.JSONPointerToDotNotation(required)
|
||||
segments := strings.Split(required, "/")
|
||||
c.AddMessage(group, text.NewValidationErrorRequired(segments[len(segments)-1]), pointer)
|
||||
}
|
||||
default:
|
||||
// The pointer can be ignored because if there is an error, we'll just use
|
||||
// the empty field (global error).
|
||||
causes := e.Causes
|
||||
if len(e.Causes) == 0 {
|
||||
pointer, _ := jsonschemax.JSONPointerToDotNotation(e.InstancePtr)
|
||||
c.AddMessage(group, translateValidationError(e), pointer)
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, ee := range causes {
|
||||
if err := c.ParseError(group, ee); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
} else if e := new(schema.ValidationListError); errors.As(err, &e) {
|
||||
for _, ee := range e.Validations {
|
||||
if err := c.ParseError(group, ee); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
var formatErrorMatcher = regexp.MustCompile(`"([^"]+)"\s+is not valid\s+"([^"]+)"`)
|
||||
|
||||
func translateValidationError(err *jsonschema.ValidationError) *text.Message {
|
||||
segments := strings.Split(err.SchemaPtr, "/")
|
||||
switch segments[len(segments)-1] {
|
||||
case "minLength":
|
||||
minLength, actual := -1, -1
|
||||
_, _ = fmt.Sscanf(err.Message, "length must be >= %d, but got %d", &minLength, &actual)
|
||||
return text.NewErrorValidationMinLength(minLength, actual)
|
||||
case "maxLength":
|
||||
maxLength, actual := -1, -1
|
||||
_, _ = fmt.Sscanf(err.Message, "length must be <= %d, but got %d", &maxLength, &actual)
|
||||
return text.NewErrorValidationMaxLength(maxLength, actual)
|
||||
case "pattern":
|
||||
pattern := ""
|
||||
_, _ = fmt.Sscanf(err.Message, "does not match pattern %q", &pattern)
|
||||
return text.NewErrorValidationInvalidFormat(pattern)
|
||||
case "minimum":
|
||||
minimum, actual := -1.0, -1.0
|
||||
_, _ = fmt.Sscanf(err.Message, "must be >= %v but found %v", &minimum, &actual)
|
||||
return text.NewErrorValidationMinimum(minimum, actual)
|
||||
case "exclusiveMinimum":
|
||||
minimum, actual := -1.0, -1.0
|
||||
_, _ = fmt.Sscanf(err.Message, "must be > %v but found %v", &minimum, &actual)
|
||||
return text.NewErrorValidationExclusiveMinimum(minimum, actual)
|
||||
case "maximum":
|
||||
maximum, actual := -1.0, -1.0
|
||||
_, _ = fmt.Sscanf(err.Message, "must be <= %v but found %v", &maximum, &actual)
|
||||
return text.NewErrorValidationMaximum(maximum, actual)
|
||||
case "exclusiveMaximum":
|
||||
maximum, actual := -1.0, -1.0
|
||||
_, _ = fmt.Sscanf(err.Message, "must be < %v but found %v", &maximum, &actual)
|
||||
return text.NewErrorValidationExclusiveMaximum(maximum, actual)
|
||||
case "multipleOf":
|
||||
base, actual := -1.0, -1.0
|
||||
_, _ = fmt.Sscanf(err.Message, "%v not multipleOf %v", &actual, &base)
|
||||
return text.NewErrorValidationMultipleOf(base, actual)
|
||||
case "maxItems":
|
||||
maxItems, actual := -1, -1
|
||||
_, _ = fmt.Sscanf(err.Message, "maximum %d items allowed, but found %d items", &maxItems, &actual)
|
||||
return text.NewErrorValidationMaxItems(maxItems, actual)
|
||||
case "minItems":
|
||||
minItems, actual := -1, -1
|
||||
_, _ = fmt.Sscanf(err.Message, "minimum %d items allowed, but found %d items", &minItems, &actual)
|
||||
return text.NewErrorValidationMinItems(minItems, actual)
|
||||
case "uniqueItems":
|
||||
indexA, indexB := -1, -1
|
||||
_, _ = fmt.Sscanf(err.Message, "items at index %d and %d are equal", &indexA, &indexB)
|
||||
return text.NewErrorValidationUniqueItems(indexA, indexB)
|
||||
case "type":
|
||||
allowedTypes, actualType, _ := strings.Cut(strings.TrimPrefix(err.Message, "expected "), ", but got ")
|
||||
return text.NewErrorValidationWrongType(strings.Split(allowedTypes, " or "), actualType)
|
||||
case "const":
|
||||
if err.Message != "const failed" {
|
||||
expectedValue := strings.TrimPrefix(err.Message, "value must be ")
|
||||
return text.NewErrorValidationConst(expectedValue)
|
||||
}
|
||||
return text.NewErrorValidationConstGeneric()
|
||||
case "format":
|
||||
m := formatErrorMatcher.FindStringSubmatch(err.Message)
|
||||
if len(m) != 3 {
|
||||
return text.NewValidationErrorGeneric(err.Message)
|
||||
}
|
||||
|
||||
value := m[1]
|
||||
format := m[2]
|
||||
|
||||
switch format {
|
||||
case "email", "email_loose":
|
||||
return text.NewErrorValidationEmail(value)
|
||||
case "tel":
|
||||
return text.NewErrorValidationPhone(value)
|
||||
default:
|
||||
return text.NewValidationErrorGeneric(err.Message)
|
||||
}
|
||||
default:
|
||||
return text.NewValidationErrorGeneric(err.Message)
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateNodeValuesFromJSON sets the container's fields to the provided values.
|
||||
func (c *Container) UpdateNodeValuesFromJSON(raw json.RawMessage, prefix string, group node.UiNodeGroup) {
|
||||
for k, v := range jsonx.Flatten(raw) {
|
||||
k = addPrefix(k, prefix, ".")
|
||||
|
||||
if n := c.Nodes.Find(k); n != nil {
|
||||
n.Attributes.SetValue(v)
|
||||
n.Group = group
|
||||
continue
|
||||
}
|
||||
|
||||
c.Nodes.Upsert(node.NewInputFieldFromJSON(k, v, group))
|
||||
}
|
||||
}
|
||||
|
||||
// Unset removes a field from the container.
|
||||
func (c *Container) UnsetNode(id string) {
|
||||
c.Nodes.Remove(id)
|
||||
}
|
||||
|
||||
// SetCSRF sets the CSRF value using e.g. nosurf.Token(r).
|
||||
func (c *Container) SetCSRF(token string) {
|
||||
c.SetNode(node.NewCSRFNode(token))
|
||||
}
|
||||
|
||||
// SetNode sets a field.
|
||||
func (c *Container) SetNode(n *node.Node) {
|
||||
c.Nodes.Upsert(n)
|
||||
}
|
||||
|
||||
// SetValue sets a container's field to the provided name and value.
|
||||
func (c *Container) SetValue(id string, n *node.Node) {
|
||||
if f := c.Nodes.Find(id); f != nil {
|
||||
f.Attributes.SetValue(n.GetValue())
|
||||
return
|
||||
}
|
||||
|
||||
c.Nodes.Upsert(n)
|
||||
}
|
||||
|
||||
// AddMessage adds the provided error, and if a non-empty names list is set,
|
||||
// adds the error on the corresponding field.
|
||||
func (c *Container) AddMessage(group node.UiNodeGroup, err *text.Message, setForFields ...string) {
|
||||
if !slices.ContainsFunc(setForFields, func(s string) bool { return strings.TrimSpace(s) != "" }) {
|
||||
c.Messages = append(c.Messages, *err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, name := range setForFields {
|
||||
if ff := c.Nodes.Find(name); ff != nil {
|
||||
ff.Messages = append(ff.Messages, *err)
|
||||
continue
|
||||
}
|
||||
|
||||
n := node.NewInputField(name, nil, node.DefaultGroup, node.InputAttributeTypeText)
|
||||
n.Messages = text.Messages{*err}
|
||||
c.Nodes = append(c.Nodes, n)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Container) Scan(value interface{}) error {
|
||||
return sqlxx.JSONScan(c, value)
|
||||
}
|
||||
|
||||
func (c *Container) Value() (driver.Value, error) {
|
||||
if c == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return json.Marshal(c)
|
||||
}
|
||||
|
||||
func addPrefix(name, prefix, separator string) string {
|
||||
if prefix == "" {
|
||||
return name
|
||||
}
|
||||
return fmt.Sprintf("%s%s%s", prefix, separator, name)
|
||||
}
|
||||
335
ui/container/container_test.go
Normal file
335
ui/container/container_test.go
Normal file
@@ -0,0 +1,335 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package container
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"github.com/ory/kratos/ui/node"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/ory/jsonschema/v3"
|
||||
|
||||
"github.com/ory/herodot"
|
||||
"github.com/ory/x/decoderx"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/ory/kratos/schema"
|
||||
"github.com/ory/kratos/text"
|
||||
)
|
||||
|
||||
func newJSONRequest(t *testing.T, j string) *http.Request {
|
||||
req, err := http.NewRequest("POST", "/", bytes.NewBufferString(j))
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
return req
|
||||
}
|
||||
|
||||
func newFormRequest(t *testing.T, values url.Values) *http.Request {
|
||||
req, err := http.NewRequest("POST", "/", bytes.NewBufferString(values.Encode()))
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
return req
|
||||
}
|
||||
|
||||
func TestContainer(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
t.Run("method=NewFromJSON", func(t *testing.T) {
|
||||
for k, tc := range []struct {
|
||||
r string
|
||||
prefix string
|
||||
expect *Container
|
||||
}{
|
||||
{
|
||||
r: `{"numby":1.5,"stringy":"foobar","objy":{"objy":{},"numby":1.5,"stringy":"foobar"}}`,
|
||||
expect: &Container{
|
||||
Nodes: node.Nodes{
|
||||
node.NewInputFieldFromJSON("numby", 1.5, node.DefaultGroup),
|
||||
node.NewInputFieldFromJSON("objy.numby", 1.5, node.DefaultGroup),
|
||||
node.NewInputFieldFromJSON("objy.stringy", "foobar", node.DefaultGroup),
|
||||
node.NewInputFieldFromJSON("stringy", "foobar", node.DefaultGroup),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
r: `{"numby":1.5,"stringy":"foobar","objy":{"objy":{},"numby":1.5,"stringy":"foobar"}}`,
|
||||
prefix: "traits",
|
||||
expect: &Container{
|
||||
Nodes: node.Nodes{
|
||||
node.NewInputFieldFromJSON("traits.numby", 1.5, node.DefaultGroup),
|
||||
node.NewInputFieldFromJSON("traits.objy.numby", 1.5, node.DefaultGroup),
|
||||
node.NewInputFieldFromJSON("traits.objy.stringy", "foobar", node.DefaultGroup),
|
||||
node.NewInputFieldFromJSON("traits.stringy", "foobar", node.DefaultGroup),
|
||||
},
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run(fmt.Sprintf("case=%d", k), func(t *testing.T) {
|
||||
actual := NewFromJSON("action", node.DefaultGroup, json.RawMessage(tc.r), tc.prefix)
|
||||
|
||||
// sort actual.fields lexicographically to have a deterministic order
|
||||
sort.SliceStable(actual.Nodes, func(i, j int) bool {
|
||||
return actual.Nodes[i].ID() < actual.Nodes[j].ID()
|
||||
})
|
||||
|
||||
assert.Equal(t, "action", actual.Action)
|
||||
assert.EqualValues(t, tc.expect.Nodes, actual.Nodes)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("method=NewFromHTTPRequest", func(t *testing.T) {
|
||||
for k, tc := range []struct {
|
||||
ref string
|
||||
r *http.Request
|
||||
expect *Container
|
||||
}{
|
||||
{
|
||||
ref: "./stub/simple.schema.json",
|
||||
r: newJSONRequest(t, `{"numby":1.5,"stringy":"foobar","objy":{"objy":{},"numby":1.5,"stringy":"foobar"}}`),
|
||||
expect: &Container{
|
||||
Nodes: node.Nodes{
|
||||
node.NewInputFieldFromJSON("numby", 1.5, node.DefaultGroup),
|
||||
node.NewInputFieldFromJSON("objy.numby", 1.5, node.DefaultGroup),
|
||||
node.NewInputFieldFromJSON("objy.stringy", "foobar", node.DefaultGroup),
|
||||
node.NewInputFieldFromJSON("stringy", "foobar", node.DefaultGroup),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
ref: "./stub/simple.schema.json",
|
||||
r: newFormRequest(t, url.Values{
|
||||
"numby": {"1.5"},
|
||||
"stringy": {"foobar"},
|
||||
"objy.numby": {"1.5"},
|
||||
"objy.stringy": {"foobar"},
|
||||
}),
|
||||
expect: &Container{
|
||||
Nodes: node.Nodes{
|
||||
node.NewInputFieldFromJSON("numby", 1.5, node.DefaultGroup),
|
||||
node.NewInputFieldFromJSON("objy.numby", 1.5, node.DefaultGroup),
|
||||
node.NewInputFieldFromJSON("objy.stringy", "foobar", node.DefaultGroup),
|
||||
node.NewInputFieldFromJSON("stringy", "foobar", node.DefaultGroup),
|
||||
},
|
||||
},
|
||||
},
|
||||
// FIXME https://github.com/ory/kratos/issues/1316
|
||||
//
|
||||
//{
|
||||
// ref: "./stub/complex.schema.json",
|
||||
// r: newFormRequest(t, url.Values{
|
||||
// "meal.chef": {"aeneas"},
|
||||
// }),
|
||||
// expect: &Container{
|
||||
// Nodes: node.Nodes{
|
||||
// node.NewInputFieldFromJSON("meal.chef", "aeneas", node.DefaultGroup),
|
||||
// &node.Node{Group: node.DefaultGroup, Type: node.Input, Attributes: &node.InputAttributes{Name: "meal.name", Type: node.InputAttributeTypeText}, Messages: text.Messages{*text.NewValidationErrorRequired("name")}, Meta: &node.Meta{}},
|
||||
// },
|
||||
// },
|
||||
//},
|
||||
} {
|
||||
t.Run(fmt.Sprintf("case=%d", k), func(t *testing.T) {
|
||||
actual, err := NewFromHTTPRequest(tc.r, node.DefaultGroup, "action", decoderx.HTTPJSONSchemaCompiler(tc.ref, nil))
|
||||
require.NoError(t, err)
|
||||
// sort actual.fields lexicographically to have a deterministic order
|
||||
sort.SliceStable(actual.Nodes, func(i, j int) bool {
|
||||
return actual.Nodes[i].ID() < actual.Nodes[j].ID()
|
||||
})
|
||||
assert.Equal(t, "action", actual.Action)
|
||||
assert.EqualValues(t, tc.expect.Nodes, actual.Nodes)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("method=NewFromJSONSchema", func(t *testing.T) {
|
||||
for k, tc := range []struct {
|
||||
ref string
|
||||
prefix string
|
||||
expect *Container
|
||||
}{
|
||||
{
|
||||
ref: "./stub/simple.schema.json",
|
||||
prefix: "",
|
||||
expect: &Container{
|
||||
Nodes: node.Nodes{
|
||||
node.NewInputField("numby", nil, node.DefaultGroup, node.InputAttributeTypeNumber),
|
||||
node.NewInputField("objy.numby", nil, node.DefaultGroup, node.InputAttributeTypeNumber),
|
||||
node.NewInputField("objy.stringy", nil, node.DefaultGroup, node.InputAttributeTypeText),
|
||||
node.NewInputField("stringy", nil, node.DefaultGroup, node.InputAttributeTypeText),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
ref: "./stub/simple.schema.json",
|
||||
prefix: "traits",
|
||||
expect: &Container{
|
||||
Nodes: node.Nodes{
|
||||
node.NewInputField("traits.numby", nil, node.DefaultGroup, node.InputAttributeTypeNumber),
|
||||
node.NewInputField("traits.objy.numby", nil, node.DefaultGroup, node.InputAttributeTypeNumber),
|
||||
node.NewInputField("traits.objy.stringy", nil, node.DefaultGroup, node.InputAttributeTypeText),
|
||||
node.NewInputField("traits.stringy", nil, node.DefaultGroup, node.InputAttributeTypeText),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
ref: "./stub/complex.schema.json",
|
||||
expect: &Container{
|
||||
Nodes: node.Nodes{
|
||||
node.NewInputField("fruits", nil, node.DefaultGroup, node.InputAttributeTypeText),
|
||||
node.NewInputField("meal.chef", nil, node.DefaultGroup, node.InputAttributeTypeText),
|
||||
node.NewInputField("meal.name", nil, node.DefaultGroup, node.InputAttributeTypeText, node.WithRequiredInputAttribute),
|
||||
|
||||
// FIXME https://github.com/ory/kratos/issues/1316
|
||||
//
|
||||
//node.NewInputField("vegetables.veggieName", nil, node.DefaultGroup, node.InputAttributeTypeText),
|
||||
//node.NewInputField("vegetables.veggieLike", nil, node.DefaultGroup, node.InputAttributeTypeText),
|
||||
//node.NewInputField("vegetables.veggieAmount", nil, node.DefaultGroup, node.InputAttributeTypeText),
|
||||
},
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run(fmt.Sprintf("case=%d", k), func(t *testing.T) {
|
||||
actual, err := NewFromJSONSchema(ctx, "action",
|
||||
node.DefaultGroup, tc.ref, tc.prefix, nil, false)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "action", actual.Action)
|
||||
assert.EqualValues(t, tc.expect.Messages, actual.Messages)
|
||||
assert.EqualValues(t, tc.expect.Nodes, actual.Nodes)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("method=ParseError", func(t *testing.T) {
|
||||
for k, tc := range []struct {
|
||||
err error
|
||||
expectErr bool
|
||||
expect Container
|
||||
}{
|
||||
{err: errors.New("foo"), expectErr: true},
|
||||
{err: herodot.ErrNotFound(), expectErr: true},
|
||||
{err: herodot.ErrBadRequest().WithReason("tests"), expect: Container{Nodes: node.Nodes{}, Messages: text.Messages{*text.NewValidationErrorGeneric("tests")}}},
|
||||
{err: schema.NewInvalidCredentialsError(), expect: Container{Nodes: node.Nodes{}, Messages: text.Messages{*text.NewErrorValidationInvalidCredentials()}}},
|
||||
{err: &jsonschema.ValidationError{Message: "test", InstancePtr: "#/foo/bar/baz"}, expect: Container{Nodes: node.Nodes{
|
||||
&node.Node{Group: node.DefaultGroup, Type: node.Input, Attributes: &node.InputAttributes{Name: "foo.bar.baz", Type: node.InputAttributeTypeText}, Messages: text.Messages{*text.NewValidationErrorGeneric("test")}, Meta: new(node.Meta)},
|
||||
}}},
|
||||
{err: &jsonschema.ValidationError{Message: "test", InstancePtr: ""}, expect: Container{Nodes: node.Nodes{}, Messages: text.Messages{*text.NewValidationErrorGeneric("test")}}},
|
||||
{err: &jsonschema.ValidationError{Message: `"asd" is not valid "email"`, InstancePtr: "", SchemaPtr: "#/email/format"}, expect: Container{Nodes: node.Nodes{}, Messages: text.Messages{*text.NewErrorValidationEmail("asd")}}},
|
||||
{err: &jsonschema.ValidationError{Message: `"asd" is not valid "tel"`, InstancePtr: "", SchemaPtr: "#/phone/format"}, expect: Container{Nodes: node.Nodes{}, Messages: text.Messages{*text.NewErrorValidationPhone("asd")}}},
|
||||
{err: &jsonschema.ValidationError{Message: `"asd" is not valid "unknown-format"`, InstancePtr: "", SchemaPtr: "#/unknown/format"}, expect: Container{Nodes: node.Nodes{}, Messages: text.Messages{*text.NewValidationErrorGeneric(`"asd" is not valid "unknown-format"`)}}},
|
||||
// Malformed format error message
|
||||
{err: &jsonschema.ValidationError{Message: `"asd" is not valid "unknown-format`, InstancePtr: "", SchemaPtr: "#/unknown/format"}, expect: Container{Nodes: node.Nodes{}, Messages: text.Messages{*text.NewValidationErrorGeneric(`"asd" is not valid "unknown-format`)}}},
|
||||
} {
|
||||
t.Run(fmt.Sprintf("case=%d", k), func(t *testing.T) {
|
||||
for _, in := range []error{tc.err, errors.WithStack(tc.err)} {
|
||||
c := New("")
|
||||
err := c.ParseError(node.DefaultGroup, in)
|
||||
if tc.expectErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
assert.EqualValues(t, tc.expect.Messages, c.Messages)
|
||||
assert.EqualValues(t, tc.expect.Nodes, c.Nodes)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("method=SetValue", func(t *testing.T) {
|
||||
c := Container{
|
||||
Nodes: node.Nodes{
|
||||
node.NewInputField("1", "foo", node.DefaultGroup, node.InputAttributeTypeText),
|
||||
node.NewInputField("2", "", node.DefaultGroup, node.InputAttributeTypeText),
|
||||
},
|
||||
}
|
||||
|
||||
assert.Len(t, c.Nodes, 2)
|
||||
|
||||
c.SetValue("1", node.NewInputFieldFromJSON("1", "baz1", node.DefaultGroup))
|
||||
c.SetValue("2", node.NewInputFieldFromJSON("2", "baz2", node.DefaultGroup))
|
||||
c.SetValue("3", node.NewInputFieldFromJSON("3", "baz3", node.DefaultGroup))
|
||||
|
||||
assert.Len(t, c.Nodes, 3)
|
||||
for _, k := range []string{"1", "2", "3"} {
|
||||
assert.EqualValues(t, fmt.Sprintf("baz%s", k), c.Nodes.Find(k).Attributes.GetValue(), "%+v", c)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("method=SetCSRF", func(t *testing.T) {
|
||||
f := &Container{Nodes: node.Nodes{}}
|
||||
f.SetCSRF("csrf-token")
|
||||
assert.Contains(t, f.Nodes, node.NewCSRFNode("csrf-token"))
|
||||
})
|
||||
|
||||
t.Run("method=AddMessage", func(t *testing.T) {
|
||||
c := Container{
|
||||
Nodes: node.Nodes{
|
||||
&node.Node{Messages: text.Messages{*text.NewValidationErrorGeneric("foo")}, Group: node.DefaultGroup, Type: node.Input, Attributes: &node.InputAttributes{Name: "1", Type: node.InputAttributeTypeText, FieldValue: ""}},
|
||||
&node.Node{Messages: text.Messages{}, Group: node.DefaultGroup, Type: node.Input, Attributes: &node.InputAttributes{Name: "2", Type: node.InputAttributeTypeText, FieldValue: ""}},
|
||||
},
|
||||
}
|
||||
assert.Len(t, c.Nodes, 2)
|
||||
c.AddMessage(node.DefaultGroup, &text.Message{Text: "baz1"}, "1")
|
||||
c.AddMessage(node.DefaultGroup, &text.Message{Text: "baz2"}, "2")
|
||||
c.AddMessage(node.DefaultGroup, &text.Message{Text: "baz3"}, "3")
|
||||
c.AddMessage(node.DefaultGroup, &text.Message{Text: "baz"}, "4", "5", "6")
|
||||
c.AddMessage(node.DefaultGroup, &text.Message{Text: "rootbar"})
|
||||
|
||||
assert.Len(t, c.Nodes, 6)
|
||||
|
||||
for _, k := range []string{"1", "2", "3"} {
|
||||
n := c.Nodes.Find(k)
|
||||
assert.EqualValues(t, fmt.Sprintf("baz%s", k), n.Messages[len(n.Messages)-1].Text, "%+v", c)
|
||||
}
|
||||
|
||||
for _, k := range []string{"4", "5", "6"} {
|
||||
assert.EqualValues(t, "baz", c.Nodes.Find(k).Messages[0].Text, "%+v", c)
|
||||
}
|
||||
|
||||
assert.Len(t, c.Messages, 1)
|
||||
assert.Equal(t, "rootbar", c.Messages[0].Text)
|
||||
})
|
||||
|
||||
t.Run("method=Reset", func(t *testing.T) {
|
||||
c := Container{
|
||||
Nodes: node.Nodes{
|
||||
&node.Node{Messages: text.Messages{{Text: "foo"}}, Group: node.DefaultGroup, Type: node.Input, Attributes: &node.InputAttributes{Name: "1", Type: node.InputAttributeTypeText, FieldValue: "foo"}},
|
||||
&node.Node{Messages: text.Messages{{Text: "bar"}}, Group: node.DefaultGroup, Type: node.Input, Attributes: &node.InputAttributes{Name: "2", Type: node.InputAttributeTypeText, FieldValue: "bar"}},
|
||||
},
|
||||
Messages: text.Messages{{Text: ""}},
|
||||
}
|
||||
c.Reset()
|
||||
|
||||
assert.Empty(t, c.Messages)
|
||||
assert.Empty(t, c.Nodes.Find("1").Messages)
|
||||
assert.Empty(t, c.Nodes.Find("1").Attributes.(*node.InputAttributes).FieldValue)
|
||||
assert.Empty(t, c.Nodes.Find("2").Messages)
|
||||
assert.Empty(t, c.Nodes.Find("2").Attributes.(*node.InputAttributes).FieldValue)
|
||||
})
|
||||
|
||||
t.Run("method=remove", func(t *testing.T) {
|
||||
c := Container{
|
||||
Nodes: node.Nodes{
|
||||
&node.Node{Messages: text.Messages{{Text: "foo"}}, Group: node.DefaultGroup, Type: node.Input, Attributes: &node.InputAttributes{Name: "1", Type: node.InputAttributeTypeText, FieldValue: "foo"}},
|
||||
&node.Node{Messages: text.Messages{{Text: "bar"}}, Group: node.DefaultGroup, Type: node.Input, Attributes: &node.InputAttributes{Name: "2", Type: node.InputAttributeTypeText, FieldValue: "bar"}},
|
||||
},
|
||||
Messages: text.Messages{{Text: ""}},
|
||||
}
|
||||
|
||||
require.Len(t, c.Nodes, 2)
|
||||
c.GetNodes().Remove("1")
|
||||
require.Len(t, c.Nodes, 1)
|
||||
require.EqualValues(t, "bar", c.Nodes[0].Attributes.GetValue())
|
||||
})
|
||||
}
|
||||
12
ui/container/error.go
Normal file
12
ui/container/error.go
Normal file
@@ -0,0 +1,12 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package container
|
||||
|
||||
type (
|
||||
richError interface {
|
||||
StatusCode() int
|
||||
Reason() string
|
||||
ID() string
|
||||
}
|
||||
)
|
||||
58
ui/container/stub/all_formats.schema.json
Normal file
58
ui/container/stub/all_formats.schema.json
Normal file
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"$id": "https://example.com/all_formats.schema.json",
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"password": {
|
||||
"type": "string",
|
||||
"test_expected_type": "password"
|
||||
},
|
||||
"csrf_token": {
|
||||
"type": "string",
|
||||
"test_expected_type": "hidden"
|
||||
},
|
||||
"simpleString": {
|
||||
"type": "string",
|
||||
"test_expected_type": "text"
|
||||
},
|
||||
"simpleNumber": {
|
||||
"type": "number",
|
||||
"test_expected_type": "number"
|
||||
},
|
||||
"simpleBoolean": {
|
||||
"type": "boolean",
|
||||
"test_expected_type": "checkbox"
|
||||
},
|
||||
"emailString": {
|
||||
"type": "string",
|
||||
"format": "email",
|
||||
"test_expected_type": "email"
|
||||
},
|
||||
"dateTimeString": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"test_expected_type": "datetime-local"
|
||||
},
|
||||
"regexString": {
|
||||
"type": "string",
|
||||
"format": "regex",
|
||||
"test_expected_type": "text"
|
||||
},
|
||||
"dateString": {
|
||||
"type": "string",
|
||||
"format": "date",
|
||||
"test_expected_type": "date"
|
||||
},
|
||||
"uriString": {
|
||||
"type": "string",
|
||||
"format": "uri",
|
||||
"test_expected_type": "url"
|
||||
},
|
||||
"patternString": {
|
||||
"type": "string",
|
||||
"pattern": "foo.*",
|
||||
"test_expected_type": "text",
|
||||
"test_expected_pattern": true
|
||||
}
|
||||
}
|
||||
}
|
||||
52
ui/container/stub/complex.schema.json
Normal file
52
ui/container/stub/complex.schema.json
Normal file
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"$id": "https://example.com/complex.schema.json",
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"type": "object",
|
||||
"required": ["meal"],
|
||||
"properties": {
|
||||
"meal": {
|
||||
"type": "object",
|
||||
"required": ["name"],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"chef": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"fruits": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"vegetables": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/veggie"
|
||||
}
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
"veggie": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"veggieName",
|
||||
"veggieLike"
|
||||
],
|
||||
"properties": {
|
||||
"veggieName": {
|
||||
"type": "string"
|
||||
},
|
||||
"veggieLike": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"veggieAmount": {
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
43
ui/container/stub/identity.schema.json
Normal file
43
ui/container/stub/identity.schema.json
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"$id": "https://example.com/registration.schema.json",
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "Person",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"traits": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"email": {
|
||||
"type": "string",
|
||||
"ory.sh/kratos": {
|
||||
"credentials": {
|
||||
"password": {
|
||||
"identifier": true
|
||||
}
|
||||
},
|
||||
"verification": {
|
||||
"via": "email"
|
||||
}
|
||||
}
|
||||
},
|
||||
"stringy": {
|
||||
"type": "string"
|
||||
},
|
||||
"numby": {
|
||||
"type": "number"
|
||||
},
|
||||
"booly": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"should_big_number": {
|
||||
"type": "number",
|
||||
"minimum": 1200
|
||||
},
|
||||
"should_long_string": {
|
||||
"type": "string",
|
||||
"minLength": 25
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
29
ui/container/stub/simple.schema.json
Normal file
29
ui/container/stub/simple.schema.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"$id": "https://example.com/registration.schema.json",
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "Person",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"stringy": {
|
||||
"type": "string"
|
||||
},
|
||||
"numby": {
|
||||
"type": "number"
|
||||
},
|
||||
"objy": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"stringy": {
|
||||
"type": "string"
|
||||
},
|
||||
"numby": {
|
||||
"type": "number"
|
||||
},
|
||||
"objy": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
54
ui/container/types.go
Normal file
54
ui/container/types.go
Normal file
@@ -0,0 +1,54 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package container
|
||||
|
||||
import (
|
||||
"github.com/ory/kratos/ui/node"
|
||||
)
|
||||
|
||||
// ErrorParser is capable of parsing and processing errors.
|
||||
type ErrorParser interface {
|
||||
// ParseError type asserts the given error and sets the forms's errors or a
|
||||
// field's errors and if the error is not something to be handled by the
|
||||
// formUI Container itself, the error is returned for further propagation (e.g. showing a 502 status code).
|
||||
ParseError(group node.UiNodeGroup, err error) error
|
||||
}
|
||||
|
||||
type NodeSetter interface {
|
||||
// SetNode sets (adds / replaces) a node.
|
||||
SetNode(field node.Node)
|
||||
}
|
||||
|
||||
type NodeUnsetter interface {
|
||||
// UnsetFields removes a node.
|
||||
UnsetNode(name string)
|
||||
}
|
||||
|
||||
type ValueSetter interface {
|
||||
// SetValue sets a value the passed node.
|
||||
SetValue(name string, value *node.Node)
|
||||
}
|
||||
|
||||
type CSRFSetter interface {
|
||||
// SetCSRF sets the CSRF value.
|
||||
SetCSRF(string)
|
||||
}
|
||||
|
||||
type Resetter interface {
|
||||
// Resets all values and messages recursively.
|
||||
Reset(exclude ...string)
|
||||
}
|
||||
|
||||
type MessageResetter interface {
|
||||
// ResetMessages resets the messages recursively.
|
||||
ResetMessages(exclude ...string)
|
||||
}
|
||||
|
||||
type FieldSorter interface {
|
||||
SortNodes(schemaRef string, prefix string, keysInOrder []string) error
|
||||
}
|
||||
|
||||
type NodeGetter interface {
|
||||
GetNodes() *node.Nodes
|
||||
}
|
||||
Reference in New Issue
Block a user