Copilot commented on code in PR #3312: URL: https://github.com/apache/apisix-dashboard/pull/3312#discussion_r2869789371
########## src/components/schema-form/validation.ts: ########## @@ -0,0 +1,178 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Ajv from 'ajv'; +import addFormats from 'ajv-formats'; +import type { FieldErrors } from 'react-hook-form'; + +import type { JSONSchema } from './types'; + +let ajvInstance: Ajv | null = null; + +function getAjv(): Ajv { + if (!ajvInstance) { + ajvInstance = new Ajv({ + allErrors: true, + verbose: true, + strict: false, + validateFormats: true, + }); + addFormats(ajvInstance); + } + return ajvInstance; +} + +export interface ValidationError { + path: string; + message: string; +} + +export function validateAgainstSchema( + schema: JSONSchema, + data: unknown +): ValidationError[] { + const ajv = getAjv(); + const errors: ValidationError[] = []; + + // Remove APISIX-specific fields that AJV doesn't understand + const cleanSchema = stripNonStandardFields(schema); + + let validate: ReturnType<Ajv['compile']>; + try { + validate = ajv.compile(cleanSchema); + } catch { + return [{ path: '', message: 'Failed to compile schema for validation' }]; + } + + const valid = validate(data); + if (!valid && validate.errors) { + for (const err of validate.errors) { + const path = ajvErrorToFieldPath(err.instancePath, err.params); + const message = formatAjvError(err); + errors.push({ path, message }); + } + } + + return errors; +} + +export function validationErrorsToFieldErrors( + errors: ValidationError[] +): FieldErrors { + const fieldErrors: FieldErrors = {}; + for (const err of errors) { + const key = err.path || 'root'; + if (!fieldErrors[key]) { + fieldErrors[key] = { message: err.message, type: 'validate' }; + } + } + return fieldErrors; +} + +function ajvErrorToFieldPath( + instancePath: string, + params?: Record<string, unknown> +): string { + let path = instancePath.replace(/^\//, '').replace(/\//g, '.'); + + if (params?.missingProperty) { + const missing = params.missingProperty as string; + path = path ? `${path}.${missing}` : missing; + } + + return path; +} + +function formatAjvError(error: { + keyword: string; + message?: string; + params?: Record<string, unknown>; + instancePath: string; +}): string { + const { keyword, message, params } = error; + + switch (keyword) { + case 'required': + return `${params?.missingProperty ?? 'Field'} is required`; + case 'type': + return `Must be of type ${params?.type ?? 'unknown'}`; + case 'minimum': + return `Must be >= ${params?.limit}`; + case 'maximum': + return `Must be <= ${params?.limit}`; + case 'exclusiveMinimum': + return `Must be > ${params?.limit}`; + case 'exclusiveMaximum': + return `Must be < ${params?.limit}`; + case 'minLength': + return `Must be at least ${params?.limit} characters`; + case 'maxLength': + return `Must be at most ${params?.limit} characters`; + case 'pattern': + return `Must match pattern: ${params?.pattern}`; + case 'enum': + return `Must be one of: ${(params?.allowedValues as unknown[])?.join(', ')}`; + case 'minItems': + return `Must have at least ${params?.limit} items`; + case 'maxItems': + return `Must have at most ${params?.limit} items`; + case 'uniqueItems': + return 'Items must be unique'; + case 'oneOf': + return 'Must match exactly one of the options'; + case 'anyOf': + return 'Must match at least one of the options'; + default: + return message ?? `Validation failed: ${keyword}`; + } +} + +function stripNonStandardFields(schema: JSONSchema): JSONSchema { + const cleaned = { ...schema }; + + delete (cleaned as Record<string, unknown>)['encrypt_fields']; + delete (cleaned as Record<string, unknown>)['_meta']; + delete (cleaned as Record<string, unknown>)['$comment']; + + if (cleaned.properties) { + const props: Record<string, JSONSchema> = {}; + for (const [key, value] of Object.entries(cleaned.properties)) { + props[key] = stripNonStandardFields(value); + } Review Comment: `stripNonStandardFields` only recurses through `properties`, `items` (non-tuple), and composition/conditional keywords. It does not recurse into other schema-bearing locations like `patternProperties`, `additionalProperties` (when it’s a schema), `dependencies` (schema form), or `not`. As a result, APISIX-specific keys can remain in nested subschemas and still break AJV compilation. Consider making the stripper fully recursive across all JSONSchema fields that can contain subschemas. ########## src/components/schema-form/utils.ts: ########## @@ -0,0 +1,180 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { JSONSchema } from './types'; + +export function getSchemaType(schema: JSONSchema): string | undefined { + if (schema.type) { + return Array.isArray(schema.type) ? schema.type[0] : schema.type; + } + if (schema.enum) return 'string'; + if (schema.properties) return 'object'; + if (schema.items) return 'array'; + if (schema.oneOf) return 'oneOf'; + if (schema.anyOf) return 'anyOf'; + return undefined; +} + +export function isEnumField(schema: JSONSchema): boolean { + return Array.isArray(schema.enum) && schema.enum.length > 0; +} + +export function isEncryptField( + fieldName: string, + encryptFields?: string[] +): boolean { + if (!encryptFields || encryptFields.length === 0) return false; + const baseName = fieldName.split('.').pop() ?? fieldName; + return encryptFields.includes(baseName); +} + +export function isRequired(fieldName: string, parentSchema: JSONSchema): boolean { + if (!parentSchema.required) return false; + const baseName = fieldName.split('.').pop() ?? fieldName; + return parentSchema.required.includes(baseName); +} + +export function getRequiredSet(schema: JSONSchema): Set<string> { + return new Set(schema.required ?? []); +} + +export function resolveDefaults(schema: JSONSchema): Record<string, unknown> { + const defaults: Record<string, unknown> = {}; + if (schema.type !== 'object' || !schema.properties) return defaults; + + for (const [key, propSchema] of Object.entries(schema.properties)) { + if (propSchema.default !== undefined) { + defaults[key] = propSchema.default; + } else if (propSchema.type === 'object' && propSchema.properties) { + const nested = resolveDefaults(propSchema); + if (Object.keys(nested).length > 0) { + defaults[key] = nested; + } + } + } + return defaults; +} + +export function fieldPath(rootPath: string, fieldName: string): string { + if (!rootPath) return fieldName; + return `${rootPath}.${fieldName}`; +} + +export function getFieldLabel(name: string, schema: JSONSchema): string { + if (schema.title) return schema.title; + const baseName = name.split('.').pop() ?? name; + return baseName + .replace(/([A-Z])/g, ' $1') + .replace(/[_-]/g, ' ') + .replace(/^\w/, (c) => c.toUpperCase()) + .trim(); +} + +export function getFieldDescription(schema: JSONSchema): string | undefined { + return schema.description; +} + +export function hasConditionals(schema: JSONSchema): boolean { + return !!(schema.if && (schema.then || schema.else)); +} + +export function hasDependencies(schema: JSONSchema): boolean { + return !!( + schema.dependencies && Object.keys(schema.dependencies).length > 0 + ); +} + +export function evaluateIfCondition( + schema: JSONSchema, + data: Record<string, unknown> +): 'then' | 'else' | null { + if (!schema.if) return null; + + const ifSchema = schema.if; + if (ifSchema.properties) { + for (const [key, propSchema] of Object.entries(ifSchema.properties)) { + const value = data[key]; + if (propSchema.enum) { + if (!propSchema.enum.includes(value)) return 'else'; + } + if (propSchema.type) { + const expectedType = Array.isArray(propSchema.type) + ? propSchema.type[0] + : propSchema.type; + if (typeof value !== expectedType) return 'else'; + } Review Comment: `evaluateIfCondition` compares JSON Schema types using `typeof value !== expectedType`, but JSON Schema uses `'integer'` while `typeof` returns `'number'`. This makes `if` conditions with `type: 'integer'` always evaluate to `'else'` even when the data is valid. Handle `'integer'` explicitly (e.g., `typeof value === 'number' && Number.isInteger(value)`) and map schema types to JS runtime types before comparing. ########## src/components/schema-form/fields/ArrayField.tsx: ########## @@ -0,0 +1,214 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + ActionIcon, + Box, + Button, + Fieldset, + Group, + TagsInput, + Text, +} from '@mantine/core'; +import { useController, useFieldArray, useFormContext } from 'react-hook-form'; + +import type { FieldProps, JSONSchema } from '../types'; +import { getFieldDescription, getFieldLabel, getSchemaType } from '../utils'; +import { SchemaField } from './SchemaField'; + +export const ArrayField: React.FC<FieldProps> = ({ + schema, + name, + required, + encryptFields, + disabled, +}) => { + const label = getFieldLabel(name, schema); + const description = getFieldDescription(schema); + + const itemSchema = (schema.items && !Array.isArray(schema.items)) + ? schema.items + : undefined; + + const itemType = itemSchema ? getSchemaType(itemSchema) : 'string'; + + // For simple string/number arrays, use TagsInput + if (itemType === 'string' && !itemSchema?.enum && !itemSchema?.properties) { + return ( + <SimpleStringArray + schema={schema} + name={name} + label={label} + description={description} + required={required} + disabled={disabled} + /> + ); + } + + // For object arrays, render repeatable fieldsets + if (itemSchema && (itemType === 'object' || itemSchema.properties)) { + return ( + <ObjectArrayField + schema={schema} + itemSchema={itemSchema} + name={name} + label={label} + description={description} + required={required} + encryptFields={encryptFields} + disabled={disabled} + /> + ); + } + + // Fallback: simple tags input + return ( + <SimpleStringArray + schema={schema} + name={name} + label={label} + description={description} + required={required} + disabled={disabled} + /> + ); +}; + +const SimpleStringArray: React.FC<{ + schema: JSONSchema; + name: string; + label: string; + description?: string; + required?: boolean; + disabled?: boolean; +}> = ({ schema, name, label, description, required, disabled }) => { + const { control } = useFormContext(); + const { + field: { value, onChange, onBlur, ref }, + } = useController({ + name, + control, + defaultValue: schema.default ?? [], + }); + + return ( + <TagsInput + label={label} + description={description} + required={required} + disabled={disabled} + ref={ref} + onBlur={onBlur} + value={Array.isArray(value) ? value.map(String) : []} + onChange={(vals) => onChange(vals)} + acceptValueOnBlur Review Comment: `SimpleStringArray` always stringifies values (`value.map(String)`) and returns the `TagsInput` values (strings) directly. Since `ArrayField` falls back to `SimpleStringArray` for non-object arrays, arrays of `number`/`integer` will be coerced to `string[]`, breaking type fidelity for plugin configs. Add explicit handling for numeric arrays (parse to numbers, validate NaN) or render a numeric array editor instead of `TagsInput` for those schemas. ########## src/components/schema-form/SchemaForm.tsx: ########## @@ -0,0 +1,176 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Alert, Box, Button, Group, Stack } from '@mantine/core'; +import { useCallback, useState } from 'react'; +import { FormProvider, useForm } from 'react-hook-form'; + +import { SchemaField } from './fields/SchemaField'; +import type { JSONSchema, SchemaFormProps } from './types'; +import { resolveDefaults } from './utils'; +import { + validateAgainstSchema, + type ValidationError, +} from './validation'; + +export const SchemaForm: React.FC<SchemaFormProps> = ({ + schema, + value, + onChange, + onSubmit, + encryptFields, + disabled, + rootPath = '', +}) => { + const defaults = resolveDefaults(schema); + const initialValues = { ...defaults, ...value }; + const [validationErrors, setValidationErrors] = useState<ValidationError[]>( + [] + ); + + const methods = useForm({ + defaultValues: initialValues, + mode: 'onBlur', + }); + + const handleChange = useCallback( + (data: Record<string, unknown>) => { + onChange?.(data); + }, + [onChange] + ); + + const handleSubmit = useCallback( + (data: Record<string, unknown>) => { + // Run AJV validation against the full schema + const errors = validateAgainstSchema(schema, data); + setValidationErrors(errors); + + if (errors.length > 0) { + // Map errors back to form fields + for (const err of errors) { + if (err.path) { + methods.setError(err.path, { + type: 'validate', + message: err.message, + }); + } + } + return; + } + + onSubmit?.(data); + }, + [schema, onSubmit, methods] + ); + + // Watch for changes and propagate + if (onChange) { + methods.watch((data) => { + handleChange(data as Record<string, unknown>); + }); + } Review Comment: `methods.watch(...)` is being called during render whenever `onChange` is provided. That creates a new subscription on every render (and never unsubscribes), which can lead to duplicate callbacks and memory leaks. Move the watch subscription into a `useEffect`, store the unsubscribe function returned by `watch`, and clean it up on unmount / when `onChange` changes. ########## src/components/schema-form/fields/StringField.tsx: ########## @@ -0,0 +1,99 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { PasswordInput, TextInput, Textarea } from '@mantine/core'; +import { useController, useFormContext } from 'react-hook-form'; + +import type { FieldProps } from '../types'; +import { getFieldDescription, getFieldLabel, isEncryptField } from '../utils'; + +export const StringField: React.FC<FieldProps> = ({ + schema, + name, + required, + encryptFields, + disabled, +}) => { + const { control } = useFormContext(); + const { + field: { value, onChange, onBlur, ref }, + fieldState: { error }, + } = useController({ + name, + control, + defaultValue: schema.default ?? '', + rules: { + required: required ? 'This field is required' : false, + minLength: schema.minLength + ? { value: schema.minLength, message: `Minimum ${schema.minLength} characters` } + : undefined, + maxLength: schema.maxLength + ? { value: schema.maxLength, message: `Maximum ${schema.maxLength} characters` } + : undefined, + pattern: schema.pattern + ? { value: new RegExp(schema.pattern), message: `Must match pattern: ${schema.pattern}` } + : undefined, Review Comment: `new RegExp(schema.pattern)` will throw at runtime if the schema provides an invalid regex, which would break rendering of the entire form. Wrap RegExp construction in a try/catch (or pre-validate) and fall back to skipping the pattern rule / showing a safe error message when the pattern is invalid. ########## src/components/schema-form/SchemaForm.tsx: ########## @@ -0,0 +1,176 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Alert, Box, Button, Group, Stack } from '@mantine/core'; +import { useCallback, useState } from 'react'; +import { FormProvider, useForm } from 'react-hook-form'; + +import { SchemaField } from './fields/SchemaField'; +import type { JSONSchema, SchemaFormProps } from './types'; +import { resolveDefaults } from './utils'; +import { + validateAgainstSchema, + type ValidationError, +} from './validation'; + +export const SchemaForm: React.FC<SchemaFormProps> = ({ + schema, + value, + onChange, + onSubmit, + encryptFields, + disabled, + rootPath = '', +}) => { + const defaults = resolveDefaults(schema); + const initialValues = { ...defaults, ...value }; + const [validationErrors, setValidationErrors] = useState<ValidationError[]>( + [] + ); + + const methods = useForm({ + defaultValues: initialValues, + mode: 'onBlur', + }); + + const handleChange = useCallback( + (data: Record<string, unknown>) => { + onChange?.(data); + }, + [onChange] + ); + + const handleSubmit = useCallback( + (data: Record<string, unknown>) => { + // Run AJV validation against the full schema + const errors = validateAgainstSchema(schema, data); + setValidationErrors(errors); + + if (errors.length > 0) { + // Map errors back to form fields + for (const err of errors) { + if (err.path) { + methods.setError(err.path, { + type: 'validate', + message: err.message, + }); + } + } + return; + } + + onSubmit?.(data); + }, + [schema, onSubmit, methods] + ); + + // Watch for changes and propagate + if (onChange) { + methods.watch((data) => { + handleChange(data as Record<string, unknown>); + }); + } + + if (!schema || !schema.properties) { + return ( + <Alert color="yellow" title="No schema"> + No renderable schema properties found. + </Alert> + ); + } + + const requiredSet = new Set(schema.required ?? []); + + return ( + <FormProvider {...methods}> + <form onSubmit={methods.handleSubmit(handleSubmit)}> + <Stack gap="md"> + {Object.entries(schema.properties).map(([key, propSchema]) => ( + <Box key={key}> + <SchemaField + schema={propSchema} + name={rootPath ? `${rootPath}.${key}` : key} + required={requiredSet.has(key)} + encryptFields={encryptFields ?? schema.encrypt_fields} + disabled={disabled} + /> + </Box> + ))} + + <ConditionalTopLevel + schema={schema} + encryptFields={encryptFields ?? schema.encrypt_fields} + disabled={disabled} + rootPath={rootPath} + /> + + {validationErrors.length > 0 && ( + <Alert color="red" title="Validation Errors"> + <ul style={{ margin: 0, paddingLeft: 16 }}> + {validationErrors.map((err, i) => ( + <li key={i}> + {err.path ? <strong>{err.path}:</strong> : null}{' '} + {err.message} + </li> + ))} + </ul> + </Alert> + )} + + {onSubmit && ( + <Group justify="flex-end"> + <Button type="submit" disabled={disabled}> + Submit + </Button> + </Group> + )} + </Stack> + </form> + </FormProvider> + ); +}; + +const ConditionalTopLevel: React.FC<{ + schema: JSONSchema; + encryptFields?: string[]; + disabled?: boolean; + rootPath: string; +}> = ({ schema, encryptFields, disabled, rootPath }) => { + // Handle top-level oneOf (required-field alternatives) + // These are typically just mode selectors and don't add extra properties + // The actual rendering is handled by individual field components + Review Comment: Top-level `oneOf` on an object schema (the common APISIX “required alternatives” pattern) is not actually rendered: this component comments about handling it but returns nothing. Since `SchemaField` treats schemas with `properties` as `object`, the `oneOf` selector UI never appears for top-level object schemas. Implement top-level `oneOf` handling here (or in `ObjectField`) so schemas like limit-conn/limit-count can select a mode and render the matching branch. ########## src/components/schema-form/fields/ObjectField.tsx: ########## @@ -0,0 +1,251 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Box, Fieldset, Text } from '@mantine/core'; +import { useFormContext, useWatch } from 'react-hook-form'; + +import type { FieldProps, JSONSchema } from '../types'; +import { + evaluateIfCondition, + getFieldDescription, + getFieldLabel, + mergeSchemas, +} from '../utils'; +import { SchemaField } from './SchemaField'; + +export const ObjectField: React.FC<FieldProps> = ({ + schema, + name, + required: _required, + encryptFields, + disabled, +}) => { + const label = getFieldLabel(name, schema); + const description = getFieldDescription(schema); + + if (!schema.properties && !schema.patternProperties) { Review Comment: `ObjectField` checks for `schema.patternProperties` (so the schema is considered renderable), but it never actually renders any UI for `patternProperties`. This means schemas that rely on `patternProperties` (especially when combined with regular `properties`) will silently drop those fields. Add a dedicated rendering section for `patternProperties` here, or ensure `SchemaField` routes to `PatternPropertiesField` in the mixed case too. ```suggestion if (!schema.properties) { ``` ########## docs/en/schema-form-developer-guide.md: ########## @@ -0,0 +1,293 @@ +--- +title: Schema Form Developer Guide +--- + +<!-- +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +--> + +# SchemaForm Developer Guide + +The `SchemaForm` component renders APISIX plugin configuration forms directly from JSON Schema definitions. This eliminates manual UI maintenance per plugin and ensures form UIs stay in sync with plugin schemas automatically. + +## Quick Start + +```tsx +import { SchemaForm } from '@/components/schema-form'; + +// Schema fetched from Admin API: GET /apisix/admin/schema/plugins/{name} +const pluginSchema = { + type: 'object', + properties: { + key: { type: 'string', minLength: 1 }, + timeout: { type: 'integer', minimum: 1, default: 3 }, + ssl_verify: { type: 'boolean', default: true }, + }, + required: ['key'], +}; + +function PluginConfigForm() { + return ( + <SchemaForm + schema={pluginSchema} + value={{ key: 'my-api-key' }} + onSubmit={(data) => console.log('Valid config:', data)} + onChange={(data) => console.log('Changed:', data)} + /> + ); +} +``` + +## Architecture + +``` +SchemaForm (entry point, provides react-hook-form context) + └── SchemaField (dispatcher — routes to correct field component) + ├── StringField → Mantine TextInput / PasswordInput / Textarea + ├── NumberField → Mantine NumberInput + ├── BooleanField → Mantine Switch + ├── EnumField → Mantine Select + ├── ObjectField → Mantine Fieldset (recursive) + │ ├── ConditionalProperties (if/then/else) + │ └── DependencyProperties (dependencies) + ├── ArrayField → TagsInput (primitives) or repeatable Fieldset (objects) + ├── OneOfField → SegmentedControl + variant fields + ├── AnyOfField → SegmentedControl + variant fields + └── PatternPropertiesField → dynamic key-value editor +``` + +### Data Flow + +1. **Schema** is passed as a prop (fetched from APISIX Admin API at runtime). +2. `SchemaForm` initializes `react-hook-form` with defaults resolved from the schema. +3. `SchemaField` dispatches to the correct field component based on schema type/features. +4. Field components use `useController` to bind to react-hook-form state. +5. On submit, **AJV** validates the full form data against the original JSON Schema. +6. Validation errors are mapped back to individual form fields. + +## Supported JSON Schema Features + +### Basic Types + +| Schema Type | Widget | Notes | +|---|---|---| +| `string` | `TextInput` | Auto-uses `Textarea` for `maxLength > 256` | +| `string` + `encrypt_fields` | `PasswordInput` | Masked input with reveal toggle | +| `string` + `enum` | `Select` | Dropdown with enum values | +| `number` / `integer` | `NumberInput` | Respects `min`/`max`/`step` | +| `boolean` | `Switch` | Left-positioned label | +| `object` | `Fieldset` | Recursive rendering of properties | +| `array` of strings | `TagsInput` | Tag-style input | +| `array` of objects | Repeatable `Fieldset` | Add/remove items | + +### Constraints + +All standard JSON Schema constraints are supported: + +- `required` — field marked as required, validated on submit +- `default` — pre-populated in the form +- `minimum` / `maximum` / `exclusiveMinimum` / `exclusiveMaximum` +- `minLength` / `maxLength` +- `pattern` — shown as placeholder hint +- `minItems` / `maxItems` — controls add/remove in arrays +- `enum` — rendered as Select dropdown + +### Composition Keywords + +#### `oneOf` + +Used in two patterns in APISIX: + +1. **Top-level required alternatives** (e.g., limit-conn: `conn+burst+key` vs `rules`): + Renders a `SegmentedControl` to select the configuration mode. + +2. **Field-level type unions** (e.g., `conn: oneOf [{integer}, {string}]`): + Renders the primary type (first option). The schema allows either an integer value or a string variable reference. + +#### `anyOf` + +Similar to `oneOf` but allows matching multiple schemas. Rendered with a `SegmentedControl` selector. + +#### `if / then / else` + +Conditional sub-schemas that show/hide fields based on other field values. Common APISIX pattern: + +```json +{ + "if": { "properties": { "policy": { "enum": ["redis"] } } }, + "then": { "properties": { "redis_host": { "type": "string" } }, "required": ["redis_host"] }, + "else": {} +} +``` + +The form watches the `policy` field and dynamically shows `redis_host` when `policy === "redis"`. + +Supports **nested** if/then/else chains (e.g., limit-conn: redis → redis-cluster fallback). + +#### `dependencies` + +Two forms: + +1. **Simple dependencies** (`dependencies: { "a": ["b"] }`): If `a` is present, `b` is required. Handled by AJV validation. + +2. **Schema dependencies with `oneOf`** (e.g., jwt-auth consumer schema): + ```json + { + "dependencies": { + "algorithm": { + "oneOf": [ + { "properties": { "algorithm": { "enum": ["HS256", "HS384"] } } }, + { "properties": { "public_key": { "type": "string" } }, "required": ["public_key"] } + ] + } + } + } + ``` + Watches the `algorithm` field and shows `public_key` when a non-HMAC algorithm is selected. + +### APISIX-Specific Features + +- **`encrypt_fields`**: Fields listed here are rendered as `PasswordInput` (masked). +- **`_meta`**: The plugin injected meta schema is stripped during validation. + +## Validation + +Validation uses [AJV](https://ajv.js.org/) (JSON Schema draft-07) configured with: + +- `allErrors: true` — collect all errors, not just the first +- `strict: false` — allow APISIX-specific keywords +- `ajv-formats` — support for `format: "ipv4"`, `format: "ipv6"`, etc. + +APISIX-specific fields (`encrypt_fields`, `_meta`, `$comment`) are stripped before compilation. + +### Error Mapping + +AJV errors are mapped to react-hook-form field paths: + +``` +/properties/redis_host → redis_host +/items/0/host → items.0.host Review Comment: The AJV error-path mapping example appears incorrect: AJV’s `instancePath` for a failing property is typically `/redis_host` (not `/properties/redis_host`). Since `ajvErrorToFieldPath` is based on `instancePath`, the docs should reflect that to avoid confusing future maintainers. ```suggestion /redis_host → redis_host /items/0/host → items.0.host ``` ########## src/components/schema-form/SchemaForm.tsx: ########## @@ -0,0 +1,176 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Alert, Box, Button, Group, Stack } from '@mantine/core'; +import { useCallback, useState } from 'react'; +import { FormProvider, useForm } from 'react-hook-form'; + +import { SchemaField } from './fields/SchemaField'; +import type { JSONSchema, SchemaFormProps } from './types'; +import { resolveDefaults } from './utils'; +import { + validateAgainstSchema, + type ValidationError, +} from './validation'; + +export const SchemaForm: React.FC<SchemaFormProps> = ({ + schema, + value, + onChange, + onSubmit, + encryptFields, + disabled, + rootPath = '', +}) => { + const defaults = resolveDefaults(schema); + const initialValues = { ...defaults, ...value }; + const [validationErrors, setValidationErrors] = useState<ValidationError[]>( + [] + ); + + const methods = useForm({ + defaultValues: initialValues, + mode: 'onBlur', + }); + + const handleChange = useCallback( + (data: Record<string, unknown>) => { + onChange?.(data); + }, + [onChange] + ); + + const handleSubmit = useCallback( + (data: Record<string, unknown>) => { + // Run AJV validation against the full schema + const errors = validateAgainstSchema(schema, data); + setValidationErrors(errors); + + if (errors.length > 0) { + // Map errors back to form fields + for (const err of errors) { + if (err.path) { + methods.setError(err.path, { Review Comment: AJV validation errors are set via `methods.setError(...)`, but when a later submission has no AJV errors the previously-set react-hook-form errors are not cleared. This can leave stale error messages in the UI even after the data becomes valid. Call `methods.clearErrors()` (or selectively clear the affected paths) when `errors.length === 0`. ########## src/components/schema-form/SchemaForm.tsx: ########## @@ -0,0 +1,176 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Alert, Box, Button, Group, Stack } from '@mantine/core'; +import { useCallback, useState } from 'react'; +import { FormProvider, useForm } from 'react-hook-form'; + +import { SchemaField } from './fields/SchemaField'; +import type { JSONSchema, SchemaFormProps } from './types'; +import { resolveDefaults } from './utils'; +import { + validateAgainstSchema, + type ValidationError, +} from './validation'; + +export const SchemaForm: React.FC<SchemaFormProps> = ({ + schema, + value, + onChange, + onSubmit, + encryptFields, + disabled, + rootPath = '', +}) => { + const defaults = resolveDefaults(schema); + const initialValues = { ...defaults, ...value }; + const [validationErrors, setValidationErrors] = useState<ValidationError[]>( + [] + ); + + const methods = useForm({ + defaultValues: initialValues, + mode: 'onBlur', + }); + + const handleChange = useCallback( + (data: Record<string, unknown>) => { + onChange?.(data); + }, + [onChange] + ); + + const handleSubmit = useCallback( + (data: Record<string, unknown>) => { + // Run AJV validation against the full schema + const errors = validateAgainstSchema(schema, data); + setValidationErrors(errors); + + if (errors.length > 0) { + // Map errors back to form fields + for (const err of errors) { + if (err.path) { + methods.setError(err.path, { + type: 'validate', + message: err.message, + }); + } + } + return; + } + + onSubmit?.(data); + }, + [schema, onSubmit, methods] + ); + + // Watch for changes and propagate + if (onChange) { + methods.watch((data) => { + handleChange(data as Record<string, unknown>); + }); + } + + if (!schema || !schema.properties) { + return ( + <Alert color="yellow" title="No schema"> + No renderable schema properties found. + </Alert> + ); + } + + const requiredSet = new Set(schema.required ?? []); + + return ( + <FormProvider {...methods}> + <form onSubmit={methods.handleSubmit(handleSubmit)}> + <Stack gap="md"> + {Object.entries(schema.properties).map(([key, propSchema]) => ( + <Box key={key}> + <SchemaField + schema={propSchema} + name={rootPath ? `${rootPath}.${key}` : key} + required={requiredSet.has(key)} + encryptFields={encryptFields ?? schema.encrypt_fields} + disabled={disabled} + /> + </Box> + ))} + + <ConditionalTopLevel + schema={schema} + encryptFields={encryptFields ?? schema.encrypt_fields} + disabled={disabled} + rootPath={rootPath} + /> + + {validationErrors.length > 0 && ( + <Alert color="red" title="Validation Errors"> + <ul style={{ margin: 0, paddingLeft: 16 }}> + {validationErrors.map((err, i) => ( + <li key={i}> + {err.path ? <strong>{err.path}:</strong> : null}{' '} + {err.message} + </li> + ))} + </ul> + </Alert> + )} + + {onSubmit && ( + <Group justify="flex-end"> + <Button type="submit" disabled={disabled}> + Submit + </Button> + </Group> + )} + </Stack> + </form> + </FormProvider> + ); +}; + +const ConditionalTopLevel: React.FC<{ + schema: JSONSchema; + encryptFields?: string[]; + disabled?: boolean; + rootPath: string; +}> = ({ schema, encryptFields, disabled, rootPath }) => { + // Handle top-level oneOf (required-field alternatives) + // These are typically just mode selectors and don't add extra properties + // The actual rendering is handled by individual field components + + // Handle top-level if/then/else + if (schema.if && (schema.then || schema.else)) { + return ( + <SchemaField + schema={{ + type: 'object', + properties: schema.properties, Review Comment: `ConditionalTopLevel` passes `properties: schema.properties` into `SchemaField`, but `SchemaForm` already renders all `schema.properties` above. This will cause the top-level fields to be rendered twice for schemas that use top-level `if/then/else`, and can register duplicate form inputs with the same names. Consider passing an empty `properties` object (so only conditional branch fields render), or refactor `SchemaForm` to delegate top-level rendering entirely to the object/conditional renderer when conditionals are present. ```suggestion // Do not re-pass top-level properties; SchemaForm already renders them. // Use an empty properties object so only conditional branch fields render. properties: {}, ``` ########## src/components/schema-form/SchemaForm.tsx: ########## @@ -0,0 +1,176 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Alert, Box, Button, Group, Stack } from '@mantine/core'; +import { useCallback, useState } from 'react'; +import { FormProvider, useForm } from 'react-hook-form'; + +import { SchemaField } from './fields/SchemaField'; +import type { JSONSchema, SchemaFormProps } from './types'; +import { resolveDefaults } from './utils'; +import { + validateAgainstSchema, + type ValidationError, +} from './validation'; + +export const SchemaForm: React.FC<SchemaFormProps> = ({ + schema, + value, + onChange, + onSubmit, + encryptFields, + disabled, + rootPath = '', +}) => { + const defaults = resolveDefaults(schema); + const initialValues = { ...defaults, ...value }; + const [validationErrors, setValidationErrors] = useState<ValidationError[]>( + [] + ); + + const methods = useForm({ + defaultValues: initialValues, + mode: 'onBlur', + }); Review Comment: `useForm({ defaultValues: initialValues })` only applies `defaultValues` on the first render; later `value` prop changes won’t update the form state. If `SchemaForm` is meant to be controlled/updated when editing an existing plugin config, add an effect that calls `methods.reset(initialValues)` when `schema` or `value` changes (being careful to avoid clobbering user edits if that’s not desired). -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected]
