papycli 0.6.0__tar.gz → 0.7.0__tar.gz

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.
Files changed (42) hide show
  1. {papycli-0.6.0 → papycli-0.7.0}/CLAUDE.md +4 -2
  2. {papycli-0.6.0 → papycli-0.7.0}/PKG-INFO +40 -1
  3. {papycli-0.6.0 → papycli-0.7.0}/README.ja.md +39 -0
  4. {papycli-0.6.0 → papycli-0.7.0}/README.md +39 -0
  5. papycli-0.7.0/examples/request_filter/README.md +82 -0
  6. papycli-0.7.0/examples/request_filter/pyproject.toml +18 -0
  7. papycli-0.7.0/examples/request_filter/src/papycli_debug_filter/__init__.py +46 -0
  8. papycli-0.7.0/examples/response_filter/README.md +82 -0
  9. papycli-0.7.0/examples/response_filter/pyproject.toml +18 -0
  10. papycli-0.7.0/examples/response_filter/src/papycli_debug_response_filter/__init__.py +42 -0
  11. {papycli-0.6.0 → papycli-0.7.0}/pyproject.toml +1 -1
  12. {papycli-0.6.0 → papycli-0.7.0}/src/papycli/api_call.py +91 -3
  13. {papycli-0.6.0 → papycli-0.7.0}/src/papycli/request_filter.py +110 -1
  14. papycli-0.7.0/tests/test_request_filter.py +755 -0
  15. papycli-0.6.0/tests/test_request_filter.py +0 -320
  16. {papycli-0.6.0 → papycli-0.7.0}/.github/workflows/release.yml +0 -0
  17. {papycli-0.6.0 → papycli-0.7.0}/.gitignore +0 -0
  18. {papycli-0.6.0 → papycli-0.7.0}/.python-version +0 -0
  19. {papycli-0.6.0 → papycli-0.7.0}/LICENSE +0 -0
  20. {papycli-0.6.0 → papycli-0.7.0}/design_doc.md +0 -0
  21. {papycli-0.6.0/examples → papycli-0.7.0/examples/petstore}/docker-compose.yml +0 -0
  22. {papycli-0.6.0/examples → papycli-0.7.0/examples/petstore}/petstore-oas3.json +0 -0
  23. {papycli-0.6.0 → papycli-0.7.0}/src/papycli/__init__.py +0 -0
  24. {papycli-0.6.0 → papycli-0.7.0}/src/papycli/checker.py +0 -0
  25. {papycli-0.6.0 → papycli-0.7.0}/src/papycli/completion.py +0 -0
  26. {papycli-0.6.0 → papycli-0.7.0}/src/papycli/config.py +0 -0
  27. {papycli-0.6.0 → papycli-0.7.0}/src/papycli/i18n.py +0 -0
  28. {papycli-0.6.0 → papycli-0.7.0}/src/papycli/init_cmd.py +0 -0
  29. {papycli-0.6.0 → papycli-0.7.0}/src/papycli/main.py +0 -0
  30. {papycli-0.6.0 → papycli-0.7.0}/src/papycli/spec_loader.py +0 -0
  31. {papycli-0.6.0 → papycli-0.7.0}/src/papycli/summary.py +0 -0
  32. {papycli-0.6.0 → papycli-0.7.0}/tests/conftest.py +0 -0
  33. {papycli-0.6.0 → papycli-0.7.0}/tests/test_api_call.py +0 -0
  34. {papycli-0.6.0 → papycli-0.7.0}/tests/test_checker.py +0 -0
  35. {papycli-0.6.0 → papycli-0.7.0}/tests/test_completion.py +0 -0
  36. {papycli-0.6.0 → papycli-0.7.0}/tests/test_config.py +0 -0
  37. {papycli-0.6.0 → papycli-0.7.0}/tests/test_i18n.py +0 -0
  38. {papycli-0.6.0 → papycli-0.7.0}/tests/test_init_cmd.py +0 -0
  39. {papycli-0.6.0 → papycli-0.7.0}/tests/test_main.py +0 -0
  40. {papycli-0.6.0 → papycli-0.7.0}/tests/test_spec_loader.py +0 -0
  41. {papycli-0.6.0 → papycli-0.7.0}/tests/test_summary.py +0 -0
  42. {papycli-0.6.0 → papycli-0.7.0}/uv.lock +0 -0
@@ -58,6 +58,8 @@ papycli/
58
58
  │ ├── test_spec_loader.py
