Skip to content

feat(plugin): JSON Schema-driven form renderer (SchemaForm) for plugin configuration#3312

Open
AryanVBW wants to merge 7 commits intoapache:masterfrom
AryanVBW:feat/schema-form-ui
Open

feat(plugin): JSON Schema-driven form renderer (SchemaForm) for plugin configuration#3312
AryanVBW wants to merge 7 commits intoapache:masterfrom
AryanVBW:feat/schema-form-ui

Conversation

@AryanVBW
Copy link

@AryanVBW AryanVBW commented Mar 1, 2026

Summary

This PR implements a reusable SchemaForm component that renders APISIX plugin configuration forms directly from JSON Schema definitions, eliminating manual UI maintenance per plugin.

Resolves #3311

What this PR does

Core Infrastructure

  • types.ts: TypeScript types for JSON Schema subset used by APISIX plugins (oneOf, anyOf, if/then/else, dependencies, patternProperties, encrypt_fields)
  • utils.ts: 16 helper functions for schema introspection, defaults resolution, conditional evaluation, and human-readable label generation
  • validation.ts: AJV draft-07 validation pipeline with ajv-formats, strips APISIX-specific fields, maps errors to react-hook-form field paths

Field Components

  • StringField: TextInput / PasswordInput (for encrypt_fields) / Textarea (for long strings)
  • NumberField: NumberInput with min/max/step, integer vs decimal
  • BooleanField: Switch toggle
  • EnumField: Select dropdown with type-preserving values
  • ObjectField: Recursive Fieldset with if/then/else conditional rendering and dependency-based field visibility
  • ArrayField: TagsInput for string arrays, repeatable Fieldset for object arrays
  • OneOfField: SegmentedControl variant selector (limit-conn, limit-count patterns)
  • AnyOfField: SegmentedControl for anyOf schemas (ai-proxy-multi)
  • PatternPropertiesField: Dynamic key-value editor (ai-proxy auth, labels)
  • SchemaField: Central dispatcher routing schemas to correct widget

Entry Point

  • SchemaForm.tsx: Wraps react-hook-form FormProvider, resolves defaults, runs AJV validation on submit, maps errors to fields
  • index.ts: Barrel exports for all public APIs

Tests (60 passing)

  • utils.test.ts (41 tests): All utility functions
  • validation.test.ts (9 tests): AJV validation pipeline
  • SchemaForm.test.tsx (10 tests): Component rendering with real-world schemas

Documentation

  • schema-form-developer-guide.md: Architecture, supported features, how to extend
  • github-issue-schema-form.md: Issue template reference

Dependencies Added

  • ajv@8.18.0, ajv-formats@3.0.1: JSON Schema validation
  • vitest, @testing-library/react, @testing-library/jest-dom, jsdom: Test infrastructure

APISIX Schema Patterns Supported

Pattern Example Plugins Component
oneOf (required alternatives) limit-conn, limit-count OneOfField
oneOf (type union) limit-conn (conn) SchemaField auto-resolves
dependencies + oneOf jwt-auth consumer ObjectField.DependencyProperties
dependencies + not response-rewrite AJV validation
if/then/else limit-conn, openid-connect, ai-proxy ObjectField.ConditionalProperties
anyOf schema_def id, ai-proxy-multi AnyOfField
enum Ubiquitous EnumField
patternProperties ai-proxy auth, labels PatternPropertiesField
encrypt_fields openid-connect, jwt-auth StringField → PasswordInput

AryanVBW added 7 commits March 2, 2026 02:06
…peline

Add core infrastructure for rendering plugin config forms from JSON Schema.

- types.ts: TypeScript types for JSON Schema subset (oneOf, anyOf,
  if/then/else, dependencies, patternProperties, encrypt_fields)
- utils.ts: 16 helper functions for schema introspection and runtime
  conditional evaluation
- validation.ts: AJV draft-07 validation with ajv-formats, strips
  APISIX-specific fields, maps errors to react-hook-form field paths
Add field components that render JSON Schema properties as Mantine widgets:

- StringField: TextInput, PasswordInput (encrypt_fields), Textarea (long)
- NumberField: NumberInput with min/max/step, integer vs decimal support
- BooleanField: Switch toggle with left-positioned label
- EnumField: Select dropdown with type-preserving value conversion
- ObjectField: Recursive Fieldset with if/then/else conditional rendering,
  dependency-based field visibility, and nested if/then/else chains
