From bcfc61b54dbcbe58d5e18b997687ed66a7d8f6f4 Mon Sep 17 00:00:00 2001 From: Magnus Hedemark Date: Sat, 11 Jul 2026 13:22:53 -0400 Subject: [PATCH] fix(forgejo-cli): harden API transport and guidance --- forgejo-cli/README.md | 8 ++- forgejo-cli/SKILL.md | 7 ++- forgejo-cli/V2-SPEC.md | 2 +- forgejo-cli/references/api-usage.md | 28 ++++++++++ forgejo-cli/references/command-reference.md | 24 ++++++++- forgejo-cli/references/endpoint-routing.md | 14 +++++ forgejo-cli/references/troubleshooting.md | 13 +++++ forgejo-cli/scripts/forgejo-cli | 57 ++++++++++++++++----- forgejo-cli/tests/test_cli.py | 48 +++++++++++++++++ 9 files changed, 182 insertions(+), 19 deletions(-) create mode 100644 forgejo-cli/references/api-usage.md create mode 100644 forgejo-cli/references/endpoint-routing.md create mode 100644 forgejo-cli/references/troubleshooting.md diff --git a/forgejo-cli/README.md b/forgejo-cli/README.md index 2b0ca20..612d8ee 100644 --- a/forgejo-cli/README.md +++ b/forgejo-cli/README.md @@ -6,12 +6,17 @@ Manage a Forgejo server from the terminal without hand-written `curl` or acciden The CLI gives agents one predictable safety contract: JSON is machine-readable, diagnostics stay off stdout, mutations require confirmation, and dry runs never need a token or network access. For version-specific features such as Actions runners and variables, packages, organizations, teams, admin APIs, notifications, and permissions, use the generic API command with your server's Swagger schema. +It deliberately does not try to duplicate every API endpoint. Instead, it offers guarded first-class commands for common repository work and a generic client for the rest. The references explain when to use each, how to select a compatible schema, and how to handle pagination and token scopes. + ## What You Get | Contents | Purpose | | --- | --- | | `scripts/forgejo-cli` | Argparse Forgejo API v1 client | | `references/command-reference.md` | Endpoint and payload guide | +| `references/api-usage.md` | Authentication, pagination, versioning, and schema-discovery procedure | +| `references/endpoint-routing.md` | Decision guide for first-class commands versus generic API calls | +| `references/troubleshooting.md` | Safe diagnosis of API and transport failures | | `V2-SPEC.md` | v2 acceptance criteria | | `tests/` | Offline stdlib contract tests | @@ -22,6 +27,7 @@ Set `FORGEJO_AGENT_TOKEN` (default) or `FORGEJO_USER_TOKEN`, then run: ```bash python3 scripts/forgejo-cli --dry-run --json repo create --name demo --private python3 scripts/forgejo-cli --server https://forge.example api --method GET --path /api/v1/user --json +python3 scripts/forgejo-cli --server https://forge.example --page 1 --limit 50 --include-response issue list --owner acme --repo app --json ``` ## Triggers @@ -32,4 +38,4 @@ python3 scripts/forgejo-cli --server https://forge.example api --method GET --pa ## Requirements -Python 3.8+ and `requests` for live API calls. `--help` and `--dry-run` need no token or dependency. Consult your Forgejo server's `/api/swagger` or `/swagger.v1.json` for exact schemas. +Python 3.8+ and `requests` for live API calls. `--help` and `--dry-run` need no token or dependency. Set `FORGEJO_AGENT_TOKEN` or `FORGEJO_USER_TOKEN` for token authentication. A deliberate `Authorization` header supports other documented schemes, but avoid putting secrets directly in shell history. Consult your Forgejo server's `/api/swagger` or `/swagger.v1.json` for exact schemas. diff --git a/forgejo-cli/SKILL.md b/forgejo-cli/SKILL.md index 42eeaa5..0f9581e 100644 --- a/forgejo-cli/SKILL.md +++ b/forgejo-cli/SKILL.md @@ -12,13 +12,14 @@ metadata: # Forgejo CLI v2 -Run `python3 scripts/forgejo-cli`. `--agent` (default) uses `FORGEJO_AGENT_TOKEN`; `--user` uses `FORGEJO_USER_TOKEN`; `--server URL` selects an installation. +Run `python3 scripts/forgejo-cli`. `--agent` (default) uses `FORGEJO_AGENT_TOKEN`; `--user` uses `FORGEJO_USER_TOKEN`; `--server URL` selects an installation. Set `FORGEJO_SERVER` in your shell if your wrapper supplies it, otherwise pass `--server` explicitly. ## Safety - Mutations require `--force`/`--yes`/`-y`, or `--dry-run`. - `--dry-run --json` emits one plan with `method`, `path`, `query`, and `body`; it makes no network request. - `--json` writes exactly one JSON value to stdout; diagnostics go to stderr. +- `--page` and `--limit` work for any list/search request. Add `--include-response` to receive the HTTP status plus `link` and `x-total-count` pagination headers. - `--help` does not read credentials or contact a server. Path segments are encoded. ## Common workflows @@ -31,3 +32,7 @@ python3 scripts/forgejo-cli --dry-run --json api --method POST \ ``` First-class groups: `issue`, `pr`, `repo`, `content`, `label`, `milestone`, `release`, `hook`, and `user`. `content` expects base64 and update/delete require the current SHA. Use `api` for any other `/api/v1/` endpoint; it supports JSON, raw files, multipart uploads/forms, and custom headers. Consult `/api/swagger` or `/swagger.v1.json` on the selected server. See [command reference](references/command-reference.md). + +## Choosing a command + +Use a first-class command for its covered daily workflow. Use `api` when the operation is absent, requires version-specific fields, or needs a schema not represented by a simple flag. Before using `api`, read [endpoint routing](references/endpoint-routing.md); read [API usage](references/api-usage.md) for authentication, pagination, compatibility, and safe schema discovery. Read [troubleshooting](references/troubleshooting.md) for 401/403/422 responses and transport failures. diff --git a/forgejo-cli/V2-SPEC.md b/forgejo-cli/V2-SPEC.md index 6e83659..4317557 100644 --- a/forgejo-cli/V2-SPEC.md +++ b/forgejo-cli/V2-SPEC.md @@ -2,4 +2,4 @@ Provide polished collaboration and repository commands plus a safe generic `/api/v1/` escape hatch. The CLI must require confirmation for mutations, produce credential-free dry-run plans, encode path segments, and keep JSON stdout machine-readable. -Acceptance: tests verify help, mutation gating, generic path validation, JSON plans, repository creation without owner/repo, and representative issue, PR, release, content, and webhook requests. +Acceptance: tests verify help, mutation gating, generic path validation, JSON plans, repository creation without owner/repo, and representative issue, PR, release, content, and webhook requests. Live transport tests must cover multipart or form delivery, caller-provided authentication, response pagination metadata, missing-server failure, and nested content paths. Documentation must explain version-aware Swagger discovery, scope-aware authentication, pagination, and when a generic endpoint is the appropriate choice. diff --git a/forgejo-cli/references/api-usage.md b/forgejo-cli/references/api-usage.md new file mode 100644 index 0000000..3281341 --- /dev/null +++ b/forgejo-cli/references/api-usage.md @@ -0,0 +1,28 @@ +# API usage, compatibility, and pagination + +## Establish the server contract + +1. Pass the target with `--server https://forge.example`; do not treat the placeholder default as a usable service. +2. Request `GET /api/v1/version` with the generic command and record the Forgejo major version. +3. Open `https://forge.example/swagger.v1.json` (or `/api/swagger`) and use that server's schema for request fields. Forgejo guarantees API compatibility within a major version; do not assume a current server has the same endpoints or fields as an older Gitea-compatible installation. The upstream usage guide is https://forgejo.org/docs/latest/user/api-usage/. + +## Authenticate safely + +Use `FORGEJO_AGENT_TOKEN` for automation and `FORGEJO_USER_TOKEN` for user-authorized work. Give tokens the narrowest Forgejo scope that can complete the request. The client sends these as `Authorization: token …`. + +The generic `--header Authorization=…` path supports Basic or Bearer authentication when an endpoint requires it. Prefer an environment-backed wrapper or a token for routine use: command-line credentials may be recorded in shell history and process listings. Never put tokens in `--data`, dry-run output, issue text, or logs. + +## Paginate intentionally + +All list/search commands accept `--page N --limit N`, including generic API calls. Use `--include-response --json` on a live request to retain Forgejo's `link` and `x-total-count` headers: + +```bash +python3 scripts/forgejo-cli --server https://forge.example --page 1 --limit 50 \ + --include-response --json issue list --owner acme --repo app +``` + +Follow the `rel="next"` URL until absent. Do not assume a fixed page size: server administrators configure defaults and maximum response items. + +## Scope boundaries + +The generic `api` command intentionally accepts only `/api/v1/` paths. Package registries and other non-v1 endpoints require their native client or a separately reviewed HTTP workflow. First-class commands provide guardrails; the generic command provides coverage, not schema validation. diff --git a/forgejo-cli/references/command-reference.md b/forgejo-cli/references/command-reference.md index 6c4d2de..71d37e9 100644 --- a/forgejo-cli/references/command-reference.md +++ b/forgejo-cli/references/command-reference.md @@ -1,6 +1,6 @@ # Command reference -Global flags: `--agent`, `--user`, `--server URL`, `--json`, `--quiet/-q`, `--verbose/-v`, `--dry-run/-n`, and `--force/--yes/-y`. POST, PUT, PATCH, and DELETE require `--force` unless dry-running. +Global flags: `--agent`, `--user`, `--server URL`, `--json`, `--quiet/-q`, `--verbose/-v`, `--dry-run/-n`, `--force/--yes/-y`, `--page`, `--limit`, and `--include-response`. POST, PUT, PATCH, and DELETE require `--force` unless dry-running. `--page` and `--limit` are appended as query parameters; `--include-response` wraps live output as `{data, status, headers}` and preserves `link` and `x-total-count`. | Group | Commands | API route | | --- | --- | --- | @@ -15,8 +15,28 @@ Global flags: `--agent`, `--user`, `--server URL`, `--json`, `--quiet/-q`, `--ve Comma-separated `--labels`, `--assignees`, and `--events` become JSON arrays. PR merge maps `--style` to Forgejo's `Do` field. Hook create accepts `--url`, `--secret`, `--events`, and `--type`; it builds Forgejo's required nested `config` payload. Release create requires `--tag-name`; its display name is `--name`. Content is base64 in `--content`; update and delete require `--sha`. +## First-class command details + +- `issue list` and `pr list` default to `--state open`; override it when reviewing closed or all work. `issue close` and `issue reopen` use the same edit endpoint with an explicit state. +- `content --path` preserves directory separators and encodes unsafe characters. Supply base64 data to `--content`, not plain text. Fetch the current file SHA before an update or delete to avoid overwriting a newer version. +- `repo list --owner OWNER` lists that owner's public/visible repositories; without `--owner`, it lists the authenticated user's repositories. `repo create` intentionally requires no owner because it creates under the authenticated user. +- `release upload --file PATH` sends a multipart attachment. `release upload --external-url URL` sends the equivalent form field. They are mutually exclusive. +- `hook` operates on repository hooks when both `--owner` and `--repo` are present, otherwise on the authenticated user's hooks. Hook creation requires `--url`. + +## Safe examples + +```bash +# See the exact update request before changing a file. +python3 scripts/forgejo-cli --dry-run --json content update \ + --owner acme --repo app --path docs/guide.md --content BASE64 --sha CURRENT_SHA + +# Obtain pagination links and a total count from a live list request. +python3 scripts/forgejo-cli --server https://forge.example --page 1 --limit 50 \ + --include-response --json pr list --owner acme --repo app +``` + ## Generic API `forgejo-cli api --method GET|POST|PUT|PATCH|DELETE --path /api/v1/... [--query KEY=VALUE] [--data JSON | --data-file FILE] [--raw-file FILE] [--upload-file FILE --form KEY=VALUE] [--header KEY=VALUE] [--content-type TYPE]` -Only `/api/v1/` paths are accepted. JSON, raw binary, multipart file/form payloads, and custom headers are supported. Use it for Actions, packages, organizations, teams, admin APIs, notifications, repository permissions, and new endpoints; consult the target server's Swagger document for schemas. +Only `/api/v1/` paths are accepted. JSON, raw binary, multipart file/form payloads, and custom headers are supported. A supplied `Authorization` header is accepted when no token environment variable is set, enabling documented Basic or Bearer authentication; avoid putting credentials in shell history. Use it for Actions, packages, organizations, teams, admin APIs, notifications, repository permissions, and new endpoints; consult the target server's Swagger document for schemas. diff --git a/forgejo-cli/references/endpoint-routing.md b/forgejo-cli/references/endpoint-routing.md new file mode 100644 index 0000000..e8dfa4c --- /dev/null +++ b/forgejo-cli/references/endpoint-routing.md @@ -0,0 +1,14 @@ +# Endpoint routing guide + +Use the first-class command when it exposes every field needed by the task. It gives semantic flags, safe path encoding, mutation confirmation, and predictable JSON output. + +| Task | Default command | Use `api` instead when | +| --- | --- | --- | +| Issues and comments | `issue` | attachments, reactions, dependencies, time tracking, or a schema field not exposed by flags | +| Pull requests | `pr` | commits/files/statuses, reviewers beyond the basic review call, or version-specific merge fields | +| Repository and files | `repo`, `content` | collaborators, branch/tag protection, keys, mirrors, transfer, archive, or advanced settings | +| Metadata and delivery | `label`, `milestone`, `release`, `hook` | a payload needs fields not represented by the first-class command | +| Account settings | `user` | tokens, notifications, subscriptions, SSH/GPG keys, organizations, teams, or admin operations | +| Actions, packages, projects | `api` | always: these APIs evolve independently and need the live Swagger schema or native package client | + +For a generic call, first read the target server's Swagger operation, copy its method and required fields exactly, then dry-run the request. Add `--force` only after reviewing the plan. If the endpoint returns a list, include `--page`, `--limit`, and `--include-response`. diff --git a/forgejo-cli/references/troubleshooting.md b/forgejo-cli/references/troubleshooting.md new file mode 100644 index 0000000..a91221c --- /dev/null +++ b/forgejo-cli/references/troubleshooting.md @@ -0,0 +1,13 @@ +# Troubleshooting Forgejo API calls + +| Symptom | Check | Safe next action | +| --- | --- | --- | +| `No API token available` | Required environment variable is absent | Export the intended token or provide a deliberate `Authorization` header for the one request | +| 401 | Token type, expiry, server URL, or authentication scheme | Run a read-only `user show`; do not retry mutations blindly | +| 403 | Token scope, repository access, or server policy | Inspect the target endpoint's required scope and permissions; use a narrower correctly scoped token rather than escalating indiscriminately | +| 404 | Owner/repo/path or server-version mismatch | Check `/api/v1/version` and the server's Swagger document before changing the path | +| 422 | Valid route but invalid payload | Compare the complete JSON body against the live Swagger schema; dry-run first-class calls to inspect generated fields | +| Missing list results | Pagination | Add `--page`, `--limit`, and `--include-response`; follow the `link` header | +| Connection error | DNS, TLS, proxy, or server availability | Verify the exact `--server` URL with a harmless version request; do not expose tokens in diagnostic output | + +Use `--verbose` only to inspect method and URL. It deliberately does not print tokens or request bodies. For mutation failures, retain the dry-run JSON plan and the status/message, but redact credentials before sharing either. diff --git a/forgejo-cli/scripts/forgejo-cli b/forgejo-cli/scripts/forgejo-cli index 60ec109..6f7243d 100755 --- a/forgejo-cli/scripts/forgejo-cli +++ b/forgejo-cli/scripts/forgejo-cli @@ -13,7 +13,7 @@ try: except ImportError: # pragma: no cover - surfaced only on actual requests requests = None -DEFAULT_SERVER = "https://forgejo.example.com" +DEFAULT_SERVER = os.getenv("FORGEJO_SERVER", "") MUTATING = {"POST", "PUT", "PATCH", "DELETE"} @@ -28,7 +28,9 @@ def env(name): path = Path.home() / ".hermes" / ".env" try: for line in path.read_text().splitlines(): - line = line.strip().removeprefix("export ") + line = line.strip() + if line.startswith("export "): + line = line[len("export "):] if line.startswith(name + "="): return line.split("=", 1)[1].strip().strip("\"'") except OSError: @@ -47,7 +49,8 @@ class Client: self.verbose = args.verbose self.token = env("FORGEJO_USER_TOKEN" if args.user else "FORGEJO_AGENT_TOKEN") - def request(self, method, path, query=None, body=None, headers=None, raw=None, files=None, form=None): + def request(self, method, path, query=None, body=None, headers=None, raw=None, files=None, form=None, + include_response=False): method = method.upper() if not path.startswith("/api/v1/"): raise ForgejoError("API path must begin with /api/v1/") @@ -56,17 +59,23 @@ class Client: "query": query or {}, "body": body, "headers": headers or {}, "raw": "" if raw is not None else None, "files": {k: getattr(v, "name", str(v)) for k, v in (files or {}).items()}, "form": form or {}} + if not self.server: + raise ForgejoError("No Forgejo server selected; pass --server URL or set FORGEJO_SERVER") if self.verbose: print(f"[verbose] {method} {self.server}{path}", file=sys.stderr) - if not self.token: - raise ForgejoError("No API token available; set FORGEJO_AGENT_TOKEN or FORGEJO_USER_TOKEN") + if not self.token and not any(key.lower() == "authorization" for key in (headers or {})): + raise ForgejoError("No API token available; set FORGEJO_AGENT_TOKEN or FORGEJO_USER_TOKEN, or provide Authorization") if requests is None: raise ForgejoError("requests is required for live API calls") try: - request_headers = {"Authorization": "token " + self.token, **(headers or {})} + request_headers = {**(headers or {})} + if self.token: + request_headers.setdefault("Authorization", "token " + self.token) kwargs = {"params": query, "headers": request_headers, "timeout": 30} if files: kwargs.update(files=files, data=form or {}) + elif form: + kwargs["data"] = form elif raw is not None: kwargs["data"] = raw elif body is not None: @@ -82,11 +91,16 @@ class Client: message = response.text raise ForgejoError(f"{method} {path}: HTTP {response.status_code}: {message}") if response.status_code == 204 or not response.content: - return {} - try: - return response.json() - except ValueError: - return {"raw": response.text} + result = {} + else: + try: + result = response.json() + except ValueError: + result = {"raw": response.text} + if include_response: + metadata = {key.lower(): response.headers[key] for key in ("Link", "X-Total-Count") if key in response.headers} + return {"data": result, "status": response.status_code, "headers": metadata} + return result def global_flags(parser): @@ -98,6 +112,9 @@ def global_flags(parser): parser.add_argument("--verbose", "-v", action="store_true") parser.add_argument("--dry-run", "-n", action="store_true", help="plan request without credentials or network") parser.add_argument("--force", "--yes", "-y", action="store_true", help="allow a mutation") + parser.add_argument("--page", type=int, help="page number for list/search endpoints") + parser.add_argument("--limit", type=int, help="items per page for list/search endpoints") + parser.add_argument("--include-response", action="store_true", help="include status and pagination headers in output") def scoped(parser, required_repo=True, index=False): @@ -201,6 +218,9 @@ def resolve_path(args): if args.group == "hook": base = f"/api/v1/repos/{seg(args.owner)}/{seg(args.repo)}/hooks" if args.owner and args.repo else "/api/v1/user/hooks" return base + (f"/{args.id}" if args.action in {"show","edit","delete"} else "") + if args.group == "content": + return "/api/v1/repos/{}/{}/contents/{}".format( + seg(args.owner), seg(args.repo), quote(args.path, safe="/")) values = {k: seg(v) for k,v in vars(args).items() if v is not None} return args.path_template.format(**values) @@ -208,13 +228,13 @@ def resolve_path(args): def normalize_global_flags(argv): """Permit documented global flags at any command nesting level.""" booleans = {"--agent", "--user", "--json", "--quiet", "-q", "--verbose", "-v", - "--dry-run", "-n", "--force", "--yes", "-y"} + "--dry-run", "-n", "--force", "--yes", "-y", "--include-response"} front, rest, i = [], [], 0 while i < len(argv): value = argv[i] if value in booleans: front.append(value) - elif value == "--server": + elif value in {"--server", "--page", "--limit"}: if i + 1 >= len(argv): rest.append(value) else: @@ -229,6 +249,8 @@ def normalize_global_flags(argv): def main(argv=None): parser = build_parser(); args = parser.parse_args(normalize_global_flags(argv or sys.argv[1:])) if args.agent and args.user: parser.error("choose only one of --agent or --user") + if args.page is not None and args.page < 1: parser.error("--page must be at least 1") + if args.limit is not None and args.limit < 1: parser.error("--limit must be at least 1") if args.group == "api": supplied = sum(bool(value) for value in (args.data, args.data_file, args.raw_file, args.upload_file)) if supplied > 1: parser.error("choose one payload source: --data, --data-file, --raw-file, or --upload-file") @@ -252,6 +274,8 @@ def main(argv=None): if args.file and args.external_url: parser.error("choose --file or --external-url, not both") if not args.file and not args.external_url: parser.error("upload requires --file or --external-url") if args.external_url: body = None + if args.page is not None: query.setdefault("page", args.page) + if args.limit is not None: query.setdefault("limit", args.limit) if method in MUTATING and not (args.force or args.dry_run): parser.error(f"{method} is a mutation; use --force/--yes or --dry-run") headers, raw, files, form = {}, None, None, None if args.group == "api": @@ -266,8 +290,13 @@ def main(argv=None): if getattr(args, "release_upload", False) and args.external_url: form = {"external_url": args.external_url} if args.name: query["name"] = args.name - try: result = Client(args).request(method, path, query, body, headers, raw, files, form) + try: + result = Client(args).request(method, path, query, body, headers, raw, files, form, + include_response=args.include_response) except (ForgejoError, json.JSONDecodeError) as exc: parser.error(str(exc)) + finally: + for file in (files or {}).values(): + file.close() if args.json: print(json.dumps(result, sort_keys=True)) elif not args.quiet: print(json.dumps(result, indent=2) if isinstance(result, (dict,list)) else result) diff --git a/forgejo-cli/tests/test_cli.py b/forgejo-cli/tests/test_cli.py index 7286d21..e9179d5 100644 --- a/forgejo-cli/tests/test_cli.py +++ b/forgejo-cli/tests/test_cli.py @@ -2,6 +2,7 @@ import importlib.machinery import io import json import pathlib +from types import SimpleNamespace import unittest from contextlib import redirect_stderr, redirect_stdout @@ -38,6 +39,16 @@ class CliTests(unittest.TestCase): self.assertNotEqual(code, 0) self.assertIn("mutation", err) + def test_live_request_requires_a_server(self): + old = cli.DEFAULT_SERVER + cli.DEFAULT_SERVER = "" + try: + code, _, err = self.run_cli(["user", "show"]) + finally: + cli.DEFAULT_SERVER = old + self.assertNotEqual(code, 0) + self.assertIn("No Forgejo server selected", err) + def test_api_path_guard_and_plan(self): self.assertNotEqual(self.run_cli(["api", "--method", "GET", "--path", "/bad"])[0], 0) plan = self.plan(["api", "--method", "PATCH", "--path", "/api/v1/user/settings", "--query", "theme=dark", "--data", '{"language":"en"}']) @@ -67,6 +78,43 @@ class CliTests(unittest.TestCase): def test_repo_creation_needs_no_owner(self): self.assertEqual(self.plan(["repo", "create", "--name", "demo", "--private"])["path"], "/api/v1/user/repos") + def test_nested_content_path_and_pagination_plan(self): + plan = self.plan(["--page", "2", "--limit", "75", "content", "get", "--owner", "me", "--repo", "x", "--path", "dir/a b.txt"]) + self.assertEqual(plan["path"], "/api/v1/repos/me/x/contents/dir/a%20b.txt") + self.assertEqual(plan["query"], {"page": 2, "limit": 75}) + + def test_form_transport_custom_authorization_and_response_metadata(self): + class Response: + status_code = 200 + content = b'{"ok":true}' + text = '{"ok":true}' + headers = {"Link": '; rel="next"', "X-Total-Count": "51"} + + def json(self): + return {"ok": True} + + class Requests: + RequestException = Exception + call = None + + @classmethod + def request(cls, *args, **kwargs): + cls.call = (args, kwargs) + return Response() + + original = cli.requests + cli.requests = Requests + try: + args = SimpleNamespace(server="https://forge.example", dry_run=False, verbose=False, user=False) + result = cli.Client(args).request( + "POST", "/api/v1/repos/me/x/releases/1/assets", headers={"Authorization": "Basic test"}, + form={"external_url": "https://example.invalid/file"}, include_response=True) + finally: + cli.requests = original + self.assertEqual(Requests.call[1]["data"], {"external_url": "https://example.invalid/file"}) + self.assertEqual(result["data"], {"ok": True}) + self.assertEqual(result["headers"]["x-total-count"], "51") + if __name__ == "__main__": unittest.main()