Vault UI V2 表单系统完全指南:从 OpenAPI 驱动脚手架到 HDS 渲染的工程实践
【免费下载链接】vaultA tool for secrets management, encryption as a service, and privileged access management项目地址: https://gitcode.com/GitHub_Trending/va/vault
导读
本文讲解 Vault Web UI(Ember 应用)中新一代V2 表单系统的设计与使用。它以数据驱动方式在 HashiCorp Design System(HDS)之上构建表单:一个FormConfig对象描述字段、分组(section)与提交逻辑,V2Form类负责维护运行时状态(payload 与校验错误),Ember 组件负责渲染与提交流程。凡是新接入 Vault API 的表单都推荐使用本系统;只要存在对应的 OpenAPI 操作,就应优先运行pnpm generate:form-config生成带类型的脚手架,而不是手写配置。读完本文,你将掌握脚手架生成、配置覆盖(override)、手写配置、模板接入、多步向导(wizard)与校验体系的完整用法。
架构总览
V2 表单系统将「配置声明」与「运行时状态」解耦,共分三层,对应仓库中ui/app/forms/v2/与ui/app/components/form/v2/两个目录:
FormConfig (plain object) └── sections[] └── fields[] ← field name, type, label, validations, options… V2Form (class, @tracked) ├── payload ← deep clone of FormConfig.payload ├── validationErrors ← Map<fieldName, string[]> ├── set(path, value) ← updates payload + re-validates field ├── validateForm() ← validates all visible fields └── submit(api) ← validateForm() → config.submit(api, payload) Ember components ├── Form::V2 ← submit task, error state, yields Form + submitTask ├── Form::V2::Renderer ← <Hds::Form>, iterates sections/fields ├── Form::V2::Section ← wraps fields in <Form.Section> with optional title ├── Form::V2::Field ← renders the correct HDS input component ├── Form::V2::ErrorAlert ← inline critical alert for submission errors ├── Form::V2::Wizard ← multi-step wizard orchestrator └── Form::V2::Apply ← final "apply changes" step with code snippet options各层职责如下:
FormConfig是一个纯对象,类型定义位于 form-config.ts。它只描述表单长什么样、提交到哪、成功/失败后做什么,不含任何运行时状态。V2Form类以@tracked追踪payload与validationErrors,具体实现见 v2-form.ts。构造时会对FormConfig.payload做一次深度克隆(structuredClone),保证表单运行期改动不会反向污染配置对象。- Ember 组件负责把配置渲染成 HDS 表单并驱动提交任务,全部组件位于 app/components/form/v2/,按类型分文件:
field.ts、renderer.ts、section.ts、error-alert.ts、wizard.ts、apply.ts与入口index.ts。
从源码结构看,这套分层让「UI 定制」与「API 模型」分离:配置可以手写、可以自动生成、也可以在生成结果之上叠加覆盖,且相互之间互不侵入。
前置条件
使用 V2 表单系统及脚手架生成器前需要满足:
- 已执行过
pnpm install(会安装tsx及若干开发依赖); - 已安装
@hashicorp/vault-client-typescript包——它提供openapi.json以及带类型的 SDK 方法。
脚手架脚本在运行时直接读取node_modules/@hashicorp/vault-client-typescript/openapi.json,该路径由脚本内部解析得出,见 generate-form-config.js。
生成 Form Config
把camelCase 形式的 API 方法名传给生成器,它会从内置 OpenAPI 规范中读取字段定义,写出一份全类型(fully-typed)脚手架。例如为POST /sys/mounts/{path}启用某个 secrets engine 的操作生成配置:
pnpm generate:form-config mountsEnableSecretsEngine该命令定义在 package.json 的scripts中,实际调用scripts/generate-form-config.js。脚本入口会先校验必填参数methodName,并给出友好的错误提示与用法示例。
生成过程发生了什么
对照 generate-form-config.js 的实现,流程可归纳为:
- 加载并解析
openapi.json,打印发现的路径总数; - 在 spec 中查找 dasherized 方法名(如
mounts-enable-secrets-engine)对应的POST操作operationId; - 提取路径参数与请求体属性,跳过 deprecated(已废弃)字段;
- 依据 spec 中的
x-vault-displayAttrs.group注解对字段分组;没有该注解时回退到default分组; - 由操作的 tag 与方法名推导 TypeScript 请求类型,例如
SystemApiMountsEnableSecretsEngineOperationRequest; - 把
.ts文件写入app/forms/v2/generated/并自动运行 Prettier 格式化。
submit 中 API 类的自动判定
submit中实际调用的 API 对象由 OpenAPI 的 tag 自动决定,映射关系如下:
| Tag | API class |
|---|---|
system | api.sys |
auth | api.auth |
identity | api.identity |
secrets | api.secrets |
以mountsEnableSecretsEngine为例,它属于systemtag,生成的submit调用的是api.sys.mountsEnableSecretsEngineRaw(payload)。若操作缺少可识别的 tag,脚本会直接报错退出并提示补写 tags。
生成产物:一个真实样例
生成器把文件写入app/forms/v2/generated/,文件名为 dasherized(短横线风格):
app/forms/v2/generated/mounts-enable-secrets-engine-config.ts仓库中保留了这个真实生成文件,见 mounts-enable-secrets-engine-config.ts。文件内容包含:
- 对类型化 SDK 请求类型的
import(SystemApiMountsEnableSecretsEngineOperationRequest); - 一个
FormConfig<RequestType, unknown>常量,携带name、path、description、submit、payload、sections六个字段; sections按 OpenAPI 规范分组:params存放路径参数,default存放请求体字段,有x-vault-displayAttrs.group注解的会生成对应命名分组;- 所有字段默认都以
TextInput类型生成——审阅后需要自行修正type(例如布尔开关应改成Toggle、枚举应改成Select)。
例如该文件的path为'/sys/mounts/{path}',description为'Mount a new backend at a new path.';payload 中路径参数path直接挂在一级,请求体字段则统一放在MountsEnableSecretsEngineRequest嵌套对象下(如config、description、external_entropy_access、local、options、plugin_name、plugin_version、seal_wrap、type),这正好对应 Vault 的挂载请求结构。
注意:生成的文件不会自动注册,需要按下一节手动接入。
生成之后:注册、覆盖、使用
生成文件顶部带有⚠️ AUTO-GENERATED FILE - DO NOT EDIT标记,不要直接编辑它。所有与 UI 相关的调整都应放在 override 中(见下一节)。接入表单需要三步:
1. 注册配置
在app/forms/v2/generated/index.ts中加入导出:
import mountsEnableSecretsEngineConfig from './mounts-enable-secrets-engine-config'; const GENERATED_CONFIGS = { mountsEnableSecretsEngine: mountsEnableSecretsEngineConfig, }; export default GENERATED_CONFIGS;2. 创建 override
用于调整字段类型、标签、可见性,或删除无关字段,方法见下一节。
3. 使用表单
用注册表 key 或配置对象实例化V2Form,方法见「在模板中使用表单」。
覆盖(Override)生成的配置
生成的配置忠实建模了 API 表面,不应被直接修改。需要调整字段顺序、标签、可见性规则或增加非 API 字段时,用configBuilder创建 override。Builder 的实现位于 override-field.ts,它以深拷贝的方式复制生成配置的sections(注意拷贝过程保留函数,注释里特别强调避免 JSON 序列化,因为它会剥离函数),随后所有改动都作用于副本,最终由build()与原始配置的非 section 属性合并返回新配置。
在app/forms/v2/overrides/下新建文件:
// app/forms/v2/overrides/mounts-enable-secrets-engine-config.ts import generatedConfig from '../generated/mounts-enable-secrets-engine-config'; import { configBuilder } from './override-field'; export default configBuilder(generatedConfig) .removeField('default', 'MountsEnableSecretsEngineRequest.seal_wrap') .updateField('params', 'path', { label: 'Mount path', helperText: 'The path to mount to. Example: "aws/east"', isRequired: true, }) .addSection({ name: 'engine_selection', title: 'Engine type', fields: [ { name: 'MountsEnableSecretsEngineRequest.type', type: 'Select', label: 'Type', options: [ { label: 'KV', value: 'kv' }, { label: 'AWS', value: 'aws' }, ], }, ], }, 0) // position 0 inserts before all other sections .build();在app/forms/v2/overrides/index.ts注册 override:
import mountsEnableSecretsEngineConfig from './mounts-enable-secrets-engine-config'; const OVERRIDE_CONFIGS = { mountsEnableSecretsEngine: mountsEnableSecretsEngineConfig, }; export default OVERRIDE_CONFIGS;getFormConfig()会先查 overrides 再查 generated configs,因此 override 自动获得优先权。这一点在 get-form-config.ts 中实现得很直观:先判断OVERRIDE_CONFIGS[configName]是否存在,命中即返回;否则回退到GENERATED_CONFIGS;两者都未命中则抛出Form configuration not found for: …错误。
可用的 builder 方法
下表列出了configBuilder的全部方法(源码签名与异常行为见 override-field.ts):
| Method | Description |
|---|---|
addSection(section, position?) | 新增 section(默认追加到末尾,也可指定position插入) |
removeSection(sectionName) | 按名称移除 section |
updateSection(sectionName, updates) | 更新 section 的title、description或isVisible |
addField(sectionName, field) | 向既有 section 增加字段 |
updateField(sectionName, fieldName, overrides) | 更新字段的任意属性(name除外),采用浅合并覆盖 |
removeField(sectionName, fieldName) | 从 section 中移除字段 |
moveField(fieldName, fromSection, toSection, position?) | 在 section 之间移动字段 |
reorderFields(sectionName, fieldNames) | 重排 section 内的字段顺序 |
build() | 返回最终的FormConfig |
需要留意的是,当传入不存在的 section 或字段名时,除removeField采用静默过滤外,多数方法会抛出形如Section "…" not found、Field "…" not found in section "…"的异常,便于尽早暴露配置错误。若只想批量调整一个 section 内若干字段的展示属性,仓库还提供了一个更轻量的辅助函数overrideFieldsInSection(generatedConfig, sectionName, fieldOverrides),它内部逐个调用updateField后直接build()。
手动编写配置
对于没有对应 OpenAPI 操作的表单(或生成的脚手架并不适用时),可以直接手写配置。核心类型FormConfig<Request, Response>与FormField、FormSection、WizardConfig等都定义在 form-config.ts,下面的例子完整展示了其契约:
import type ApiService from 'vault/services/api'; import type { FormConfig } from 'vault/forms/v2/form-config'; interface MyPayload { name: string; ttl: number; enabled: boolean; } const myFormConfig: FormConfig<MyPayload, unknown> = { name: 'myForm', path: '/sys/example/{name}', title: 'Create example', payload: { name: '', ttl: 0, enabled: false, }, submit: async (api: ApiService, payload: MyPayload) => { return await api.sys.someMethodRaw(payload); }, onSuccess: (response) => { // optional: redirect, show toast, etc. }, sections: [ { name: 'basic', title: 'Basic settings', fields: [ { name: 'name', type: 'TextInput', label: 'Name', isRequired: true, }, { name: 'ttl', type: 'TextInput', inputType: 'number', label: 'TTL', helperText: 'Time-to-live in seconds', }, { name: 'enabled', type: 'Toggle', label: 'Enable', }, ], }, ], };结合 form-config.ts 的类型定义,补充几个字段语义要点:
name唯一标识表单,通常与 API 方法名一致;path用于生成 CURL 请求片段(源码注释中明确说明此用途);submit是提交处理器,接收 API 服务与类型化 payload,返回类型化的 API 响应;onSuccess在提交成功后回调(可做跳转、toast 等);onError在提交失败时收到提取出的错误消息;- 字段的
name支持点路径(dotted-path)记法以表达嵌套属性(如user.address.street); FieldValue的合法取值包括string | number | boolean | string[] | null | undefined。
字段类型
type到 HDS 渲染组件的映射如下:
type | HDS component rendered |
|---|---|
TextInput | Hds::Form::TextInput::Field |
TextArea | Hds::Form::Textarea::Field |
Select | Hds::Form::Select::Field |
Toggle | Hds::Form::Toggle::Field |
Checkbox | Hds::Form::Checkbox::Field |
Radio | Hds::Form::Radio::Group |
RadioCard | Hds::Form::RadioCard::Group |
MaskedInput | Hds::Form::MaskedInput::Field |
在 form-config.ts 中,FormElement被建模为上述八个字符串的联合类型,因此不认识的type在类型层面就会被编译器拒绝。若编译期已通过但运行期仍遇到未识别类型,Form::V2::Field会回退到文本输入并在控制台打印[Form::V2::Field] Unsupported field type "…"警告。
新字段类型的支持是按需(as-needed)添加的:如果迁移到 V2 的表单需要上表之外的组件,应当在Form::V2::Field中同步补充支持。
TextInput还可以接受可选的inputType属性来指定 HTML input 类型,合法值来自源码中的TextInputType联合类型,包括text、email、password、url、search、date、time、datetime-local、month、week、tel(严格取自 form-config.ts,并非所有 HTML 类型都可用)。
Select、Radio、RadioCard必须提供options数组:
options: [ { label: 'Option A', value: 'a' }, { label: 'Option B', value: 'b', description: 'Only for RadioCard' }, ]其中FieldOption的value类型为string | number | boolean,description仅对 RadioCard 生效(见 form-config.ts)。
条件可见性
字段与 section 都接受isVisible属性,类型为boolean | ((payload) => boolean)(即VisibilityRule):
// Static — always hidden { isVisible: false } // Dynamic — based on current payload { isVisible: (payload) => payload.type === 'advanced' }隐藏字段的行为有两层保障,对应 v2-form.ts 的实现:
- 校验排除:
validateForm()与字段级校验只作用于「可见字段集合」,计算方式是先过滤可见 section、再拍平并过滤可见字段; - 错误清理:每次 payload 变化后调用
#pruneHiddenFieldErrors(),把不可见字段的错误从validationErrors中剔除,避免残留报错。
在模板中使用表单
路由 / 组件中的初始化
V2Form构造器支持两种入参(FormConfigKey或配置对象),这一点在 v2-form.ts 的注释中有详细说明:
// my-route.ts or my-component.ts import V2Form from 'vault/forms/v2/v2-form'; // Option 1: registry key (config must be registered in generated/index.ts or overrides/index.ts) form = new V2Form('mountsEnableSecretsEngine'); // Option 2: direct config object form = new V2Form(myFormConfig);- 按名称实例化适合单步表单,从注册表中解析配置;
- 按配置对象实例化适合向导步骤等需要局部覆盖(如动态 payload)的场景;类型上建议以
new V2Form<any, any>(config)简化书写。
构造时会自动执行#injectRequiredValidations():为所有isRequired: true的字段在validations最前面补入一条required规则(消息为「{field.label} is required」),除非该字段已有required规则。
默认用法(自动渲染字段 + 提交按钮)
<Form::V2 @form={{this.form}} @onSuccess={{this.handleSuccess}} />Form::V2会管理提交任务(submit task)与错误状态,并把Form与submitTask作为块参数(block params)向下传递。
自定义提交按钮
若需要自定义按钮区域,可在块形式中拿到Form与submitTask:
<Form::V2 @form={{this.form}} @onSuccess={{this.handleSuccess}} as |Form submitTask|> <Form.Section> <Hds::ButtonSet> <Hds::Button @text="Save" @color="primary" type="submit" disabled={{or (not this.form.isValid) submitTask.isRunning}} {{on "click" (perform submitTask)}} /> <Hds::Button @text="Cancel" @color="secondary" @route="vault.cluster.index" /> </Hds::ButtonSet> </Form.Section> </Form::V2>disabled同时考察this.form.isValid(对应validationErrors.size === 0,见 v2-form.ts)与submitTask.isRunning,防止无效或进行中的重复提交。
隐藏自动渲染字段(自定义布局)
传入@hideFields={{true}}可抑制自动渲染的字段,同时保留提交、错误处理以及Form上下文:
<Form::V2 @form={{this.form}} @hideFields={{true}} as |Form submitTask|> {{! render fields manually using Form.Section, Form.Field, etc. }} </Form::V2>多步向导(Multi-step Wizards)
当一个业务场景需要按顺序提交多张表单、且后续步骤依赖前序结果时,用WizardConfig+Form::V2::Wizard。WizardConfig与相关类型(WizardStep、WizardStepState、WizardState)同样定义在 form-config.ts。
定义WizardConfig:
import type { WizardConfig } from 'vault/forms/v2/form-config'; import step1Config from 'vault/forms/v2/generated/step-one-config'; import step2Config from 'vault/forms/v2/generated/step-two-config'; const wizardConfig: WizardConfig = { title: 'Enable secrets engine', applyChanges: true, // adds a final "Apply changes" step with code snippet options steps: [ { name: 'mountConfig', title: 'Mount configuration', heading: 'Configure the mount', formConfig: step1Config, }, { name: 'engineConfig', title: 'Engine settings', formConfig: { ...step2Config, // Dynamic payload: read the path entered in step 1 payload: (wizardState) => ({ ...step2Config.payload, mount: wizardState.mountConfig?.payload?.path ?? '', }), }, }, ], };在模板中挂载向导:
<Form::V2::Wizard @config={{this.wizardConfig}} @onSuccess={{this.handleComplete}} @onCancel={{this.handleCancel}} />跨步骤数据共享
步骤的payload可以是函数(wizardState) => payload。wizardState是一个以步骤name为 key 的映射,每个已完成的步骤包含{ payload, response, error? }(即WizardStepState,见 form-config.ts)。这样后一步可以基于前一步的提交结果(如第一步输入的挂载路径)预填字段。WizardState只存数据,执行状态由 ember-concurrency 的任务属性推导(源码注释明确说明了这一设计取舍)。
applyChanges 最终步骤
当applyChanges: true时,向导末尾会追加一个「Apply changes」步骤,渲染Form::V2::Apply——它是一个 radio card 选择器,让用户选择通过Terraform HCL、Vault CLI/API curl 命令或直接在 UI 中执行来应用变更。UI 组件 apply.ts 即为该步骤的实现载体。
校验体系
内置校验器
任何字段都可以挂validations数组:
{ name: 'email', type: 'TextInput', label: 'Email', isRequired: true, // shorthand — auto-injects a 'required' rule validations: [ { type: 'email', message: 'Please enter a valid email address' }, { type: 'maxLength', message: 'Email must be under 255 characters', options: { maxLength: 255 } }, ], }内置校验器的行为对照 form-validators.ts 的实现:
type | Validates |
|---|---|
required | 非空值(拒绝null、undefined、''、[]、{}) |
email | Email 格式 |
url | URL 格式(使用new URL()) |
pattern | 正则——提供options.pattern(字符串或RegExp),可选options.flags |
minLength | 最小字符串长度——提供options.minLength |
maxLength | 最大字符串长度——提供options.maxLength |
min | 最小数值——提供options.min |
max | 最大数值——提供options.max |
几个值得注意的实现细节(均可在 form-validators.ts 中验证):
required对字符串做trim().length > 0判断,纯空白字符串视为无效;email、url、pattern、minLength、maxLength等对空值返回true(视为通过),因此强制必填需要配合required规则使用——这正是isRequired快捷方式存在的原因;min对null、undefined、''也直接放行;pattern接受字符串或RegExp两种形态,字符串时可通过flags指定修饰符。
自定义校验器
当内置规则不够用时,可以传validator函数:
{ name: 'path', type: 'TextInput', label: 'Path', validations: [ { validator: (formData) => !String(formData.path).includes(' '), message: 'Path must not contain spaces', }, ], }自定义校验器收到的是整个表单 payload(formData),因此天然支持跨字段联合校验。
isRequired快捷方式
对字段设置isRequired: true会自动在validations前部插入(prepend)一条required校验规则,同时把@isRequired传给 HDS 组件,在标签上渲染星号。插入逻辑见 v2-form.ts:只有当字段尚未包含type === 'required'的规则时才注入,避免重复。
校验时机
- 字段变更即校验:每次调用
form.set()更新 payload 后,立即对对应字段执行#validateField(propPath)(同时触发#pruneHiddenFieldErrors()清理隐藏字段错误); - 全部字段在提交前统一校验:
submit(api)先调用validateForm()(仅校验可见字段),校验失败则抛出Form validation failed,不会调用config.submit; - 隐藏字段自动豁免校验。
常见问题排查
Form configuration not found for: "…"—— 配置 key 未注册。请把它加入app/forms/v2/generated/index.ts或app/forms/v2/overrides/index.ts。对应异常抛出点在 get-form-config.ts。Operation "…" not found in openapi.json—— 方法名可能拼写错误,或该操作属于 enterprise-only / 尚未纳入打包的 spec。请到node_modules/@hashicorp/vault-client-typescript/openapi.json中核对确切的operationId。脚本内部用dasherize(methodName)把 camelCase 转成短横线形式再去匹配,因此传入拼写不符的驼峰名会在此报错(见 generate-form-config.js)。Could not determine API class for "…"—— OpenAPI 操作缺少可识别的 tag(system、auth、identity、secrets)。可考虑改用手写配置。[Form::V2::Field] Unsupported field type "…"—— 配置中的type值不在受支持的FormElement联合类型中。对照上文的字段类型表修正即可。Section "…" not found—— 某个configBuilder方法使用了基配置中不存在的 section 名。可调用builder.getSections()检查现有 section 名。字段没有触发校验—— 确认字段上存在
isRequired: true或validations数组。没有任何校验规则的字段永远被视为有效(validateField对空规则集返回空错误列表)。
小结
V2 表单系统为 Vault UI 提供了一条「OpenAPI 规范 → 类型化配置 → HDS 表单」的工业化通路:脚手架生成器把 API 表面直接转成可编辑的配置骨架,override 机制在不污染生成文件的前提下完成 UI 定制,V2Form统一了状态、校验与提交流程,而 wizard 则把多步骤、跨步骤共享数据的复杂交互收敛为一份声明式配置。对任何需要新增 Vault API 表单的开发者来说,这套系统把「从零手写一个表单」降级为「生成、覆盖、接入」三个确定的步骤。若需要进一步深挖,可以直接阅读 form-config.ts(全部类型契约)、v2-form.ts(运行时状态机)、override-field.ts(builder 实现)以及 generate-form-config.js(脚手架主流程)。
【免费下载链接】vaultA tool for secrets management, encryption as a service, and privileged access management项目地址: https://gitcode.com/GitHub_Trending/va/vault
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考