diff --git a/internal/watchsync/plugin_provider.go b/internal/watchsync/plugin_provider.go index 234836a5..99cb5e84 100644 --- a/internal/watchsync/plugin_provider.go +++ b/internal/watchsync/plugin_provider.go @@ -8,6 +8,7 @@ import ( "fmt" "net/url" "regexp" + "strconv" "strings" "time" "unicode" @@ -774,17 +775,24 @@ func validateRequiredConnectionSchemaIsRenderable(schema *pluginv1.ConfigSchema) } } for key, property := range document.Properties { + field := explicit[key] switch property.Type { case "string", watchSyncJSONSchemaNumberType, "integer", "boolean": continue case "array": - field := explicit[key] if field != nil && field.GetControl() == pluginv1.AdminFormControl_ADMIN_FORM_CONTROL_MULTI_SELECT && (property.Items == nil || property.Items.Type == "string" || property.Items.Type == watchSyncJSONSchemaNumberType || property.Items.Type == "integer" || property.Items.Type == "boolean") { continue } default: + // A property whose shape comes from enum/const/$ref cannot be + // inferred from type alone, but an explicit scalar form control is + // still a complete input mechanism. Direct object properties remain + // unsupported because none of these controls produces an object. + if property.Type != "object" && connectionAdminFieldRendersValue(field) { + continue + } } return fmt.Errorf( "required connection config %q property %q needs a renderable admin_form field because type %q cannot be inferred", @@ -796,6 +804,24 @@ func validateRequiredConnectionSchemaIsRenderable(schema *pluginv1.ConfigSchema) return nil } +func connectionAdminFieldRendersValue(field *pluginv1.AdminFormField) bool { + if field == nil { + return false + } + switch field.GetControl() { + case pluginv1.AdminFormControl_ADMIN_FORM_CONTROL_TEXT, + pluginv1.AdminFormControl_ADMIN_FORM_CONTROL_TEXTAREA, + pluginv1.AdminFormControl_ADMIN_FORM_CONTROL_PASSWORD, + pluginv1.AdminFormControl_ADMIN_FORM_CONTROL_NUMBER, + pluginv1.AdminFormControl_ADMIN_FORM_CONTROL_SWITCH, + pluginv1.AdminFormControl_ADMIN_FORM_CONTROL_SELECT, + pluginv1.AdminFormControl_ADMIN_FORM_CONTROL_MULTI_SELECT: + return true + default: + return false + } +} + func validateConnectionAdminFormValue(schema *pluginv1.ConfigSchema, value map[string]any) error { if schema == nil || schema.GetAdminForm() == nil { return nil @@ -868,6 +894,9 @@ func connectionConfigNumber(value any) (float64, bool) { case json.Number: number, err := typed.Float64() return number, err == nil + case string: + number, err := strconv.ParseFloat(strings.TrimSpace(typed), 64) + return number, err == nil default: return 0, false } diff --git a/internal/watchsync/plugin_provider_test.go b/internal/watchsync/plugin_provider_test.go index 5f0484da..6983eb06 100644 --- a/internal/watchsync/plugin_provider_test.go +++ b/internal/watchsync/plugin_provider_test.go @@ -398,6 +398,9 @@ func TestPluginProviderRejectsRequiredConnectionConfigTheWebCannotRender(t *test ConnectionConfigSchema: []*pluginv1.ConfigSchema{{ Key: "server", Required: true, JsonSchema: `{"type":"object","properties":{"headers":{"type":"object"}}}`, + AdminForm: &pluginv1.AdminFormDescriptor{Fields: []*pluginv1.AdminFormField{{ + Key: "headers", Control: pluginv1.AdminFormControl_ADMIN_FORM_CONTROL_TEXTAREA, + }}}, }}, ResolveClient: func(context.Context, int, string) (WatchSyncPluginClient, error) { return &fakeWatchSyncPluginClient{}, nil @@ -408,7 +411,7 @@ func TestPluginProviderRejectsRequiredConnectionConfigTheWebCannotRender(t *test } } -func TestPluginProviderAcceptsRequiredScalarFieldsMissingFromPartialAdminForm(t *testing.T) { +func TestPluginProviderAcceptsRenderableRequiredConnectionConfig(t *testing.T) { _, err := NewPluginProvider(PluginProviderOptions{ InstallationID: 4, ProviderKey: testPluginProviderKey, CapabilityID: testPluginCapabilityID, Descriptor: &pluginv1.WatchSyncProviderDescriptor{AuthMethods: []pluginv1.WatchSyncAuthMethod{ @@ -430,6 +433,21 @@ func TestPluginProviderAcceptsRequiredScalarFieldsMissingFromPartialAdminForm(t Options: []*pluginv1.AdminFormOption{{Value: "true", Label: "Enabled"}, {Value: "false", Label: "Disabled"}}, }}}, }, + { + Key: "mode", Required: true, + JsonSchema: `{"type":"object","properties":{"value":{"enum":["standard","anime"]}}}`, + AdminForm: &pluginv1.AdminFormDescriptor{Fields: []*pluginv1.AdminFormField{{ + Key: "value", Control: pluginv1.AdminFormControl_ADMIN_FORM_CONTROL_SELECT, + Options: []*pluginv1.AdminFormOption{{Value: "standard", Label: "Standard"}, {Value: "anime", Label: "Anime"}}, + }}}, + }, + { + Key: "reference", Required: true, + JsonSchema: `{"type":"object","properties":{"endpoint":{"$ref":"#/$defs/endpoint"}},"$defs":{"endpoint":{"type":"string","format":"uri"}}}`, + AdminForm: &pluginv1.AdminFormDescriptor{Fields: []*pluginv1.AdminFormField{{ + Key: "endpoint", Control: pluginv1.AdminFormControl_ADMIN_FORM_CONTROL_TEXT, + }}}, + }, }, ResolveClient: func(context.Context, int, string) (WatchSyncPluginClient, error) { return &fakeWatchSyncPluginClient{}, nil @@ -452,7 +470,7 @@ func TestPluginProviderEnforcesConnectionAdminFormValidation(t *testing.T) { }}, ConnectionConfigSchema: []*pluginv1.ConfigSchema{{ Key: "server", Required: true, - JsonSchema: `{"type":"object","properties":{"name":{"type":"string"},"port":{"type":"number"},"password":{"type":"string","format":"password"}},"required":["name","port","password"]}`, + JsonSchema: `{"type":"object","properties":{"name":{"type":"string"},"port":{},"password":{"type":"string","format":"password"}},"required":["name","port","password"]}`, AdminForm: &pluginv1.AdminFormDescriptor{Fields: []*pluginv1.AdminFormField{ {Key: "name", Control: pluginv1.AdminFormControl_ADMIN_FORM_CONTROL_TEXT, Validation: &pluginv1.AdminFormValidation{Pattern: `^[a-z]+$`, MinLength: 3, MaxLength: 8}}, {Key: "port", Control: pluginv1.AdminFormControl_ADMIN_FORM_CONTROL_NUMBER, Validation: &pluginv1.AdminFormValidation{HasMin: true, Min: 1, HasMax: true, Max: 65535}}, @@ -474,6 +492,7 @@ func TestPluginProviderEnforcesConnectionAdminFormValidation(t *testing.T) { }{ {name: "pattern", values: map[string]any{"name": "Bad", "port": 443.0, "password": "long-enough"}, message: "is invalid"}, {name: "number", values: map[string]any{"name": "good", "port": 70000.0, "password": "long-enough"}, message: "at most 65535"}, + {name: "numeric string", values: map[string]any{"name": "good", "port": "70000", "password": "long-enough"}, message: "at most 65535"}, {name: "secret length", values: map[string]any{"name": "good", "port": 443.0, "password": "leaky"}, message: "at least 8 characters"}, } for _, tt := range tests {