59
59
  │ └── test_summary.py
60
60
  ├── examples/
61
+ │ ├── request_filter/ # リクエストフィルタープラグイン実装例
62
+ │ ├── response_filter/ # レスポンスフィルタープラグイン実装例
61
63
  │ ├── docker-compose.yml
62
64
  │ └── petstore-oas3.json
63
65
  ├── pyproject.toml
@@ -90,8 +92,8 @@ bash / zsh 向けの補完スクリプトを生成する。補完の候補はメ
90
92
  **`config.py`** — 設定管理
91
93
  `papycli.conf` の読み書きと、`PAPYCLI_CONF_DIR` 環境変数の解決を行う。ログファイルパスの取得・設定・削除も担当する。
92
94
 
93
- **`request_filter.py`** — リクエストフィルタープラグイン機構
94
- エントリポイントグループ `papycli.request_filters` に登録されたフィルター関数をプラグイン名の昇順で呼び出し、リクエスト送信前に URL・クエリパラメータ・ボディ・ヘッダーを変換できるようにする。`RequestContext` データクラスと `load_filters()` / `apply_filters()` 関数を提供する。
95
+ **`request_filter.py`** — リクエスト・レスポンスフィルタープラグイン機構
96
+ エントリポイントグループ `papycli.request_filters` に登録されたフィルター関数をプラグイン名の昇順で呼び出し、リクエスト送信前に URL・クエリパラメータ・ボディ・ヘッダーを変換できるようにする。同様に `papycli.response_filters` グループのフィルター関数を呼び出し、レスポンス受信後にステータスコード・理由フレーズ(reason)・ボディ・ヘッダーを参照・変更できるようにする。`RequestContext` / `ResponseContext` データクラスと `load_filters()` / `apply_filters()` / `load_response_filters()` / `apply_response_filters()` 関数を提供する。
95
97
 
96
98
  **`summary.py`** — サマリー表示
97
99
  登録済み API のエンドポイント一覧を整形して出力する。`--summary-csv` では CSV 形式で出力する。
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: papycli
3
- Version: 0.6.0
3
+ Version: 0.7.0
4
4
  Summary: A CLI tool to call REST APIs defined in OpenAPI 3.0 specs
5
5
  Project-URL: Homepage, https://github.com/tmonj1/papycli
6
6
  Project-URL: Repository, https://github.com/tmonj1/papycli
@@ -39,6 +39,7 @@ Description-Content-Type: text/markdown
39
39
  - Automatically coerces `-p` values to the correct JSON type (integer, number, boolean) based on the API spec
40
40
  - Log requests and responses to a file with `papycli config log`
41
41
  - Extend request processing with request filter plugins (`papycli.request_filters` entry point)
42
+ - Inspect and transform responses with response filter plugins (`papycli.response_filters` entry point)
42
43
 
43
44
  ## Requirements
44
45
 
@@ -293,6 +294,44 @@ Install the package and filters are applied automatically on every request, sort
293
294
 
294
295
  ---
295
296
 
297
+ ## Response Filter Plugins
298
+
299
+ You can inspect and transform incoming responses by writing a response filter plugin.
300
+
301
+ A filter is a callable that receives a `ResponseContext` and returns a modified `ResponseContext`:
302
+
303
+ ```python
304
+ # my_plugin.py
305
+ from papycli.request_filter import ResponseContext
306
+
307
+ def response_filter(ctx: ResponseContext) -> ResponseContext:
308
+ if isinstance(ctx.body, dict):
309
+ ctx.body["_status"] = ctx.status_code
310
+ return ctx
311
+ ```
312
+
313
+ Register it in your package's `pyproject.toml`:
314
+
315
+ ```toml
316
+ [project.entry-points."papycli.response_filters"]
317
+ my-filter = "my_plugin:response_filter"
318
+ ```
319
+
320
+ Install the package and the filters are applied automatically after every response, sorted by plugin name.
321
+
322
+ `ResponseContext` fields:
323
+
324
+ | Field | Type | Description |
325
+ |-------|------|-------------|
326
+ | `method` | `str` | HTTP method used for the request (lowercase). |
327
+ | `url` | `str` | Full URL of the request. |
328
+ | `status_code` | `int` | HTTP response status code. |
329
+ | `reason` | `str` | HTTP response reason phrase (e.g. `"OK"`, `"Not Found"`). |
330
+ | `headers` | `dict[str, str]` | Response headers. |
331
+ | `body` | `dict \| list \| str \| int \| float \| bool \| None` | Parsed response body. Modify this field to replace the response body. |
332
+
333
+ ---
334
+
296
335
  ## Limitations
