Переглянути джерело

feat(ds): DSClient.post / cli post 加 form-encoded 支持

DS 3.x API 多用 @RequestParam(form-encoded),原 DSClient 只
支持 json_body 调不通;新增 form_data 互斥参数,cli 加 -d k=v。
单测覆盖 form_data / json_body 互斥 / cli -d 解析。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
tianyu.chu 1 день тому
батько
коміт
f6e4d415de
4 змінених файлів з 51 додано та 6 видалено
  1. 8 2
      dw_base/ds/api.py
  2. 8 2
      dw_base/ds/cli.py
  3. 16 0
      tests/unit/ds/test_api.py
  4. 19 2
      tests/unit/ds/test_cli.py

+ 8 - 2
dw_base/ds/api.py

@@ -45,8 +45,14 @@ class DSClient:
             raise RuntimeError('GET {} 返回非 JSON(可能 path 错被 SPA fallback): {}'.format(
                 path, resp.text[:200]))
 
-    def post(self, path: str, json_body: Optional[Dict[str, Any]] = None) -> Any:
-        resp = self.session.post(self._url(path), json=json_body)
+    def post(self, path: str, json_body: Optional[Dict[str, Any]] = None,
+             form_data: Optional[Dict[str, Any]] = None) -> Any:
+        if json_body is not None and form_data is not None:
+            raise ValueError('json_body 与 form_data 互斥')
+        if form_data is not None:
+            resp = self.session.post(self._url(path), data=form_data)
+        else:
+            resp = self.session.post(self._url(path), json=json_body)
         if resp.status_code // 100 != 2:
             raise RuntimeError('POST {} failed rc={}: {}'.format(
                 path, resp.status_code, resp.text))

+ 8 - 2
dw_base/ds/cli.py

@@ -40,7 +40,9 @@ def main(argv=None):
 
     p = sub.add_parser('post', help='HTTP POST')
     p.add_argument('path')
-    p.add_argument('-j', dest='body', default=None, metavar='JSON', help='JSON body')
+    p.add_argument('-j', dest='body', default=None, metavar='JSON', help='JSON body(与 -d 互斥)')
+    p.add_argument('-d', dest='form', action='append', default=[], metavar='k=v',
+                   help='form-data 参数(可多次,DS 3.x 多用 form-encoded;与 -j 互斥)')
     p.add_argument('-o', dest='output', default=None, metavar='FILE', help='写 JSON 到文件(不打 stdout)')
 
     args = parser.parse_args(argv)
@@ -50,7 +52,11 @@ def main(argv=None):
         result = client.get(args.path, params=_parse_kv(args.p))
     else:
         body = json.loads(args.body) if args.body else None
-        result = client.post(args.path, json_body=body)
+        form = _parse_kv(args.form) if args.form else None
+        if body is not None and form is not None:
+            sys.stderr.write('-j 与 -d 互斥\n')
+            sys.exit(1)
+        result = client.post(args.path, json_body=body, form_data=form)
 
     text = json.dumps(result, ensure_ascii=False, indent=2)
     if args.output:

+ 16 - 0
tests/unit/ds/test_api.py

@@ -121,3 +121,19 @@ def test_post_non_2xx_raises(client):
         mock_post.return_value = MagicMock(status_code=403, text='Forbidden')
         with pytest.raises(RuntimeError, match='POST .*failed rc=403'):
             client.post('/foo')
+
+
+def test_post_form_data_uses_data_kwarg(client):
+    """form_data 走 requests data= kwarg(form-encoded),不走 json="""
+    with patch.object(client.session, 'post') as mock_post:
+        resp = MagicMock(status_code=200)
+        resp.json.return_value = {'ok': True}
+        mock_post.return_value = resp
+        client.post('/foo', form_data={'name': 'x', 'desc': 'hi'})
+        mock_post.assert_called_once_with(
+            'http://example/dolphinscheduler/foo', data={'name': 'x', 'desc': 'hi'})
+
+
+def test_post_json_and_form_mutex(client):
+    with pytest.raises(ValueError, match='互斥'):
+        client.post('/foo', json_body={'k': 'v'}, form_data={'k': 'v'})

+ 19 - 2
tests/unit/ds/test_cli.py

@@ -46,7 +46,7 @@ def test_post_with_json_body(MockClient, capsys):
 
     cli.main(['post', '/foo', '-j', '{"a":1,"b":"x"}'])
 
-    inst.post.assert_called_once_with('/foo', json_body={'a': 1, 'b': 'x'})
+    inst.post.assert_called_once_with('/foo', json_body={'a': 1, 'b': 'x'}, form_data=None)
 
 
 @patch('dw_base.ds.cli.DSClient')
@@ -57,7 +57,24 @@ def test_post_no_body(MockClient, capsys):
 
     cli.main(['post', '/foo'])
 
-    inst.post.assert_called_once_with('/foo', json_body=None)
+    inst.post.assert_called_once_with('/foo', json_body=None, form_data=None)
+
+
+@patch('dw_base.ds.cli.DSClient')
+def test_post_with_form_data(MockClient, capsys):
+    inst = MagicMock()
+    inst.post.return_value = {'ok': True}
+    MockClient.return_value = inst
+
+    cli.main(['post', '/foo', '-d', 'name=x', '-d', 'desc=hi'])
+
+    inst.post.assert_called_once_with(
+        '/foo', json_body=None, form_data={'name': 'x', 'desc': 'hi'})
+
+
+def test_post_j_and_d_mutex_exits():
+    with pytest.raises(SystemExit):
+        cli.main(['post', '/foo', '-j', '{}', '-d', 'k=v'])
 
 
 def test_no_subcommand_exits():