diff --git a/api/generated/oas_json_gen.go b/api/generated/oas_json_gen.go index ced9c0799..5fcab29da 100644 --- a/api/generated/oas_json_gen.go +++ b/api/generated/oas_json_gen.go @@ -5880,6 +5880,10 @@ func (s *FieldType) Decode(d *jx.Decoder) error { *s = FieldTypeDate case FieldTypeHidden: *s = FieldTypeHidden + case FieldTypeCheckbox: + *s = FieldTypeCheckbox + case FieldTypeSelect: + *s = FieldTypeSelect default: *s = FieldType(v) } @@ -5927,12 +5931,23 @@ func (s *FieldValidation) encodeFields(e *jx.Encoder) { s.MaxLength.Encode(e) } } + { + if s.Enum != nil { + e.FieldStart("enum") + e.ArrStart() + for _, elem := range s.Enum { + e.Str(elem) + } + e.ArrEnd() + } + } } -var jsonFieldsNameOfFieldValidation = [3]string{ +var jsonFieldsNameOfFieldValidation = [4]string{ 0: "format", 1: "min_length", 2: "max_length", + 3: "enum", } // Decode decodes FieldValidation from json. @@ -5973,6 +5988,25 @@ func (s *FieldValidation) Decode(d *jx.Decoder) error { }(); err != nil { return errors.Wrap(err, "decode field \"max_length\"") } + case "enum": + if err := func() error { + s.Enum = make([]string, 0) + if err := d.Arr(func(d *jx.Decoder) error { + var elem string + v, err := d.Str() + elem = string(v) + if err != nil { + return err + } + s.Enum = append(s.Enum, elem) + return nil + }); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"enum\"") + } default: return d.Skip() } diff --git a/api/generated/oas_schemas_gen.go b/api/generated/oas_schemas_gen.go index c62eab789..77dc8cc9e 100644 --- a/api/generated/oas_schemas_gen.go +++ b/api/generated/oas_schemas_gen.go @@ -2373,10 +2373,11 @@ func (s *FactorMethod) UnmarshalText(data []byte) error { // Does not contain display text — only a `text_key` resolved client-side. // Ref: # type Field struct { - // The input kind the client should render. Encodes the formats that - // have a matching HTML input type (email, url, date). For other - // formats (e.g. uuid) the type is `text` and `validation.format` - // carries the rule. + // The input kind the client should render. Encodes the formats and + // JSON types that map to a familiar HTML input: `email`, `url`, + // `date` come from `format`; `checkbox` from JSON `type: boolean`; + // `select` from a closed `enum`. For other formats (e.g. uuid) the + // type is `text` and `validation.format` carries the rule. Type FieldType `json:"type"` // Localization key for the field label. TextKey string `json:"text_key"` @@ -2445,10 +2446,11 @@ func (s *Field) SetValidation(val OptFieldValidation) { s.Validation = val } -// The input kind the client should render. Encodes the formats that -// have a matching HTML input type (email, url, date). For other -// formats (e.g. uuid) the type is `text` and `validation.format` -// carries the rule. +// The input kind the client should render. Encodes the formats and +// JSON types that map to a familiar HTML input: `email`, `url`, +// `date` come from `format`; `checkbox` from JSON `type: boolean`; +// `select` from a closed `enum`. For other formats (e.g. uuid) the +// type is `text` and `validation.format` carries the rule. type FieldType string const ( @@ -2460,6 +2462,8 @@ const ( FieldTypeURL FieldType = "url" FieldTypeDate FieldType = "date" FieldTypeHidden FieldType = "hidden" + FieldTypeCheckbox FieldType = "checkbox" + FieldTypeSelect FieldType = "select" ) // AllValues returns all FieldType values. @@ -2473,6 +2477,8 @@ func (FieldType) AllValues() []FieldType { FieldTypeURL, FieldTypeDate, FieldTypeHidden, + FieldTypeCheckbox, + FieldTypeSelect, } } @@ -2495,6 +2501,10 @@ func (s FieldType) MarshalText() ([]byte, error) { return []byte(s), nil case FieldTypeHidden: return []byte(s), nil + case FieldTypeCheckbox: + return []byte(s), nil + case FieldTypeSelect: + return []byte(s), nil default: return nil, errors.Errorf("invalid value: %q", s) } @@ -2527,6 +2537,12 @@ func (s *FieldType) UnmarshalText(data []byte) error { case FieldTypeHidden: *s = FieldTypeHidden return nil + case FieldTypeCheckbox: + *s = FieldTypeCheckbox + return nil + case FieldTypeSelect: + *s = FieldTypeSelect + return nil default: return errors.Errorf("invalid value: %q", data) } @@ -2550,6 +2566,11 @@ type FieldValidation struct { MinLength OptInt `json:"min_length"` // Maximum length in characters (inclusive). Mirrors `maxLength`. MaxLength OptInt `json:"max_length"` + // Closed list of allowed values. Mirrors the JSON Schema `enum` + // keyword on the underlying user property. When `type` is + // `select` the client renders these as options; for other types + // the rule still applies as a membership check. + Enum []string `json:"enum"` } // GetFormat returns the value of Format. @@ -2567,6 +2588,11 @@ func (s *FieldValidation) GetMaxLength() OptInt { return s.MaxLength } +// GetEnum returns the value of Enum. +func (s *FieldValidation) GetEnum() []string { + return s.Enum +} + // SetFormat sets the value of Format. func (s *FieldValidation) SetFormat(val OptFieldValidationFormat) { s.Format = val @@ -2582,6 +2608,11 @@ func (s *FieldValidation) SetMaxLength(val OptInt) { s.MaxLength = val } +// SetEnum sets the value of Enum. +func (s *FieldValidation) SetEnum(val []string) { + s.Enum = val +} + // Semantic format the value must match. Values mirror the user // meta-schema. When `type` already encodes the format (email, // url, date), this key is informative and the input type is diff --git a/api/generated/oas_validators_gen.go b/api/generated/oas_validators_gen.go index bf95b265b..b87221c1b 100644 --- a/api/generated/oas_validators_gen.go +++ b/api/generated/oas_validators_gen.go @@ -868,6 +868,10 @@ func (s FieldType) Validate() error { return nil case "hidden": return nil + case "checkbox": + return nil + case "select": + return nil default: return errors.Errorf("invalid value: %v", s) } diff --git a/api/openapi/components/flows/field.yaml b/api/openapi/components/flows/field.yaml index 35f4ac34b..476c893fb 100644 --- a/api/openapi/components/flows/field.yaml +++ b/api/openapi/components/flows/field.yaml @@ -6,12 +6,13 @@ description: | properties: type: type: string - enum: [text, email, password, tel, number, url, date, hidden] + enum: [text, email, password, tel, number, url, date, hidden, checkbox, select] description: | - The input kind the client should render. Encodes the formats that - have a matching HTML input type (email, url, date). For other - formats (e.g. uuid) the type is `text` and `validation.format` - carries the rule. + The input kind the client should render. Encodes the formats and + JSON types that map to a familiar HTML input: `email`, `url`, + `date` come from `format`; `checkbox` from JSON `type: boolean`; + `select` from a closed `enum`. For other formats (e.g. uuid) the + type is `text` and `validation.format` carries the rule. text_key: type: string description: Localization key for the field label. @@ -53,3 +54,12 @@ properties: type: integer minimum: 0 description: Maximum length in characters (inclusive). Mirrors `maxLength`. + enum: + type: array + items: + type: string + description: | + Closed list of allowed values. Mirrors the JSON Schema `enum` + keyword on the underlying user property. When `type` is + `select` the client renders these as options; for other types + the rule still applies as a membership check. diff --git a/internal/domain/flow_field_resolver.go b/internal/domain/flow_field_resolver.go index e53e061ba..ca8e560b4 100644 --- a/internal/domain/flow_field_resolver.go +++ b/internal/domain/flow_field_resolver.go @@ -115,6 +115,7 @@ const ( // - Format ↔ `format` (enum: email, date-time, uuid, uri) // - MinLength ↔ `minLength` // - MaxLength ↔ `maxLength` +// - Enum ↔ `enum` (closed set of allowed string values) // // Zero values mean "no rule". JSON Schema's `pattern` keyword is not // part of the user meta-schema and is intentionally not surfaced. @@ -122,6 +123,7 @@ type FlowFieldValidation struct { Format string MinLength int MaxLength int + Enum []string } // FlowFieldType names the input kind the client should render. Mirrors @@ -137,6 +139,8 @@ const ( FlowFieldTypeURL FlowFieldType = "url" FlowFieldTypeDate FlowFieldType = "date" FlowFieldTypeHidden FlowFieldType = "hidden" + FlowFieldTypeCheckbox FlowFieldType = "checkbox" + FlowFieldTypeSelect FlowFieldType = "select" ) // FlowFieldValidationRule names a schema-derived validation rule the @@ -200,3 +204,10 @@ func ImplicitOutcomesForChallenge(c FlowFieldChallenge) []string { // ErrFlowFieldUnknown is returned by [FlowFieldResolver.Resolve] when a // requested field name is not part of the resolver's schema or catalog. var ErrFlowFieldUnknown = errors.New("flow field: not in resolver catalog") + +// ErrFlowFieldUnsupportedType is returned by [FlowFieldResolver.Resolve] +// when a property declares a JSON `type` set the resolver cannot +// reduce to a single input kind. The nullable idiom `["null", X]` is +// reduced to X and does not trigger this error; any other multi-entry +// union does. +var ErrFlowFieldUnsupportedType = errors.New("flow field: unsupported JSON type") diff --git a/internal/domain/flow_field_resolver_schema.go b/internal/domain/flow_field_resolver_schema.go index cd8cd3549..9627ce805 100644 --- a/internal/domain/flow_field_resolver_schema.go +++ b/internal/domain/flow_field_resolver_schema.go @@ -69,7 +69,10 @@ func (r *SchemaFieldResolver) Resolve( if !ok { return FlowResolvedFields{}, fmt.Errorf("%w: %q", ErrFlowFieldUnknown, name) } - field := buildFlowField(stepName, name, propSchema, required, passwordEnabled) + field, err := buildFlowField(stepName, name, propSchema, required, passwordEnabled) + if err != nil { + return FlowResolvedFields{}, fmt.Errorf("flow field %q: %w", name, err) + } fields[name] = field if outcomes := ImplicitOutcomesForChallenge(field.Challenge); len(outcomes) > 0 { implicit[name] = append(implicit[name], outcomes...) @@ -83,11 +86,17 @@ func (r *SchemaFieldResolver) Resolve( } // buildFlowField translates a user-schema property into a [FlowField]. -func buildFlowField(stepName, name string, propSchema *jsonschema.Schema, required map[string]struct{}, passwordEnabled bool) FlowField { +// Returns [ErrFlowFieldUnsupportedType] when the property's JSON `type` +// keyword cannot be reduced to a single input kind. +func buildFlowField(stepName, name string, propSchema *jsonschema.Schema, required map[string]struct{}, passwordEnabled bool) (FlowField, error) { unique := deriveUnique(propSchema) + fieldType, err := deriveFieldType(propSchema) + if err != nil { + return FlowField{}, err + } field := FlowField{ TextKey: stepName + ".field." + name, - Type: deriveFieldType(propSchema), + Type: fieldType, Challenge: deriveChallenge(propSchema, unique, passwordEnabled), Unique: unique, } @@ -97,24 +106,38 @@ func buildFlowField(stepName, name string, propSchema *jsonschema.Schema, requir if v := buildValidation(propSchema); v != nil { field.Validation = v } - return field + return field, nil } -// deriveFieldType maps the property's `format` to a [FlowFieldType]. -// `x-password: true` forces a password input regardless of `format`. -func deriveFieldType(propSchema *jsonschema.Schema) FlowFieldType { +// deriveFieldType maps the property's `enum`, `format`, and JSON +// `type` keywords to a [FlowFieldType]. `x-password: true` forces a +// password input regardless of the other keywords. A closed `enum` +// surfaces as `select`; JSON `type: boolean` surfaces as `checkbox`. +// Returns [ErrFlowFieldUnsupportedType] when the JSON `type` is an +// ambiguous union the resolver cannot reduce to a single kind. +func deriveFieldType(propSchema *jsonschema.Schema) (FlowFieldType, error) { + jsonType, err := lookupJSONType(propSchema) + if err != nil { + return "", err + } if isPassword(propSchema) { - return FlowFieldTypePassword + return FlowFieldTypePassword, nil + } + if len(lookupStringEnum(propSchema)) > 0 { + return FlowFieldTypeSelect, nil } switch lookupString(propSchema, "format") { case "email": - return FlowFieldTypeEmail + return FlowFieldTypeEmail, nil case "uri": - return FlowFieldTypeURL + return FlowFieldTypeURL, nil case "date", "date-time": - return FlowFieldTypeDate + return FlowFieldTypeDate, nil + } + if jsonType == "boolean" { + return FlowFieldTypeCheckbox, nil } - return FlowFieldTypeText + return FlowFieldTypeText, nil } // deriveChallenge resolves the unified [FlowFieldChallenge]. A @@ -148,13 +171,77 @@ func buildValidation(propSchema *jsonschema.Schema) *FlowFieldValidation { Format: lookupString(propSchema, "format"), MinLength: lookupInt(propSchema, "minLength"), MaxLength: lookupInt(propSchema, "maxLength"), + Enum: lookupStringEnum(propSchema), } - if v.Format == "" && v.MinLength == 0 && v.MaxLength == 0 { + if v.Format == "" && v.MinLength == 0 && v.MaxLength == 0 && len(v.Enum) == 0 { return nil } return &v } +// lookupJSONType returns the property's single JSON `type` keyword. +// JSON Schema allows `type` to be either a string or an array of +// strings; the nullable idiom `["null", X]` (in either order) is +// reduced to X. Any other multi-entry union yields +// [ErrFlowFieldUnsupportedType], since the resolver has no rule for +// picking one input kind over the other. +func lookupJSONType(schema *jsonschema.Schema) (string, error) { + v, ok := schema.LookupKeyword("type") + if !ok { + return "", nil + } + s, ok := v.(types.PartStringOrStrings) + if !ok { + return "", nil + } + if s.String != "" { + return s.String, nil + } + var nonNull []string + for _, t := range s.Strings { + if t != "null" { + nonNull = append(nonNull, t) + } + } + switch len(nonNull) { + case 0: + return "", nil + case 1: + return nonNull[0], nil + default: + return "", fmt.Errorf("%w: %v", ErrFlowFieldUnsupportedType, s.Strings) + } +} + +// lookupStringEnum returns the property's `enum` keyword, restricted to +// string entries. Non-string entries are skipped: the user meta-schema +// surfaces enums only for closed text choices today; numeric/boolean +// enums (if ever added) would need a richer wire type. +func lookupStringEnum(schema *jsonschema.Schema) []string { + v, ok := schema.LookupKeyword("enum") + if !ok { + return nil + } + part, ok := v.(types.PartAny) + if !ok { + return nil + } + raw, ok := part.V.([]any) + if !ok { + return nil + } + out := make([]string, 0, len(raw)) + for _, item := range raw { + if s, ok := item.(string); ok { + out = append(out, s) + } + } + if len(out) == 0 { + return nil + } + return out +} + // passwordAuthEnabled reports whether the root schema declares // `x-auth-methods.password.enabled = true`. Password is the only // credential the resolver surfaces, so the broader set isn't needed. diff --git a/internal/domain/flow_field_resolver_schema_test.go b/internal/domain/flow_field_resolver_schema_test.go index 04c687ba9..2f643a26e 100644 --- a/internal/domain/flow_field_resolver_schema_test.go +++ b/internal/domain/flow_field_resolver_schema_test.go @@ -275,25 +275,33 @@ func TestSchemaFieldResolver_Resolve_FormatAndTypeVariants(t *testing.T) { "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "properties": { - "website": { "type": "string", "format": "uri" }, - "birthday": { "type": "string", "format": "date" }, - "created": { "type": "string", "format": "date-time" }, - "nickname": { "type": "string" } + "website": { "type": "string", "format": "uri" }, + "birthday": { "type": "string", "format": "date" }, + "created": { "type": "string", "format": "date-time" }, + "nickname": { "type": "string" }, + "gender": { "type": "string", "enum": ["female", "male", "non_binary"] }, + "newsletter": { "type": "boolean" }, + "opt_in": { "type": ["null", "boolean"] }, + "opt_in_rev": { "type": ["boolean", "null"] } } }`) resolver := domain.NewSchemaFieldResolver(newFakeResolver(t, map[string][]byte{url: bytes})) got, err := resolver.Resolve(t.Context(), nil, testProjectID, url, "step", - []string{"website", "birthday", "created", "nickname"}) + []string{"website", "birthday", "created", "nickname", "gender", "newsletter", "opt_in", "opt_in_rev"}) if err != nil { t.Fatalf("Resolve returned error: %v", err) } wantTypes := map[string]domain.FlowFieldType{ - "website": domain.FlowFieldTypeURL, - "birthday": domain.FlowFieldTypeDate, - "created": domain.FlowFieldTypeDate, - "nickname": domain.FlowFieldTypeText, + "website": domain.FlowFieldTypeURL, + "birthday": domain.FlowFieldTypeDate, + "created": domain.FlowFieldTypeDate, + "nickname": domain.FlowFieldTypeText, + "gender": domain.FlowFieldTypeSelect, + "newsletter": domain.FlowFieldTypeCheckbox, + "opt_in": domain.FlowFieldTypeCheckbox, + "opt_in_rev": domain.FlowFieldTypeCheckbox, } for name, want := range wantTypes { if got.Fields[name].Type != want { @@ -303,4 +311,29 @@ func TestSchemaFieldResolver_Resolve_FormatAndTypeVariants(t *testing.T) { if got.Fields["nickname"].Validation != nil { t.Errorf("Resolve nickname Validation = %+v, want nil (no rules)", got.Fields["nickname"].Validation) } + if v := got.Fields["gender"].Validation; v == nil { + t.Errorf("Resolve gender Validation = nil, want enum rule") + } else if !slices.Equal(v.Enum, []string{"female", "male", "non_binary"}) { + t.Errorf("Resolve gender Validation.Enum = %v, want [female male non_binary]", v.Enum) + } + if got.Fields["newsletter"].Validation != nil { + t.Errorf("Resolve newsletter Validation = %+v, want nil (no rules)", got.Fields["newsletter"].Validation) + } +} + +func TestSchemaFieldResolver_Resolve_AmbiguousJSONTypeRejected(t *testing.T) { + const url = "https://example.test/ambiguous-type.json" + bytes := []byte(`{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "either": { "type": ["string", "boolean"] } + } + }`) + resolver := domain.NewSchemaFieldResolver(newFakeResolver(t, map[string][]byte{url: bytes})) + + _, err := resolver.Resolve(t.Context(), nil, testProjectID, url, "step", []string{"either"}) + if !errors.Is(err, domain.ErrFlowFieldUnsupportedType) { + t.Fatalf("Resolve err = %v, want %v", err, domain.ErrFlowFieldUnsupportedType) + } }