297
336
 
298
337
  - Request bodies are `application/json` only
@@ -12,6 +12,7 @@
12
12
  - API 仕様に基づいて `-p` の値を適切な JSON 型(integer / number / boolean)に自動変換
13
13
  - `papycli config log` によるリクエスト/レスポンスのファイルログ
14
14
  - リクエストフィルタープラグイン(`papycli.request_filters` エントリポイント)によるリクエスト処理の拡張
15
+ - レスポンスフィルタープラグイン(`papycli.response_filters` エントリポイント)によるレスポンスの参照・変換
15
16
 
16
17
  ## 必要環境
17
18
 
@@ -265,6 +266,44 @@ my-filter = "my_plugin:request_filter"
265
266
 
266
267
  ---
267
268
 
269
+ ## レスポンスフィルタープラグイン
270
+
271
+ レスポンスフィルタープラグインを作成することで、受信後のレスポンスを参照・変換できます。
272
+
273
+ フィルターは `ResponseContext` を受け取り、変更した `ResponseContext` を返す callable です:
274
+
275
+ ```python
276
+ # my_plugin.py
277
+ from papycli.request_filter import ResponseContext
278
+
279
+ def response_filter(ctx: ResponseContext) -> ResponseContext:
280
+ if isinstance(ctx.body, dict):
281
+ ctx.body["_status"] = ctx.status_code
282
+ return ctx
283
+ ```
284
+
285
+ パッケージの `pyproject.toml` にエントリポイントを登録します:
286
+
287
+ ```toml
288
+ [project.entry-points."papycli.response_filters"]
289
+ my-filter = "my_plugin:response_filter"
290
+ ```
291
+
292
+ パッケージをインストールすると、すべてのレスポンスに対してフィルターがプラグイン名の昇順で自動適用されます。
293
+
294
+ `ResponseContext` のフィールド:
295
+
296
+ | フィールド | 型 | 説明 |
297
+ |-----------|-----|------|
298
+ | `method` | `str` | リクエストに使用した HTTP メソッド(小文字)。 |
299
+ | `url` | `str` | リクエストに使用した完全 URL。 |
300
+ | `status_code` | `int` | HTTP レスポンスステータスコード。 |
301
+ | `reason` | `str` | HTTP レスポンスの理由フレーズ(例:`"OK"`、`"Not Found"`)。 |
302
+ | `headers` | `dict[str, str]` | レスポンスヘッダー。 |
303
+ | `body` | `dict \| list \| str \| int \| float \| bool \| None` | パース済みレスポンスボディ。このフィールドを変更するとレスポンスボディを差し替えられる。 |
304
+
305
+ ---
306
+
268
307
  ## 制限事項
269
308
 
270
309
  - リクエストボディは `application/json` のみ対応
@@ -12,6 +12,7 @@
12
12
  - Automatically coerces `-p` values to the correct JSON type (integer, number, boolean) based on the API spec
13
13
  - Log requests and responses to a file with `papycli config log`
14
14
  - Extend request processing with request filter plugins (`papycli.request_filters` entry point)
15
+ - Inspect and transform responses with response filter plugins (`papycli.response_filters` entry point)
15
16
 
16
17
  ## Requirements
17
18
 
@@ -266,6 +267,44 @@ Install the package and filters are applied automatically on every request, sort
266
267
 
267
268
  ---
268
269
 
270
+ ## Response Filter Plugins
271
+
272
+ You can inspect and transform incoming responses by writing a response filter plugin.
273
+
274
+ A filter is a callable that receives a `ResponseContext` and returns a modified `ResponseContext`:
275
+
276
+ ```python
277
+ # my_plugin.py
278
+ from papycli.request_filter import ResponseContext
279
+
280
+ def response_filter(ctx: ResponseContext) -> ResponseContext:
281
+ if isinstance(ctx.body, dict):
282
+ ctx.body["_status"] = ctx.status_code
283
+ return ctx
284
+ ```
285
+
286
+ Register it in your package's `pyproject.toml`:
287
+
288
+ ```toml
289
+ [project.entry-points."papycli.response_filters"]
290
+ my-filter = "my_plugin:response_filter"
291
+ ```
292
+
293
+ Install the package and the filters are applied automatically after every response, sorted by plugin name.
294
+
295
+ `ResponseContext` fields:
296
+
297
+ | Field | Type | Description |
298
+ |-------|------|-------------|
299
+ | `method` | `str` | HTTP method used for the request (lowercase). |
300
+ | `url` | `str` | Full URL of the request. |
301
+ | `status_code` | `int` | HTTP response status code. |
302
+ | `reason` | `str` | HTTP response reason phrase (e.g. `"OK"`, `"Not Found"`). |
303
+ | `headers` | `dict[str, str]` | Response headers. |
304
+ | `body` | `dict \| list \| str \| int \| float \| bool \| None` | Parsed response body. Modify this field to replace the response body. |
305
+
306
+ ---
307
+
269
308
  ## Limitations
