From 185873c9f7ae5ec8d929d58882a0773b69b0b331 Mon Sep 17 00:00:00 2001 From: jinbagi <4094424+jinbagi@users.noreply.github.com> Date: Sat, 4 Jul 2026 02:14:04 +0900 Subject: [PATCH] feat: centralize schema ux templates --- docs/design/schema-ux-todo.md | 29 +++ e2e/tests/schema-template.spec.ts | 157 ++++++++++++ .../FormItemPlugins/PluginEditorDrawer.tsx | 220 +--------------- src/components/schema-form/SchemaForm.tsx | 25 +- src/components/schema-form/schemaTemplate.ts | 236 ++++++++++++++++++ 5 files changed, 431 insertions(+), 236 deletions(-) create mode 100644 docs/design/schema-ux-todo.md create mode 100644 e2e/tests/schema-template.spec.ts create mode 100644 src/components/schema-form/schemaTemplate.ts diff --git a/docs/design/schema-ux-todo.md b/docs/design/schema-ux-todo.md new file mode 100644 index 00000000..451e523d --- /dev/null +++ b/docs/design/schema-ux-todo.md @@ -0,0 +1,29 @@ +# Schema UX TODO + +This checklist tracks usability problems where the dashboard already knows the +Admin API schema, but the create/edit experience does not fully use that +knowledge yet. + +## Completed + +- [x] Prefill plugin Add JSON with required fields, not only schema defaults. +- [x] Keep primitive `oneOf` / `anyOf` required fields from becoming `{}` when a + plugin schema offers scalar alternatives. +- [x] Centralize JSON Schema template generation so plugin JSON and schema form + defaults share the same placeholder rules. +- [x] Cover union required fields, conditional required fields, and required + array items with regression tests. + +## Next + +- [ ] Audit complex plugin schemas in the live APISIX catalog: + `openid-connect`, `ai-proxy`, `ai-proxy-multi`, `saml-auth`, `proxy-cache`, + and Redis-backed `limit-count` variants. +- [ ] Verify Fields to JSON to Fields round trips for plugin schemas with + nested `oneOf`, `anyOf`, `if` / `then`, and `minItems`. +- [ ] Expand save-failure recovery checks across create Raw JSON, resource Raw + JSON, and plugin JSON drawers. +- [ ] Add clone-flow payload checks so cloned Routes, Services, and Upstreams + never submit read-only fields such as `id`, `create_time`, or `update_time`. +- [ ] Compare conditional required markers with generated JSON templates for + Routes, SSLs, Upstreams, Secrets, and plugin configs. diff --git a/e2e/tests/schema-template.spec.ts b/e2e/tests/schema-template.spec.ts new file mode 100644 index 00000000..dc3d6e13 --- /dev/null +++ b/e2e/tests/schema-template.spec.ts @@ -0,0 +1,157 @@ +/** + * 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 { expect, test } from '@playwright/test'; + +import { buildJsonSchemaTemplate } from '@/components/schema-form/schemaTemplate'; +import { + type JSONSchema, + validateSchemaValue, +} from '@/components/schema-form/schemaValidation'; + +const positiveIntegerOrString: JSONSchema = { + oneOf: [ + { type: 'integer', exclusiveMinimum: 0 }, + { type: 'string' }, + ], +}; + +test('builds a minimal template for union-based plugin required fields', () => { + const limitCountSchema: JSONSchema = { + type: 'object', + oneOf: [ + { required: ['count', 'time_window'] }, + { required: ['rules'] }, + ], + properties: { + count: positiveIntegerOrString, + time_window: positiveIntegerOrString, + rules: { + type: 'array', + minItems: 1, + items: { + type: 'object', + required: ['count', 'time_window', 'key'], + properties: { + count: positiveIntegerOrString, + time_window: positiveIntegerOrString, + key: { type: 'string', minLength: 1 }, + }, + }, + }, + }, + }; + + const template = buildJsonSchemaTemplate(limitCountSchema); + + expect(template).toEqual({ + count: 1, + time_window: 1, + }); + expect(validateSchemaValue(limitCountSchema, template)).toEqual([]); +}); + +test('fills conditional plugin requirements from the selected branch', () => { + const redisLimitCountSchema: JSONSchema = { + type: 'object', + properties: { + policy: { + type: 'string', + enum: ['local', 'redis-sentinel'], + default: 'local', + }, + }, + if: { + properties: { + policy: { enum: ['redis-sentinel'] }, + }, + }, + then: { + required: ['redis_sentinels', 'redis_master_name'], + properties: { + redis_sentinels: { + type: 'array', + minItems: 1, + items: { + type: 'object', + required: ['host', 'port'], + properties: { + host: { type: 'string', minLength: 2 }, + port: { type: 'integer', minimum: 1 }, + }, + }, + }, + redis_master_name: { type: 'string', minLength: 1 }, + }, + }, + }; + + const template = buildJsonSchemaTemplate(redisLimitCountSchema, { + policy: 'redis-sentinel', + }); + + expect(template).toEqual({ + policy: 'redis-sentinel', + redis_sentinels: [ + { + host: 'value', + port: 1, + }, + ], + redis_master_name: 'value', + }); + expect(validateSchemaValue(redisLimitCountSchema, template)).toEqual([]); +}); + +test('creates required array items for multi-instance plugin schemas', () => { + const aiProxyMultiSchema: JSONSchema = { + type: 'object', + required: ['instances'], + properties: { + instances: { + type: 'array', + minItems: 1, + items: { + type: 'object', + required: ['name', 'provider', 'auth', 'weight'], + properties: { + name: { type: 'string', minLength: 1 }, + provider: { type: 'string', minLength: 1 }, + auth: { + type: 'object', + additionalProperties: false, + }, + weight: { type: 'integer', minimum: 0 }, + }, + }, + }, + }, + }; + + const template = buildJsonSchemaTemplate(aiProxyMultiSchema); + + expect(template).toEqual({ + instances: [ + { + name: 'value', + provider: 'value', + auth: {}, + weight: 0, + }, + ], + }); + expect(validateSchemaValue(aiProxyMultiSchema, template)).toEqual([]); +}); diff --git a/src/components/form-slice/FormItemPlugins/PluginEditorDrawer.tsx b/src/components/form-slice/FormItemPlugins/PluginEditorDrawer.tsx index a74eac57..907326e2 100644 --- a/src/components/form-slice/FormItemPlugins/PluginEditorDrawer.tsx +++ b/src/components/form-slice/FormItemPlugins/PluginEditorDrawer.tsx @@ -23,11 +23,11 @@ import { FormSubmitBtn } from '@/components/form/Btn'; import { FormItemEditor } from '@/components/form/Editor'; import { SchemaForm } from '@/components/schema-form/SchemaForm'; import { - getActiveRequiredFields, - getResolvedSchema, - getSchemaProperties, + applyJsonSchemaDefaults, + buildJsonSchemaTemplate, +} from '@/components/schema-form/schemaTemplate'; +import { type JSONSchema, - schemaType, validateSchemaValue, } from '@/components/schema-form/schemaValidation'; import IconContentCopy from '~icons/material-symbols/content-copy'; @@ -67,219 +67,13 @@ const isRecord = (value: unknown): value is Record => { return !!value && typeof value === 'object' && !Array.isArray(value); }; -const cloneDefault = (value: unknown): unknown => { - if (Array.isArray(value)) return value.map(cloneDefault); - if (isRecord(value)) { - return Object.fromEntries( - Object.entries(value).map(([key, nestedValue]) => [key, cloneDefault(nestedValue)]) - ); - } - return value; -}; - -const isSchemaDefaultCompatible = ( - schema: JSONSchema, - value: unknown -): boolean => { - const types = Array.isArray(schema.type) - ? schema.type - : schema.type - ? [schema.type] - : []; - if (types.length === 0) return true; - return types.some((type) => { - if (type === 'null') return value === null; - if (type === 'array') return Array.isArray(value); - if (type === 'object') { - return !!value && typeof value === 'object' && !Array.isArray(value); - } - if (type === 'integer') return typeof value === 'number' && Number.isInteger(value); - if (type === 'number') return typeof value === 'number'; - return typeof value === type; - }); -}; - -const applySchemaDefaults = ( - schema: object | undefined, - config: Record | undefined, - rootSchema?: JSONSchema -): Record => { - const base = isRecord(config) ? { ...config } : {}; - if (!schema || !isRecord(schema)) return base; - const typedSchema = schema as JSONSchema; - const root = rootSchema ?? typedSchema; - - for (const [key, propSchema] of Object.entries(typedSchema.properties ?? {})) { - if (!isRecord(propSchema)) continue; - const resolvedPropSchema = getResolvedSchema(propSchema, root); - if ( - base[key] === undefined && - 'default' in resolvedPropSchema && - isSchemaDefaultCompatible(resolvedPropSchema, resolvedPropSchema.default) - ) { - base[key] = cloneDefault(resolvedPropSchema.default); - } - } - - const properties = getSchemaProperties(typedSchema, root, base); - for (const [key, rawPropSchema] of Object.entries(properties)) { - const propSchema = getResolvedSchema(rawPropSchema, root); - if (isRecord(base[key]) && isRecord(propSchema.properties)) { - base[key] = applySchemaDefaults( - propSchema, - base[key] as Record, - root - ); - } - if ( - base[key] === undefined && - 'default' in propSchema && - isSchemaDefaultCompatible(propSchema, propSchema.default) - ) { - base[key] = cloneDefault(propSchema.default); - } - } - - return base; -}; - -const collectTemplateRequiredFields = ( - schema: JSONSchema, - value: Record, - rootSchema: JSONSchema, - required: Set -) => { - const resolvedSchema = getResolvedSchema(schema, rootSchema); - - const matchingVariants = [ - ...(resolvedSchema.oneOf ?? []), - ...(resolvedSchema.anyOf ?? []), - ].filter( - (variant) => validateSchemaValue(variant, value, '', rootSchema).length === 0 - ); - const unionVariants = [ - ...(resolvedSchema.oneOf ?? []), - ...(resolvedSchema.anyOf ?? []), - ]; - if (matchingVariants.length === 0 && unionVariants[0]) { - for (const key of getActiveRequiredFields(unionVariants[0], value, rootSchema)) { - required.add(key); - } - collectTemplateRequiredFields(unionVariants[0], value, rootSchema, required); - } - - for (const variant of resolvedSchema.allOf ?? []) { - collectTemplateRequiredFields(variant, value, rootSchema, required); - } -}; - -const placeholderForSchema = ( - schema: JSONSchema, - rootSchema: JSONSchema -): unknown => { - const resolvedSchema = getResolvedSchema(schema, rootSchema); - - if ( - 'default' in resolvedSchema && - isSchemaDefaultCompatible(resolvedSchema, resolvedSchema.default) - ) { - return cloneDefault(resolvedSchema.default); - } - if ('const' in resolvedSchema) return cloneDefault(resolvedSchema.const); - if (resolvedSchema.enum?.length) return cloneDefault(resolvedSchema.enum[0]); - - const firstVariant = resolvedSchema.oneOf?.[0] ?? resolvedSchema.anyOf?.[0]; - if (!schemaType(resolvedSchema) && firstVariant) { - return placeholderForSchema(firstVariant, rootSchema); - } - - const type = schemaType(resolvedSchema); - const hasResolvedProperties = - Object.keys(resolvedSchema.properties ?? {}).length > 0; - if (type === 'object' || hasResolvedProperties) { - return buildSchemaTemplate(resolvedSchema, {}, rootSchema); - } - if (type === 'array') { - if (resolvedSchema.minItems && resolvedSchema.minItems > 0 && resolvedSchema.items) { - return [placeholderForSchema(resolvedSchema.items, rootSchema)]; - } - return []; - } - if (type === 'integer') { - return resolvedSchema.minimum ?? ( - resolvedSchema.exclusiveMinimum !== undefined - ? Math.floor(resolvedSchema.exclusiveMinimum) + 1 - : 0 - ); - } - if (type === 'number') { - return resolvedSchema.minimum ?? ( - resolvedSchema.exclusiveMinimum !== undefined - ? resolvedSchema.exclusiveMinimum + 1 - : 0 - ); - } - if (type === 'boolean') return false; - if (type === 'null') return null; - if (resolvedSchema.format === 'uri' || resolvedSchema.format === 'uri-reference') { - return 'https://example.com'; - } - if (resolvedSchema.format === 'hostname') return 'example.com'; - if (resolvedSchema.format === 'ipv4') return '127.0.0.1'; - if (resolvedSchema.format === 'ipv6') return '::1'; - if (resolvedSchema.format === 'email') return 'user@example.com'; - if (resolvedSchema.format === 'date-time') return '2026-01-01T00:00:00Z'; - if (type === 'string' && resolvedSchema.minLength && resolvedSchema.minLength > 0) { - return 'value'; - } - return ''; -}; - -const buildSchemaTemplate = ( - schema: object | undefined, - config: Record | undefined, - rootSchema?: JSONSchema -): Record => { - const base = applySchemaDefaults(schema, config, rootSchema); - if (!schema || !isRecord(schema)) return base; - - const sourceSchema = schema as JSONSchema; - const root = rootSchema ?? sourceSchema; - const typedSchema = getResolvedSchema(sourceSchema, root); - const properties = getSchemaProperties(typedSchema, root, base); - const required = new Set([ - ...(typedSchema.required ?? []), - ...getActiveRequiredFields(typedSchema, base, root), - ]); - collectTemplateRequiredFields(typedSchema, base, root, required); - const requiredKeys = [...required]; - - for (const key of requiredKeys) { - if (base[key] !== undefined) continue; - const propSchema = properties[key] ?? typedSchema.properties?.[key]; - base[key] = propSchema ? placeholderForSchema(propSchema, root) : ''; - } - - for (const [key, value] of Object.entries(base)) { - const propSchema = properties[key] ?? typedSchema.properties?.[key]; - if (isRecord(value) && propSchema) { - const resolvedPropSchema = getResolvedSchema(propSchema, root); - if (resolvedPropSchema.properties) { - base[key] = buildSchemaTemplate(resolvedPropSchema, value, root); - } - } - } - - return base; -}; - const getEditableConfig = ( schema: object | undefined, config: Record | undefined, mode: PluginCardListProps['mode'] ): Record => { const base = isRecord(config) ? { ...config } : {}; - return mode === 'add' ? buildSchemaTemplate(schema, base) : base; + return mode === 'add' ? buildJsonSchemaTemplate(schema, base) : base; }; const MAX_LIVE_ISSUES = 5; @@ -403,8 +197,8 @@ export const PluginEditorDrawer = (props: PluginEditorDrawerProps) => { const applyTemplate = (template: Record) => { const nextValue = mode === 'add' - ? buildSchemaTemplate(schema, template) - : applySchemaDefaults(schema, template); + ? buildJsonSchemaTemplate(schema, template) + : applyJsonSchemaDefaults(schema, template); setFormValue(nextValue); methods.setValue('config', toConfigStr(nextValue)); setActiveTab(canUseForm ? 'form' : 'json'); diff --git a/src/components/schema-form/SchemaForm.tsx b/src/components/schema-form/SchemaForm.tsx index 689b3875..964ab7b2 100644 --- a/src/components/schema-form/SchemaForm.tsx +++ b/src/components/schema-form/SchemaForm.tsx @@ -31,6 +31,7 @@ import { import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { getSchemaControlKind } from '@/components/schema-form/schemaControls'; +import { placeholderForJsonSchema } from '@/components/schema-form/schemaTemplate'; import { getActiveRequiredFields, getResolvedSchema, @@ -255,29 +256,7 @@ const isSchemaValueCompatible = ( const defaultForSchema = ( schema: JSONSchemaProperty, rootSchema = schema -): unknown => { - const resolvedSchema = getResolvedSchema(schema, rootSchema); - if ( - 'default' in resolvedSchema && - isSchemaValueCompatible(resolvedSchema, resolvedSchema.default) - ) { - return resolvedSchema.default; - } - const type = schemaType(resolvedSchema); - if (type === 'object') { - return Object.fromEntries( - Object.entries(getSchemaProperties(resolvedSchema, rootSchema)) - .map(([key, propertySchema]) => [ - key, - defaultForSchema(propertySchema, rootSchema), - ]) - .filter(([, value]) => value !== undefined) - ); - } - if (type === 'array') return []; - if (type === 'boolean') return false; - return undefined; -}; +): unknown => placeholderForJsonSchema(schema, rootSchema); const inputPlaceholder = ( schema: JSONSchemaProperty, diff --git a/src/components/schema-form/schemaTemplate.ts b/src/components/schema-form/schemaTemplate.ts new file mode 100644 index 00000000..371584e4 --- /dev/null +++ b/src/components/schema-form/schemaTemplate.ts @@ -0,0 +1,236 @@ +/** + * 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 { + getActiveRequiredFields, + getResolvedSchema, + getSchemaProperties, + type JSONSchema, + schemaType, + validateSchemaValue, +} from './schemaValidation'; + +const isRecord = (value: unknown): value is Record => + !!value && typeof value === 'object' && !Array.isArray(value); + +const cloneSchemaValue = (value: unknown): unknown => { + if (Array.isArray(value)) return value.map(cloneSchemaValue); + if (isRecord(value)) { + return Object.fromEntries( + Object.entries(value).map(([key, nestedValue]) => [ + key, + cloneSchemaValue(nestedValue), + ]) + ); + } + return value; +}; + +const isSchemaValueCompatible = ( + schema: JSONSchema, + value: unknown +): boolean => { + const types = Array.isArray(schema.type) + ? schema.type + : schema.type + ? [schema.type] + : []; + if (types.length === 0) return true; + return types.some((type) => { + if (type === 'null') return value === null; + if (type === 'array') return Array.isArray(value); + if (type === 'object') { + return !!value && typeof value === 'object' && !Array.isArray(value); + } + if (type === 'integer') return typeof value === 'number' && Number.isInteger(value); + if (type === 'number') return typeof value === 'number'; + return typeof value === type; + }); +}; + +const hasSchemaProperties = (schema: JSONSchema): boolean => + Object.keys(schema.properties ?? {}).length > 0; + +export const applyJsonSchemaDefaults = ( + schema: object | undefined, + config: Record | undefined, + rootSchema?: JSONSchema +): Record => { + const base = isRecord(config) ? { ...config } : {}; + if (!schema || !isRecord(schema)) return base; + const typedSchema = schema as JSONSchema; + const root = rootSchema ?? typedSchema; + + for (const [key, propSchema] of Object.entries(typedSchema.properties ?? {})) { + if (!isRecord(propSchema)) continue; + const resolvedPropSchema = getResolvedSchema(propSchema, root); + if ( + base[key] === undefined && + 'default' in resolvedPropSchema && + isSchemaValueCompatible(resolvedPropSchema, resolvedPropSchema.default) + ) { + base[key] = cloneSchemaValue(resolvedPropSchema.default); + } + } + + const properties = getSchemaProperties(typedSchema, root, base); + for (const [key, rawPropSchema] of Object.entries(properties)) { + const propSchema = getResolvedSchema(rawPropSchema, root); + if (isRecord(base[key]) && hasSchemaProperties(propSchema)) { + base[key] = applyJsonSchemaDefaults( + propSchema, + base[key] as Record, + root + ); + } + if ( + base[key] === undefined && + 'default' in propSchema && + isSchemaValueCompatible(propSchema, propSchema.default) + ) { + base[key] = cloneSchemaValue(propSchema.default); + } + } + + return base; +}; + +const collectTemplateRequiredFields = ( + schema: JSONSchema, + value: Record, + rootSchema: JSONSchema, + required: Set +) => { + const resolvedSchema = getResolvedSchema(schema, rootSchema); + + const matchingVariants = [ + ...(resolvedSchema.oneOf ?? []), + ...(resolvedSchema.anyOf ?? []), + ].filter( + (variant) => validateSchemaValue(variant, value, '', rootSchema).length === 0 + ); + const unionVariants = [ + ...(resolvedSchema.oneOf ?? []), + ...(resolvedSchema.anyOf ?? []), + ]; + if (matchingVariants.length === 0 && unionVariants[0]) { + for (const key of getActiveRequiredFields(unionVariants[0], value, rootSchema)) { + required.add(key); + } + collectTemplateRequiredFields(unionVariants[0], value, rootSchema, required); + } + + for (const variant of resolvedSchema.allOf ?? []) { + collectTemplateRequiredFields(variant, value, rootSchema, required); + } +}; + +export const placeholderForJsonSchema = ( + schema: JSONSchema, + rootSchema: JSONSchema +): unknown => { + const resolvedSchema = getResolvedSchema(schema, rootSchema); + + if ( + 'default' in resolvedSchema && + isSchemaValueCompatible(resolvedSchema, resolvedSchema.default) + ) { + return cloneSchemaValue(resolvedSchema.default); + } + if ('const' in resolvedSchema) return cloneSchemaValue(resolvedSchema.const); + if (resolvedSchema.enum?.length) return cloneSchemaValue(resolvedSchema.enum[0]); + + const firstVariant = resolvedSchema.oneOf?.[0] ?? resolvedSchema.anyOf?.[0]; + if (!schemaType(resolvedSchema) && firstVariant) { + return placeholderForJsonSchema(firstVariant, rootSchema); + } + + const type = schemaType(resolvedSchema); + if (type === 'object' || hasSchemaProperties(resolvedSchema)) { + return buildJsonSchemaTemplate(resolvedSchema, {}, rootSchema); + } + if (type === 'array') { + if (resolvedSchema.minItems && resolvedSchema.minItems > 0 && resolvedSchema.items) { + return [placeholderForJsonSchema(resolvedSchema.items, rootSchema)]; + } + return []; + } + if (type === 'integer') { + return resolvedSchema.minimum ?? ( + resolvedSchema.exclusiveMinimum !== undefined + ? Math.floor(resolvedSchema.exclusiveMinimum) + 1 + : 0 + ); + } + if (type === 'number') { + return resolvedSchema.minimum ?? ( + resolvedSchema.exclusiveMinimum !== undefined + ? resolvedSchema.exclusiveMinimum + 1 + : 0 + ); + } + if (type === 'boolean') return false; + if (type === 'null') return null; + if (resolvedSchema.format === 'uri' || resolvedSchema.format === 'uri-reference') { + return 'https://example.com'; + } + if (resolvedSchema.format === 'hostname') return 'example.com'; + if (resolvedSchema.format === 'ipv4') return '127.0.0.1'; + if (resolvedSchema.format === 'ipv6') return '::1'; + if (resolvedSchema.format === 'email') return 'user@example.com'; + if (resolvedSchema.format === 'date-time') return '2026-01-01T00:00:00Z'; + if (type === 'string' && resolvedSchema.minLength && resolvedSchema.minLength > 0) { + return 'value'; + } + return ''; +}; + +export const buildJsonSchemaTemplate = ( + schema: object | undefined, + config: Record | undefined = {}, + rootSchema?: JSONSchema +): Record => { + const base = applyJsonSchemaDefaults(schema, config, rootSchema); + if (!schema || !isRecord(schema)) return base; + + const sourceSchema = schema as JSONSchema; + const root = rootSchema ?? sourceSchema; + const typedSchema = getResolvedSchema(sourceSchema, root); + const properties = getSchemaProperties(typedSchema, root, base); + const required = new Set([ + ...(typedSchema.required ?? []), + ...getActiveRequiredFields(typedSchema, base, root), + ]); + collectTemplateRequiredFields(typedSchema, base, root, required); + + for (const key of required) { + if (base[key] !== undefined) continue; + const propSchema = properties[key] ?? typedSchema.properties?.[key]; + base[key] = propSchema ? placeholderForJsonSchema(propSchema, root) : ''; + } + + for (const [key, value] of Object.entries(base)) { + const propSchema = properties[key] ?? typedSchema.properties?.[key]; + if (isRecord(value) && propSchema) { + const resolvedPropSchema = getResolvedSchema(propSchema, root); + if (hasSchemaProperties(resolvedPropSchema)) { + base[key] = buildJsonSchemaTemplate(resolvedPropSchema, value, root); + } + } + } + + return base; +};