- ArrayField: TagsInput for string arrays, repeatable Fieldset for objects
  with add/remove controls and maxItems enforcement
- SchemaField: Central dispatcher that routes schemas to correct widget
  based on type, enum, oneOf, anyOf, encrypt_fields, patternProperties
Support complex JSON Schema composition patterns used by APISIX plugins:

- OneOfField: SegmentedControl variant selector for oneOf schemas.
  Handles top-level required alternatives (limit-conn: conn+burst vs rules)
  and field-level type unions (integer | string). Auto-detects matching
  variant from current form data.
- AnyOfField: Similar to OneOfField for anyOf schemas (ai-proxy-multi
  fallback_strategy, schema_def id_schema).
- PatternPropertiesField: Dynamic key-value editor for schemas with
  patternProperties (ai-proxy auth_schema, labels_def). Supports
  add/remove entries with pattern-based key validation.
- SchemaForm.tsx: Main entry point that wraps react-hook-form FormProvider,
  resolves defaults from schema, runs AJV validation on submit, maps errors
  back to form fields, and renders top-level properties + conditionals.
  Props: schema, value, onChange, onSubmit, encryptFields, disabled, rootPath.
- index.ts: Barrel exports for all public APIs (SchemaForm, SchemaField,
  types, validation functions, utility functions).
Add comprehensive test suite with Vitest + Testing Library:

- vitest.config.ts: Vitest config with jsdom environment, react-swc plugin
- src/test-setup.ts: Mantine jsdom mocks (matchMedia, ResizeObserver)

Test files:
- utils.test.ts (41 tests): getSchemaType, isEnumField, isEncryptField,
  isRequired, getRequiredSet, resolveDefaults, fieldPath, getFieldLabel,
  hasConditionals, hasDependencies, evaluateIfCondition, mergeSchemas,
  getOneOfMatchIndex, getOneOfLabel
- validation.test.ts (9 tests): valid data, missing required, type/string/
  enum constraints, limit-count-like schema, APISIX-specific field stripping,
  error-to-field mapping
- SchemaForm.test.tsx (10 tests): renders string/number/boolean/enum/object
  fields, empty schema alert, key-auth-like schema, encrypt_fields password
  input, submit button conditional rendering
… scripts

Dependencies added:
- ajv@8.18.0, ajv-formats@3.0.1: JSON Schema draft-07 validation
- vitest@4.0.18: Unit test runner
- @testing-library/react@16.3.2, @testing-library/jest-dom@6.9.1,
  @testing-library/user-event@14.6.1: React component testing
- jsdom@28.1.0: DOM environment for tests

Scripts added:
- "test": "vitest run" (single run)
- "test:watch": "vitest" (watch mode)
- schema-form-developer-guide.md: Complete developer guide covering
  architecture, data flow, supported JSON Schema features (basic types,
  constraints, oneOf, anyOf, if/then/else, dependencies), APISIX-specific
  features (encrypt_fields), validation pipeline, how to extend with
  custom widgets, testing instructions, and file structure reference.
- github-issue-schema-form.md: Ready-to-file GitHub issue template for
  apache/apisix-dashboard with motivation, deliverables (P0/P1/P2),
  technical notes, and APISIX plugin schema patterns table.
Copilot AI review requested due to automatic review settings March 1, 2026 20:39
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces a JSON Schema-driven SchemaForm system to render APISIX plugin configuration UIs from schema definitions, along with a Vitest-based test setup and developer documentation to support ongoing extension.

Changes:

  • Added a new src/components/schema-form/ module (types, utilities, AJV validation, and Mantine-based field components) plus barrel exports.
  • Added Vitest + Testing Library configuration and setup, and introduced unit/integration tests for the new SchemaForm stack.
  • Added developer documentation and an issue-template reference document for SchemaForm feature work.

Reviewed changes

Copilot reviewed 23 out of 24 changed files in this pull request and generated 11 comments.

