diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..de99516 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,25 @@ +--- +name: Bug Report +about: Report a bug in layerv-qurl +labels: bug +--- + +## Description + + + +## Expected Behavior + + + +## Steps to Reproduce + +1. +2. +3. + +## Environment + +- Python version: +- layerv-qurl version: +- OS: diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..1a68c61 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,13 @@ +--- +name: Feature Request +about: Suggest a feature for layerv-qurl +labels: enhancement +--- + +## Description + + + +## Use Case + + diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..d38bb7b --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,11 @@ +## Summary + + + +## Test plan + + + +- [ ] `ruff check` passes +- [ ] `mypy src/` passes +- [ ] `pytest tests/ -v` passes diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..abd2e58 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,10 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + - package-ecosystem: pip + directory: / + schedule: + interval: weekly diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1dcff4e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,29 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.10", "3.12", "3.13"] + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: pip install -e ".[dev,langchain]" + - name: Lint + run: ruff check + - name: Type check + if: matrix.python-version == '3.12' + run: mypy src/ + - name: Test + run: pytest tests/ -v diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml new file mode 100644 index 0000000..e06e15c --- /dev/null +++ b/.github/workflows/claude-code-review.yml @@ -0,0 +1,42 @@ +name: Claude Code Review + +on: + pull_request: + types: [opened, synchronize] + +jobs: + claude-review: + if: github.actor != 'dependabot[bot]' + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + issues: read + id-token: write + + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 1 + + - name: Run Claude Code Review + uses: anthropics/claude-code-action@1fc90f3ed982521116d8ff6d85b948c9b12cae3e # v1 + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + prompt: | + REPO: ${{ github.repository }} + PR NUMBER: ${{ github.event.pull_request.number }} + + Please review this pull request and provide feedback on: + - Code quality and best practices + - Potential bugs or issues + - Performance considerations + - Security concerns + - Test coverage + + Use the repository's CLAUDE.md for guidance on style and conventions. Be constructive and helpful in your feedback. + + Use `gh pr comment` with your Bash tool to leave your review as a comment on the PR. + + claude_args: '--model claude-opus-4-6 --allowed-tools "Bash(gh issue view:*),Bash(gh search:*),Bash(gh issue list:*),Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*)"' diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml new file mode 100644 index 0000000..a86eb95 --- /dev/null +++ b/.github/workflows/release-please.yml @@ -0,0 +1,62 @@ +name: Release Please + +on: + push: + branches: [main] + +permissions: + contents: write + pull-requests: write + +jobs: + release-please: + runs-on: ubuntu-latest + outputs: + release_created: ${{ steps.release.outputs.release_created }} + tag_name: ${{ steps.release.outputs.tag_name }} + steps: + - uses: googleapis/release-please-action@16a9c90856f42705d54a6fda1823352bdc62cf38 # v4 + id: release + with: + config-file: release-please-config.json + manifest-file: .release-please-manifest.json + + test: + needs: release-please + if: needs.release-please.outputs.release_created + runs-on: ubuntu-latest + strategy: + fail-fast: true + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13"] + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: pip install -e ".[dev,langchain]" + - name: Test + run: pytest tests/ -v + + publish: + needs: [release-please, test] + if: needs.release-please.outputs.release_created + runs-on: ubuntu-latest + environment: pypi + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.12" + - name: Install build tools + run: pip install build + - name: Build + run: python -m build + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@76f52bc884231f62b54a752585ace28ca25210fc # v1.12.4 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2f5f02a --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.venv/ +__pycache__/ +*.egg-info/ +dist/ +build/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000..8e1b0d9 --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1 @@ +{".": "0.1.0"} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..825c32f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1 @@ +# Changelog diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..d8128bf --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,33 @@ +# CLAUDE.md — qurl-python + +## Critical Rules + +- **NEVER push directly to `main`.** Always create a branch and PR. +- All commits must be signed. + +## Project + +Python SDK for the QURL API (`pip install layerv-qurl`). Extracted from `layervai/qurl-integrations`. + +## Commands + +```bash +pip install -e ".[dev]" # Install with dev dependencies +ruff check # Lint +mypy src/ # Type check +pytest tests/ -v # Test +``` + +## Commit Format + +``` +: + +type: feat | fix | chore | docs | test | refactor | ci +``` + +Conventional commits drive Release Please versioning. + +## Release + +Merging to `main` triggers Release Please. Merging the release PR publishes to PyPI via OIDC. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..8458031 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,45 @@ +# Contributing + +Thanks for your interest in contributing to `layerv-qurl`! + +## Development Setup + +```bash +git clone https://github.com/layervai/qurl-python.git +cd qurl-python +python -m venv .venv && source .venv/bin/activate +pip install -e ".[dev,langchain]" +``` + +## Running Checks + +```bash +ruff check # Lint +mypy src/ # Type check +pytest tests/ -v # Tests +``` + +All three must pass before submitting a PR. + +## Pull Requests + +1. Fork the repo and create a branch from `main` +2. Follow [Conventional Commits](https://www.conventionalcommits.org/) (`feat:`, `fix:`, `docs:`, etc.) +3. Add tests for new functionality +4. Ensure all checks pass +5. Open a PR — CI runs automatically + +## Commit Signing + +All commits must be GPG or SSH signed. GitHub will reject unsigned commits. + +## Releases + +Releases are automated via [Release Please](https://github.com/googleapis/release-please). Conventional commit messages drive version bumps: +- `feat:` → minor version bump +- `fix:` → patch version bump +- `feat!:` or `BREAKING CHANGE:` → major version bump + +## License + +By contributing, you agree that your contributions will be licensed under the MIT License. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..30b94ab --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 LayerV, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..3640cc7 --- /dev/null +++ b/README.md @@ -0,0 +1,180 @@ +# layerv-qurl + +[![PyPI](https://img.shields.io/pypi/v/layerv-qurl)](https://pypi.org/project/layerv-qurl/) +[![CI](https://github.com/layervai/qurl-python/actions/workflows/ci.yml/badge.svg)](https://github.com/layervai/qurl-python/actions/workflows/ci.yml) +[![Python](https://img.shields.io/pypi/pyversions/layerv-qurl)](https://pypi.org/project/layerv-qurl/) +[![License](https://img.shields.io/github/license/layervai/qurl-python)](LICENSE) + +Python SDK for the [QURL API](https://docs.layerv.ai) — secure, time-limited access links for AI agents. + +## Why QURL? + +AI agents need to access APIs, databases, and internal tools — but permanent credentials are a security risk. QURL creates **time-limited, auditable access links** that automatically expire: + +- **Time-limited** — links expire after minutes, hours, or days +- **IP-scoped** — firewall opens only for the requesting IP via NHP +- **Auditable** — every access is logged with who, when, and from where +- **Revocable** — kill access instantly if something goes wrong + +## Installation + +```bash +pip install layerv-qurl +``` + +For LangChain integration: + +```bash +pip install layerv-qurl[langchain] +``` + +## Quick Start + +```python +from layerv_qurl import QURLClient + +client = QURLClient(api_key="lv_live_xxx") + +# Create a protected link +result = client.create( + target_url="https://api.example.com/data", + expires_in="24h", + description="API access for agent", +) +print(result.qurl_link) # Share this link + +# Resolve a token (opens firewall for your IP) +access = client.resolve("at_k8xqp9h2sj9lx7r4a") +print(f"Access granted to {access.target_url} for {access.access_grant.expires_in}s") + +# Extend a QURL's expiration +qurl = client.extend("r_xxx", "7d") + +# Update metadata and policy +qurl = client.update("r_xxx", description="extended", extend_by="7d") +``` + +## Async Usage + +```python +import asyncio +from layerv_qurl import AsyncQURLClient + +async def main(): + async with AsyncQURLClient(api_key="lv_live_xxx") as client: + result = await client.create(target_url="https://example.com", expires_in="1h") + access = await client.resolve("at_...") + + # Extend expiration + qurl = await client.extend("r_xxx", "7d") + +asyncio.run(main()) +``` + +## Pagination + +```python +# Iterate all active QURLs (auto-paginates) +for qurl in client.list_all(status="active"): + print(f"{qurl.resource_id}: {qurl.target_url}") + +# Or fetch a single page +page = client.list(status="active", limit=10) +for qurl in page.qurls: + print(qurl.resource_id) +``` + +## Error Handling + +Every API error maps to a specific exception class, so you can catch exactly what you need: + +```python +from layerv_qurl import ( + QURLClient, + QURLError, + QURLNetworkError, + QURLTimeoutError, +) +from layerv_qurl.errors import ( + AuthenticationError, + AuthorizationError, + NotFoundError, + RateLimitError, + ValidationError, +) + +client = QURLClient(api_key="lv_live_xxx") + +try: + client.resolve("at_k8xqp9h2sj9lx7r4a") +except AuthenticationError: + print("Bad API key") +except AuthorizationError: + print("Valid key but missing qurl:resolve scope") +except NotFoundError: + print("Token doesn't exist or already expired") +except RateLimitError as e: + print(f"Rate limited — retry in {e.retry_after}s") +except ValidationError as e: + print(f"Bad request: {e.detail}") + if e.invalid_fields: + for field, reason in e.invalid_fields.items(): + print(f" {field}: {reason}") +except QURLTimeoutError: + print("Request timed out") +except QURLNetworkError as e: + print(f"Network error: {e}") +except QURLError as e: + # Catch-all for any other API error + print(f"API error {e.status}: {e.detail}") +``` + +All error classes inherit from `QURLError`, so `except QURLError` catches everything. + +## Typed Quota + +```python +quota = client.get_quota() +print(f"Plan: {quota.plan}") +print(f"Active QURLs: {quota.usage.active_qurls}") +print(f"Rate limit: {quota.rate_limits.create_per_minute}/min") +``` + +## Debug Logging + +Enable debug logs to see every request and retry: + +```python +import logging +logging.getLogger("layerv_qurl").setLevel(logging.DEBUG) + +# Output: +# DEBUG:layerv_qurl:POST https://api.layerv.ai/v1/qurl +# DEBUG:layerv_qurl:POST https://api.layerv.ai/v1/qurl → 201 +``` + +## LangChain Integration + +```python +from layerv_qurl import QURLClient +from layerv_qurl.langchain import QURLToolkit + +client = QURLClient(api_key="lv_live_xxx") +toolkit = QURLToolkit(client=client) +tools = toolkit.get_tools() # [CreateQURLTool, ResolveQURLTool, ListQURLsTool, DeleteQURLTool] +``` + +## Configuration + +| Parameter | Required | Default | +|-----------|----------|---------| +| `api_key` | Yes | — | +| `base_url` | No | `https://api.layerv.ai` | +| `timeout` | No | `30.0` | +| `max_retries` | No | `3` | +| `user_agent` | No | `qurl-python-sdk/` | +| `http_client` | No | Auto-created `httpx.Client` | + +## License + +MIT diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..030166a --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,17 @@ +# Security Policy + +## Reporting a Vulnerability + +If you discover a security vulnerability, please report it responsibly: + +**Email:** security@layerv.ai + +Please do **not** open a public GitHub issue for security vulnerabilities. + +We will acknowledge your report within 48 hours and aim to provide a fix within 7 days for critical issues. + +## Supported Versions + +| Version | Supported | +|---------|-----------| +| 0.x | Yes | diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..7598cf5 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,61 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "layerv-qurl" +version = "0.1.0" +description = "Python SDK for the QURL API - secure, time-limited access links" +readme = "README.md" +license = "MIT" +requires-python = ">=3.10" +authors = [ + { name = "LayerV AI", email = "engineering@layerv.ai" }, +] +keywords = ["qurl", "layerv", "security", "ai-agents", "zero-trust"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Typing :: Typed", +] +dependencies = [ + "httpx>=0.27,<1", +] + +[project.optional-dependencies] +langchain = ["langchain-core>=0.3,<1"] +dev = [ + "pytest>=8", + "pytest-asyncio>=0.25", + "respx>=0.22", + "ruff>=0.11", + "mypy>=1.14", +] + +[project.urls] +Homepage = "https://github.com/layervai/qurl-python" +Repository = "https://github.com/layervai/qurl-python" +Documentation = "https://docs.layerv.ai" + +[tool.hatch.build.targets.wheel] +packages = ["src/layerv_qurl"] + +[tool.ruff] +target-version = "py310" +line-length = 100 + +[tool.ruff.lint] +select = ["E", "F", "I", "N", "W", "UP", "B", "SIM", "TCH"] + +[tool.mypy] +strict = true +python_version = "3.10" + +[tool.pytest.ini_options] +asyncio_mode = "auto" diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 0000000..272b383 --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "packages": { + ".": { + "release-type": "python", + "changelog-path": "CHANGELOG.md", + "bump-minor-pre-major": true, + "bump-patch-for-minor-pre-major": true, + "extra-files": ["pyproject.toml"] + } + } +} diff --git a/src/layerv_qurl/__init__.py b/src/layerv_qurl/__init__.py new file mode 100644 index 0000000..da3c2d3 --- /dev/null +++ b/src/layerv_qurl/__init__.py @@ -0,0 +1,63 @@ +"""QURL Python SDK — secure, time-limited access links for AI agents.""" + +from importlib.metadata import PackageNotFoundError +from importlib.metadata import version as _pkg_version + +from layerv_qurl.async_client import AsyncQURLClient +from layerv_qurl.client import QURLClient +from layerv_qurl.errors import ( + AuthenticationError, + AuthorizationError, + NotFoundError, + QURLError, + QURLNetworkError, + QURLTimeoutError, + RateLimitError, + ServerError, + ValidationError, +) +from layerv_qurl.types import ( + QURL, + AccessGrant, + AccessPolicy, + CreateOutput, + ListOutput, + MintOutput, + Quota, + QURLStatus, + RateLimits, + ResolveOutput, + Usage, +) + +__all__ = [ + "AsyncQURLClient", + "QURLClient", + # Errors + "AuthenticationError", + "AuthorizationError", + "NotFoundError", + "QURLError", + "QURLNetworkError", + "QURLTimeoutError", + "RateLimitError", + "ServerError", + "ValidationError", + # Types + "QURLStatus", + "AccessGrant", + "AccessPolicy", + "CreateOutput", + "ListOutput", + "MintOutput", + "QURL", + "Quota", + "RateLimits", + "ResolveOutput", + "Usage", +] + +try: + __version__ = _pkg_version("layerv-qurl") +except PackageNotFoundError: + __version__ = "dev" diff --git a/src/layerv_qurl/_utils.py b/src/layerv_qurl/_utils.py new file mode 100644 index 0000000..3d8d8d9 --- /dev/null +++ b/src/layerv_qurl/_utils.py @@ -0,0 +1,277 @@ +"""Shared utilities for sync and async clients.""" + +from __future__ import annotations + +import dataclasses +import functools +import logging +import random +import re +from datetime import datetime +from importlib.metadata import PackageNotFoundError +from importlib.metadata import version as _pkg_version +from typing import TYPE_CHECKING, Any + +from layerv_qurl.errors import ( + AuthenticationError, + AuthorizationError, + NotFoundError, + QURLError, + RateLimitError, + ServerError, + ValidationError, +) +from layerv_qurl.types import ( + QURL, + AccessGrant, + AccessPolicy, + CreateOutput, + ListOutput, + MintOutput, + Quota, + RateLimits, + ResolveOutput, + Usage, + _parse_dt, +) + +if TYPE_CHECKING: + import httpx + +logger = logging.getLogger("layerv_qurl") + +DEFAULT_BASE_URL = "https://api.layerv.ai" +DEFAULT_TIMEOUT = 30.0 +DEFAULT_MAX_RETRIES = 3 +RETRYABLE_STATUS = {429, 502, 503, 504} +RETRYABLE_STATUS_POST = {429} # POST is not idempotent — only retry rate limits + +_RESOURCE_ID_RE = re.compile(r"^[a-zA-Z0-9_\-]+$") + +_ERROR_CLASS_MAP: dict[int, type[QURLError]] = { + 400: ValidationError, + 401: AuthenticationError, + 403: AuthorizationError, + 404: NotFoundError, + 422: ValidationError, + 429: RateLimitError, +} + + +@functools.lru_cache(maxsize=1) +def default_user_agent() -> str: + """Return the default User-Agent string, caching the version lookup.""" + try: + v = _pkg_version("layerv-qurl") + except PackageNotFoundError: + v = "dev" + return f"qurl-python-sdk/{v}" + + +def validate_id(value: str, name: str = "resource_id") -> str: + """Validate that an ID is non-empty and contains no path traversal characters.""" + if not value or not _RESOURCE_ID_RE.match(value): + raise ValueError(f"Invalid {name}: {value!r}") + return value + + +def build_body(kwargs: dict[str, Any]) -> dict[str, Any]: + """Build a request body dict from kwargs, dropping None values. + + Always returns a dict (at least ``{}``) so POST/PATCH endpoints + receive a valid JSON body. + """ + body: dict[str, Any] = {} + for k, v in kwargs.items(): + if v is None: + continue + if isinstance(v, datetime): + body[k] = v.isoformat() + elif dataclasses.is_dataclass(v) and not isinstance(v, type): + body[k] = { + f.name: getattr(v, f.name) + for f in dataclasses.fields(v) + if getattr(v, f.name) is not None + } + else: + body[k] = v + return body + + +def parse_qurl(data: dict[str, Any]) -> QURL: + """Parse a QURL resource from API response data.""" + policy = None + if data.get("access_policy"): + p = data["access_policy"] + policy = AccessPolicy( + ip_allowlist=p.get("ip_allowlist"), + ip_denylist=p.get("ip_denylist"), + geo_allowlist=p.get("geo_allowlist"), + geo_denylist=p.get("geo_denylist"), + user_agent_allow_regex=p.get("user_agent_allow_regex"), + user_agent_deny_regex=p.get("user_agent_deny_regex"), + ) + return QURL( + resource_id=data["resource_id"], + target_url=data["target_url"], + status=data["status"], + created_at=_parse_dt(data.get("created_at")), + expires_at=_parse_dt(data.get("expires_at")), + one_time_use=data.get("one_time_use", False), + max_sessions=data.get("max_sessions"), + description=data.get("description"), + qurl_site=data.get("qurl_site"), + qurl_link=data.get("qurl_link"), + access_policy=policy, + ) + + +def parse_create_output(data: dict[str, Any]) -> CreateOutput: + """Parse a CreateOutput from API response data.""" + return CreateOutput( + resource_id=data["resource_id"], + qurl_link=data["qurl_link"], + qurl_site=data["qurl_site"], + expires_at=_parse_dt(data.get("expires_at")), + ) + + +def parse_mint_output(data: dict[str, Any]) -> MintOutput: + """Parse a MintOutput from API response data.""" + return MintOutput( + qurl_link=data["qurl_link"], + expires_at=_parse_dt(data.get("expires_at")), + ) + + +def parse_resolve_output(data: dict[str, Any]) -> ResolveOutput: + """Parse a ResolveOutput from API response data.""" + grant = None + if data.get("access_grant"): + g = data["access_grant"] + grant = AccessGrant( + expires_in=g["expires_in"], + granted_at=_parse_dt(g.get("granted_at")), + src_ip=g.get("src_ip", ""), + ) + return ResolveOutput( + target_url=data["target_url"], + resource_id=data["resource_id"], + access_grant=grant, + ) + + +def parse_quota(data: dict[str, Any]) -> Quota: + """Parse a Quota from API response data.""" + rl = None + if data.get("rate_limits"): + r = data["rate_limits"] + rl = RateLimits( + create_per_minute=r.get("create_per_minute", 0), + create_per_hour=r.get("create_per_hour", 0), + list_per_minute=r.get("list_per_minute", 0), + resolve_per_minute=r.get("resolve_per_minute", 0), + max_active_qurls=r.get("max_active_qurls", 0), + max_tokens_per_qurl=r.get("max_tokens_per_qurl", 0), + ) + usage = None + if data.get("usage"): + u = data["usage"] + usage = Usage( + qurls_created=u.get("qurls_created", 0), + active_qurls=u.get("active_qurls", 0), + active_qurls_percent=u.get("active_qurls_percent", 0.0), + total_accesses=u.get("total_accesses", 0), + ) + return Quota( + plan=data.get("plan", ""), + period_start=_parse_dt(data.get("period_start")), + period_end=_parse_dt(data.get("period_end")), + rate_limits=rl, + usage=usage, + ) + + +def parse_list_output(data: Any, meta: dict[str, Any] | None) -> ListOutput: + """Parse a ListOutput from API response data.""" + qurls = [parse_qurl(q) for q in data] if isinstance(data, list) else [] + return ListOutput( + qurls=qurls, + next_cursor=meta.get("next_cursor") if meta else None, + has_more=meta.get("has_more", False) if meta else False, + ) + + +def parse_error(response: httpx.Response) -> QURLError: + """Parse an API error response into the appropriate QURLError subclass.""" + retry_after = None + if response.status_code == 429: + ra = response.headers.get("Retry-After") + if ra and ra.isdigit(): + retry_after = int(ra) + + # Pick the right subclass, defaulting to ServerError for 5xx or QURLError + cls: type[QURLError] + if response.status_code >= 500: + cls = ServerError + else: + cls = _ERROR_CLASS_MAP.get(response.status_code, QURLError) + + try: + envelope = response.json() + err = envelope.get("error", {}) + return cls( + status=err.get("status", response.status_code), + code=err.get("code", "unknown"), + title=err.get("title", response.reason_phrase or ""), + detail=err.get("detail", ""), + invalid_fields=err.get("invalid_fields"), + request_id=envelope.get("meta", {}).get("request_id"), + retry_after=retry_after, + ) + except (ValueError, KeyError, TypeError): + return cls( + status=response.status_code, + code="unknown", + title=response.reason_phrase or "", + detail=response.text, + retry_after=retry_after, + ) + + +def retry_delay(attempt: int, last_error: Exception | None) -> float: + """Compute retry delay with exponential backoff, jitter, and Retry-After cap.""" + if isinstance(last_error, QURLError) and last_error.retry_after: + return min(float(last_error.retry_after), 30.0) + base: float = 0.5 * (2 ** (attempt - 1)) + jitter = random.random() * base * 0.5 # noqa: S311 + return min(base + jitter, 30.0) + + +def build_list_params( + limit: int | None, + cursor: str | None, + status: str | None, + q: str | None, + sort: str | None, +) -> dict[str, str]: + """Build query params for list endpoints, dropping None values.""" + params: dict[str, str] = {} + if limit is not None: + params["limit"] = str(limit) + if cursor: + params["cursor"] = cursor + if status: + params["status"] = status + if q: + params["q"] = q + if sort: + params["sort"] = sort + return params + + +def mask_key(api_key: str) -> str: + """Mask an API key for display, showing first 4 + last 4 chars.""" + if len(api_key) > 8: + return api_key[:4] + "***" + api_key[-4:] + return "***" diff --git a/src/layerv_qurl/async_client.py b/src/layerv_qurl/async_client.py new file mode 100644 index 0000000..6159aea --- /dev/null +++ b/src/layerv_qurl/async_client.py @@ -0,0 +1,354 @@ +"""Asynchronous QURL API client. + +NOTE: Business logic mirrors client.py — keep both in sync. +""" + +from __future__ import annotations + +import asyncio +from typing import TYPE_CHECKING, Any + +import httpx + +from layerv_qurl._utils import ( + DEFAULT_BASE_URL, + DEFAULT_MAX_RETRIES, + DEFAULT_TIMEOUT, + RETRYABLE_STATUS, + RETRYABLE_STATUS_POST, + build_body, + build_list_params, + default_user_agent, + logger, + mask_key, + parse_create_output, + parse_error, + parse_list_output, + parse_mint_output, + parse_quota, + parse_qurl, + parse_resolve_output, + retry_delay, + validate_id, +) +from layerv_qurl.errors import QURLError, QURLNetworkError, QURLTimeoutError + +if TYPE_CHECKING: + from collections.abc import AsyncIterator + from datetime import datetime + + from layerv_qurl.types import ( + QURL, + AccessPolicy, + CreateOutput, + ListOutput, + MintOutput, + Quota, + QURLStatus, + ResolveOutput, + ) + + +class AsyncQURLClient: + """Asynchronous QURL API client. + + Usage:: + + import asyncio + from layerv_qurl import AsyncQURLClient + + async def main(): + async with AsyncQURLClient(api_key="lv_live_xxx") as client: + result = await client.create(target_url="https://example.com") + access = await client.resolve("at_k8xqp9h2sj9lx7r4a") + + asyncio.run(main()) + """ + + def __init__( + self, + api_key: str, + *, + base_url: str = DEFAULT_BASE_URL, + timeout: float = DEFAULT_TIMEOUT, + max_retries: int = DEFAULT_MAX_RETRIES, + user_agent: str | None = None, + http_client: httpx.AsyncClient | None = None, + ) -> None: + if not api_key or not api_key.strip(): + raise ValueError("api_key must not be empty") + + self._base_url = base_url.rstrip("/") + self._api_key = api_key + self._max_retries = max_retries + self._user_agent = user_agent or default_user_agent() + self._client = http_client or httpx.AsyncClient(timeout=timeout) + self._owns_client = http_client is None + self._base_headers: dict[str, str] = { + "Authorization": f"Bearer {api_key}", + "Accept": "application/json", + "User-Agent": self._user_agent, + } + + def __repr__(self) -> str: + return f"AsyncQURLClient(api_key='{mask_key(self._api_key)}', base_url='{self._base_url}')" + + async def close(self) -> None: + """Close the underlying HTTP client (only if owned by this instance).""" + if self._owns_client: + await self._client.aclose() + + async def __aenter__(self) -> AsyncQURLClient: + return self + + async def __aexit__(self, *_: object) -> None: + await self.close() + + # --- Public API --- + + async def create( + self, + target_url: str, + *, + expires_in: str | None = None, + expires_at: datetime | str | None = None, + description: str | None = None, + one_time_use: bool | None = None, + max_sessions: int | None = None, + access_policy: AccessPolicy | None = None, + ) -> CreateOutput: + """Create a new QURL. + + Returns a :class:`CreateOutput` with the ``resource_id``, ``qurl_link``, + ``qurl_site``, and ``expires_at``. Use :meth:`get` to fetch the full + :class:`QURL` object with status, timestamps, and policy details. + + Args: + target_url: The URL to protect. + expires_in: Duration string (e.g. ``"24h"``, ``"7d"``). + expires_at: Absolute expiry as datetime or ISO string. + description: Human-readable description. + one_time_use: If True, the QURL can only be used once. + max_sessions: Maximum concurrent sessions allowed. + access_policy: IP/geo/user-agent access restrictions. + """ + body = build_body({ + "target_url": target_url, + "expires_in": expires_in, + "expires_at": expires_at, + "description": description, + "one_time_use": one_time_use, + "max_sessions": max_sessions, + "access_policy": access_policy, + }) + resp = await self._request("POST", "/v1/qurl", body=body) + return parse_create_output(resp) + + async def get(self, resource_id: str) -> QURL: + """Get a QURL by ID.""" + validate_id(resource_id) + resp = await self._request("GET", f"/v1/qurls/{resource_id}") + return parse_qurl(resp) + + async def list( + self, + *, + limit: int | None = None, + cursor: str | None = None, + status: QURLStatus | None = None, + q: str | None = None, + sort: str | None = None, + ) -> ListOutput: + """List QURLs with optional filters. + + Args: + limit: Maximum number of results per page. + cursor: Pagination cursor from a previous response. + status: Filter by QURL status + (``"active"``, ``"expired"``, ``"revoked"``, ``"consumed"``, ``"frozen"``). + q: Search query string. + sort: Sort order (e.g. ``"created_at"``, ``"-created_at"``). + """ + params = build_list_params(limit, cursor, status, q, sort) + data, meta = await self._raw_request("GET", "/v1/qurls", params=params) + return parse_list_output(data, meta) + + async def list_all( + self, + *, + status: QURLStatus | None = None, + q: str | None = None, + sort: str | None = None, + page_size: int = 50, + ) -> AsyncIterator[QURL]: + """Iterate over all QURLs, automatically paginating. + + Yields individual :class:`QURL` objects, fetching pages transparently. + """ + cursor: str | None = None + while True: + page = await self.list( + limit=page_size, cursor=cursor, status=status, q=q, sort=sort, + ) + for qurl in page.qurls: + yield qurl + if not page.has_more or not page.next_cursor: + break + cursor = page.next_cursor + + async def delete(self, resource_id: str) -> None: + """Delete (revoke) a QURL.""" + validate_id(resource_id) + await self._request("DELETE", f"/v1/qurls/{resource_id}") + + async def extend(self, resource_id: str, duration: str) -> QURL: + """Extend a QURL's expiration. + + Convenience method — equivalent to ``await update(resource_id, extend_by=duration)``. + + Args: + resource_id: QURL resource ID. + duration: Duration to add (e.g. ``"7d"``, ``"24h"``). + """ + return await self.update(resource_id, extend_by=duration) + + async def update( + self, + resource_id: str, + *, + extend_by: str | None = None, + expires_at: datetime | str | None = None, + description: str | None = None, + access_policy: AccessPolicy | None = None, + ) -> QURL: + """Update a QURL — extend expiration, change description, etc. + + All fields are optional; only provided fields are sent. + + Args: + resource_id: QURL resource ID. + extend_by: Duration to add (e.g. ``"7d"``). + expires_at: New absolute expiry. + description: New description. + access_policy: New access restrictions. + """ + validate_id(resource_id) + body = build_body({ + "extend_by": extend_by, + "expires_at": expires_at, + "description": description, + "access_policy": access_policy, + }) + resp = await self._request("PATCH", f"/v1/qurls/{resource_id}", body=body) + return parse_qurl(resp) + + async def mint_link( + self, + resource_id: str, + *, + expires_at: datetime | str | None = None, + ) -> MintOutput: + """Mint a new access link for a QURL. + + Args: + resource_id: QURL resource ID. + expires_at: Optional expiry override for the minted link. + """ + validate_id(resource_id) + body = build_body({"expires_at": expires_at}) + resp = await self._request("POST", f"/v1/qurls/{resource_id}/mint_link", body=body) + return parse_mint_output(resp) + + async def resolve(self, access_token: str) -> ResolveOutput: + """Resolve a QURL access token (headless). + + Triggers an NHP knock to open firewall access for the caller's IP. + Requires ``qurl:resolve`` scope on the API key. + + Args: + access_token: The access token string (e.g. ``"at_k8xqp9h2sj9lx7r4a"``). + """ + validate_id(access_token, "access_token") + resp = await self._request("POST", "/v1/resolve", body={"access_token": access_token}) + return parse_resolve_output(resp) + + async def get_quota(self) -> Quota: + """Get quota and usage information.""" + resp = await self._request("GET", "/v1/quota") + return parse_quota(resp) + + # --- Internal HTTP plumbing --- + + async def _request( + self, + method: str, + path: str, + *, + body: dict[str, Any] | None = None, + params: dict[str, str] | None = None, + ) -> Any: + data, _ = await self._raw_request(method, path, body=body, params=params) + return data + + async def _raw_request( + self, + method: str, + path: str, + *, + body: dict[str, Any] | None = None, + params: dict[str, str] | None = None, + ) -> tuple[Any, dict[str, Any] | None]: + url = f"{self._base_url}{path}" + last_error: Exception | None = None + + for attempt in range(self._max_retries + 1): + if attempt > 0: + delay = retry_delay(attempt, last_error) + logger.debug("Retry %d/%d after %.1fs", attempt, self._max_retries, delay) + await asyncio.sleep(delay) + + logger.debug("%s %s", method, url) + + try: + response = await self._client.request( + method, + url, + json=body, + params=params, + headers=self._base_headers, + ) + except httpx.TimeoutException as exc: + logger.debug("%s %s timed out", method, url) + if attempt < self._max_retries: + last_error = exc + continue + raise QURLTimeoutError(str(exc), cause=exc) from exc + except httpx.TransportError as exc: + logger.debug("%s %s transport error: %s", method, url, exc) + if attempt < self._max_retries: + last_error = exc + continue + raise QURLNetworkError(str(exc), cause=exc) from exc + + logger.debug("%s %s → %d", method, url, response.status_code) + + if response.status_code < 400: + if response.status_code == 204 or not response.content: + return None, None + envelope = response.json() + return envelope.get("data"), envelope.get("meta") + + err = parse_error(response) + retryable = RETRYABLE_STATUS_POST if method == "POST" else RETRYABLE_STATUS + if response.status_code in retryable and attempt < self._max_retries: + last_error = err + continue + raise err + + if isinstance(last_error, httpx.TimeoutException): + raise QURLTimeoutError(str(last_error), cause=last_error) from last_error + if isinstance(last_error, httpx.TransportError): + raise QURLNetworkError(str(last_error), cause=last_error) from last_error + raise last_error or QURLError( + status=0, code="unknown", title="Request failed", detail="Exhausted retries" + ) diff --git a/src/layerv_qurl/client.py b/src/layerv_qurl/client.py new file mode 100644 index 0000000..b63b9e1 --- /dev/null +++ b/src/layerv_qurl/client.py @@ -0,0 +1,371 @@ +"""Synchronous QURL API client.""" + +from __future__ import annotations + +import time +from typing import TYPE_CHECKING, Any + +import httpx + +from layerv_qurl._utils import ( + DEFAULT_BASE_URL, + DEFAULT_MAX_RETRIES, + DEFAULT_TIMEOUT, + RETRYABLE_STATUS, + RETRYABLE_STATUS_POST, + build_body, + build_list_params, + default_user_agent, + logger, + mask_key, + parse_create_output, + parse_error, + parse_list_output, + parse_mint_output, + parse_quota, + parse_qurl, + parse_resolve_output, + retry_delay, + validate_id, +) +from layerv_qurl.errors import QURLError, QURLNetworkError, QURLTimeoutError + +if TYPE_CHECKING: + from collections.abc import Iterator + from datetime import datetime + + from layerv_qurl.types import ( + QURL, + AccessPolicy, + CreateOutput, + ListOutput, + MintOutput, + Quota, + QURLStatus, + ResolveOutput, + ) + + +class QURLClient: + """Synchronous QURL API client. + + Usage:: + + from layerv_qurl import QURLClient + + client = QURLClient(api_key="lv_live_xxx") + + # Create a protected link + result = client.create(target_url="https://example.com", expires_in="24h") + + # Resolve an access token (opens firewall for your IP) + access = client.resolve("at_k8xqp9h2sj9lx7r4a") + + # Extend a QURL's expiration + qurl = client.extend("r_xxx", "7d") + + # Update metadata + qurl = client.update("r_xxx", description="updated") + + # Iterate all active QURLs + for qurl in client.list_all(status="active"): + print(qurl.resource_id) + + Enable debug logging to see requests:: + + import logging + logging.getLogger("layerv_qurl").setLevel(logging.DEBUG) + """ + + def __init__( + self, + api_key: str, + *, + base_url: str = DEFAULT_BASE_URL, + timeout: float = DEFAULT_TIMEOUT, + max_retries: int = DEFAULT_MAX_RETRIES, + user_agent: str | None = None, + http_client: httpx.Client | None = None, + ) -> None: + if not api_key or not api_key.strip(): + raise ValueError("api_key must not be empty") + + self._base_url = base_url.rstrip("/") + self._api_key = api_key + self._max_retries = max_retries + self._user_agent = user_agent or default_user_agent() + self._client = http_client or httpx.Client(timeout=timeout) + self._owns_client = http_client is None + self._base_headers: dict[str, str] = { + "Authorization": f"Bearer {api_key}", + "Accept": "application/json", + "User-Agent": self._user_agent, + } + + def __repr__(self) -> str: + return f"QURLClient(api_key='{mask_key(self._api_key)}', base_url='{self._base_url}')" + + def close(self) -> None: + """Close the underlying HTTP client (only if owned by this instance).""" + if self._owns_client: + self._client.close() + + def __enter__(self) -> QURLClient: + return self + + def __exit__(self, *_: object) -> None: + self.close() + + # --- Public API --- + + def create( + self, + target_url: str, + *, + expires_in: str | None = None, + expires_at: datetime | str | None = None, + description: str | None = None, + one_time_use: bool | None = None, + max_sessions: int | None = None, + access_policy: AccessPolicy | None = None, + ) -> CreateOutput: + """Create a new QURL. + + Returns a :class:`CreateOutput` with the ``resource_id``, ``qurl_link``, + ``qurl_site``, and ``expires_at``. Use :meth:`get` to fetch the full + :class:`QURL` object with status, timestamps, and policy details. + + Args: + target_url: The URL to protect. + expires_in: Duration string (e.g. ``"24h"``, ``"7d"``). + expires_at: Absolute expiry as datetime or ISO string. + description: Human-readable description. + one_time_use: If True, the QURL can only be used once. + max_sessions: Maximum concurrent sessions allowed. + access_policy: IP/geo/user-agent access restrictions. + """ + body = build_body({ + "target_url": target_url, + "expires_in": expires_in, + "expires_at": expires_at, + "description": description, + "one_time_use": one_time_use, + "max_sessions": max_sessions, + "access_policy": access_policy, + }) + resp = self._request("POST", "/v1/qurl", body=body) + return parse_create_output(resp) + + def get(self, resource_id: str) -> QURL: + """Get a QURL by ID.""" + validate_id(resource_id) + resp = self._request("GET", f"/v1/qurls/{resource_id}") + return parse_qurl(resp) + + def list( + self, + *, + limit: int | None = None, + cursor: str | None = None, + status: QURLStatus | None = None, + q: str | None = None, + sort: str | None = None, + ) -> ListOutput: + """List QURLs with optional filters. + + Args: + limit: Maximum number of results per page. + cursor: Pagination cursor from a previous response. + status: Filter by QURL status + (``"active"``, ``"expired"``, ``"revoked"``, ``"consumed"``, ``"frozen"``). + q: Search query string. + sort: Sort order (e.g. ``"created_at"``, ``"-created_at"``). + """ + params = build_list_params(limit, cursor, status, q, sort) + data, meta = self._raw_request("GET", "/v1/qurls", params=params) + return parse_list_output(data, meta) + + def list_all( + self, + *, + status: QURLStatus | None = None, + q: str | None = None, + sort: str | None = None, + page_size: int = 50, + ) -> Iterator[QURL]: + """Iterate over all QURLs, automatically paginating. + + Yields individual :class:`QURL` objects, fetching pages transparently. + + Args: + status: Filter by status (``"active"``, ``"expired"``, etc.). + q: Search query string. + sort: Sort order. + page_size: Number of items per page (default 50). + """ + cursor: str | None = None + while True: + page = self.list( + limit=page_size, cursor=cursor, status=status, q=q, sort=sort, + ) + yield from page.qurls + if not page.has_more or not page.next_cursor: + break + cursor = page.next_cursor + + def delete(self, resource_id: str) -> None: + """Delete (revoke) a QURL.""" + validate_id(resource_id) + self._request("DELETE", f"/v1/qurls/{resource_id}") + + def extend(self, resource_id: str, duration: str) -> QURL: + """Extend a QURL's expiration. + + Convenience method — equivalent to ``update(resource_id, extend_by=duration)``. + + Args: + resource_id: QURL resource ID. + duration: Duration to add (e.g. ``"7d"``, ``"24h"``). + """ + return self.update(resource_id, extend_by=duration) + + def update( + self, + resource_id: str, + *, + extend_by: str | None = None, + expires_at: datetime | str | None = None, + description: str | None = None, + access_policy: AccessPolicy | None = None, + ) -> QURL: + """Update a QURL — extend expiration, change description, etc. + + All fields are optional; only provided fields are sent. + + Args: + resource_id: QURL resource ID. + extend_by: Duration to add (e.g. ``"7d"``). + expires_at: New absolute expiry. + description: New description. + access_policy: New access restrictions. + """ + validate_id(resource_id) + body = build_body({ + "extend_by": extend_by, + "expires_at": expires_at, + "description": description, + "access_policy": access_policy, + }) + resp = self._request("PATCH", f"/v1/qurls/{resource_id}", body=body) + return parse_qurl(resp) + + def mint_link( + self, + resource_id: str, + *, + expires_at: datetime | str | None = None, + ) -> MintOutput: + """Mint a new access link for a QURL. + + Args: + resource_id: QURL resource ID. + expires_at: Optional expiry override for the minted link. + """ + validate_id(resource_id) + body = build_body({"expires_at": expires_at}) + resp = self._request("POST", f"/v1/qurls/{resource_id}/mint_link", body=body) + return parse_mint_output(resp) + + def resolve(self, access_token: str) -> ResolveOutput: + """Resolve a QURL access token (headless). + + Triggers an NHP knock to open firewall access for the caller's IP. + Requires ``qurl:resolve`` scope on the API key. + + Args: + access_token: The access token string (e.g. ``"at_k8xqp9h2sj9lx7r4a"``). + """ + validate_id(access_token, "access_token") + resp = self._request("POST", "/v1/resolve", body={"access_token": access_token}) + return parse_resolve_output(resp) + + def get_quota(self) -> Quota: + """Get quota and usage information.""" + resp = self._request("GET", "/v1/quota") + return parse_quota(resp) + + # --- Internal HTTP plumbing --- + + def _request( + self, + method: str, + path: str, + *, + body: dict[str, Any] | None = None, + params: dict[str, str] | None = None, + ) -> Any: + data, _ = self._raw_request(method, path, body=body, params=params) + return data + + def _raw_request( + self, + method: str, + path: str, + *, + body: dict[str, Any] | None = None, + params: dict[str, str] | None = None, + ) -> tuple[Any, dict[str, Any] | None]: + url = f"{self._base_url}{path}" + last_error: Exception | None = None + + for attempt in range(self._max_retries + 1): + if attempt > 0: + delay = retry_delay(attempt, last_error) + logger.debug("Retry %d/%d after %.1fs", attempt, self._max_retries, delay) + time.sleep(delay) + + logger.debug("%s %s", method, url) + + try: + response = self._client.request( + method, + url, + json=body, + params=params, + headers=self._base_headers, + ) + except httpx.TimeoutException as exc: + logger.debug("%s %s timed out", method, url) + if attempt < self._max_retries: + last_error = exc + continue + raise QURLTimeoutError(str(exc), cause=exc) from exc + except httpx.TransportError as exc: + logger.debug("%s %s transport error: %s", method, url, exc) + if attempt < self._max_retries: + last_error = exc + continue + raise QURLNetworkError(str(exc), cause=exc) from exc + + logger.debug("%s %s → %d", method, url, response.status_code) + + if response.status_code < 400: + if response.status_code == 204 or not response.content: + return None, None + envelope = response.json() + return envelope.get("data"), envelope.get("meta") + + err = parse_error(response) + retryable = RETRYABLE_STATUS_POST if method == "POST" else RETRYABLE_STATUS + if response.status_code in retryable and attempt < self._max_retries: + last_error = err + continue + raise err + + if isinstance(last_error, httpx.TimeoutException): + raise QURLTimeoutError(str(last_error), cause=last_error) from last_error + if isinstance(last_error, httpx.TransportError): + raise QURLNetworkError(str(last_error), cause=last_error) from last_error + raise last_error or QURLError( + status=0, code="unknown", title="Request failed", detail="Exhausted retries" + ) diff --git a/src/layerv_qurl/errors.py b/src/layerv_qurl/errors.py new file mode 100644 index 0000000..5dc1ddd --- /dev/null +++ b/src/layerv_qurl/errors.py @@ -0,0 +1,105 @@ +"""Error types for the QURL API client.""" + +from __future__ import annotations + + +class QURLError(Exception): + """Error raised for API-level errors (4xx/5xx responses). + + Catch specific subclasses for fine-grained handling:: + + try: + client.resolve("at_xxx") + except AuthenticationError: + print("Bad API key") + except NotFoundError: + print("QURL doesn't exist") + except RateLimitError as e: + print(f"Rate limited — retry in {e.retry_after}s") + except QURLError as e: + print(f"API error: {e.status} {e.code}") + """ + + def __init__( + self, + *, + status: int, + code: str, + title: str, + detail: str, + invalid_fields: dict[str, str] | None = None, + request_id: str | None = None, + retry_after: int | None = None, + ) -> None: + super().__init__(f"{title} ({status}): {detail}") + self.status = status + self.code = code + self.title = title + self.detail = detail + self.invalid_fields = invalid_fields + self.request_id = request_id + self.retry_after = retry_after + + +class AuthenticationError(QURLError): + """401 Unauthorized — invalid or missing API key.""" + + +class AuthorizationError(QURLError): + """403 Forbidden — valid key but insufficient permissions/scope.""" + + +class NotFoundError(QURLError): + """404 Not Found — resource does not exist.""" + + +class ValidationError(QURLError): + """400/422 — invalid request parameters. + + Check :attr:`invalid_fields` for per-field details:: + + except ValidationError as e: + if e.invalid_fields: + for field, reason in e.invalid_fields.items(): + print(f" {field}: {reason}") + """ + + +class RateLimitError(QURLError): + """429 Too Many Requests. + + Check :attr:`retry_after` for the server-suggested wait time:: + + except RateLimitError as e: + if e.retry_after: + time.sleep(e.retry_after) + """ + + +class ServerError(QURLError): + """5xx server-side error.""" + + +class QURLNetworkError(Exception): + """Error raised for transport-level failures (DNS, connection refused).""" + + def __init__(self, message: str, cause: Exception | None = None) -> None: + super().__init__(message) + self.__cause__ = cause + + +class QURLTimeoutError(QURLNetworkError): + """Error raised when a request times out. + + Subclass of :class:`QURLNetworkError` — caught by + ``except QURLNetworkError`` but can also be caught specifically:: + + try: + client.resolve("at_xxx") + except QURLTimeoutError: + print("Request timed out — server may be slow") + except QURLNetworkError: + print("Network issue — DNS, connection, etc.") + except QURLError: + print("API error — 4xx/5xx") + """ diff --git a/src/layerv_qurl/langchain.py b/src/layerv_qurl/langchain.py new file mode 100644 index 0000000..f6f7849 --- /dev/null +++ b/src/layerv_qurl/langchain.py @@ -0,0 +1,162 @@ +"""LangChain tool integration for QURL. + +Install with: pip install layerv-qurl[langchain] +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +try: + from langchain_core.tools import BaseTool + _HAS_LANGCHAIN = True +except ImportError: + _HAS_LANGCHAIN = False + BaseTool = object # type: ignore[misc,assignment] + +if TYPE_CHECKING: + from langchain_core.callbacks import CallbackManagerForToolRun + + from layerv_qurl.client import QURLClient + +__all__ = [ + "CreateQURLTool", + "DeleteQURLTool", + "ListQURLsTool", + "QURLToolkit", + "ResolveQURLTool", +] + + +class CreateQURLTool(BaseTool): + """Create a secure, time-limited access link to a protected URL.""" + + name: str = "create_qurl" + description: str = ( + "Create a QURL — a secure, time-limited access link. " + "Input should be a JSON string with 'target_url' (required), " + "and optionally 'expires_in' (e.g. '24h', '7d'), 'description', " + "'one_time_use' (bool), 'max_sessions' (int)." + ) + client: Any = None # QURLClient, typed as Any for Pydantic compatibility + + def _run( + self, + target_url: str, + expires_in: str = "24h", + description: str | None = None, + one_time_use: bool | None = None, + max_sessions: int | None = None, + run_manager: CallbackManagerForToolRun | None = None, + ) -> str: + result = self.client.create( + target_url=target_url, + expires_in=expires_in, + description=description, + one_time_use=one_time_use, + max_sessions=max_sessions, + ) + return ( + f"Created QURL {result.resource_id}\n" + f"Link: {result.qurl_link}\n" + f"Site: {result.qurl_site}\n" + f"Expires: {result.expires_at or 'N/A'}" + ) + + +class ResolveQURLTool(BaseTool): + """Resolve a QURL access token to open firewall access.""" + + name: str = "resolve_qurl" + description: str = ( + "Resolve a QURL access token to gain firewall access to the protected resource. " + "Input should be the access token string (e.g. 'at_k8xqp9h2sj9lx7r4a')." + ) + client: Any = None + + def _run( + self, + access_token: str, + run_manager: CallbackManagerForToolRun | None = None, + ) -> str: + result = self.client.resolve(access_token) + grant = result.access_grant + lines = [ + f"Resolved: {result.target_url}", + f"Resource: {result.resource_id}", + ] + if grant: + lines.append(f"Access expires in: {grant.expires_in}s") + lines.append(f"Granted to IP: {grant.src_ip}") + return "\n".join(lines) + + +class ListQURLsTool(BaseTool): + """List active QURL links.""" + + name: str = "list_qurls" + description: str = ( + "List active QURL links. Optionally filter by status (active, expired, revoked, consumed)." + ) + client: Any = None + + def _run( + self, + status: str = "active", + limit: int = 10, + run_manager: CallbackManagerForToolRun | None = None, + ) -> str: + result = self.client.list(status=status, limit=limit) + if not result.qurls: + return "No QURLs found." + lines = [] + for q in result.qurls: + lines.append(f"- {q.resource_id}: {q.target_url} [{q.status}] expires={q.expires_at}") + return "\n".join(lines) + + +class DeleteQURLTool(BaseTool): + """Revoke a QURL, immediately ending all access.""" + + name: str = "delete_qurl" + description: str = "Revoke (delete) a QURL by resource ID (e.g. 'r_k8xqp9h2sj9')." + client: Any = None + + def _run( + self, + resource_id: str, + run_manager: CallbackManagerForToolRun | None = None, + ) -> str: + self.client.delete(resource_id) + return f"QURL {resource_id} has been revoked." + + +class QURLToolkit: + """LangChain toolkit providing all QURL tools. + + Usage:: + + from layerv_qurl import QURLClient + from layerv_qurl.langchain import QURLToolkit + + client = QURLClient(api_key="lv_live_xxx") + toolkit = QURLToolkit(client=client) + tools = toolkit.get_tools() + """ + + def __init__(self, client: QURLClient) -> None: + if not _HAS_LANGCHAIN: + raise ImportError( + "langchain-core is required for LangChain integration. " + "Install with: pip install layerv-qurl[langchain]" + ) + self.client = client + + def get_tools(self) -> list[BaseTool]: + """Return all QURL tools configured with the client.""" + return [ + CreateQURLTool(client=self.client), + ResolveQURLTool(client=self.client), + ListQURLsTool(client=self.client), + DeleteQURLTool(client=self.client), + ] diff --git a/src/layerv_qurl/py.typed b/src/layerv_qurl/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/layerv_qurl/types.py b/src/layerv_qurl/types.py new file mode 100644 index 0000000..9f8a39c --- /dev/null +++ b/src/layerv_qurl/types.py @@ -0,0 +1,127 @@ +"""Type definitions for the QURL API.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime +from typing import Literal + +#: Valid QURL status values. Accepts known values for IDE autocomplete, +#: plus ``str`` for forward compatibility with new API statuses. +QURLStatus = Literal["active", "expired", "revoked", "consumed", "frozen"] | str + + +def _parse_dt(s: str | None) -> datetime | None: + """Parse an ISO 8601 datetime string, handling Z suffix for Python 3.10 compat.""" + if s is None: + return None + if s.endswith("Z"): + s = s[:-1] + "+00:00" + return datetime.fromisoformat(s) + + +@dataclass +class AccessPolicy: + """Access control policy for a QURL.""" + + ip_allowlist: list[str] | None = None + ip_denylist: list[str] | None = None + geo_allowlist: list[str] | None = None + geo_denylist: list[str] | None = None + user_agent_allow_regex: str | None = None + user_agent_deny_regex: str | None = None + + +@dataclass +class QURL: + """A QURL resource as returned by the API.""" + + resource_id: str + target_url: str + status: QURLStatus + created_at: datetime | None = None + expires_at: datetime | None = None + one_time_use: bool = False + max_sessions: int | None = None + description: str | None = None + qurl_site: str | None = None + qurl_link: str | None = None + access_policy: AccessPolicy | None = None + + +@dataclass +class CreateOutput: + """Response from creating a QURL.""" + + resource_id: str + qurl_link: str + qurl_site: str + expires_at: datetime | None = None + + +@dataclass +class MintOutput: + """Response from minting an access link.""" + + qurl_link: str + expires_at: datetime | None = None + + +@dataclass +class AccessGrant: + """Details of the firewall access that was granted.""" + + expires_in: int + granted_at: datetime | None = None + src_ip: str = "" + + +@dataclass +class ResolveOutput: + """Response from headless resolution.""" + + target_url: str + resource_id: str + access_grant: AccessGrant | None = None + + +@dataclass +class ListOutput: + """Response from listing QURLs.""" + + qurls: list[QURL] = field(default_factory=list) + next_cursor: str | None = None + has_more: bool = False + + +@dataclass +class RateLimits: + """Rate limit configuration.""" + + create_per_minute: int = 0 + create_per_hour: int = 0 + list_per_minute: int = 0 + resolve_per_minute: int = 0 + max_active_qurls: int = 0 + max_tokens_per_qurl: int = 0 + + +@dataclass +class Usage: + """Usage statistics.""" + + qurls_created: int = 0 + active_qurls: int = 0 + active_qurls_percent: float = 0.0 + total_accesses: int = 0 + + +@dataclass +class Quota: + """Quota and usage information.""" + + plan: str = "" + period_start: datetime | None = None + period_end: datetime | None = None + rate_limits: RateLimits | None = None + usage: Usage | None = None diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_client.py b/tests/test_client.py new file mode 100644 index 0000000..f2c4579 --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,1132 @@ +"""Tests for the QURL Python client.""" + +from __future__ import annotations + +import json +from datetime import datetime, timezone +from unittest.mock import patch + +import httpx +import pytest +import respx + +from layerv_qurl import ( + AsyncQURLClient, + QURLClient, + QURLError, + QURLNetworkError, + QURLTimeoutError, +) +from layerv_qurl.errors import ( + AuthenticationError, + AuthorizationError, + NotFoundError, + RateLimitError, + ServerError, + ValidationError, +) +from layerv_qurl.types import AccessPolicy + +BASE_URL = "https://api.test.layerv.ai" + +_ERR_429 = { + "error": { + "status": 429, "code": "rate_limited", + "title": "Rate Limited", "detail": "Slow down", + }, +} +_ERR_503 = { + "error": { + "status": 503, "code": "unavailable", + "title": "Unavailable", "detail": "Down", + }, +} +_QUOTA_OK = { + "data": { + "plan": "growth", + "period_start": "2026-03-01T00:00:00Z", + "period_end": "2026-04-01T00:00:00Z", + }, +} + + +def _qurl_item(rid: str, url: str) -> dict: + return { + "resource_id": rid, + "target_url": url, + "status": "active", + "created_at": "2026-03-10T10:00:00Z", + } + + +@pytest.fixture +def client() -> QURLClient: + return QURLClient(api_key="lv_live_test", base_url=BASE_URL, max_retries=0) + + +@pytest.fixture +async def async_client() -> AsyncQURLClient: + client = AsyncQURLClient(api_key="lv_live_test", base_url=BASE_URL, max_retries=0) + yield client # type: ignore[misc] + await client.close() + + +@pytest.fixture +def retry_client() -> QURLClient: + return QURLClient(api_key="lv_live_test", base_url=BASE_URL, max_retries=2) + + +# --- Constructor tests --- + + +def test_path_traversal_rejected(client: QURLClient) -> None: + """resource_id with path traversal characters is rejected.""" + with pytest.raises(ValueError, match="Invalid resource_id"): + client.get("../../admin/secrets") + + +def test_empty_resource_id_rejected(client: QURLClient) -> None: + with pytest.raises(ValueError, match="Invalid resource_id"): + client.delete("") + + +def test_empty_api_key_raises() -> None: + with pytest.raises(ValueError, match="api_key must not be empty"): + QURLClient(api_key="") + + +def test_whitespace_api_key_raises() -> None: + with pytest.raises(ValueError, match="api_key must not be empty"): + QURLClient(api_key=" ") + + +def test_repr_masks_api_key() -> None: + c = QURLClient(api_key="lv_live_abcdefghij", base_url=BASE_URL) + r = repr(c) + assert "lv_l" in r + assert "ghij" in r + assert "abcdefghij" not in r + assert "QURLClient(" in r + c.close() + + +def test_repr_short_api_key() -> None: + c = QURLClient(api_key="short123", base_url=BASE_URL) + r = repr(c) + assert "***" in r + assert "short123" not in r + c.close() + + +# --- CRUD tests with kwargs API --- + + +@respx.mock +def test_create(client: QURLClient) -> None: + respx.post(f"{BASE_URL}/v1/qurl").mock( + return_value=httpx.Response( + 201, + json={ + "data": { + "resource_id": "r_abc123def45", + "qurl_link": "https://qurl.link/#at_test", + "qurl_site": "https://r_abc123def45.qurl.site", + "expires_at": "2026-03-15T10:00:00Z", + }, + "meta": {"request_id": "req_1"}, + }, + ) + ) + + result = client.create(target_url="https://example.com", expires_in="24h") + assert result.resource_id == "r_abc123def45" + assert result.qurl_link == "https://qurl.link/#at_test" + assert result.qurl_site == "https://r_abc123def45.qurl.site" + assert isinstance(result.expires_at, datetime) + + +@respx.mock +def test_create_sends_correct_body(client: QURLClient) -> None: + route = respx.post(f"{BASE_URL}/v1/qurl").mock( + return_value=httpx.Response( + 201, + json={ + "data": { + "resource_id": "r_abc", + "qurl_link": "https://qurl.link/#at_test", + "qurl_site": "https://r_abc.qurl.site", + }, + }, + ) + ) + + client.create( + target_url="https://example.com", + expires_in="24h", + description="test", + one_time_use=True, + ) + body = json.loads(route.calls[0].request.content) + assert body == { + "target_url": "https://example.com", + "expires_in": "24h", + "description": "test", + "one_time_use": True, + } + + +@respx.mock +def test_create_omits_none_values(client: QURLClient) -> None: + route = respx.post(f"{BASE_URL}/v1/qurl").mock( + return_value=httpx.Response( + 201, + json={ + "data": { + "resource_id": "r_abc", + "qurl_link": "https://qurl.link/#at_test", + "qurl_site": "https://r_abc.qurl.site", + }, + }, + ) + ) + + client.create(target_url="https://example.com") + body = json.loads(route.calls[0].request.content) + assert body == {"target_url": "https://example.com"} + assert "expires_in" not in body + assert "description" not in body + + +@respx.mock +def test_get(client: QURLClient) -> None: + respx.get(f"{BASE_URL}/v1/qurls/r_abc123def45").mock( + return_value=httpx.Response( + 200, + json={ + "data": { + "resource_id": "r_abc123def45", + "target_url": "https://example.com", + "status": "active", + "created_at": "2026-03-10T10:00:00Z", + "expires_at": "2026-03-15T10:00:00Z", + "one_time_use": False, + }, + "meta": {"request_id": "req_2"}, + }, + ) + ) + + result = client.get("r_abc123def45") + assert result.resource_id == "r_abc123def45" + assert result.status == "active" + assert isinstance(result.created_at, datetime) + assert result.created_at == datetime(2026, 3, 10, 10, 0, 0, tzinfo=timezone.utc) + + +@respx.mock +def test_list(client: QURLClient) -> None: + respx.get(f"{BASE_URL}/v1/qurls").mock( + return_value=httpx.Response( + 200, + json={ + "data": [ + { + "resource_id": "r_abc123def45", + "target_url": "https://example.com", + "status": "active", + "created_at": "2026-03-10T10:00:00Z", + } + ], + "meta": {"has_more": False, "page_size": 20}, + }, + ) + ) + + result = client.list(status="active", limit=10) + assert len(result.qurls) == 1 + assert result.qurls[0].resource_id == "r_abc123def45" + assert result.has_more is False + + +@respx.mock +def test_list_all_paginates(client: QURLClient) -> None: + route = respx.get(f"{BASE_URL}/v1/qurls") + route.side_effect = [ + httpx.Response(200, json={ + "data": [_qurl_item("r_1", "https://1.com"), _qurl_item("r_2", "https://2.com")], + "meta": {"has_more": True, "next_cursor": "cur_abc"}, + }), + httpx.Response(200, json={ + "data": [_qurl_item("r_3", "https://3.com")], + "meta": {"has_more": False}, + }), + ] + + all_qurls = list(client.list_all(status="active", page_size=2)) + assert len(all_qurls) == 3 + assert [q.resource_id for q in all_qurls] == ["r_1", "r_2", "r_3"] + assert route.call_count == 2 + + +@respx.mock +def test_delete(client: QURLClient) -> None: + respx.delete(f"{BASE_URL}/v1/qurls/r_abc123def45").mock( + return_value=httpx.Response(204) + ) + client.delete("r_abc123def45") # Should not raise + + +@respx.mock +def test_update_with_extend(client: QURLClient) -> None: + route = respx.patch(f"{BASE_URL}/v1/qurls/r_abc123def45").mock( + return_value=httpx.Response( + 200, + json={ + "data": { + "resource_id": "r_abc123def45", + "target_url": "https://example.com", + "status": "active", + "created_at": "2026-03-10T10:00:00Z", + "expires_at": "2026-03-20T10:00:00Z", + }, + }, + ) + ) + + result = client.update("r_abc123def45", extend_by="7d") + assert isinstance(result.expires_at, datetime) + body = json.loads(route.calls[0].request.content) + assert body == {"extend_by": "7d"} + + +@respx.mock +def test_update_with_description(client: QURLClient) -> None: + route = respx.patch(f"{BASE_URL}/v1/qurls/r_abc123def45").mock( + return_value=httpx.Response( + 200, + json={ + "data": { + "resource_id": "r_abc123def45", + "target_url": "https://example.com", + "status": "active", + "created_at": "2026-03-10T10:00:00Z", + "description": "new desc", + }, + }, + ) + ) + + result = client.update("r_abc123def45", description="new desc") + assert result.description == "new desc" + body = json.loads(route.calls[0].request.content) + assert body == {"description": "new desc"} + + +@respx.mock +def test_update_combined(client: QURLClient) -> None: + """update() can extend and change description in one call.""" + route = respx.patch(f"{BASE_URL}/v1/qurls/r_abc").mock( + return_value=httpx.Response( + 200, + json={ + "data": { + "resource_id": "r_abc", + "target_url": "https://example.com", + "status": "active", + "created_at": "2026-03-10T10:00:00Z", + "expires_at": "2026-03-20T10:00:00Z", + "description": "updated", + }, + }, + ) + ) + + client.update("r_abc", extend_by="7d", description="updated") + body = json.loads(route.calls[0].request.content) + assert body == {"extend_by": "7d", "description": "updated"} + + +@respx.mock +def test_mint_link(client: QURLClient) -> None: + respx.post(f"{BASE_URL}/v1/qurls/r_abc123def45/mint_link").mock( + return_value=httpx.Response( + 200, + json={ + "data": { + "qurl_link": "https://qurl.link/#at_newtoken", + "expires_at": "2026-03-20T10:00:00Z", + }, + }, + ) + ) + + result = client.mint_link("r_abc123def45", expires_at="2026-03-20T10:00:00Z") + assert result.qurl_link == "https://qurl.link/#at_newtoken" + assert isinstance(result.expires_at, datetime) + + +@respx.mock +def test_mint_link_no_input(client: QURLClient) -> None: + respx.post(f"{BASE_URL}/v1/qurls/r_abc123def45/mint_link").mock( + return_value=httpx.Response( + 200, + json={ + "data": { + "qurl_link": "https://qurl.link/#at_default", + }, + }, + ) + ) + + result = client.mint_link("r_abc123def45") + assert result.qurl_link == "https://qurl.link/#at_default" + assert result.expires_at is None + + +@respx.mock +def test_resolve_plain_string(client: QURLClient) -> None: + """resolve() accepts a plain string token.""" + respx.post(f"{BASE_URL}/v1/resolve").mock( + return_value=httpx.Response( + 200, + json={ + "data": { + "target_url": "https://api.example.com/data", + "resource_id": "r_abc123def45", + "access_grant": { + "expires_in": 305, + "granted_at": "2026-03-10T15:30:00Z", + "src_ip": "203.0.113.42", + }, + }, + }, + ) + ) + + result = client.resolve("at_k8xqp9h2sj9lx7r4a") + assert result.target_url == "https://api.example.com/data" + assert result.access_grant is not None + assert result.access_grant.expires_in == 305 + assert result.access_grant.src_ip == "203.0.113.42" + assert isinstance(result.access_grant.granted_at, datetime) + + +@respx.mock +def test_error_handling(client: QURLClient) -> None: + respx.get(f"{BASE_URL}/v1/qurls/r_notfound0000").mock( + return_value=httpx.Response( + 404, + json={ + "error": { + "type": "https://api.qurl.link/problems/not_found", + "title": "Not Found", + "status": 404, + "detail": "QURL not found", + "code": "not_found", + }, + "meta": {"request_id": "req_err"}, + }, + ) + ) + + with pytest.raises(QURLError) as exc_info: + client.get("r_notfound0000") + + err = exc_info.value + assert err.status == 404 + assert err.code == "not_found" + assert err.request_id == "req_err" + + +@respx.mock +def test_quota_typed(client: QURLClient) -> None: + """get_quota() returns typed RateLimits and Usage objects.""" + respx.get(f"{BASE_URL}/v1/quota").mock( + return_value=httpx.Response( + 200, + json={ + "data": { + "plan": "growth", + "period_start": "2026-03-01T00:00:00Z", + "period_end": "2026-04-01T00:00:00Z", + "rate_limits": { + "create_per_minute": 60, + "create_per_hour": 1000, + "list_per_minute": 120, + "resolve_per_minute": 300, + "max_active_qurls": 5000, + "max_tokens_per_qurl": 10, + }, + "usage": { + "qurls_created": 10, + "active_qurls": 5, + "active_qurls_percent": 0.1, + "total_accesses": 42, + }, + }, + }, + ) + ) + + result = client.get_quota() + assert result.plan == "growth" + assert isinstance(result.period_start, datetime) + + # Typed RateLimits + assert result.rate_limits is not None + assert result.rate_limits.create_per_minute == 60 + assert result.rate_limits.max_active_qurls == 5000 + + # Typed Usage + assert result.usage is not None + assert result.usage.active_qurls == 5 + assert result.usage.qurls_created == 10 + assert result.usage.total_accesses == 42 + + +# --- Injected http_client --- + + +@respx.mock +def test_injected_http_client_gets_auth_headers() -> None: + custom_client = httpx.Client(timeout=10) + qurl = QURLClient(api_key="lv_live_custom", base_url=BASE_URL, http_client=custom_client) + + route = respx.get(f"{BASE_URL}/v1/quota").mock( + return_value=httpx.Response( + 200, + json={ + "data": { + "plan": "free", + "period_start": "2026-03-01T00:00:00Z", + "period_end": "2026-04-01T00:00:00Z", + }, + }, + ) + ) + + qurl.get_quota() + assert route.called + req = route.calls[0].request + assert req.headers["authorization"] == "Bearer lv_live_custom" + # Content-Type should NOT be set for GET requests + assert "content-type" not in req.headers + custom_client.close() + + +# --- Retry logic --- + + +@respx.mock +def test_retry_success_after_429(retry_client: QURLClient) -> None: + route = respx.get(f"{BASE_URL}/v1/quota") + route.side_effect = [ + httpx.Response(429, json=_ERR_429), + httpx.Response(200, json=_QUOTA_OK), + ] + + with patch("layerv_qurl.client.time.sleep"): + result = retry_client.get_quota() + + assert result.plan == "growth" + assert route.call_count == 2 + + +@respx.mock +def test_retry_exhausted_raises_last_error(retry_client: QURLClient) -> None: + route = respx.get(f"{BASE_URL}/v1/quota") + route.side_effect = [ + httpx.Response(503, json=_ERR_503), + httpx.Response(503, json=_ERR_503), + httpx.Response(503, json=_ERR_503), + ] + + with patch("layerv_qurl.client.time.sleep"), pytest.raises(QURLError) as exc_info: + retry_client.get_quota() + + assert exc_info.value.status == 503 + assert route.call_count == 3 + + +@respx.mock +def test_retry_after_header_respected(retry_client: QURLClient) -> None: + route = respx.get(f"{BASE_URL}/v1/quota") + route.side_effect = [ + httpx.Response( + 429, headers={"Retry-After": "5"}, json=_ERR_429, + ), + httpx.Response(200, json=_QUOTA_OK), + ] + + with patch("layerv_qurl.client.time.sleep") as mock_sleep: + result = retry_client.get_quota() + + assert result.plan == "growth" + mock_sleep.assert_called_once_with(5.0) + + +@respx.mock +def test_retry_after_capped_at_30s(retry_client: QURLClient) -> None: + route = respx.get(f"{BASE_URL}/v1/quota") + route.side_effect = [ + httpx.Response( + 429, headers={"Retry-After": "120"}, json=_ERR_429, + ), + httpx.Response(200, json=_QUOTA_OK), + ] + + with patch("layerv_qurl.client.time.sleep") as mock_sleep: + retry_client.get_quota() + + mock_sleep.assert_called_once_with(30.0) + + +# --- Non-JSON error --- + + +@respx.mock +def test_non_json_error_response(client: QURLClient) -> None: + respx.get(f"{BASE_URL}/v1/qurls/r_bad").mock( + return_value=httpx.Response( + 500, + text="Internal Server Error", + headers={"content-type": "text/plain"}, + ) + ) + + with pytest.raises(QURLError) as exc_info: + client.get("r_bad") + + err = exc_info.value + assert err.status == 500 + assert err.code == "unknown" + assert "Internal Server Error" in err.detail + + +# --- Network error wrapping --- + + +@respx.mock +def test_network_error_wrapped(client: QURLClient) -> None: + """httpx errors are wrapped in QURLNetworkError.""" + respx.get(f"{BASE_URL}/v1/quota").mock( + side_effect=httpx.ConnectError("Connection refused") + ) + + with pytest.raises(QURLNetworkError, match="Connection refused"): + client.get_quota() + + +@respx.mock +def test_network_error_preserves_cause(client: QURLClient) -> None: + """QURLNetworkError preserves the original httpx exception as __cause__.""" + respx.get(f"{BASE_URL}/v1/quota").mock( + side_effect=httpx.ConnectError("DNS lookup failed") + ) + + with pytest.raises(QURLNetworkError) as exc_info: + client.get_quota() + + assert isinstance(exc_info.value.__cause__, httpx.ConnectError) + + +@respx.mock +def test_timeout_error_wrapped(client: QURLClient) -> None: + """httpx.TimeoutException is wrapped in QURLTimeoutError.""" + respx.get(f"{BASE_URL}/v1/quota").mock( + side_effect=httpx.ReadTimeout("Read timed out") + ) + + with pytest.raises(QURLTimeoutError, match="Read timed out"): + client.get_quota() + + +@respx.mock +def test_timeout_error_is_network_error(client: QURLClient) -> None: + """QURLTimeoutError is a subclass of QURLNetworkError.""" + respx.get(f"{BASE_URL}/v1/quota").mock( + side_effect=httpx.ReadTimeout("Read timed out") + ) + + with pytest.raises(QURLNetworkError): + client.get_quota() + + +@respx.mock +def test_timeout_retried_then_wrapped() -> None: + """Timeout errors are retried, then wrapped as QURLTimeoutError.""" + c = QURLClient(api_key="lv_live_test", base_url=BASE_URL, max_retries=1) + route = respx.get(f"{BASE_URL}/v1/quota") + route.side_effect = [ + httpx.ReadTimeout("timeout 1"), + httpx.ReadTimeout("timeout 2"), + ] + + with patch("layerv_qurl.client.time.sleep"), pytest.raises(QURLTimeoutError): + c.get_quota() + + assert route.call_count == 2 + + +@respx.mock +def test_network_error_retried_then_wrapped() -> None: + """Network errors are retried, then wrapped if all retries fail.""" + c = QURLClient(api_key="lv_live_test", base_url=BASE_URL, max_retries=1) + route = respx.get(f"{BASE_URL}/v1/quota") + route.side_effect = [ + httpx.ConnectError("fail 1"), + httpx.ConnectError("fail 2"), + ] + + with patch("layerv_qurl.client.time.sleep"), pytest.raises(QURLNetworkError): + c.get_quota() + + assert route.call_count == 2 + + +# --- Context manager / close() tests --- + + +def test_close_closes_owned_client() -> None: + c = QURLClient(api_key="lv_live_test", base_url=BASE_URL) + assert c._owns_client is True + c.close() + assert c._client.is_closed + + +def test_close_does_not_close_injected_client() -> None: + custom = httpx.Client(timeout=10) + c = QURLClient(api_key="lv_live_test", base_url=BASE_URL, http_client=custom) + assert c._owns_client is False + c.close() + assert not custom.is_closed + custom.close() + + +def test_context_manager_closes_owned_client() -> None: + with QURLClient(api_key="lv_live_test", base_url=BASE_URL) as c: + assert c._owns_client is True + assert c._client.is_closed + + +def test_context_manager_does_not_close_injected_client() -> None: + custom = httpx.Client(timeout=10) + with QURLClient(api_key="lv_live_test", base_url=BASE_URL, http_client=custom) as c: + assert c._owns_client is False + assert not custom.is_closed + custom.close() + + +# --- Content-Type header tests --- + + +@respx.mock +def test_get_request_has_no_content_type(client: QURLClient) -> None: + """GET requests should not send Content-Type header.""" + route = respx.get(f"{BASE_URL}/v1/qurls/r_abc").mock( + return_value=httpx.Response( + 200, + json={ + "data": { + "resource_id": "r_abc", + "target_url": "https://example.com", + "status": "active", + "created_at": "2026-03-10T10:00:00Z", + }, + }, + ) + ) + + client.get("r_abc") + req = route.calls[0].request + assert "content-type" not in req.headers + + +@respx.mock +def test_post_request_has_content_type(client: QURLClient) -> None: + """POST requests with body should send Content-Type: application/json.""" + route = respx.post(f"{BASE_URL}/v1/qurl").mock( + return_value=httpx.Response( + 201, + json={ + "data": { + "resource_id": "r_abc", + "qurl_link": "https://qurl.link/#at_test", + "qurl_site": "https://r_abc.qurl.site", + }, + }, + ) + ) + + client.create(target_url="https://example.com") + req = route.calls[0].request + assert req.headers["content-type"] == "application/json" + + +# --- Async client --- + + +@respx.mock +@pytest.mark.asyncio +async def test_async_create(async_client: AsyncQURLClient) -> None: + respx.post(f"{BASE_URL}/v1/qurl").mock( + return_value=httpx.Response( + 201, + json={ + "data": { + "resource_id": "r_async", + "qurl_link": "https://qurl.link/#at_async", + "qurl_site": "https://r_async.qurl.site", + "expires_at": "2026-03-15T10:00:00Z", + }, + }, + ) + ) + + result = await async_client.create(target_url="https://example.com", expires_in="24h") + assert result.resource_id == "r_async" + assert isinstance(result.expires_at, datetime) + + +@respx.mock +@pytest.mark.asyncio +async def test_async_resolve(async_client: AsyncQURLClient) -> None: + respx.post(f"{BASE_URL}/v1/resolve").mock( + return_value=httpx.Response( + 200, + json={ + "data": { + "target_url": "https://api.example.com/data", + "resource_id": "r_async", + "access_grant": { + "expires_in": 305, + "granted_at": "2026-03-10T15:30:00Z", + "src_ip": "203.0.113.42", + }, + }, + }, + ) + ) + + result = await async_client.resolve("at_test_token") + assert result.target_url == "https://api.example.com/data" + assert result.access_grant is not None + assert result.access_grant.expires_in == 305 + + +@respx.mock +@pytest.mark.asyncio +async def test_async_list_all(async_client: AsyncQURLClient) -> None: + route = respx.get(f"{BASE_URL}/v1/qurls") + route.side_effect = [ + httpx.Response(200, json={ + "data": [_qurl_item("r_1", "https://1.com")], + "meta": {"has_more": True, "next_cursor": "cur_abc"}, + }), + httpx.Response(200, json={ + "data": [_qurl_item("r_2", "https://2.com")], + "meta": {"has_more": False}, + }), + ] + + all_qurls = [q async for q in async_client.list_all(status="active", page_size=1)] + assert len(all_qurls) == 2 + assert [q.resource_id for q in all_qurls] == ["r_1", "r_2"] + + +@respx.mock +@pytest.mark.asyncio +async def test_async_network_error_wrapped(async_client: AsyncQURLClient) -> None: + respx.get(f"{BASE_URL}/v1/quota").mock( + side_effect=httpx.ConnectError("Connection refused") + ) + + with pytest.raises(QURLNetworkError, match="Connection refused"): + await async_client.get_quota() + + +@respx.mock +@pytest.mark.asyncio +async def test_async_timeout_error_wrapped(async_client: AsyncQURLClient) -> None: + """Async: httpx.TimeoutException is wrapped in QURLTimeoutError.""" + respx.get(f"{BASE_URL}/v1/quota").mock( + side_effect=httpx.ReadTimeout("Read timed out") + ) + + with pytest.raises(QURLTimeoutError, match="Read timed out"): + await async_client.get_quota() + + +@respx.mock +@pytest.mark.asyncio +async def test_async_timeout_is_network_error(async_client: AsyncQURLClient) -> None: + """Async: QURLTimeoutError is caught by except QURLNetworkError.""" + respx.get(f"{BASE_URL}/v1/quota").mock( + side_effect=httpx.ReadTimeout("Read timed out") + ) + + with pytest.raises(QURLNetworkError): + await async_client.get_quota() + + +def test_async_repr() -> None: + c = AsyncQURLClient(api_key="lv_live_abcdefghij", base_url=BASE_URL) + r = repr(c) + assert "AsyncQURLClient(" in r + assert "lv_l" in r + assert "ghij" in r + assert "abcdefghij" not in r + + +# --- Error subclass tests --- + + +@respx.mock +def test_401_raises_authentication_error(client: QURLClient) -> None: + respx.get(f"{BASE_URL}/v1/quota").mock( + return_value=httpx.Response( + 401, + json={ + "error": { + "status": 401, "code": "unauthorized", + "title": "Unauthorized", "detail": "Invalid API key", + }, + }, + ) + ) + + with pytest.raises(AuthenticationError) as exc_info: + client.get_quota() + assert exc_info.value.status == 401 + assert isinstance(exc_info.value, QURLError) + + +@respx.mock +def test_403_raises_authorization_error(client: QURLClient) -> None: + respx.get(f"{BASE_URL}/v1/quota").mock( + return_value=httpx.Response( + 403, + json={ + "error": { + "status": 403, "code": "forbidden", + "title": "Forbidden", "detail": "Insufficient scope", + }, + }, + ) + ) + + with pytest.raises(AuthorizationError) as exc_info: + client.get_quota() + assert exc_info.value.status == 403 + + +@respx.mock +def test_404_raises_not_found_error(client: QURLClient) -> None: + respx.get(f"{BASE_URL}/v1/qurls/r_notfound0000").mock( + return_value=httpx.Response( + 404, + json={ + "error": { + "status": 404, "code": "not_found", + "title": "Not Found", "detail": "QURL not found", + }, + "meta": {"request_id": "req_err"}, + }, + ) + ) + + with pytest.raises(NotFoundError) as exc_info: + client.get("r_notfound0000") + assert exc_info.value.status == 404 + assert exc_info.value.request_id == "req_err" + + +@respx.mock +def test_422_raises_validation_error(client: QURLClient) -> None: + respx.post(f"{BASE_URL}/v1/qurl").mock( + return_value=httpx.Response( + 422, + json={ + "error": { + "status": 422, "code": "validation_error", + "title": "Validation Error", "detail": "Invalid target_url", + "invalid_fields": {"target_url": "must be a valid URL"}, + }, + }, + ) + ) + + with pytest.raises(ValidationError) as exc_info: + client.create(target_url="not-a-url") + assert exc_info.value.invalid_fields == {"target_url": "must be a valid URL"} + + +@respx.mock +def test_429_raises_rate_limit_error(client: QURLClient) -> None: + respx.get(f"{BASE_URL}/v1/quota").mock( + return_value=httpx.Response( + 429, + headers={"Retry-After": "10"}, + json={ + "error": { + "status": 429, "code": "rate_limited", + "title": "Rate Limited", "detail": "Slow down", + }, + }, + ) + ) + + with pytest.raises(RateLimitError) as exc_info: + client.get_quota() + assert exc_info.value.retry_after == 10 + + +@respx.mock +def test_500_raises_server_error(client: QURLClient) -> None: + respx.get(f"{BASE_URL}/v1/quota").mock( + return_value=httpx.Response( + 500, + json={ + "error": { + "status": 500, "code": "internal", + "title": "Internal Server Error", "detail": "Something broke", + }, + }, + ) + ) + + with pytest.raises(ServerError) as exc_info: + client.get_quota() + assert exc_info.value.status == 500 + + +@respx.mock +def test_400_raises_validation_error(client: QURLClient) -> None: + respx.post(f"{BASE_URL}/v1/qurl").mock( + return_value=httpx.Response( + 400, + json={ + "error": { + "status": 400, "code": "bad_request", + "title": "Bad Request", "detail": "Missing target_url", + }, + }, + ) + ) + + with pytest.raises(ValidationError): + client.create(target_url="") + + +# --- extend() convenience method --- + + +@respx.mock +def test_extend(client: QURLClient) -> None: + """extend() delegates to update(extend_by=...).""" + route = respx.patch(f"{BASE_URL}/v1/qurls/r_abc123def45").mock( + return_value=httpx.Response( + 200, + json={ + "data": { + "resource_id": "r_abc123def45", + "target_url": "https://example.com", + "status": "active", + "created_at": "2026-03-10T10:00:00Z", + "expires_at": "2026-03-20T10:00:00Z", + }, + }, + ) + ) + + result = client.extend("r_abc123def45", "7d") + assert isinstance(result.expires_at, datetime) + body = json.loads(route.calls[0].request.content) + assert body == {"extend_by": "7d"} + + +@respx.mock +@pytest.mark.asyncio +async def test_async_extend(async_client: AsyncQURLClient) -> None: + """Async extend() delegates to update(extend_by=...).""" + route = respx.patch(f"{BASE_URL}/v1/qurls/r_abc").mock( + return_value=httpx.Response( + 200, + json={ + "data": { + "resource_id": "r_abc", + "target_url": "https://example.com", + "status": "active", + "created_at": "2026-03-10T10:00:00Z", + "expires_at": "2026-03-20T10:00:00Z", + }, + }, + ) + ) + + result = await async_client.extend("r_abc", "24h") + assert result.resource_id == "r_abc" + body = json.loads(route.calls[0].request.content) + assert body == {"extend_by": "24h"} + + +# --- AccessPolicy serialization --- + + +@respx.mock +def test_access_policy_serialized(client: QURLClient) -> None: + """AccessPolicy dataclass is serialized correctly in create().""" + route = respx.post(f"{BASE_URL}/v1/qurl").mock( + return_value=httpx.Response( + 201, + json={ + "data": { + "resource_id": "r_policy", + "qurl_link": "https://qurl.link/#at_test", + "qurl_site": "https://r_policy.qurl.site", + }, + }, + ) + ) + + policy = AccessPolicy( + ip_allowlist=["10.0.0.0/8"], + geo_denylist=["CN", "RU"], + ) + client.create(target_url="https://example.com", access_policy=policy) + body = json.loads(route.calls[0].request.content) + assert body["access_policy"] == { + "ip_allowlist": ["10.0.0.0/8"], + "geo_denylist": ["CN", "RU"], + } + # None fields should be omitted from the serialized policy + assert "ip_denylist" not in body["access_policy"] + assert "geo_allowlist" not in body["access_policy"] + + +@respx.mock +def test_access_policy_in_update(client: QURLClient) -> None: + """AccessPolicy can also be passed to update().""" + route = respx.patch(f"{BASE_URL}/v1/qurls/r_abc").mock( + return_value=httpx.Response( + 200, + json={ + "data": { + "resource_id": "r_abc", + "target_url": "https://example.com", + "status": "active", + "created_at": "2026-03-10T10:00:00Z", + "access_policy": { + "user_agent_deny_regex": "curl.*", + }, + }, + }, + ) + ) + + policy = AccessPolicy(user_agent_deny_regex="curl.*") + result = client.update("r_abc", access_policy=policy) + assert result.access_policy is not None + assert result.access_policy.user_agent_deny_regex == "curl.*" + body = json.loads(route.calls[0].request.content) + assert body["access_policy"] == {"user_agent_deny_regex": "curl.*"} diff --git a/tests/test_langchain.py b/tests/test_langchain.py new file mode 100644 index 0000000..492aa04 --- /dev/null +++ b/tests/test_langchain.py @@ -0,0 +1,140 @@ +"""Tests for the LangChain tool integration.""" + +from __future__ import annotations + +from datetime import datetime, timezone +from unittest.mock import MagicMock + +from layerv_qurl.langchain import ( + CreateQURLTool, + DeleteQURLTool, + ListQURLsTool, + QURLToolkit, + ResolveQURLTool, +) +from layerv_qurl.types import ( + QURL, + AccessGrant, + CreateOutput, + ListOutput, + ResolveOutput, +) + + +def _mock_client() -> MagicMock: + return MagicMock() + + +def test_create_qurl_tool() -> None: + client = _mock_client() + client.create.return_value = CreateOutput( + resource_id="r_abc123def45", + qurl_link="https://qurl.link/#at_test", + qurl_site="https://r_abc123def45.qurl.site", + expires_at=datetime(2026, 3, 15, 10, 0, 0, tzinfo=timezone.utc), + ) + + tool = CreateQURLTool(client=client) + result = tool._run(target_url="https://example.com", expires_in="24h") + + assert "r_abc123def45" in result + assert "https://qurl.link/#at_test" in result + client.create.assert_called_once_with( + target_url="https://example.com", + expires_in="24h", + description=None, + one_time_use=None, + max_sessions=None, + ) + + +def test_resolve_qurl_tool() -> None: + client = _mock_client() + client.resolve.return_value = ResolveOutput( + target_url="https://api.example.com/data", + resource_id="r_abc123def45", + access_grant=AccessGrant( + expires_in=305, + granted_at=datetime(2026, 3, 10, 15, 30, 0, tzinfo=timezone.utc), + src_ip="203.0.113.42", + ), + ) + + tool = ResolveQURLTool(client=client) + result = tool._run(access_token="at_k8xqp9h2sj9lx7r4a") + + assert "https://api.example.com/data" in result + assert "305" in result + assert "203.0.113.42" in result + # resolve() now takes a plain string + client.resolve.assert_called_once_with("at_k8xqp9h2sj9lx7r4a") + + +def test_resolve_qurl_tool_no_grant() -> None: + client = _mock_client() + client.resolve.return_value = ResolveOutput( + target_url="https://api.example.com/data", + resource_id="r_abc123def45", + access_grant=None, + ) + + tool = ResolveQURLTool(client=client) + result = tool._run(access_token="at_k8xqp9h2sj9lx7r4a") + + assert "https://api.example.com/data" in result + assert "r_abc123def45" in result + + +def test_list_qurls_tool() -> None: + client = _mock_client() + client.list.return_value = ListOutput( + qurls=[ + QURL( + resource_id="r_abc123def45", + target_url="https://example.com", + status="active", + created_at=datetime(2026, 3, 10, 10, 0, 0, tzinfo=timezone.utc), + expires_at=datetime(2026, 3, 15, 10, 0, 0, tzinfo=timezone.utc), + ) + ], + has_more=False, + ) + + tool = ListQURLsTool(client=client) + result = tool._run(status="active", limit=10) + + assert "r_abc123def45" in result + assert "https://example.com" in result + client.list.assert_called_once_with(status="active", limit=10) + + +def test_list_qurls_tool_empty() -> None: + client = _mock_client() + client.list.return_value = ListOutput(qurls=[], has_more=False) + + tool = ListQURLsTool(client=client) + result = tool._run() + + assert result == "No QURLs found." + + +def test_delete_qurl_tool() -> None: + client = _mock_client() + client.delete.return_value = None + + tool = DeleteQURLTool(client=client) + result = tool._run(resource_id="r_abc123def45") + + assert "r_abc123def45" in result + assert "revoked" in result + client.delete.assert_called_once_with("r_abc123def45") + + +def test_toolkit_returns_all_tools() -> None: + client = _mock_client() + toolkit = QURLToolkit(client=client) + tools = toolkit.get_tools() + + assert len(tools) == 4 + names = {t.name for t in tools} + assert names == {"create_qurl", "resolve_qurl", "list_qurls", "delete_qurl"}