@qingflow-tech/qingflow-cli 1.1.43-beta.2 → 1.1.43-beta.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -5
- package/entry_point.py +1 -1
- package/npm/bin/qingflow.mjs +1 -2
- package/npm/lib/runtime.mjs +3 -7
- package/package.json +1 -2
- package/pyproject.toml +1 -3
- package/skills/qingflow-app-form/SKILL.md +23 -11
- package/skills/qingflow-cli/SKILL.md +4 -2
- package/skills/qingflow-cli/reference/QINGFLOW_CLI_FIELD_DATA_TYPES.md +1 -1
- package/skills/qingflow-cli/reference/QINGFLOW_CLI_SCHEMA_APPLY_FIELD_TYPES_AND_SCENARIOS.md +1 -1
- package/skills/qingflow-cli/reference/builder/10-build-single-app.md +3 -3
- package/skills/qingflow-cli/reference/builder/20-build-complete-system.md +1 -1
- package/skills/qingflow-cli/reference/builder/30-schema-fields.md +25 -24
- package/skills/qingflow-cli/reference/builder/40-layout.md +5 -5
- package/skills/qingflow-cli/reference/builder/README.md +3 -3
- package/skills/qingflow-cli/reference/builder/code-integrations/README.md +1 -1
- package/skills/qingflow-cli/reference/builder/reference/app-delivery-sop.md +7 -7
- package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_FIELD_DATA_TYPES.md +1 -1
- package/skills/qingflow-cli/reference/examples/README.md +1 -1
- package/skills/qingflow-common/SKILL.md +47 -0
- package/skills/qingflow-common/agents/openai.yaml +4 -0
- package/src/qingflow_mcp/__main__.py +2 -3
- package/src/qingflow_mcp/app_form_compat.py +11 -27
- package/src/qingflow_mcp/app_form_validator.py +201 -35
- package/src/qingflow_mcp/builder_facade/app_form.py +762 -148
- package/src/qingflow_mcp/builder_facade/app_form_schema.py +114 -3
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/complete.json +310 -245
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/address.json +82 -27
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/amount.json +85 -32
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/attachment.json +82 -30
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/code_block.json +113 -37
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/data_relation.json +87 -64
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/date.json +82 -27
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/datetime.json +82 -27
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/department.json +83 -28
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/email.json +82 -27
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/link.json +82 -27
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/long_text.json +82 -27
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/member.json +83 -28
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/multi_select.json +82 -27
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/number.json +85 -32
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/phone.json +82 -27
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/q_linker.json +95 -40
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/relation.json +100 -28
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/single_choice.json +82 -27
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/single_select.json +82 -27
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/subtable.json +212 -117
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/text.json +82 -27
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/summary.json +143 -161
- package/src/qingflow_mcp/builder_facade/service.py +3 -2
- package/src/qingflow_mcp/response_trim.py +2 -0
- package/src/qingflow_mcp/server.py +24 -3
- package/src/qingflow_mcp/server_app_builder.py +2 -2
- package/src/qingflow_mcp/tools/record_tools.py +3 -3
- package/src/qingflow_mcp/version.py +2 -4
- package/docs/local-agent-install.md +0 -370
- package/skills/qingflow-mcp-setup/SKILL.md +0 -115
- package/skills/qingflow-mcp-setup/agents/openai.yaml +0 -4
- package/skills/qingflow-mcp-setup/references/claude-desktop.md +0 -34
- package/skills/qingflow-mcp-setup/references/environments.md +0 -62
- package/skills/qingflow-mcp-setup/references/generic-stdio.md +0 -32
- package/skills/qingflow-mcp-setup/scripts/check_local_server.sh +0 -38
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
# Builder 应用交付重流程 SOP:分组 → 应用定位 → 读场 → 改场 → 发布校验
|
|
2
2
|
|
|
3
|
-
本文是历史低层交付备忘,不是当前表单写入 SOP。稳定 CLI 公开入口仍为 `qingflow builder` / `qingflow build
|
|
3
|
+
本文是历史低层交付备忘,不是当前表单写入 SOP。稳定 CLI 公开入口仍为 `qingflow builder` / `qingflow build`,但表单字段、分组、布局和设置必须使用 `qingflow --json builder app-form schema/get/validate/apply`,应用删除使用 `qingflow --json builder app delete --app-key APP_KEY`;本文件中的 `app_schema_apply` / `app_layout_apply` / `builder schema apply` / `builder layout apply` 只用于识别旧载荷和迁移,不得直接调用。
|
|
4
4
|
|
|
5
5
|
> **权限**:应用基础信息(`app_name` / 图标 / 可见性)、表单、流程、自定义按钮本体、应用级关联资源池走 **应用搭建 / EditAppAuth**;视图更新/删除、按钮的视图位置配置(`view_configs`)和视图内关联资源展示配置走 **视图管理 / ViewManagementAuth**(后端 `beingViewManageStatus`;未开启高级应用权限时等价回落到 DataManageAuth);**新建视图**额外需要 **DataManageAuth**;报表走 **数据管理 / DataManageAuth**。失败码常见 **40002 / 40161 / 编辑锁** 等,见主技能与 **ADMIN** 速查。
|
|
6
6
|
|
|
7
|
-
> **契约**:当前表单契约固定通过 `
|
|
7
|
+
> **契约**:当前表单契约固定通过 `qingflow --json builder app-form schema` 获取;其它公开 `app_*_apply` 仅是 `builder contract --tool-name` 的参数值,**不是可执行 CLI 子命令**。其 **`allowed_keys` / `allowed_values` / `execution_notes` / 示例** 以在线契约为准,命令:
|
|
8
8
|
> `qingflow --json builder contract --tool-name <app_flow_apply|app_views_apply|app_custom_buttons_apply|app_associated_resources_apply|app_publish_verify>`
|
|
9
9
|
>
|
|
10
10
|
> **CLI 文件载荷注意**:契约示例里可能出现 `"profile": "default"`,那是请求上下文示例;使用 CLI 的 `--*-file` 时不要把 `profile` 写进 JSON 文件。需要指定账号上下文时用根级参数:`qingflow --profile default ...`。
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
| D | **`app_get` / `app_get_fields`** | `qingflow --json builder app get --app-key <APP_KEY>` / `qingflow --json builder app get --app-key <APP_KEY> fields` | 默认 `app_get` 是应用地图,含字段摘要、视图、图表、自定义按钮和关联资源池;`fields` 只读表单可搭建字段 |
|
|
28
28
|
| D | **`app_read_layout_summary`** | `qingflow --json builder app get --app-key <APP_KEY> layout` | 读 **布局**(段落、`rows` 字段名矩阵、`unplaced_fields`) |
|
|
29
29
|
| D | **`app_read_flow_summary`** | `qingflow --json builder app get --app-key <APP_KEY> flow` | 读 **流程**是否启用、节点摘要(公开面能力有限) |
|
|
30
|
-
| E |
|
|
30
|
+
| E | **AppForm** | `qingflow --json builder app-form validate --schema-version VERSION --file DECLARATION.json` -> `qingflow --json builder app-form apply --file DECLARATION.json` | 完整 AppForm:字段、分组、行、设置和应用创建/更新 |
|
|
31
31
|
| F | — | 由 AppForm 的 `body` 一并维护 | 表单段落 / 行 / 单元格随完整声明提交 |
|
|
32
32
|
| G | **`app_flow_apply`** | `qingflow builder flow apply …` | **线性流程** 节点与转移(见契约限制) |
|
|
33
33
|
| G′(维护) | **`app_custom_buttons_apply`** | `qingflow builder button apply …` | 仅用于用户明确要求维护已有独立按钮:样式/图标、跨视图复用、删除按钮本体或批量重排 |
|
|
@@ -120,9 +120,9 @@ qingflow --json builder app resolve \
|
|
|
120
120
|
|
|
121
121
|
### 3.2 单应用新建(历史 `app_schema_apply` 迁移说明)
|
|
122
122
|
|
|
123
|
-
当前新建和更新请使用
|
|
123
|
+
当前新建和更新请使用 `qingflow --json builder app-form schema/get/validate/apply`;下方旧命令仅用于迁移脚本识别,不能执行。
|
|
124
124
|
|
|
125
|
-
历史脚本可能先读取图标候选;当前 AppForm
|
|
125
|
+
历史脚本可能先读取图标候选;当前 AppForm 新建同样先读取并固定合法图标和颜色:
|
|
126
126
|
|
|
127
127
|
```bash
|
|
128
128
|
qingflow --json builder icon catalog
|
|
@@ -259,7 +259,7 @@ qingflow --json builder app get --app-key "<APP_KEY>" flow
|
|
|
259
259
|
|
|
260
260
|
## 5. 改场三口(历史 schema/layout 迁移说明 + 当前 flow)
|
|
261
261
|
|
|
262
|
-
`app_schema_apply` / `app_layout_apply` 已从公开 MCP/CLI surface 移除。下方 schema/layout
|
|
262
|
+
`app_schema_apply` / `app_layout_apply` 已从公开 MCP/CLI surface 移除。下方 schema/layout 命令只用于迁移旧脚本;当前表单和布局必须使用 `qingflow --json builder app-form schema/get/validate/apply`。
|
|
263
263
|
|
|
264
264
|
### 5.1 历史字段载荷:`builder schema apply`(仅迁移识别)
|
|
265
265
|
|
|
@@ -324,7 +324,7 @@ qingflow builder flow apply \
|
|
|
324
324
|
|
|
325
325
|
## 6. 字段类型迁移对照(旧 `app_schema_apply` 载荷)
|
|
326
326
|
|
|
327
|
-
以下内容只帮助迁移旧载荷;当前字段类型、camelCase
|
|
327
|
+
以下内容只帮助迁移旧载荷;当前字段类型、camelCase 配置和必填项以固定版本的 `qingflow --json builder app-form schema` 返回结果为准。
|
|
328
328
|
|
|
329
329
|
智能体可优先写更自然的字段类型,工具会在写入前归一化;读回仍显示 canonical 类型:
|
|
330
330
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
> **用途**:说明 **`record schema …`** 的 **`fields[]`**、**`record list` / `record get` / `record access`** 行内值、以及 **insert/update 写入 payload** 的常见对应关系;并单独说明 **选项联动、关联记录、引用填充、公式/系统题、AI 与受限类型** 等在 CLI 下的表现与处置。
|
|
4
4
|
> **依据**:当前 CLI 在 **`--json`** 下会对 **schema** 响应做 **裁剪**:通常只保留本节列出的键;**`kind`** 由内部写入类别归并而来(见下表)。**`que_type` 等若未出现在输出中**,以 **`kind` + 实跑预检** 为准。
|
|
5
|
-
> **关联**:写入值细节与成员/部门见 **[QINGFLOW_CLI_RECORD_CREATE_WORKFLOW.md](../record/QINGFLOW_CLI_RECORD_CREATE_WORKFLOW.md)**;更新流程见 **[QINGFLOW_CLI_RECORD_UPDATE_WORKFLOW.md](../record/QINGFLOW_CLI_RECORD_UPDATE_WORKFLOW.md)**;只读见 **[QINGFLOW_CLI_DATA_RETRIEVAL_WORKFLOW.md](./QINGFLOW_CLI_DATA_RETRIEVAL_WORKFLOW.md)**;**搭建侧** AppForm 的 `type`(与本文 `kind`
|
|
5
|
+
> **关联**:写入值细节与成员/部门见 **[QINGFLOW_CLI_RECORD_CREATE_WORKFLOW.md](../record/QINGFLOW_CLI_RECORD_CREATE_WORKFLOW.md)**;更新流程见 **[QINGFLOW_CLI_RECORD_UPDATE_WORKFLOW.md](../record/QINGFLOW_CLI_RECORD_UPDATE_WORKFLOW.md)**;只读见 **[QINGFLOW_CLI_DATA_RETRIEVAL_WORKFLOW.md](./QINGFLOW_CLI_DATA_RETRIEVAL_WORKFLOW.md)**;**搭建侧** AppForm 的 `type`(与本文 `kind` 命名体系不同)及其 CLI 命令见 **[30-schema-fields.md](../builder/30-schema-fields.md)**;主技能 **[../SKILL.md](../../SKILL.md)**。
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -4,7 +4,7 @@ Use these files as copyable bases after reading the matching resource document.
|
|
|
4
4
|
|
|
5
5
|
| Resource | Main example | Notes |
|
|
6
6
|
|----------|--------------|-------|
|
|
7
|
-
| Historical single-app schema probe | [schema/schema_single_app_form_standard.example.json](./schema/schema_single_app_form_standard.example.json) | Retired `--form-file` shape; use
|
|
7
|
+
| Historical single-app schema probe | [schema/schema_single_app_form_standard.example.json](./schema/schema_single_app_form_standard.example.json) | Retired `--form-file` shape; use `builder app-form schema/get/validate/apply` for current declarations. |
|
|
8
8
|
| Historical multi-app schema probe | [schema/schema_multi_app_form_relation_layout.example.json](./schema/schema_multi_app_form_relation_layout.example.json) | Retired `--apps-file` shape; current systems create independent AppForms and then bind confirmed app keys. |
|
|
9
9
|
| Existing-app layout probe | [layout/layout_sections_full.example.json](./layout/layout_sections_full.example.json) | Historical layout-patch shape; current new and existing forms use the complete AppForm `spec.body`. |
|
|
10
10
|
| Views | [views/views_upsert_table_minimal.example.json](./views/views_upsert_table_minimal.example.json), [views/views_batch_full.example.json](./views/views_batch_full.example.json) | `views[]` shape with filters, query panel, view-bound buttons, batch upsert/patch/remove. |
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: qingflow-common
|
|
3
|
+
description: Route Qingflow work through the shared public concepts and choose the matching CLI or MCP package. Use when comparing Qingflow CLI and MCP capabilities, selecting an execution surface, or handing work between record, task, form, workflow, and builder skills.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Qingflow Common Concepts
|
|
7
|
+
|
|
8
|
+
Treat CLI and MCP as two invocation surfaces for the same Qingflow public contract. Choose the surface that is installed in the current runtime, then keep the same business workflow and declaration shape unless that operation is explicitly surface-specific.
|
|
9
|
+
|
|
10
|
+
## Choose A Surface
|
|
11
|
+
|
|
12
|
+
| Available runtime | Use | Best fit |
|
|
13
|
+
| --- | --- | --- |
|
|
14
|
+
| Terminal, script, CI, or a local file workflow | Qingflow CLI | Repeatable commands and JSON files |
|
|
15
|
+
| Agent has `qingflow-mcp` tools | Qingflow MCP | Records, tasks, packages, forms, views, workflows, portals, and builder configuration |
|
|
16
|
+
|
|
17
|
+
Do not require both surfaces. If only one is installed, complete the workflow on that surface. Do not expose a CLI-only command as an MCP tool, or vice versa.
|
|
18
|
+
|
|
19
|
+
## Shared Concepts
|
|
20
|
+
|
|
21
|
+
- **AppForm** is the versioned declarative definition for an application form: app metadata, fields, sections, layout, settings, and field configuration. The same JSON declaration is used by CLI and Qingflow MCP.
|
|
22
|
+
- **Record** is runtime data written to an existing app. Use the record schema before an insert, update, import, or analysis workflow.
|
|
23
|
+
- **Task** is a workflow work item. Prefer task operations for approval, transfer, rollback, and assignee actions.
|
|
24
|
+
- **Builder resource** is package, app, view, chart, portal, navigation, or workflow configuration. It is separate from record data.
|
|
25
|
+
- **Schema version** is part of the public contract. Pin it for one AppForm or workflow operation and do not combine values from different versions.
|
|
26
|
+
|
|
27
|
+
## Invocation Mapping
|
|
28
|
+
|
|
29
|
+
| Business operation | CLI | MCP |
|
|
30
|
+
| --- | --- | --- |
|
|
31
|
+
| Form schema/read/apply/delete | `qingflow --json builder app-form ...` / `builder app delete` | `app_form_schema`, `app_form_get`, `app_form_apply`, `app_delete` |
|
|
32
|
+
| AppForm static validation | `qingflow --json builder app-form validate --file ...` | Performed inside `app_form_apply`; no separate tool |
|
|
33
|
+
| Record and task work | `qingflow --json record ...` / `task ...` | Qingflow MCP record and task tools |
|
|
34
|
+
| Package, view, portal, chart, workflow | `qingflow --json builder ...` | Qingflow MCP configuration tools |
|
|
35
|
+
|
|
36
|
+
The operation names and payload schemas are defined by the public surface. Use the current Schema or tool input schema as the source of truth, not a legacy command name or backend payload.
|
|
37
|
+
|
|
38
|
+
## Package Boundaries
|
|
39
|
+
|
|
40
|
+
- `@qingflow-tech/qingflow-cli` packages CLI execution guidance and CLI-supported workflows.
|
|
41
|
+
- `@qingflow-tech/qingflow-mcp` packages every MCP Skill and exposes one unified MCP service. Record operations and system configuration are task routes in this service, not package or server choices.
|
|
42
|
+
|
|
43
|
+
Each package includes this common Skill. The CLI package installs its CLI guidance; the MCP package installs every MCP domain Skill. The source of every packaged Skill is `qingflow-support/mcp-server/skills/`.
|
|
44
|
+
|
|
45
|
+
## Route Next
|
|
46
|
+
|
|
47
|
+
Use the installed package's relevant domain Skill. In the unified MCP package, select the Skill by the requested work, not by an assumed user or builder server role. Do not link to or invoke a domain Skill that the current package did not install.
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
"""Entry point for running qingflow_mcp as a module.
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
entrypoints can still start the builder server explicitly.
|
|
3
|
+
Start the unified Qingflow MCP surface.
|
|
5
4
|
"""
|
|
6
|
-
from qingflow_mcp.
|
|
5
|
+
from qingflow_mcp.server import main
|
|
7
6
|
|
|
8
7
|
if __name__ == "__main__":
|
|
9
8
|
main()
|
|
@@ -5,7 +5,6 @@ from typing import Any
|
|
|
5
5
|
|
|
6
6
|
from .builder_facade.app_form_schema import AppFormContractError
|
|
7
7
|
from .json_types import JSONObject
|
|
8
|
-
from .solution.compiler.icon_utils import validate_workspace_icon_choice
|
|
9
8
|
|
|
10
9
|
|
|
11
10
|
TYPE_ALIASES = {
|
|
@@ -40,18 +39,25 @@ def normalize_app_form_declaration(payload: JSONObject) -> tuple[JSONObject, lis
|
|
|
40
39
|
warnings: list[JSONObject] = []
|
|
41
40
|
|
|
42
41
|
def visit(field: JSONObject, path: str) -> None:
|
|
43
|
-
raw_type = str(field.get("type") or "").strip()
|
|
42
|
+
raw_type = str(field.get("questionType") or field.get("type") or "").strip()
|
|
44
43
|
canonical_type = TYPE_ALIASES.get(raw_type, raw_type)
|
|
45
44
|
if canonical_type:
|
|
46
|
-
field["
|
|
45
|
+
field["questionType"] = canonical_type
|
|
46
|
+
if "type" in field:
|
|
47
|
+
if "questionType" in field and str(field.get("type") or "").strip() != raw_type:
|
|
48
|
+
raise AppFormContractError("INVALID_SPEC", "conflicting aliases: type and questionType", path=path)
|
|
49
|
+
field.pop("type", None)
|
|
47
50
|
if canonical_type != raw_type and raw_type == "boolean":
|
|
48
51
|
warnings.append({"code": "LEGACY_BOOLEAN_NORMALIZED", "path": f"{path}/type"})
|
|
49
52
|
|
|
50
53
|
for old, new in (
|
|
51
|
-
("
|
|
52
|
-
("
|
|
54
|
+
("required", "beingRequired"),
|
|
55
|
+
("description", "questionDescription"),
|
|
53
56
|
("default_type", "defaultType"),
|
|
54
57
|
("default_value", "defaultValue"),
|
|
58
|
+
("question_description", "questionDescription"),
|
|
59
|
+
("question_type", "questionType"),
|
|
60
|
+
("being_required", "beingRequired"),
|
|
55
61
|
):
|
|
56
62
|
if old not in field:
|
|
57
63
|
continue
|
|
@@ -96,25 +102,3 @@ def normalize_app_form_declaration(payload: JSONObject) -> tuple[JSONObject, lis
|
|
|
96
102
|
if isinstance(field, dict):
|
|
97
103
|
visit(field, f"/spec/body/{block_index}/rows/{row_index}/fields/{field_index}")
|
|
98
104
|
return result, warnings
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
def validate_app_form_create_icon(payload: JSONObject) -> None:
|
|
102
|
-
if "appKey" in payload:
|
|
103
|
-
return
|
|
104
|
-
spec = payload.get("spec")
|
|
105
|
-
if not isinstance(spec, dict):
|
|
106
|
-
return
|
|
107
|
-
ok, error_code, message, details = validate_workspace_icon_choice(
|
|
108
|
-
icon=str(spec.get("icon") or "") or None,
|
|
109
|
-
color=str(spec.get("color") or "") or None,
|
|
110
|
-
require_explicit=True,
|
|
111
|
-
disallow_generic=True,
|
|
112
|
-
)
|
|
113
|
-
if not ok:
|
|
114
|
-
color_errors = {"WORKSPACE_ICON_COLOR_REQUIRED", "WORKSPACE_ICON_COLOR_NOT_FOUND"}
|
|
115
|
-
raise AppFormContractError(
|
|
116
|
-
error_code or "WORKSPACE_ICON_INVALID",
|
|
117
|
-
message or "invalid workspace icon configuration",
|
|
118
|
-
path="/spec/color" if error_code in color_errors else "/spec/icon",
|
|
119
|
-
details=details,
|
|
120
|
-
)
|
|
@@ -2,12 +2,27 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
from copy import deepcopy
|
|
4
4
|
import json
|
|
5
|
+
import re
|
|
5
6
|
from typing import Any, Iterable
|
|
6
7
|
|
|
7
8
|
from jsonschema import Draft202012Validator, FormatChecker
|
|
8
9
|
|
|
9
|
-
from .app_form_compat import normalize_app_form_declaration
|
|
10
|
-
from .builder_facade.app_form_schema import AppFormContractError
|
|
10
|
+
from .app_form_compat import normalize_app_form_declaration
|
|
11
|
+
from .builder_facade.app_form_schema import AppFormContractError, compact_schema_errors, schema_error_message
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
_INTEGRATION_OUTPUT_TARGET_FIELD_TYPES = {
|
|
15
|
+
"text",
|
|
16
|
+
"long_text",
|
|
17
|
+
"number",
|
|
18
|
+
"amount",
|
|
19
|
+
"date",
|
|
20
|
+
"datetime",
|
|
21
|
+
"single_choice",
|
|
22
|
+
"single_select",
|
|
23
|
+
"multi_select",
|
|
24
|
+
}
|
|
25
|
+
_Q_LINKER_TEMPLATE_RE = re.compile(r"\{\{(?P<key>[^{}\s]+)\}\}")
|
|
11
26
|
|
|
12
27
|
|
|
13
28
|
class AppFormValidationError(ValueError):
|
|
@@ -20,13 +35,17 @@ class AppFormValidationError(ValueError):
|
|
|
20
35
|
self.api_version = api_version
|
|
21
36
|
|
|
22
37
|
|
|
38
|
+
def _field_type(field: dict[str, Any]) -> str:
|
|
39
|
+
return str(field.get("questionType") or field.get("type") or "").strip()
|
|
40
|
+
|
|
41
|
+
|
|
23
42
|
def _field_types(payload: dict[str, Any]) -> tuple[str, ...]:
|
|
24
43
|
found: set[str] = set()
|
|
25
44
|
|
|
26
45
|
def visit(field: Any) -> None:
|
|
27
46
|
if not isinstance(field, dict):
|
|
28
47
|
return
|
|
29
|
-
field_type =
|
|
48
|
+
field_type = _field_type(field)
|
|
30
49
|
if field_type:
|
|
31
50
|
found.add(field_type)
|
|
32
51
|
for child in field.get("subfields") or []:
|
|
@@ -77,14 +96,6 @@ def _compose(summary: dict[str, Any], details: list[dict[str, Any]]) -> dict[str
|
|
|
77
96
|
return schema
|
|
78
97
|
|
|
79
98
|
|
|
80
|
-
def _leaf_errors(error: Any) -> Iterable[Any]:
|
|
81
|
-
if not error.context:
|
|
82
|
-
yield error
|
|
83
|
-
return
|
|
84
|
-
for child in error.context:
|
|
85
|
-
yield from _leaf_errors(child)
|
|
86
|
-
|
|
87
|
-
|
|
88
99
|
def _pointer(parts: Iterable[Any]) -> str:
|
|
89
100
|
encoded = [str(item).replace("~", "~0").replace("/", "~1") for item in parts]
|
|
90
101
|
return "/" + "/".join(encoded) if encoded else "/"
|
|
@@ -116,38 +127,198 @@ def _static_errors(payload: dict[str, Any]) -> list[tuple[str, str]]:
|
|
|
116
127
|
for field_index, field in enumerate(fields):
|
|
117
128
|
if isinstance(field, dict):
|
|
118
129
|
top_fields.append((f"{row_path}/{field_index}", field))
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
130
|
+
def resolve_local_selector(selector: Any) -> tuple[str, tuple[str, dict[str, Any]] | None]:
|
|
131
|
+
"""Resolve against the declared body, preserving backend-dependent ID-only cases."""
|
|
132
|
+
if not isinstance(selector, dict):
|
|
133
|
+
return "schema", None
|
|
134
|
+
raw_que_id = selector.get("queId")
|
|
135
|
+
que_id = raw_que_id if isinstance(raw_que_id, int) and not isinstance(raw_que_id, bool) else None
|
|
136
|
+
raw_name = selector.get("name")
|
|
137
|
+
name = raw_name if isinstance(raw_name, str) and raw_name else ""
|
|
138
|
+
if raw_que_id is not None and que_id is None:
|
|
139
|
+
return "schema", None
|
|
140
|
+
if raw_name is not None and not name:
|
|
141
|
+
return "schema", None
|
|
142
|
+
if que_id is not None:
|
|
143
|
+
matches = [(path, field) for path, field in top_fields if field.get("queId") == que_id]
|
|
144
|
+
if len(matches) != 1:
|
|
145
|
+
return "invalid", None
|
|
146
|
+
declared_name = str(matches[0][1].get("name") or "")
|
|
147
|
+
if name and declared_name and name != declared_name:
|
|
148
|
+
return "invalid", None
|
|
149
|
+
return "resolved", matches[0]
|
|
150
|
+
if name:
|
|
151
|
+
matches = [(path, field) for path, field in top_fields if str(field.get("name") or "") == name]
|
|
152
|
+
if len(matches) == 1:
|
|
153
|
+
return "resolved", matches[0]
|
|
154
|
+
if len(matches) > 1:
|
|
155
|
+
return "invalid", None
|
|
156
|
+
if any(not str(field.get("name") or "") for _, field in top_fields):
|
|
157
|
+
return "unknown", None
|
|
158
|
+
return "invalid", None
|
|
159
|
+
return "schema", None
|
|
160
|
+
|
|
161
|
+
title_selector = spec.get("dataTitleField")
|
|
162
|
+
if top_fields:
|
|
163
|
+
if "dataTitleField" not in spec and "appKey" in payload:
|
|
164
|
+
pass
|
|
165
|
+
elif title_selector is None or resolve_local_selector(title_selector)[0] == "invalid":
|
|
166
|
+
errors.append(("/spec/dataTitleField", "dataTitleField must resolve to exactly one top-level field"))
|
|
167
|
+
elif title_selector is not None:
|
|
168
|
+
errors.append(("/spec/dataTitleField", "dataTitleField must be null when body is empty"))
|
|
169
|
+
|
|
170
|
+
cover_selector = spec.get("dataCoverField")
|
|
171
|
+
if cover_selector is not None:
|
|
172
|
+
cover_resolution, cover = resolve_local_selector(cover_selector)
|
|
173
|
+
if cover_resolution == "invalid":
|
|
174
|
+
errors.append(("/spec/dataCoverField", "dataCoverField must resolve to exactly one top-level field"))
|
|
175
|
+
elif cover_resolution == "resolved" and cover is not None and _field_type(cover[1]) != "attachment":
|
|
176
|
+
errors.append(("/spec/dataCoverField", "dataCoverField must select a top-level attachment field"))
|
|
127
177
|
names: dict[str, str] = {}
|
|
128
178
|
fields_by_id = {
|
|
129
179
|
field.get("queId"): field
|
|
130
180
|
for _, field in top_fields
|
|
131
181
|
if isinstance(field.get("queId"), int) and not isinstance(field.get("queId"), bool)
|
|
132
182
|
}
|
|
183
|
+
output_targets: dict[str, tuple[str, str]] = {}
|
|
133
184
|
for path, field in top_fields:
|
|
134
185
|
name = str(field.get("name") or "")
|
|
135
|
-
if name
|
|
136
|
-
|
|
186
|
+
if name:
|
|
187
|
+
if name in names:
|
|
188
|
+
errors.append((f"{path}/name", f"duplicate top-level field name; first declared at {names[name]}"))
|
|
189
|
+
else:
|
|
190
|
+
names[name] = f"{path}/name"
|
|
191
|
+
field_type = _field_type(field)
|
|
192
|
+
if field_type == "data_relation":
|
|
193
|
+
config = field.get("config") if isinstance(field.get("config"), dict) else {}
|
|
194
|
+
filters = config.get("filters") if isinstance(config.get("filters"), dict) else {}
|
|
195
|
+
if filters.get("mode") == "simple":
|
|
196
|
+
for group_index, group in enumerate(filters.get("groups") or []):
|
|
197
|
+
for condition_index, condition in enumerate(group if isinstance(group, list) else []):
|
|
198
|
+
if not isinstance(condition, dict):
|
|
199
|
+
continue
|
|
200
|
+
operator = condition.get("operator")
|
|
201
|
+
operand = condition.get("operand") if isinstance(condition.get("operand"), dict) else {}
|
|
202
|
+
values = operand.get("values") if isinstance(operand.get("values"), list) else None
|
|
203
|
+
operand_path = (
|
|
204
|
+
f"{path}/config/filters/groups/{group_index}/{condition_index}/operand"
|
|
205
|
+
)
|
|
206
|
+
if operator in {"is_empty", "is_not_empty"}:
|
|
207
|
+
if operand.get("kind") != "literal" or values != []:
|
|
208
|
+
errors.append(
|
|
209
|
+
(
|
|
210
|
+
operand_path,
|
|
211
|
+
"is_empty and is_not_empty require a literal operand with values: []",
|
|
212
|
+
)
|
|
213
|
+
)
|
|
214
|
+
elif operand.get("kind") == "literal" and not values:
|
|
215
|
+
errors.append(
|
|
216
|
+
(
|
|
217
|
+
f"{operand_path}/values",
|
|
218
|
+
"literal operands for non-empty operators must contain at least one value",
|
|
219
|
+
)
|
|
220
|
+
)
|
|
221
|
+
if field_type == "q_linker":
|
|
222
|
+
binding_name = "qLinkerBinding"
|
|
223
|
+
elif field_type == "code_block":
|
|
224
|
+
binding_name = "codeBlockBinding"
|
|
137
225
|
else:
|
|
138
|
-
names[name] = f"{path}/name"
|
|
139
|
-
if field.get("type") != "q_linker":
|
|
140
226
|
continue
|
|
141
|
-
binding = (field.get("config") or {}).get(
|
|
142
|
-
|
|
143
|
-
|
|
227
|
+
binding = (field.get("config") or {}).get(binding_name) if isinstance(field.get("config"), dict) else None
|
|
228
|
+
if not isinstance(binding, dict):
|
|
229
|
+
continue
|
|
230
|
+
for index, item in enumerate(binding.get("inputs") or []):
|
|
231
|
+
if not isinstance(item, dict):
|
|
144
232
|
continue
|
|
145
233
|
selector = item.get("field") if isinstance(item.get("field"), dict) else {}
|
|
146
|
-
|
|
234
|
+
input_path = f"{path}/config/{binding_name}/inputs/{index}"
|
|
235
|
+
resolution, selected = resolve_local_selector(selector)
|
|
236
|
+
if resolution == "invalid":
|
|
237
|
+
errors.append((f"{input_path}/field", "input field must resolve to exactly one top-level field"))
|
|
238
|
+
continue
|
|
239
|
+
if field_type != "q_linker" or item.get("source") != "json_path":
|
|
240
|
+
continue
|
|
241
|
+
selected_name = selected[1].get("name") if selected is not None else selector.get("name")
|
|
147
242
|
if not selected_name and selector.get("queId") in fields_by_id:
|
|
148
243
|
selected_name = fields_by_id[selector["queId"]].get("name")
|
|
149
244
|
if selected_name and item.get("key") != selected_name:
|
|
150
|
-
errors.append((f"{
|
|
245
|
+
errors.append((f"{input_path}/key", "json_path key must equal the selected field name"))
|
|
246
|
+
|
|
247
|
+
if field_type == "q_linker":
|
|
248
|
+
request = binding.get("request") if isinstance(binding.get("request"), dict) else {}
|
|
249
|
+
template_paths: dict[str, list[str]] = {}
|
|
250
|
+
for property_name in ("url", "jsonValue", "xmlValue"):
|
|
251
|
+
raw_template = request.get(property_name)
|
|
252
|
+
if not isinstance(raw_template, str):
|
|
253
|
+
continue
|
|
254
|
+
for match in _Q_LINKER_TEMPLATE_RE.finditer(raw_template):
|
|
255
|
+
template_paths.setdefault(match.group("key"), []).append(
|
|
256
|
+
f"{path}/config/{binding_name}/request/{property_name}"
|
|
257
|
+
)
|
|
258
|
+
input_items = [item for item in binding.get("inputs") or [] if isinstance(item, dict)]
|
|
259
|
+
json_path_inputs: dict[str, list[int]] = {}
|
|
260
|
+
for index, item in enumerate(input_items):
|
|
261
|
+
key = str(item.get("key") or "")
|
|
262
|
+
if key and item.get("source") == "json_path":
|
|
263
|
+
json_path_inputs.setdefault(key, []).append(index)
|
|
264
|
+
for key, request_paths in template_paths.items():
|
|
265
|
+
providers = json_path_inputs.get(key) or []
|
|
266
|
+
if len(providers) == 1:
|
|
267
|
+
continue
|
|
268
|
+
if not providers:
|
|
269
|
+
message = (
|
|
270
|
+
f"placeholder '{{{{{key}}}}}' requires an input with source 'json_path' and the same key; "
|
|
271
|
+
"query_param, header, and url_encoded inputs do not replace request templates"
|
|
272
|
+
)
|
|
273
|
+
else:
|
|
274
|
+
message = f"placeholder '{{{{{key}}}}}' must have exactly one json_path input"
|
|
275
|
+
errors.append((request_paths[0], message))
|
|
276
|
+
for key, indexes in json_path_inputs.items():
|
|
277
|
+
if key in template_paths:
|
|
278
|
+
continue
|
|
279
|
+
for index in indexes:
|
|
280
|
+
errors.append(
|
|
281
|
+
(
|
|
282
|
+
f"{path}/config/{binding_name}/inputs/{index}/source",
|
|
283
|
+
f"json_path input key '{key}' must appear as '{{{{{key}}}}}' in request.url, request.jsonValue, or request.xmlValue",
|
|
284
|
+
)
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
producer = name or str(field.get("queId") or field_type)
|
|
288
|
+
for index, item in enumerate(binding.get("outputs") or []):
|
|
289
|
+
if not isinstance(item, dict):
|
|
290
|
+
continue
|
|
291
|
+
selector = item.get("targetField")
|
|
292
|
+
target_path = f"{path}/config/{binding_name}/outputs/{index}/targetField"
|
|
293
|
+
resolution, selected = resolve_local_selector(selector)
|
|
294
|
+
if resolution == "invalid":
|
|
295
|
+
errors.append((target_path, "targetField must resolve to exactly one top-level field"))
|
|
296
|
+
continue
|
|
297
|
+
if resolution != "resolved" or selected is None:
|
|
298
|
+
continue
|
|
299
|
+
selected_path, target_field = selected
|
|
300
|
+
target_name = str(target_field.get("name") or target_field.get("queId") or "")
|
|
301
|
+
target_type = _field_type(target_field)
|
|
302
|
+
if target_type and target_type not in _INTEGRATION_OUTPUT_TARGET_FIELD_TYPES:
|
|
303
|
+
allowed = ", ".join(sorted(_INTEGRATION_OUTPUT_TARGET_FIELD_TYPES))
|
|
304
|
+
errors.append(
|
|
305
|
+
(
|
|
306
|
+
target_path,
|
|
307
|
+
f"output target field '{target_name}' uses unsupported questionType '{target_type}'; allowed types: {allowed}",
|
|
308
|
+
)
|
|
309
|
+
)
|
|
310
|
+
previous = output_targets.get(selected_path)
|
|
311
|
+
if previous is None:
|
|
312
|
+
output_targets[selected_path] = (producer, target_path)
|
|
313
|
+
continue
|
|
314
|
+
previous_producer, previous_path = previous
|
|
315
|
+
errors.append(
|
|
316
|
+
(
|
|
317
|
+
target_path,
|
|
318
|
+
f"field '{target_name}' is already targeted by output from '{previous_producer}' at {previous_path}; "
|
|
319
|
+
"each field can receive output from only one Q-Linker or code block relation",
|
|
320
|
+
)
|
|
321
|
+
)
|
|
151
322
|
return errors
|
|
152
323
|
|
|
153
324
|
|
|
@@ -184,15 +355,10 @@ def validate_declaration(
|
|
|
184
355
|
Draft202012Validator.check_schema(schema)
|
|
185
356
|
validator = Draft202012Validator(schema, format_checker=FormatChecker())
|
|
186
357
|
found: dict[tuple[str, str], None] = {}
|
|
187
|
-
for error in validator.iter_errors(declaration):
|
|
188
|
-
|
|
189
|
-
found[(_pointer(leaf.absolute_path), f"{leaf.validator}: {leaf.message}")] = None
|
|
358
|
+
for error in compact_schema_errors(validator.iter_errors(declaration), schema):
|
|
359
|
+
found[(_pointer(error.absolute_path), f"{error.validator}: {schema_error_message(error)}")] = None
|
|
190
360
|
for pointer, message in _static_errors(declaration):
|
|
191
361
|
found[(pointer, message)] = None
|
|
192
|
-
try:
|
|
193
|
-
validate_app_form_create_icon(declaration)
|
|
194
|
-
except AppFormContractError as error:
|
|
195
|
-
found[(error.path or "/", error.message)] = None
|
|
196
362
|
if found:
|
|
197
363
|
issues = [{"pointer": pointer, "message": message} for pointer, message in sorted(found)]
|
|
198
364
|
raise AppFormValidationError(issues, schema_version=pinned, api_version=api_version)
|