Show a summary per file
File Description
vitest.config.ts Adds Vitest configuration (jsdom, setup file, include patterns).
src/test-setup.ts Adds jsdom test shims for Mantine dependencies (ResizeObserver, matchMedia).
src/components/schema-form/validation.ts Implements AJV draft-07 validation and error formatting/path mapping.
src/components/schema-form/utils.ts Adds schema introspection, defaults, conditional evaluation, and oneOf helpers.
src/components/schema-form/types.ts Defines the JSON Schema subset types and SchemaForm/field prop types.
src/components/schema-form/index.ts Barrel exports for SchemaForm, fields, types, utils, validation.
src/components/schema-form/fields/StringField.tsx String widget (TextInput/PasswordInput/Textarea) with RHF rules.
src/components/schema-form/fields/SchemaField.tsx Central dispatcher routing schemas to specific field components.
src/components/schema-form/fields/PatternPropertiesField.tsx Implements a dynamic key/value editor UI.
src/components/schema-form/fields/OneOfField.tsx Adds oneOf selector UI + branch rendering.
src/components/schema-form/fields/ObjectField.tsx Renders object properties plus conditional/dependency-driven fields.
src/components/schema-form/fields/NumberField.tsx Number/integer widget with min/max/step handling.
src/components/schema-form/fields/EnumField.tsx Enum widget using Mantine Select with type-preserving mapping.
src/components/schema-form/fields/BooleanField.tsx Boolean widget using Mantine Switch.
src/components/schema-form/fields/ArrayField.tsx Array widget (TagsInput for primitives; repeatable fieldsets for objects).
src/components/schema-form/fields/AnyOfField.tsx Adds anyOf selector UI + branch rendering.
src/components/schema-form/tests/validation.test.ts Unit tests for AJV pipeline and schema stripping.
src/components/schema-form/tests/utils.test.ts Unit tests for schema utilities.
src/components/schema-form/tests/SchemaForm.test.tsx Integration-ish rendering tests for SchemaForm with MantineProvider.
src/components/schema-form/SchemaForm.tsx Top-level form wrapper (RHF provider, defaults, AJV validation on submit).
package.json Adds AJV, Vitest, Testing Library, jsdom dependencies and test scripts.
pnpm-lock.yaml Lockfile updates for the new dependencies.
docs/en/schema-form-developer-guide.md Adds an architecture and extension guide for SchemaForm.
docs/en/github-issue-schema-form.md Adds a document describing the SchemaForm feature-request template.
Files not reviewed (1)
  • pnpm-lock.yaml: Language not supported

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +151 to +155
if (cleaned.properties) {
const props: Record<string, JSONSchema> = {};
for (const [key, value] of Object.entries(cleaned.properties)) {
props[key] = stripNonStandardFields(value);
}
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +47 to +49
pattern: schema.pattern
? { value: new RegExp(schema.pattern), message: `Must match pattern: ${schema.pattern}` }
: undefined,
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +114 to +119
if (propSchema.type) {
const expectedType = Array.isArray(propSchema.type)
? propSchema.type[0]
: propSchema.type;
if (typeof value !== expectedType) return 'else';
}
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
const label = getFieldLabel(name, schema);
const description = getFieldDescription(schema);

if (!schema.properties && !schema.patternProperties) {
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
if (!schema.properties && !schema.patternProperties) {
if (!schema.properties) {

Copilot uses AI. Check for mistakes.
Comment on lines +117 to +119
value={Array.isArray(value) ? value.map(String) : []}
onChange={(vals) => onChange(vals)}
acceptValueOnBlur
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +153 to +156
// 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

Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +82 to +86
if (onChange) {
methods.watch((data) => {
handleChange(data as Record<string, unknown>);
});
}
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
<SchemaField
schema={{
type: 'object',
properties: schema.properties,
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
properties: schema.properties,
// Do not re-pass top-level properties; SchemaForm already renders them.
// Use an empty properties object so only conditional branch fields render.
properties: {},

Copilot uses AI. Check for mistakes.
Comment on lines +63 to +67
if (errors.length > 0) {
// Map errors back to form fields
for (const err of errors) {
if (err.path) {
methods.setError(err.path, {
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +45 to +48
const methods = useForm({
defaultValues: initialValues,
mode: 'onBlur',
});
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(plugin): JSON Schema-driven form renderer (SchemaForm) for plugin configuration

2 participants