270
309
 
271
310
  - Request bodies are `application/json` only
@@ -0,0 +1,82 @@
1
+ # papycli-debug-filter
2
+
3
+ A minimal example of a [papycli](https://github.com/tmonj1/papycli) request filter plugin.
4
+
5
+ This plugin prints the outgoing `RequestContext` to **stderr** before each request — useful as a starting point for writing your own filter.
6
+
7
+ > **Warning:** This plugin is intended for **debugging and development only**.
8
+ > It prints request headers without redaction, which may expose sensitive values such as
9
+ > Authorization tokens or API keys. Do NOT use this plugin in production environments.
10
+
11
+ ## What it does
12
+
13
+ Before every `papycli` API call, it writes to stderr:
14
+
15
+ ```
16
+ [papycli-debug-filter]
17
+ Method : GET
18
+ URL : http://localhost:8080/api/v3/store/inventory
19
+ Query : (none)
20
+ Body : (none)
21
+ Headers: (none)
22
+ ```
23
+
24
+ ## Installation
25
+
26
+ Install papycli and this plugin into the same Python environment:
27
+
28
+ ```bash
29
+ pip install papycli
30
+ pip install -e /path/to/papycli/examples/request_filter
31
+ ```
32
+
33
+ Once installed, the filter is picked up automatically — no additional configuration needed.
34
+
35
+ ## Usage
36
+
37
+ Run any papycli command. The filter output appears on **stderr**, while the API response is printed to **stdout** as usual:
38
+
39
+ ```bash
40
+ papycli get /store/inventory
41
+ ```
42
+
43
+ ```
44
+ # stderr:
45
+ [papycli-debug-filter]
46
+ Method : GET
47
+ URL : http://localhost:8080/api/v3/store/inventory
48
+ Query : (none)
49
+ Body : (none)
50
+ Headers: (none)
51
+
52
+ # stdout:
53
+ {
54
+ "approved": 1,
55
+ ...
56
+ }
57
+ ```
58
+
59
+ Because debug output goes to stderr, stdout remains machine-readable and piping still works:
60
+
61
+ ```bash
62
+ papycli get /store/inventory | jq '.approved'
63
+ ```
64
+
65
+ ## How it works
66
+
67
+ The plugin is registered via the `papycli.request_filters` entry point in `pyproject.toml`:
68
+
69
+ ```toml
70
+ [project.entry-points."papycli.request_filters"]
71
+ debug = "papycli_debug_filter:request_filter"
72
+ ```
73
+
74
+ papycli discovers all installed plugins in this group, sorts them by name, and calls each one before sending the request. The filter receives a `RequestContext` and must return a (possibly modified) `RequestContext`.
75
+
76
+ ## Writing your own filter
77
+
78
+ 1. Copy this directory as a starting point.
79
+ 2. Edit `src/papycli_debug_filter/__init__.py` — modify `ctx` fields as needed and return it.
80
+ 3. Change the package name in `pyproject.toml` and re-install with `pip install -e .`.
81
+
82
+ See the [papycli README](../../README.md#request-filter-plugins) for the full `RequestContext` field reference.
@@ -0,0 +1,18 @@
1
+ [project]
2
+ name = "papycli-debug-filter"
3
+ version = "0.1.0"
4
+ description = "A papycli request filter plugin that prints request context to stderr"
5
+ requires-python = ">=3.12"
6
+ dependencies = [
7
+ "papycli>=0.6.0",
8
+ ]
9
+
10
+ [project.entry-points."papycli.request_filters"]
11
+ debug = "papycli_debug_filter:request_filter"
12
+
13
+ [build-system]
14
+ requires = ["hatchling"]
15
+ build-backend = "hatchling.build"
16
+
17
+ [tool.hatch.build.targets.wheel]
18
+ packages = ["src/papycli_debug_filter"]
@@ -0,0 +1,46 @@
1
+ """papycli request filter plugin — debug filter.
2
+
3
+ Prints the outgoing RequestContext to stderr before each request.
4
+
5
+ WARNING: This plugin is intended for debugging and development only.
6
+ It prints request headers without redaction, which may expose
7
+ sensitive values such as Authorization tokens or API keys.
8
+ Do NOT use this plugin in production environments.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import sys
15
+
16
+ from papycli.request_filter import RequestContext
17
+
18
+
19
+ def _to_json(value: object) -> str:
20
+ """Serialize value to a JSON string, falling back to repr() on TypeError."""
21
+ try:
22
+ return json.dumps(value, ensure_ascii=False)
23
+ except TypeError:
24
+ return repr(value)
25
+
26
+
27
+ def request_filter(ctx: RequestContext) -> RequestContext:
28
+ """Print request context fields to stderr and return it unchanged."""
29
+ q_dict: dict[str, object] = {}
30
+ for k, v in ctx.query_params:
31
+ existing = q_dict.get(k)
32
+ if existing is None:
33
+ q_dict[k] = v
34
+ elif isinstance(existing, list):
35
+ existing.append(v)
36
+ else:
37
+ q_dict[k] = [existing, v]
38
+
39
+ print("[papycli-debug-filter]", file=sys.stderr)
40
+ print(f" Method : {ctx.method.upper()}", file=sys.stderr)
41
+ print(f" URL : {ctx.url}", file=sys.stderr)
42
+ print(f" Query : {_to_json(q_dict) if q_dict else '(none)'}", file=sys.stderr)
43
+ print(f" Body : {_to_json(ctx.body) if ctx.body is not None else '(none)'}", file=sys.stderr)
44
+ print(f" Headers: {_to_json(ctx.headers) if ctx.headers else '(none)'}", file=sys.stderr)
45
+
46
+ return ctx
@@ -0,0 +1,82 @@
1
+ # papycli-debug-response-filter
2
+
3
+ A minimal example of a [papycli](https://github.com/tmonj1/papycli) response filter plugin.
4
+
5
+ This plugin prints the received `ResponseContext` to **stderr** after each request — useful as a starting point for writing your own filter.
6
+
7
+ > **Warning:** This plugin is intended for **debugging and development only**.
8
+ > It prints response headers without redaction, which may expose sensitive values such as
9
+ > Set-Cookie headers or authentication tokens. Do NOT use this plugin in production environments.
10
+
11
+ ## What it does
12
+
13
+ After every `papycli` API call, it writes to stderr:
14
+
15
+ ```
16
+ [papycli-debug-response-filter]
17
+ Method : GET
18
+ URL : http://localhost:8080/api/v3/store/inventory
19
+ Status : 200 OK
20
+ Headers : {"Content-Type": "application/json", ...}
21
+ Body : {"approved": 1, ...}
22
+ ```
23
+
24
+ ## Installation
25
+
26
+ Install papycli and this plugin into the same Python environment:
27
+
28
+ ```bash
29
+ pip install papycli
30
+ pip install -e /path/to/papycli/examples/response_filter
31
+ ```
32
+
33
+ Once installed, the filter is picked up automatically — no additional configuration needed.
34
+
35
+ ## Usage
36
+
37
+ Run any papycli command. The filter output appears on **stderr**, while the API response is printed to **stdout** as usual:
38
+
39
+ ```bash
40
+ papycli get /store/inventory
41
+ ```
42
+
43
+ ```
44
+ # stderr:
45
+ [papycli-debug-response-filter]
46
+ Method : GET
47
+ URL : http://localhost:8080/api/v3/store/inventory
48
+ Status : 200 OK
49
+ Headers : {"Content-Type": "application/json; charset=utf-8"}
50
+ Body : {"approved": 1, "sold": 2}
51
+
52
+ # stdout:
53
+ {
54
+ "approved": 1,
55
+ "sold": 2
56
+ }
57
+ ```
58
+
59
+ Because debug output goes to stderr, stdout remains machine-readable and piping still works:
60
+
61
+ ```bash
62
+ papycli get /store/inventory | jq '.approved'
63
+ ```
64
+
65
+ ## How it works
66
+
67
+ The plugin is registered via the `papycli.response_filters` entry point in `pyproject.toml`:
68
+
69
+ ```toml
70
+ [project.entry-points."papycli.response_filters"]
71
+ debug = "papycli_debug_response_filter:response_filter"
72
+ ```
73
+
74
+ papycli discovers all installed plugins in this group, sorts them by name, and calls each one after receiving the response. The filter receives a `ResponseContext` and must return a (possibly modified) `ResponseContext`.
75
+
76
+ ## Writing your own filter
77
+
78
+ 1. Copy this directory as a starting point.
79
+ 2. Edit `src/papycli_debug_response_filter/__init__.py` — inspect or modify `ctx` fields as needed and return it.
80
+ 3. Change the package name in `pyproject.toml` and re-install with `pip install -e .`.
81
+
82
+ See the [papycli README](../../README.md#response-filter-plugins) for the full `ResponseContext` field reference.
@@ -0,0 +1,18 @@
1
+ [project]
2
+ name = "papycli-debug-response-filter"
3
+ version = "0.1.0"
4
+ description = "A papycli response filter plugin that prints response context to stderr"
5
+ requires-python = ">=3.12"
6
+ dependencies = [
7
+ "papycli>=0.7.0",
8
+ ]
9
+
10
+ [project.entry-points."papycli.response_filters"]
11
+ debug = "papycli_debug_response_filter:response_filter"
12
+
13
+ [build-system]
14
+ requires = ["hatchling"]
15
+ build-backend = "hatchling.build"
16
+
17
+ [tool.hatch.build.targets.wheel]
18
+ packages = ["src/papycli_debug_response_filter"]
@@ -0,0 +1,42 @@
1
+ """papycli response filter plugin — debug filter.
2
+
3
+ Prints the received ResponseContext to stderr after each request.
4
+
5
+ WARNING: This plugin is intended for debugging and development only.
6
+ It prints response headers without redaction, which may expose
7
+ sensitive values such as Set-Cookie or authentication tokens.
8
+ Do NOT use this plugin in production environments.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import sys
15
+
16
+ from papycli.request_filter import ResponseContext
17
+
18
+
19
+ def _to_json(value: object) -> str:
20
+ """Serialize value to a JSON string, falling back to repr() on TypeError."""
21
+ try:
22
+ return json.dumps(value, ensure_ascii=False)
23
+ except TypeError:
24
+ return repr(value)
25
+
26
+
27
+ def response_filter(ctx: ResponseContext) -> ResponseContext:
28
+ """Print response context fields to stderr and return it unchanged."""
29
+ print("[papycli-debug-response-filter]", file=sys.stderr)
30
+ print(f" Method : {ctx.method.upper()}", file=sys.stderr)
31
+ print(f" URL : {ctx.url}", file=sys.stderr)
32
+ print(f" Status : {ctx.status_code} {ctx.reason}", file=sys.stderr)
33
+ print(
34
+ f" Headers : {_to_json(ctx.headers) if ctx.headers else '(none)'}",
35
+ file=sys.stderr,
36
+ )
37
+ print(
38
+ f" Body : {_to_json(ctx.body) if ctx.body is not None else '(none)'}",
39
+ file=sys.stderr,
40
+ )
41
+
42
+ return ctx
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "papycli"
3
- version = "0.6.0"
3
+ version = "0.7.0"
4
4
  description = "A CLI tool to call REST APIs defined in OpenAPI 3.0 specs"
5
5
  readme = "README.md"
6
6
  license = {text = "MIT"}
@@ -262,7 +262,14 @@ def call_api(
262
262
  logfile: str | None = None,
263
263
  ) -> requests.Response:
264
264
  """API を呼び出し、レスポンスを返す。"""
265
- from papycli.request_filter import RequestContext, apply_filters, load_filters
265
+ from papycli.request_filter import (
266
+ RequestContext,
267
+ ResponseContext,
268
+ apply_filters,
269
+ apply_response_filters,
270
+ load_filters,
271
+ load_response_filters,
272
+ )
266
273
 
267
274
  if not base_url:
268
275
  raise RuntimeError(
@@ -318,8 +325,89 @@ def call_api(
318
325
  )
319
326
 
320
327
  if logfile:
321
- # ログにはフィルター適用前の値を使う。フィルタープラグインが追加した
322
- # URL・クエリ・ボディ・ヘッダーには機密情報が含まれる可能性があるため記録しない。
328
+ # ログにはリクエスト側フィルター適用前の値(url/query/body/headers)とサーバーが
329
+ # 返した元のレスポンス(レスポンスフィルター適用前)を記録する。
323
330
  _write_log(logfile, method, url, list(query_params), json_body, headers, resp)
324
331
 
332
+ # レスポンスフィルターを事前にロードし、フィルターが存在する場合のみ
333
+ # レスポンスボディのパースと ResponseContext 構築を行う。
334
+ response_filters = load_response_filters()
335
+ if response_filters:
336
+ content_type = resp.headers.get("Content-Type", "").lower()
337
+ if "application/json" in content_type:
338
+ try:
339
+ resp_body: (
340
+ dict[str, Any] | list[Any] | str | int | float | bool | None
341
+ ) = resp.json()
342
+ except ValueError:
343
+ resp_body = resp.text or None
344
+ else:
345
+ resp_body = resp.text or None
346
+
347
+ resp_ctx = ResponseContext(
348
+ method=method,
349
+ url=resp.url,
350
+ status_code=resp.status_code,
351
+ reason=resp.reason or "",
352
+ headers=dict(resp.headers),
353
+ body=resp_body,
354
+ )
355
+ resp_ctx = apply_response_filters(resp_ctx, response_filters)
356
+
357
+ # フィルターがフィールドを変更した場合、resp に反映する。
358
+ # ボディ: 値の等価比較で変更を検出し、_content・encoding・Content-Type を更新する。
359
+ # json.dumps が TypeError を送出した場合(非シリアライズ可能な値が含まれる場合等)は
360
+ # 警告を出力して元のレスポンスを維持する。
361
+ if resp_ctx.body != resp_body:
362
+ is_json_body = resp_ctx.body is not None and not isinstance(resp_ctx.body, str)
363
+ try:
364
+ if resp_ctx.body is None:
365
+ new_content: bytes = b""
366
+ elif isinstance(resp_ctx.body, str):
367
+ new_content = resp_ctx.body.encode("utf-8")
368
+ else:
369
+ new_content = json.dumps(resp_ctx.body, ensure_ascii=False).encode("utf-8")
370
+ except (TypeError, ValueError) as e:
371
+ print(
372
+ f"Warning: response filter returned a non-serializable body ({e});"
373
+ " keeping original response",
374
+ file=sys.stderr,
375
+ )
376
+ else:
377
+ resp._content = new_content
378
+ resp.encoding = "utf-8"
379
+ # Content-Length を新しいバイト長に更新する。
380
+ resp_ctx.headers["Content-Length"] = str(len(new_content))
381
+ # Content-Type をボディのシリアライズ方式にあわせて更新する。
382
+ # resp_ctx.headers を更新し、後続ヘッダー処理で一括して resp.headers に適用する。
383
+ ct = resp_ctx.headers.get("Content-Type", "")
384
+ if is_json_body:
385
+ # dict/list 等は JSON シリアライズされるため application/json に設定する。
386
+ resp_ctx.headers["Content-Type"] = "application/json; charset=utf-8"
387
+ else:
388
+ # str/None ボディは既存の Content-Type を尊重しつつ charset だけ更新する。
389
+ if ct:
390
+ parts = [p.strip() for p in ct.split(";") if p.strip()]
391
+ if parts:
392
+ base_type = parts[0]
393
+ base_type_lower = base_type.lower()
394
+ is_text = base_type_lower.startswith("text/")
395
+ if is_text or base_type_lower == "application/json":
396
+ other_params = [
397
+ p for p in parts[1:]
398
+ if not p.lower().startswith("charset=")
399
+ ]
400
+ resp_ctx.headers["Content-Type"] = "; ".join(
401
+ [base_type, *other_params, "charset=utf-8"]
402
+ )
403
+ else:
404
+ resp_ctx.headers["Content-Type"] = "text/plain; charset=utf-8"
405
+ # ステータスコード・理由フレーズ・ヘッダー: 変更があれば resp に反映する。
406
+ if resp_ctx.status_code != resp.status_code:
407
+ resp.status_code = resp_ctx.status_code
408
+ if resp_ctx.reason != (resp.reason or ""):
409
+ resp.reason = resp_ctx.reason
410
+ if resp_ctx.headers != dict(resp.headers):
411
+ resp.headers = requests.structures.CaseInsensitiveDict(resp_ctx.headers)
412
+
325
413
  return resp