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.
- {papycli-0.6.0 → papycli-0.7.0}/CLAUDE.md +4 -2
- {papycli-0.6.0 → papycli-0.7.0}/PKG-INFO +40 -1
- {papycli-0.6.0 → papycli-0.7.0}/README.ja.md +39 -0
- {papycli-0.6.0 → papycli-0.7.0}/README.md +39 -0
- papycli-0.7.0/examples/request_filter/README.md +82 -0
- papycli-0.7.0/examples/request_filter/pyproject.toml +18 -0
- papycli-0.7.0/examples/request_filter/src/papycli_debug_filter/__init__.py +46 -0
- papycli-0.7.0/examples/response_filter/README.md +82 -0
- papycli-0.7.0/examples/response_filter/pyproject.toml +18 -0
- papycli-0.7.0/examples/response_filter/src/papycli_debug_response_filter/__init__.py +42 -0
- {papycli-0.6.0 → papycli-0.7.0}/pyproject.toml +1 -1
- {papycli-0.6.0 → papycli-0.7.0}/src/papycli/api_call.py +91 -3
- {papycli-0.6.0 → papycli-0.7.0}/src/papycli/request_filter.py +110 -1
- papycli-0.7.0/tests/test_request_filter.py +755 -0
- papycli-0.6.0/tests/test_request_filter.py +0 -320
- {papycli-0.6.0 → papycli-0.7.0}/.github/workflows/release.yml +0 -0
- {papycli-0.6.0 → papycli-0.7.0}/.gitignore +0 -0
- {papycli-0.6.0 → papycli-0.7.0}/.python-version +0 -0
- {papycli-0.6.0 → papycli-0.7.0}/LICENSE +0 -0
- {papycli-0.6.0 → papycli-0.7.0}/design_doc.md +0 -0
- {papycli-0.6.0/examples → papycli-0.7.0/examples/petstore}/docker-compose.yml +0 -0
- {papycli-0.6.0/examples → papycli-0.7.0/examples/petstore}/petstore-oas3.json +0 -0
- {papycli-0.6.0 → papycli-0.7.0}/src/papycli/__init__.py +0 -0
- {papycli-0.6.0 → papycli-0.7.0}/src/papycli/checker.py +0 -0
- {papycli-0.6.0 → papycli-0.7.0}/src/papycli/completion.py +0 -0
- {papycli-0.6.0 → papycli-0.7.0}/src/papycli/config.py +0 -0
- {papycli-0.6.0 → papycli-0.7.0}/src/papycli/i18n.py +0 -0
- {papycli-0.6.0 → papycli-0.7.0}/src/papycli/init_cmd.py +0 -0
- {papycli-0.6.0 → papycli-0.7.0}/src/papycli/main.py +0 -0
- {papycli-0.6.0 → papycli-0.7.0}/src/papycli/spec_loader.py +0 -0
- {papycli-0.6.0 → papycli-0.7.0}/src/papycli/summary.py +0 -0
- {papycli-0.6.0 → papycli-0.7.0}/tests/conftest.py +0 -0
- {papycli-0.6.0 → papycli-0.7.0}/tests/test_api_call.py +0 -0
- {papycli-0.6.0 → papycli-0.7.0}/tests/test_checker.py +0 -0
- {papycli-0.6.0 → papycli-0.7.0}/tests/test_completion.py +0 -0
- {papycli-0.6.0 → papycli-0.7.0}/tests/test_config.py +0 -0
- {papycli-0.6.0 → papycli-0.7.0}/tests/test_i18n.py +0 -0
- {papycli-0.6.0 → papycli-0.7.0}/tests/test_init_cmd.py +0 -0
- {papycli-0.6.0 → papycli-0.7.0}/tests/test_main.py +0 -0
- {papycli-0.6.0 → papycli-0.7.0}/tests/test_spec_loader.py +0 -0
- {papycli-0.6.0 → papycli-0.7.0}/tests/test_summary.py +0 -0
- {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
|
|
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.
|
|
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
|
|
@@ -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
|
|
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
|
-
#
|
|
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
|