@qingflow-tech/qingflow-cli 1.1.41 → 1.1.43-beta.1
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 +4 -4
- package/package.json +2 -1
- package/pyproject.toml +3 -2
- package/skills/qingflow-cli/reference/QINGFLOW_CLI_TASK_CONTEXT_WORKFLOW.md +1 -1
- package/skills/qingflow-cli/reference/task/QINGFLOW_CLI_TASK_CONTEXT_WORKFLOW.md +1 -1
- package/src/qingflow_mcp/app_form_compat.py +115 -0
- package/src/qingflow_mcp/app_form_validator.py +199 -0
- package/src/qingflow_mcp/builder_facade/app_form.py +2836 -0
- package/src/qingflow_mcp/builder_facade/app_form_schema.py +269 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/manifest.json +37 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/complete.json +1766 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/address.json +76 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/amount.json +76 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/attachment.json +76 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/code_block.json +223 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/data_relation.json +538 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/date.json +76 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/datetime.json +76 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/department.json +149 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/email.json +76 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/link.json +76 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/long_text.json +76 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/member.json +76 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/multi_select.json +92 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/number.json +76 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/phone.json +76 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/q_linker.json +338 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/relation.json +137 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/single_choice.json +92 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/single_select.json +92 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/subtable.json +810 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/text.json +76 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/summary.json +713 -0
- package/src/qingflow_mcp/builder_facade/service.py +5 -2
- package/src/qingflow_mcp/cli/commands/builder.py +91 -28
- package/src/qingflow_mcp/cli/json_io.py +21 -2
- package/src/qingflow_mcp/cli/main.py +3 -39
- package/src/qingflow_mcp/cloud_auth.py +11 -0
- package/src/qingflow_mcp/cloud_context.py +5 -0
- package/src/qingflow_mcp/cloud_oauth_proxy.py +453 -0
- package/src/qingflow_mcp/cloud_redis.py +25 -0
- package/src/qingflow_mcp/config.py +44 -0
- package/src/qingflow_mcp/public_surface.py +5 -2
- package/src/qingflow_mcp/response_trim.py +8 -0
- package/src/qingflow_mcp/server_app_builder.py +38 -86
- package/src/qingflow_mcp/server_app_user.py +2 -1
- package/src/qingflow_mcp/server_cloud.py +245 -22
- package/src/qingflow_mcp/tools/ai_builder_tools.py +93 -100
- package/src/qingflow_mcp/tools/app_tools.py +46 -65
- package/src/qingflow_mcp/tools/base.py +2 -1
- package/src/qingflow_mcp/tools/task_context_tools.py +6 -0
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@ Qingflow CLI 是轻流面向开发者和 AI Agent 的命令行工具。它把轻
|
|
|
7
7
|
## 安装
|
|
8
8
|
|
|
9
9
|
```bash
|
|
10
|
-
npm install @qingflow-tech/qingflow-cli@1.1.
|
|
10
|
+
npm install @qingflow-tech/qingflow-cli@1.1.43-beta.1
|
|
11
11
|
```
|
|
12
12
|
|
|
13
13
|
推荐从官网复制这一条一键安装 Qingflow CLI:
|
|
@@ -21,7 +21,7 @@ npx qingflow-cli@latest install
|
|
|
21
21
|
## 运行
|
|
22
22
|
|
|
23
23
|
```bash
|
|
24
|
-
npx -y -p @qingflow-tech/qingflow-cli@1.1.
|
|
24
|
+
npx -y -p @qingflow-tech/qingflow-cli@1.1.43-beta.1 qingflow
|
|
25
25
|
```
|
|
26
26
|
|
|
27
27
|
全局安装后可以直接运行:
|
|
@@ -38,7 +38,7 @@ qingflow
|
|
|
38
38
|
|
|
39
39
|
首次安装时会自动初始化本地 Python 运行环境,随后提供 `qingflow` 命令行入口。
|
|
40
40
|
|
|
41
|
-
## 内置 Skills
|
|
41
|
+
## Bundled skills: 内置 Skills
|
|
42
42
|
|
|
43
43
|
- `skills/qingflow-cli`
|
|
44
44
|
- `skills/qingflow-mcp-setup`
|
|
@@ -55,7 +55,7 @@ qingflow-skills install --agent codex --scope user
|
|
|
55
55
|
如果你通过一次性 `npx -p` 执行 skills 安装,建议加 `--copy`,避免 symlink 指向 npm 临时执行缓存:
|
|
56
56
|
|
|
57
57
|
```bash
|
|
58
|
-
npx -y -p @qingflow-tech/qingflow-cli@1.1.
|
|
58
|
+
npx -y -p @qingflow-tech/qingflow-cli@1.1.43-beta.1 qingflow-skills install --agent codex --scope user --copy
|
|
59
59
|
```
|
|
60
60
|
|
|
61
61
|
`qingflow-skills install` 默认使用 symlink,且遇到同名 skill 不会覆盖;只有显式传入 `--force` 才会替换。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@qingflow-tech/qingflow-cli",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.43-beta.1",
|
|
4
4
|
"description": "Human-friendly Qingflow command line interface for auth, record operations, import, tasks, and stable builder flows.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
"pyproject.toml",
|
|
17
17
|
"entry_point.py",
|
|
18
18
|
"src/qingflow_mcp/**/*.py",
|
|
19
|
+
"src/qingflow_mcp/builder_facade/schemas/**/*.json",
|
|
19
20
|
"src/qingflow_mcp/py.typed",
|
|
20
21
|
"qingflow",
|
|
21
22
|
"npm/",
|
package/pyproject.toml
CHANGED
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "qingflow-mcp"
|
|
7
|
-
version = "1.1.
|
|
7
|
+
version = "1.1.43b1"
|
|
8
8
|
description = "User-authenticated MCP server for Qingflow"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -25,6 +25,7 @@ classifiers = [
|
|
|
25
25
|
dependencies = [
|
|
26
26
|
"mcp>=1.9.4,<2.0.0",
|
|
27
27
|
"httpx>=0.27,<1.0",
|
|
28
|
+
"jsonschema>=4.23,<5.0",
|
|
28
29
|
"keyring>=25.5,<26.0",
|
|
29
30
|
"openpyxl>=3.1,<4.0",
|
|
30
31
|
"pydantic>=2.8,<3.0",
|
|
@@ -64,7 +65,7 @@ Issues = "https://github.com/qingflow/qingflow-mcp/issues"
|
|
|
64
65
|
where = ["src"]
|
|
65
66
|
|
|
66
67
|
[tool.setuptools.package-data]
|
|
67
|
-
qingflow_mcp = ["py.typed"]
|
|
68
|
+
qingflow_mcp = ["py.typed", "builder_facade/schemas/app_form/**/*.json"]
|
|
68
69
|
|
|
69
70
|
[tool.pytest.ini_options]
|
|
70
71
|
testpaths = ["tests"]
|
|
@@ -114,7 +114,7 @@ qingflow --json task action \
|
|
|
114
114
|
- **定位只认 `task_id`**:来自 `task list` 的 `data.items[].task_id`;`app_key`、`record_id`、`workflow_node_id` 不是动作执行入参。
|
|
115
115
|
- `**approve` / `reject`**:执行上下文以当前待办详情或用户/前端已给出的显式 `formId` 为准;只有缺少 `formId` 时才兜底读取应用 baseInfo。不要把应用 baseInfo 的 `40002` 当作待办动作最终无权结论,最终是否可执行以后端审批动作接口为准。
|
|
116
116
|
- `**save_only`**:必须 提供非空 `**--fields-file`**(或 payload 与 fields 不能同时塞 `answers` 与 fields,具体以报错为准)。以当前待办节点的 `data.editable_fields` / `editableQueIds` 为准;若 `editableQueIds` 被当前权限链路挡住,但任务详情里的 `queAuthSetting` 已暴露可编辑字段,CLI 会按任务详情继续允许 save-only,并由最终保存接口裁决。申请表单 schema 或辅助 `editableQueIds` 的 `40002` 不是待办字段保存的最终无权结论。
|
|
117
|
-
- `**transfer**`:`**payload` 内需要** `target_member_id`(或别名 `uid` / `targetMemberId
|
|
117
|
+
- `**transfer**`:`**payload` 内需要** `target_member_id`(或别名 `uid` / `targetMemberId`);不能转给当前登录人。未提供 `payload.answers` 且没有通过 `--fields-file` 修改字段时,CLI 会先读取当前待办详情,并将已有 `answers` 原样带入转交请求;显式 `payload.answers` 或 `--fields-file` 合并结果优先。
|
|
118
118
|
- **意见必填**:若详情里 `**action_constraints.feedback_required_for`** 包含当前动作,需在 `**payload.audit_feedback`**(实现从 payload 抽取审计意见,具体键名以 `--help` / 服务端为准)中提供,否则会 config 错误。
|
|
119
119
|
- `**urge`**:**不支持** 与 `fields` 同时提交。
|
|
120
120
|
- **禁止臆测动作**:只允许 `**data.available_actions`** 中出现的动作;`approve` 不在列表则不可硬放行。
|
|
@@ -116,7 +116,7 @@ qingflow --json task action \
|
|
|
116
116
|
- **定位只认 `task_id`**:来自 `task list` 的 `data.items[].task_id`;`app_key`、`record_id`、`workflow_node_id` 不是动作执行入参。
|
|
117
117
|
- `**approve` / `reject`**:执行上下文以当前待办详情或用户/前端已给出的显式 `formId` 为准;只有缺少 `formId` 时才兜底读取应用 baseInfo。不要把应用 baseInfo 的 `40002` 当作待办动作最终无权结论,最终是否可执行以后端审批动作接口为准。
|
|
118
118
|
- `**save_only`**:必须 提供非空 `**--fields-file`**(或 payload 与 fields 不能同时塞 `answers` 与 fields,具体以报错为准)。以当前待办节点的 `data.editable_fields` / `editableQueIds` 为准;若 `editableQueIds` 被当前权限链路挡住,但任务详情里的 `queAuthSetting` 已暴露可编辑字段,CLI 会按任务详情继续允许 save-only,并由最终保存接口裁决。申请表单 schema 或辅助 `editableQueIds` 的 `40002` 不是待办字段保存的最终无权结论。
|
|
119
|
-
- `**transfer**`:`**payload` 内需要** `target_member_id`(或别名 `uid` / `targetMemberId
|
|
119
|
+
- `**transfer**`:`**payload` 内需要** `target_member_id`(或别名 `uid` / `targetMemberId`);不能转给当前登录人。未提供 `payload.answers` 且没有通过 `--fields-file` 修改字段时,CLI 会先读取当前待办详情,并将已有 `answers` 原样带入转交请求;显式 `payload.answers` 或 `--fields-file` 合并结果优先。
|
|
120
120
|
- **意见必填**:若详情里 `**action_constraints.feedback_required_for`** 包含当前动作,需在 `**payload.audit_feedback`**(实现从 payload 抽取审计意见,具体键名以 `--help` / 服务端为准)中提供,否则会 config 错误。
|
|
121
121
|
- `**urge`**:**不支持** 与 `fields` 同时提交。
|
|
122
122
|
- **禁止臆测动作**:只允许 `**data.available_actions`** 中出现的动作;`approve` 不在列表则不可硬放行。
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from copy import deepcopy
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from .builder_facade.app_form_schema import AppFormContractError
|
|
7
|
+
from .json_types import JSONObject
|
|
8
|
+
from .solution.compiler.icon_utils import validate_workspace_icon_choice
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
TYPE_ALIASES = {
|
|
12
|
+
"multiline": "long_text",
|
|
13
|
+
"textarea": "long_text",
|
|
14
|
+
"radio": "single_choice",
|
|
15
|
+
"boolean": "single_choice",
|
|
16
|
+
"select": "single_select",
|
|
17
|
+
"dropdown": "single_select",
|
|
18
|
+
"checkbox": "multi_select",
|
|
19
|
+
"multi_choice": "multi_select",
|
|
20
|
+
"url": "link",
|
|
21
|
+
"hyperlink": "link",
|
|
22
|
+
"reference": "relation",
|
|
23
|
+
"quote": "relation",
|
|
24
|
+
}
|
|
25
|
+
FIELD_CONFIG_ALIASES = {
|
|
26
|
+
"options": "options",
|
|
27
|
+
"department_scope": "departmentScope",
|
|
28
|
+
"target_app_key": "targetAppKey",
|
|
29
|
+
"relation_mode": "relationMode",
|
|
30
|
+
"display_field": "displayField",
|
|
31
|
+
"visible_fields": "visibleFields",
|
|
32
|
+
"q_linker_binding": "qLinkerBinding",
|
|
33
|
+
"code_block_binding": "codeBlockBinding",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def normalize_app_form_declaration(payload: JSONObject) -> tuple[JSONObject, list[JSONObject]]:
|
|
38
|
+
"""Normalize supported v1alpha1 compatibility aliases before Schema validation."""
|
|
39
|
+
result = deepcopy(payload)
|
|
40
|
+
warnings: list[JSONObject] = []
|
|
41
|
+
|
|
42
|
+
def visit(field: JSONObject, path: str) -> None:
|
|
43
|
+
raw_type = str(field.get("type") or "").strip()
|
|
44
|
+
canonical_type = TYPE_ALIASES.get(raw_type, raw_type)
|
|
45
|
+
if canonical_type:
|
|
46
|
+
field["type"] = canonical_type
|
|
47
|
+
if canonical_type != raw_type and raw_type == "boolean":
|
|
48
|
+
warnings.append({"code": "LEGACY_BOOLEAN_NORMALIZED", "path": f"{path}/type"})
|
|
49
|
+
|
|
50
|
+
for old, new in (("as_data_title", "dataTitle"), ("as_data_cover", "dataCover")):
|
|
51
|
+
if old not in field:
|
|
52
|
+
continue
|
|
53
|
+
if new in field:
|
|
54
|
+
raise AppFormContractError("INVALID_SPEC", f"conflicting aliases: {old} and {new}", path=path)
|
|
55
|
+
field[new] = field.pop(old)
|
|
56
|
+
|
|
57
|
+
config = field.get("config")
|
|
58
|
+
if config is None:
|
|
59
|
+
config = {}
|
|
60
|
+
if not isinstance(config, dict):
|
|
61
|
+
return
|
|
62
|
+
for old, new in FIELD_CONFIG_ALIASES.items():
|
|
63
|
+
if old not in field:
|
|
64
|
+
continue
|
|
65
|
+
if new in config:
|
|
66
|
+
raise AppFormContractError("INVALID_SPEC", f"conflicting aliases: {old} and config.{new}", path=path)
|
|
67
|
+
config[new] = field.pop(old)
|
|
68
|
+
if raw_type == "boolean" and "options" not in config:
|
|
69
|
+
config["options"] = ["是", "否"]
|
|
70
|
+
if config:
|
|
71
|
+
field["config"] = config
|
|
72
|
+
|
|
73
|
+
if "fields" in field:
|
|
74
|
+
if "subfields" in field:
|
|
75
|
+
raise AppFormContractError("INVALID_SPEC", "conflicting aliases: fields and subfields", path=path)
|
|
76
|
+
field["subfields"] = field.pop("fields")
|
|
77
|
+
children = field.get("subfields")
|
|
78
|
+
for index, child in enumerate(children if isinstance(children, list) else []):
|
|
79
|
+
if isinstance(child, dict):
|
|
80
|
+
visit(child, f"{path}/subfields/{index}")
|
|
81
|
+
|
|
82
|
+
spec = result.get("spec")
|
|
83
|
+
for block_index, block in enumerate(spec.get("body") or [] if isinstance(spec, dict) else []):
|
|
84
|
+
if not isinstance(block, dict):
|
|
85
|
+
continue
|
|
86
|
+
rows = [block] if block.get("kind") == "row" else block.get("rows") or []
|
|
87
|
+
for row_index, row in enumerate(rows):
|
|
88
|
+
if not isinstance(row, dict):
|
|
89
|
+
continue
|
|
90
|
+
for field_index, field in enumerate(row.get("fields") or []):
|
|
91
|
+
if isinstance(field, dict):
|
|
92
|
+
visit(field, f"/spec/body/{block_index}/rows/{row_index}/fields/{field_index}")
|
|
93
|
+
return result, warnings
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def validate_app_form_create_icon(payload: JSONObject) -> None:
|
|
97
|
+
if "appKey" in payload:
|
|
98
|
+
return
|
|
99
|
+
spec = payload.get("spec")
|
|
100
|
+
if not isinstance(spec, dict):
|
|
101
|
+
return
|
|
102
|
+
ok, error_code, message, details = validate_workspace_icon_choice(
|
|
103
|
+
icon=str(spec.get("icon") or "") or None,
|
|
104
|
+
color=str(spec.get("color") or "") or None,
|
|
105
|
+
require_explicit=True,
|
|
106
|
+
disallow_generic=True,
|
|
107
|
+
)
|
|
108
|
+
if not ok:
|
|
109
|
+
color_errors = {"WORKSPACE_ICON_COLOR_REQUIRED", "WORKSPACE_ICON_COLOR_NOT_FOUND"}
|
|
110
|
+
raise AppFormContractError(
|
|
111
|
+
error_code or "WORKSPACE_ICON_INVALID",
|
|
112
|
+
message or "invalid workspace icon configuration",
|
|
113
|
+
path="/spec/color" if error_code in color_errors else "/spec/icon",
|
|
114
|
+
details=details,
|
|
115
|
+
)
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from copy import deepcopy
|
|
4
|
+
import json
|
|
5
|
+
from typing import Any, Iterable
|
|
6
|
+
|
|
7
|
+
from jsonschema import Draft202012Validator, FormatChecker
|
|
8
|
+
|
|
9
|
+
from .app_form_compat import normalize_app_form_declaration, validate_app_form_create_icon
|
|
10
|
+
from .builder_facade.app_form_schema import AppFormContractError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AppFormValidationError(ValueError):
|
|
14
|
+
"""Raised when a declaration fails local AppForm validation."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, issues: list[dict[str, str]], *, schema_version: str, api_version: str) -> None:
|
|
17
|
+
super().__init__("AppForm declaration is invalid")
|
|
18
|
+
self.issues = issues
|
|
19
|
+
self.schema_version = schema_version
|
|
20
|
+
self.api_version = api_version
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _field_types(payload: dict[str, Any]) -> tuple[str, ...]:
|
|
24
|
+
found: set[str] = set()
|
|
25
|
+
|
|
26
|
+
def visit(field: Any) -> None:
|
|
27
|
+
if not isinstance(field, dict):
|
|
28
|
+
return
|
|
29
|
+
field_type = str(field.get("type") or "").strip()
|
|
30
|
+
if field_type:
|
|
31
|
+
found.add(field_type)
|
|
32
|
+
for child in field.get("subfields") or []:
|
|
33
|
+
visit(child)
|
|
34
|
+
|
|
35
|
+
spec = payload.get("spec")
|
|
36
|
+
for block in spec.get("body") or [] if isinstance(spec, dict) else []:
|
|
37
|
+
if not isinstance(block, dict):
|
|
38
|
+
continue
|
|
39
|
+
rows = [block] if block.get("kind") == "row" else block.get("rows") or []
|
|
40
|
+
for row in rows:
|
|
41
|
+
for field in row.get("fields") or [] if isinstance(row, dict) else []:
|
|
42
|
+
visit(field)
|
|
43
|
+
return tuple(sorted(found))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _local_schema_call(schema_version: str | None, field_type: str | None) -> dict[str, Any]:
|
|
47
|
+
from .builder_facade.app_form_schema import app_form_schema_registry
|
|
48
|
+
|
|
49
|
+
return {"status": "success", **app_form_schema_registry().schema(schema_version=schema_version, field_type=field_type)}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _compose(summary: dict[str, Any], details: list[dict[str, Any]]) -> dict[str, Any]:
|
|
53
|
+
if not details:
|
|
54
|
+
raise RuntimeError("at least one field Schema is required to compose AppForm validation")
|
|
55
|
+
schema = deepcopy(summary["schema"])
|
|
56
|
+
shared_defs = deepcopy(schema.get("$defs") or {})
|
|
57
|
+
field_branches = []
|
|
58
|
+
for detail in details:
|
|
59
|
+
detail_schema = detail["schema"]
|
|
60
|
+
for name, definition in (detail_schema.get("$defs") or {}).items():
|
|
61
|
+
existing = shared_defs.get(name)
|
|
62
|
+
if existing is not None and existing != definition:
|
|
63
|
+
raise RuntimeError(f"field Schema definition conflict: {name}")
|
|
64
|
+
shared_defs[name] = deepcopy(definition)
|
|
65
|
+
branch = {
|
|
66
|
+
key: deepcopy(value)
|
|
67
|
+
for key, value in detail_schema.items()
|
|
68
|
+
if key not in {"$schema", "$id", "$defs", "title"}
|
|
69
|
+
}
|
|
70
|
+
field_branches.append(branch)
|
|
71
|
+
shared_defs["TopField"] = {"oneOf": field_branches}
|
|
72
|
+
schema["$defs"] = shared_defs
|
|
73
|
+
schema["properties"]["spec"] = {
|
|
74
|
+
"$ref": "#/$defs/AppFormSpec",
|
|
75
|
+
"description": "Application form target state.",
|
|
76
|
+
}
|
|
77
|
+
return schema
|
|
78
|
+
|
|
79
|
+
|
|
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
|
+
def _pointer(parts: Iterable[Any]) -> str:
|
|
89
|
+
encoded = [str(item).replace("~", "~0").replace("/", "~1") for item in parts]
|
|
90
|
+
return "/" + "/".join(encoded) if encoded else "/"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _static_errors(payload: dict[str, Any]) -> list[tuple[str, str]]:
|
|
94
|
+
errors: list[tuple[str, str]] = []
|
|
95
|
+
spec = payload.get("spec")
|
|
96
|
+
body = spec.get("body") if isinstance(spec, dict) else None
|
|
97
|
+
if not isinstance(body, list):
|
|
98
|
+
return errors
|
|
99
|
+
top_fields: list[tuple[str, dict[str, Any]]] = []
|
|
100
|
+
for block_index, block in enumerate(body):
|
|
101
|
+
if not isinstance(block, dict):
|
|
102
|
+
continue
|
|
103
|
+
rows = [block] if block.get("kind") == "row" else block.get("rows") or []
|
|
104
|
+
for row_index, row in enumerate(rows):
|
|
105
|
+
if not isinstance(row, dict):
|
|
106
|
+
continue
|
|
107
|
+
fields = row.get("fields")
|
|
108
|
+
if not isinstance(fields, list):
|
|
109
|
+
continue
|
|
110
|
+
row_path = f"/spec/body/{block_index}/" + ("fields" if block.get("kind") == "row" else f"rows/{row_index}/fields")
|
|
111
|
+
widths = ["width" in field for field in fields if isinstance(field, dict)]
|
|
112
|
+
if widths and any(widths) and not all(widths):
|
|
113
|
+
errors.append((row_path, "width must be present on every field in the row or omitted from all"))
|
|
114
|
+
if widths and all(widths) and sum(field.get("width", 0) for field in fields if isinstance(field, dict)) != 100:
|
|
115
|
+
errors.append((row_path, "field widths must total 100"))
|
|
116
|
+
for field_index, field in enumerate(fields):
|
|
117
|
+
if isinstance(field, dict):
|
|
118
|
+
top_fields.append((f"{row_path}/{field_index}", field))
|
|
119
|
+
if top_fields and sum(field.get("dataTitle") is True for _, field in top_fields) != 1:
|
|
120
|
+
errors.append(("/spec/body", "a non-empty body must contain exactly one dataTitle field"))
|
|
121
|
+
covers = [(path, field) for path, field in top_fields if field.get("dataCover") is True]
|
|
122
|
+
if len(covers) > 1:
|
|
123
|
+
errors.append(("/spec/body", "body may contain at most one dataCover field"))
|
|
124
|
+
for path, field in covers:
|
|
125
|
+
if field.get("type") != "attachment":
|
|
126
|
+
errors.append((f"{path}/dataCover", "dataCover is only valid on attachment fields"))
|
|
127
|
+
names: dict[str, str] = {}
|
|
128
|
+
fields_by_id = {
|
|
129
|
+
field.get("queId"): field
|
|
130
|
+
for _, field in top_fields
|
|
131
|
+
if isinstance(field.get("queId"), int) and not isinstance(field.get("queId"), bool)
|
|
132
|
+
}
|
|
133
|
+
for path, field in top_fields:
|
|
134
|
+
name = str(field.get("name") or "")
|
|
135
|
+
if name in names:
|
|
136
|
+
errors.append((f"{path}/name", f"duplicate top-level field name; first declared at {names[name]}"))
|
|
137
|
+
else:
|
|
138
|
+
names[name] = f"{path}/name"
|
|
139
|
+
if field.get("type") != "q_linker":
|
|
140
|
+
continue
|
|
141
|
+
binding = (field.get("config") or {}).get("qLinkerBinding") if isinstance(field.get("config"), dict) else None
|
|
142
|
+
for index, item in enumerate((binding or {}).get("inputs") or [] if isinstance(binding, dict) else []):
|
|
143
|
+
if not isinstance(item, dict) or item.get("source") != "json_path":
|
|
144
|
+
continue
|
|
145
|
+
selector = item.get("field") if isinstance(item.get("field"), dict) else {}
|
|
146
|
+
selected_name = selector.get("name")
|
|
147
|
+
if not selected_name and selector.get("queId") in fields_by_id:
|
|
148
|
+
selected_name = fields_by_id[selector["queId"]].get("name")
|
|
149
|
+
if selected_name and item.get("key") != selected_name:
|
|
150
|
+
errors.append((f"{path}/config/qLinkerBinding/inputs/{index}/key", "json_path key must equal the selected field name"))
|
|
151
|
+
return errors
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def validate_declaration(
|
|
155
|
+
payload: dict[str, Any],
|
|
156
|
+
*,
|
|
157
|
+
schema_version: str | None = None,
|
|
158
|
+
schema_call: Any = _local_schema_call,
|
|
159
|
+
) -> dict[str, Any]:
|
|
160
|
+
"""Validate a complete AppForm declaration without network or write calls."""
|
|
161
|
+
summary = schema_call(schema_version, None)
|
|
162
|
+
if not isinstance(summary, dict) or summary.get("status") != "success" or not isinstance(summary.get("schema"), dict):
|
|
163
|
+
raise RuntimeError(f"AppForm Schema query failed: {json.dumps(summary, ensure_ascii=False)}")
|
|
164
|
+
pinned = str(summary.get("schemaVersion") or "")
|
|
165
|
+
api_version = str(summary.get("apiVersion") or "")
|
|
166
|
+
if schema_version and pinned != schema_version:
|
|
167
|
+
raise ValueError(f"/: requested schemaVersion {schema_version!r} resolved to {pinned!r}")
|
|
168
|
+
try:
|
|
169
|
+
declaration, _warnings = normalize_app_form_declaration(payload)
|
|
170
|
+
except AppFormContractError as error:
|
|
171
|
+
raise AppFormValidationError(
|
|
172
|
+
[{"pointer": error.path or "/", "message": error.message}],
|
|
173
|
+
schema_version=pinned,
|
|
174
|
+
api_version=api_version,
|
|
175
|
+
) from error
|
|
176
|
+
if declaration.get("apiVersion") != api_version:
|
|
177
|
+
raise ValueError(f"/apiVersion: expected {api_version!r} for schemaVersion {pinned!r}")
|
|
178
|
+
types = _field_types(declaration)
|
|
179
|
+
details = [schema_call(pinned, field_type) for field_type in (types or ("text",))]
|
|
180
|
+
for detail in details:
|
|
181
|
+
if not isinstance(detail, dict) or detail.get("status") != "success" or detail.get("schemaVersion") != pinned or detail.get("apiVersion") != api_version:
|
|
182
|
+
raise RuntimeError("/: AppForm Schema responses mixed versions")
|
|
183
|
+
schema = _compose(summary, details)
|
|
184
|
+
Draft202012Validator.check_schema(schema)
|
|
185
|
+
validator = Draft202012Validator(schema, format_checker=FormatChecker())
|
|
186
|
+
found: dict[tuple[str, str], None] = {}
|
|
187
|
+
for error in validator.iter_errors(declaration):
|
|
188
|
+
for leaf in _leaf_errors(error):
|
|
189
|
+
found[(_pointer(leaf.absolute_path), f"{leaf.validator}: {leaf.message}")] = None
|
|
190
|
+
for pointer, message in _static_errors(declaration):
|
|
191
|
+
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
|
+
if found:
|
|
197
|
+
issues = [{"pointer": pointer, "message": message} for pointer, message in sorted(found)]
|
|
198
|
+
raise AppFormValidationError(issues, schema_version=pinned, api_version=api_version)
|
|
199
|
+
return {"valid": True, "schemaVersion": pinned, "apiVersion": api_version, "fieldTypes": list(types)}
|