diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4e34bc6..a0c52b7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,9 @@ jobs: test: runs-on: ubuntu-latest strategy: + # Don't cancel sibling matrix legs if one fails — we want full + # visibility into which Python versions broke. + fail-fast: false matrix: python-version: ["3.10", "3.12", "3.13"] steps: @@ -22,18 +25,40 @@ jobs: 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 + # ============================================================================ + # TYPE CHECK - Dedicated mypy job + # ============================================================================ + # Runs in its own job so the gate can't silently vanish if the test + # matrix is restructured. Pins to the minimum supported Python version + # (matching `python_version` in pyproject.toml) so the strictness stays + # aligned with what the SDK promises to consumers. + type-check: + name: Type check (mypy) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Set up Python 3.10 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: "3.10" + - name: Install dependencies + # Use the full dev+langchain extras so mypy sees the optional + # langchain integration's real types. Without langchain-core + # installed, mypy reports spurious errors about missing modules + # and unused type-ignore comments in src/layerv_qurl/langchain.py. + run: pip install -e ".[dev,langchain]" + - name: Run mypy + run: mypy src/ + # ============================================================================ # NOTIFY - Send Slack notification on build completion # ============================================================================ notify: name: Notify - needs: [test] + needs: [test, type-check] if: always() && github.event_name != 'pull_request' runs-on: ubuntu-latest timeout-minutes: 5 @@ -43,6 +68,7 @@ jobs: env: SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK_URL }} TEST_RESULT: ${{ needs.test.result }} + TYPE_CHECK_RESULT: ${{ needs.type-check.result }} COMMIT_MESSAGE: ${{ github.event.head_commit.message }} # All github context moved to env to prevent script injection # via crafted branch names or actor names containing shell metacharacters. @@ -77,14 +103,27 @@ jobs: *) TRIGGER_TEXT="$TRIGGER" ;; esac + # Aggregate status across both gates (test matrix + mypy). A + # mypy failure is a red build even if every python matrix leg + # passed, so the Slack message must reflect the worst of the two. + if [[ "$TEST_RESULT" == "success" && "$TYPE_CHECK_RESULT" == "success" ]]; then + AGGREGATE="success" + elif [[ "$TEST_RESULT" == "failure" || "$TYPE_CHECK_RESULT" == "failure" ]]; then + AGGREGATE="failure" + elif [[ "$TEST_RESULT" == "cancelled" || "$TYPE_CHECK_RESULT" == "cancelled" ]]; then + AGGREGATE="cancelled" + else + AGGREGATE="incomplete" + fi + # Determine status - if [[ "$TEST_RESULT" == "success" ]]; then + if [[ "$AGGREGATE" == "success" ]]; then COLOR="#36a64f"; EMOJI="white_check_mark" HEADER="QURL Python SDK Build"; STATUS_TEXT="successful" - elif [[ "$TEST_RESULT" == "failure" ]]; then + elif [[ "$AGGREGATE" == "failure" ]]; then COLOR="#dc3545"; EMOJI="x" HEADER="QURL Python SDK Build"; STATUS_TEXT="failed" - elif [[ "$TEST_RESULT" == "cancelled" ]]; then + elif [[ "$AGGREGATE" == "cancelled" ]]; then COLOR="#ffc107"; EMOJI="warning" HEADER="QURL Python SDK Build"; STATUS_TEXT="cancelled" else @@ -92,12 +131,18 @@ jobs: HEADER="QURL Python SDK"; STATUS_TEXT="incomplete" fi - # Determine test emoji + # Determine per-gate emoji so the Slack message shows which + # gate failed when the aggregate is red. case "$TEST_RESULT" in success) TEST_EMOJI=":white_check_mark:" ;; failure) TEST_EMOJI=":x:" ;; *) TEST_EMOJI=":warning:" ;; esac + case "$TYPE_CHECK_RESULT" in + success) TYPE_CHECK_EMOJI=":white_check_mark:" ;; + failure) TYPE_CHECK_EMOJI=":x:" ;; + *) TYPE_CHECK_EMOJI=":warning:" ;; + esac # Extract first line of commit message (safe for any content) FIRST_LINE=$(echo "$COMMIT_MESSAGE" | head -n1 | cut -c1-100) @@ -114,6 +159,7 @@ jobs: --arg trigger "$TRIGGER_TEXT" \ --arg actor "$GH_ACTOR" \ --arg test_emoji "$TEST_EMOJI" \ + --arg type_check_emoji "$TYPE_CHECK_EMOJI" \ --arg commit "$FIRST_LINE" \ --arg commit_url "$COMMIT_URL" \ --arg workflow_url "$WORKFLOW_URL" \ @@ -123,6 +169,7 @@ jobs: {type: "mrkdwn", text: ("*Branch:*\n`\($branch)` (\($sha))")}, {type: "mrkdwn", text: ("*Trigger:*\n\($trigger) by \($actor)")}, {type: "mrkdwn", text: ("*Tests:*\n\($test_emoji)")}, + {type: "mrkdwn", text: ("*Type check:*\n\($type_check_emoji)")}, {type: "mrkdwn", text: "*Python:*\n3.10, 3.12, 3.13"} ]}, {type: "section", text: {type: "mrkdwn", text: ("*Commit:* `\($commit)`")}}, diff --git a/pyproject.toml b/pyproject.toml index 3df1da7..a40e598 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,13 @@ dev = [ "respx>=0.22", "ruff>=0.11", "mypy>=1.14", + # Pull the optional langchain integration into the default dev install + # via the self-reference pattern. Single source of truth for the + # langchain-core version constraint lives in the `[langchain]` extra + # above — updating it once keeps dev and CI in lockstep. Without + # langchain-core, local mypy produces spurious errors about a missing + # module and an unused type-ignore in langchain.py. + "layerv-qurl[langchain]", ] [project.urls] diff --git a/src/layerv_qurl/__init__.py b/src/layerv_qurl/__init__.py index 4fb9e56..fb05601 100644 --- a/src/layerv_qurl/__init__.py +++ b/src/layerv_qurl/__init__.py @@ -21,13 +21,20 @@ AccessGrant, AccessPolicy, AccessToken, + AIAgentPolicy, + BatchCreateItem, + BatchCreateOutput, + BatchItemError, + BatchItemResult, CreateOutput, ListOutput, MintOutput, Quota, + QuotaPlan, QURLStatus, RateLimits, ResolveOutput, + TokenStatus, Usage, ) @@ -45,7 +52,14 @@ "ServerError", "ValidationError", # Types + "AIAgentPolicy", + "BatchCreateItem", + "BatchCreateOutput", + "BatchItemError", + "BatchItemResult", + "QuotaPlan", "QURLStatus", + "TokenStatus", "AccessGrant", "AccessPolicy", "AccessToken", diff --git a/src/layerv_qurl/_utils.py b/src/layerv_qurl/_utils.py index 158e3fc..9c840e3 100644 --- a/src/layerv_qurl/_utils.py +++ b/src/layerv_qurl/_utils.py @@ -1,4 +1,12 @@ -"""Shared utilities for sync and async clients.""" +"""Shared utilities for sync and async clients. + +Underscore-prefixed helpers here (e.g. :func:`_validate_batch_create_shape`) +are **package-internal**, not strict module-private: they're imported +by both ``client.py`` and ``async_client.py`` to keep sync/async logic +in lockstep, but are excluded from the public ``from layerv_qurl import`` +surface and carry no stability guarantees. Downstream consumers should +not import them directly. +""" from __future__ import annotations @@ -26,6 +34,10 @@ AccessGrant, AccessPolicy, AccessToken, + AIAgentPolicy, + BatchCreateOutput, + BatchItemError, + BatchItemResult, CreateOutput, ListOutput, MintOutput, @@ -76,31 +88,206 @@ def validate_id(value: str, name: str = "resource_id") -> str: return value +def _serialize_value(v: Any) -> Any: + """Serialize a single value for JSON, handling dataclasses/datetimes/lists/dicts. + + Unlike :func:`build_body` (which strips top-level ``None`` values so the + request body only carries fields the caller explicitly set), this + recursive helper preserves ``None`` values inside lists and nested + dicts. This matters because some API fields use explicit ``null`` as a + signalling value (e.g. ``"access_policy": {"ai_agent_policy": null}`` + to clear a policy). Dataclass fields still skip ``None`` because the + dataclass itself distinguishes "unset" from "explicitly null". + """ + if v is None: + return None + if isinstance(v, datetime): + return v.isoformat() + if dataclasses.is_dataclass(v) and not isinstance(v, type): + return { + f.name: _serialize_value(getattr(v, f.name)) + for f in dataclasses.fields(v) + if getattr(v, f.name) is not None + } + if isinstance(v, list): + return [_serialize_value(item) for item in v] + if isinstance(v, dict): + # Preserve explicit None inside nested dicts — callers who want + # "drop this field" should omit it from the dict, not set it to None. + return {k: _serialize_value(val) for k, val in v.items()} + return v + + def build_body(kwargs: dict[str, Any]) -> dict[str, Any]: - """Build a request body dict from kwargs, dropping None values. + """Build a request body dict from kwargs, dropping top-level None values. - Always returns a dict (at least ``{}``) so POST/PATCH endpoints - receive a valid JSON body. + Only strips ``None`` at the top level — nested values are preserved + as-is by :func:`_serialize_value`. Always returns a dict (at least + ``{}``) so POST/PATCH endpoints receive a valid JSON body. Nested + dataclasses are recursively serialized to dicts. """ 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 + body[k] = _serialize_value(v) return body +# ---- Spec-derived input validation -------------------------------------- +# These mirror the constraints documented on each request schema in +# qurl/api/openapi.yaml so obvious mistakes fail fast with a ValueError +# instead of round-tripping to the API and coming back as a generic 400. + +MAX_TARGET_URL = 2048 +MAX_LABEL = 500 +MAX_DESCRIPTION = 500 +MAX_CUSTOM_DOMAIN = 253 +MAX_MAX_SESSIONS = 1000 +MAX_TAGS = 10 +MAX_TAG_LENGTH = 50 +# Tag item pattern mirrored from the OpenAPI spec schema +# `UpdateQurlRequest.tags.items.pattern` in qurl/api/openapi.yaml. +# Keep in lockstep with the spec — if the server pattern ever relaxes +# (e.g. allowing colons) the SDK must widen this regex or it will +# reject strings the API would otherwise accept. +_TAG_PATTERN = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9 _-]*$") +RESOURCE_ID_PREFIX = "r_" +# target_url must use an http(s) scheme per the API's SSRF protection. +# This is a cheap client-side sanity check — the server is still the +# authoritative validator (e.g. it rejects localhost, cloud metadata, +# and private-range hosts; the SDK doesn't need to duplicate that). +_ALLOWED_URL_SCHEMES = ("http://", "https://") + + +def _require_max_length(value: str | None, field_name: str, maximum: int) -> None: + if value is not None and len(value) > maximum: + raise ValueError( + f"{field_name}: must be {maximum} characters or fewer (got {len(value)})" + ) + + +def _require_max_sessions_in_range(value: int | None) -> None: + if value is None: + return + # `bool` is a subclass of `int` in Python (True == 1, False == 0), so + # a caller passing `max_sessions=True` would sneak through an + # `isinstance(value, int)` check alone. Reject bool explicitly so + # obvious type confusion fails loudly instead of silently meaning 1. + if not isinstance(value, int) or isinstance(value, bool): + raise ValueError(f"max_sessions: must be an integer (got {type(value).__name__})") + if value < 0 or value > MAX_MAX_SESSIONS: + raise ValueError( + f"max_sessions: must be an integer between 0 and {MAX_MAX_SESSIONS} (got {value})" + ) + + +def _require_valid_tags(tags: list[str] | None) -> None: + if tags is None: + return + if len(tags) > MAX_TAGS: + raise ValueError(f"tags: max {MAX_TAGS} items allowed (got {len(tags)})") + for tag in tags: + if not isinstance(tag, str) or len(tag) < 1 or len(tag) > MAX_TAG_LENGTH: + raise ValueError( + f"tags: each tag must be 1-{MAX_TAG_LENGTH} characters " + f"(got {len(tag) if isinstance(tag, str) else type(tag).__name__})" + ) + if not _TAG_PATTERN.match(tag): + raise ValueError( + "tags: each tag must start with an alphanumeric and contain only " + "letters, numbers, spaces, underscores, or hyphens" + ) + + +def validate_create_input( + *, + target_url: str, + label: str | None = None, + max_sessions: int | None = None, + custom_domain: str | None = None, +) -> None: + """Validate a single create_qurl input against spec-documented constraints. + + Used by both ``create()`` and ``batch_create()`` (for each item). + Raises ``ValueError`` on constraint violations so obvious mistakes + fail fast instead of round-tripping to the API. + """ + if not isinstance(target_url, str) or not target_url.startswith(_ALLOWED_URL_SCHEMES): + # `repr(...)[:40]` instead of `target_url[:32]!r` — the original + # subscript would raise `TypeError` on any non-subscriptable + # input (None, int, bool, …) *before* the ValueError could + # surface, masking the real validation failure with a cryptic + # slicing error. `repr()` works on any object. + raise ValueError( + f"target_url: must start with http:// or https:// (got {repr(target_url)[:40]})" + ) + _require_max_length(target_url, "target_url", MAX_TARGET_URL) + _require_max_length(label, "label", MAX_LABEL) + _require_max_length(custom_domain, "custom_domain", MAX_CUSTOM_DOMAIN) + _require_max_sessions_in_range(max_sessions) + + +def validate_update_input( + *, + description: str | None = None, + tags: list[str] | None = None, +) -> None: + """Validate update_qurl input against spec-documented constraints.""" + _require_max_length(description, "description", MAX_DESCRIPTION) + _require_valid_tags(tags) + + +def validate_mint_input( + *, + label: str | None = None, + max_sessions: int | None = None, +) -> None: + """Validate mint_link input against spec-documented constraints.""" + _require_max_length(label, "label", MAX_LABEL) + _require_max_sessions_in_range(max_sessions) + + +def require_resource_id_prefix(resource_id: str, operation: str = "delete") -> None: + """Enforce the ``r_`` prefix on endpoints that only accept resource IDs. + + Per the OpenAPI spec, ``DELETE /v1/qurls/:id`` explicitly requires a + resource ID (r_ prefix) — the token-scoped endpoint is + ``DELETE /v1/resources/:id/qurls/:qurl_id``. Catch the common mistake + of passing a ``q_`` display ID here with a clear client-side error. + """ + if not resource_id.startswith(RESOURCE_ID_PREFIX): + # Don't echo the raw ID — even truncated to 16 chars it may + # contain caller-sensitive data that ends up in error logs. + # Echo only the 2-char prefix so the caller sees which kind of + # ID they passed without leaking the value. + observed_prefix = resource_id[:2] + # TODO: update the "not yet available in this SDK version" + # wording once a token-scoped `revoke_token()` / equivalent + # method lands on the client. Until then, the error points + # users at the API-level endpoint without a concrete SDK call. + raise ValueError( + f"{operation}: only resource IDs ({RESOURCE_ID_PREFIX} prefix) are accepted — " + f"got an ID starting with {observed_prefix!r}. To revoke a single " + "access token, use the token-scoped revoke endpoint (not yet " + "available in this SDK version)." + ) + + def _parse_access_policy(data: dict[str, Any]) -> AccessPolicy: """Parse an AccessPolicy from API response data.""" + ai_policy = None + ap = data.get("ai_agent_policy") + # Guard against non-dict values (e.g. API returning a bare string + # or boolean for ai_agent_policy). Without this, `.get("block_all")` + # would raise AttributeError. Consistent with the defensive posture + # in `_validate_batch_create_shape`. + if ap is not None and isinstance(ap, dict): + ai_policy = AIAgentPolicy( + block_all=ap.get("block_all"), + deny_categories=ap.get("deny_categories"), + allow_categories=ap.get("allow_categories"), + ) return AccessPolicy( ip_allowlist=data.get("ip_allowlist"), ip_denylist=data.get("ip_denylist"), @@ -108,6 +295,7 @@ def _parse_access_policy(data: dict[str, Any]) -> AccessPolicy: geo_denylist=data.get("geo_denylist"), user_agent_allow_regex=data.get("user_agent_allow_regex"), user_agent_deny_regex=data.get("user_agent_deny_regex"), + ai_agent_policy=ai_policy, ) @@ -145,7 +333,9 @@ def parse_qurl(data: dict[str, Any]) -> QURL: created_at=_parse_dt(data.get("created_at")), expires_at=_parse_dt(data.get("expires_at")), description=data.get("description"), + tags=data.get("tags", []), qurl_site=data.get("qurl_site"), + custom_domain=data.get("custom_domain"), qurl_count=data.get("qurl_count"), access_tokens=tokens, ) @@ -153,11 +343,19 @@ def parse_qurl(data: dict[str, Any]) -> QURL: def parse_create_output(data: dict[str, Any]) -> CreateOutput: """Parse a CreateOutput from API response data.""" + # Normalize empty-string `qurl_id` → None for idiomatic truthiness + # checks. Intentionally asymmetric with `label` (preserved as-is): + # `""` is never a meaningful identifier but IS a meaningful "cleared" + # value for user-facing metadata. + qurl_id_raw = data.get("qurl_id") + qurl_id = qurl_id_raw if qurl_id_raw else None 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")), + qurl_id=qurl_id, + label=data.get("label"), ) @@ -198,6 +396,7 @@ def parse_quota(data: dict[str, Any]) -> Quota: resolve_per_minute=limits_data.get("resolve_per_minute", 0), max_active_qurls=limits_data.get("max_active_qurls", 0), max_tokens_per_qurl=limits_data.get("max_tokens_per_qurl", 0), + max_expiry_seconds=limits_data.get("max_expiry_seconds", 0), ) usage = None if data.get("usage") is not None: @@ -205,11 +404,20 @@ def parse_quota(data: dict[str, Any]) -> Quota: usage = Usage( qurls_created=usage_data.get("qurls_created", 0), active_qurls=usage_data.get("active_qurls", 0), - active_qurls_percent=usage_data.get("active_qurls_percent", 0.0), + # Nullable per the API spec — the field is null when + # max_active_qurls is unlimited. + active_qurls_percent=usage_data.get("active_qurls_percent"), total_accesses=usage_data.get("total_accesses", 0), ) return Quota( - plan=data.get("plan", ""), + # Fall back to the same sentinel the dataclass default uses + # (see ``Quota.plan`` in types.py) so a malformed API response + # that omits the field produces a consistent "not-yet-populated" + # value regardless of whether the Quota was constructed via + # parse_quota or directly. In practice the /v1/quota endpoint + # always returns a populated plan string, so this fallback is + # only hit for malformed responses or internal bootstrap paths. + plan=data.get("plan", "unknown"), period_start=_parse_dt(data.get("period_start")), period_end=_parse_dt(data.get("period_end")), rate_limits=rate_limits, @@ -227,11 +435,160 @@ def parse_list_output(data: Any, meta: dict[str, Any] | None) -> ListOutput: ) +def _validate_batch_create_shape(data: Any) -> None: + """Defense-in-depth structural check for batch_create responses. + + The ``batch_create`` endpoint whitelists HTTP 400 into the success + path so the populated ``BatchCreateOutput`` body is surfaced instead + of being swallowed by the generic error path. If the API ever returns + 400 with a *different* body (e.g., a plain validation error envelope, + a proxy error, or malformed JSON), ``parse_batch_create_output`` would + silently produce ``(succeeded=0, failed=0, results=[])`` via its + ``.get()`` defaults — the caller would get no indication anything went + wrong. + + Raise a clear error instead when the shape doesn't match. The error + message intentionally does not embed the raw body — an unexpected + body could contain sensitive data (auth details, request echoes) + and error strings may end up in client-side logs. Structural hints + (type name, top-level keys) are emitted at DEBUG level for + production triage, which is safe because JSON key names come from + the API's published schema — not user-supplied data. + """ + + def _fail(reason: str, *, top_level_keys: list[str] | None = None) -> ValidationError: + # DEBUG log carries structural hints (type + top-level key names + # only — JSON keys come from the published schema, not user + # data) so operators can triage shape-guard trips without + # leaking raw body content into logs. + logger.debug( + "batch_create shape guard tripped: %s (type=%s, top_level_keys=%s)", + reason, + type(data).__name__, + top_level_keys, + ) + # Uses `ValidationError` (subclass) not bare `QURLError` so + # `except ValidationError` catches shape-guard trips; `code= + # "unexpected_response"` distinguishes from client-side + # preflight (`client_validation`). `status=0` is the SDK + # convention for all client-detected failures (not real HTTP + # status). See qurl-typescript's `unexpectedResponseError`. + return ValidationError( + status=0, + code="unexpected_response", + title="Unexpected Response", + detail="Unexpected response shape from POST /v1/qurls/batch", + ) + + if not isinstance(data, dict): + raise _fail("not a dict") + top_keys = sorted(data.keys()) + # `bool` is a subclass of `int` in Python, so a response with + # `"succeeded": True` would silently pass an `isinstance(..., int)` + # check and then slip a truthy bool into the counts. Reject + # explicitly — matches the same guard in `_require_max_sessions_in_range`. + succeeded = data.get("succeeded") + failed = data.get("failed") + if ( + not isinstance(succeeded, int) + or isinstance(succeeded, bool) + or not isinstance(failed, int) + or isinstance(failed, bool) + ): + raise _fail("succeeded/failed missing or wrong type", top_level_keys=top_keys) + if not isinstance(data.get("results"), list): + raise _fail("results missing or not a list", top_level_keys=top_keys) + results = data["results"] + # Arithmetic invariant: `succeeded + failed` must equal the number of + # result entries. A mismatch indicates either a proxy/middleware + # mangled the response or the API returned inconsistent counts — + # both cases warrant raising rather than trusting the data. + if succeeded + failed != len(results): + raise _fail( + f"counts/results length mismatch (succeeded={succeeded}, " + f"failed={failed}, len(results)={len(results)})", + top_level_keys=top_keys, + ) + # Each entry must carry a boolean `success` discriminant so consumers + # can reliably branch on it — anything else would break the + # BatchItemResult contract. Deeper per-field validation is + # intentionally left to the API. + for i, entry in enumerate(results): + if not isinstance(entry, dict) or not isinstance(entry.get("success"), bool): + raise _fail( + f"results[{i}] missing boolean 'success' discriminant", + top_level_keys=top_keys, + ) + + +def parse_batch_create_output(data: dict[str, Any]) -> BatchCreateOutput: + """Parse a BatchCreateOutput from API response data. + + Runs :func:`_validate_batch_create_shape` internally before + parsing, so a malformed envelope raises :class:`ValidationError` + here rather than silently producing an empty result. This enforces + the shape contract at the parser boundary rather than relying on + every call site to remember to validate first — previously the + validation was called explicitly by ``batch_create`` and a future + refactor that forgot the step would silently get ``(succeeded=0, + failed=0, results=[])`` from the ``.get()`` defaults below. + """ + _validate_batch_create_shape(data) + results: list[BatchItemResult] = [] + for item in data.get("results", []): + err = None + if item.get("error"): + e = item["error"] + err = BatchItemError( + code=e.get("code", ""), + message=e.get("message", ""), + ) + results.append( + BatchItemResult( + index=item.get("index", 0), + success=item.get("success", False), + resource_id=item.get("resource_id"), + qurl_link=item.get("qurl_link"), + qurl_site=item.get("qurl_site"), + expires_at=_parse_dt(item.get("expires_at")), + error=err, + ) + ) + return BatchCreateOutput( + succeeded=data.get("succeeded", 0), + failed=data.get("failed", 0), + results=results, + ) + + def parse_error(response: httpx.Response) -> QURLError: - """Parse an API error response into the appropriate QURLError subclass.""" + """Parse an API error response into the appropriate QURLError subclass. + + Handles the full RFC 7807 Problem Details shape (``type``, ``title``, + ``status``, ``detail``, ``instance``, ``code``) plus the pre-RFC-7807 + legacy ``{error: {code, message}}`` envelope for backward compatibility. + + The ``detail`` fallback chain is: + 1. ``err.detail`` — RFC 7807 primary + 2. ``err.message`` — legacy pre-RFC-7807 shape + 3. ``err.title`` — RFC 7807 required field + 4. ``HTTP {status}`` — final safety net + + This prevents ``"Title (403): "`` when the API omits ``detail``. + """ retry_after = None if response.status_code == 429: retry_after_header = response.headers.get("Retry-After") + # Per RFC 7231 §7.1.3, `Retry-After` can be either a + # delay-seconds integer OR an HTTP-date. The `.isdigit()` check + # accepts only the integer form — HTTP-date strings contain + # letters/spaces/commas and deliberately fall through to `None`, + # which causes the retry path to use exponential backoff + # instead. This is a safe fallback: we don't honor the server's + # exact hint, but we also don't hang waiting for a parsed date + # value or crash on an unexpected header format. If full + # HTTP-date support becomes a requirement, replace `.isdigit()` + # with a `parsedate_to_datetime`-based parse. if retry_after_header and retry_after_header.isdigit(): retry_after = int(retry_after_header) @@ -244,14 +601,29 @@ def parse_error(response: httpx.Response) -> QURLError: try: envelope = response.json() - err = envelope.get("error", {}) + # `.get("error") or {}` — not the more-common `.get("error", {})` — + # because the API may return `"error": null` explicitly, not just + # omit the key. Both cases must collapse to the empty-dict default + # so the subsequent `err.get(...)` chains don't raise + # `AttributeError` on `None`. Same pattern is applied to `meta` + # below and to `envelope.get("data")` callers elsewhere. + err = envelope.get("error") or {} + title = err.get("title") or response.reason_phrase or "" + detail = ( + err.get("detail") + or err.get("message") # legacy envelope + or title + or f"HTTP {response.status_code}" + ) 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", ""), + title=title, + detail=detail, + type=err.get("type"), + instance=err.get("instance"), invalid_fields=err.get("invalid_fields"), - request_id=envelope.get("meta", {}).get("request_id"), + request_id=(envelope.get("meta") or {}).get("request_id"), retry_after=retry_after, ) except (ValueError, KeyError, TypeError): @@ -259,7 +631,7 @@ def parse_error(response: httpx.Response) -> QURLError: status=response.status_code, code="unknown", title=response.reason_phrase or "", - detail=response.text, + detail=response.text or f"HTTP {response.status_code}", retry_after=retry_after, ) @@ -279,20 +651,45 @@ def build_list_params( status: str | None, q: str | None, sort: str | None, + created_after: datetime | str | None = None, + created_before: datetime | str | None = None, + expires_before: datetime | str | None = None, + expires_after: datetime | str | None = None, ) -> dict[str, str]: """Build query params for list endpoints, dropping None values.""" - params: dict[str, str] = {} + # Per the OpenAPI spec (GET /v1/qurls → limit: integer, minimum: 1, + # maximum: 100, default: 20). Client-side validation catches obvious + # mistakes before a round-trip, matching the existing style for + # max_sessions, tag count, URL length. Omitting `limit` (None) lets + # the server apply its default page size. 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 + if not isinstance(limit, int) or isinstance(limit, bool): + raise ValueError( + f"limit: must be an integer between 1 and 100 (got {type(limit).__name__})" + ) + if limit < 1 or limit > 100: + raise ValueError( + f"limit: must be an integer between 1 and 100 (got {limit})" + ) + # ``status`` is a QURLStatus (Literal | str) — covered by the ``str`` arm. + # Ordered most-specific to least-specific: datetime/int are concrete + # types, str is the widening arm, None is the "drop" sentinel. + pairs: dict[str, datetime | int | str | None] = { + "limit": limit, + "cursor": cursor, + "status": status, + "q": q, + "sort": sort, + "created_after": created_after, + "created_before": created_before, + "expires_before": expires_before, + "expires_after": expires_after, + } + return { + k: v.isoformat() if isinstance(v, datetime) else str(v) + for k, v in pairs.items() + if v is not None + } def mask_key(api_key: str) -> str: diff --git a/src/layerv_qurl/async_client.py b/src/layerv_qurl/async_client.py index 6159aea..5b23502 100644 --- a/src/layerv_qurl/async_client.py +++ b/src/layerv_qurl/async_client.py @@ -6,7 +6,8 @@ from __future__ import annotations import asyncio -from typing import TYPE_CHECKING, Any +import json +from typing import TYPE_CHECKING, Any, cast import httpx @@ -21,6 +22,7 @@ default_user_agent, logger, mask_key, + parse_batch_create_output, parse_create_output, parse_error, parse_list_output, @@ -28,18 +30,30 @@ parse_quota, parse_qurl, parse_resolve_output, + require_resource_id_prefix, retry_delay, + validate_create_input, validate_id, + validate_mint_input, + validate_update_input, ) from layerv_qurl.errors import QURLError, QURLNetworkError, QURLTimeoutError if TYPE_CHECKING: - from collections.abc import AsyncIterator + # The `list()` method on AsyncQURLClient shadows the `list` type + # inside the class body, so parameter/return annotations that need + # the builtin must reference `builtins.list[...]` explicitly. The + # import lives in a TYPE_CHECKING block because it's only needed + # for type annotations. + import builtins + from collections.abc import AsyncIterator, Sequence from datetime import datetime from layerv_qurl.types import ( QURL, AccessPolicy, + BatchCreateItem, + BatchCreateOutput, CreateOutput, ListOutput, MintOutput, @@ -111,11 +125,12 @@ async def create( target_url: str, *, expires_in: str | None = None, - expires_at: datetime | str | None = None, - description: str | None = None, + label: str | None = None, one_time_use: bool | None = None, max_sessions: int | None = None, + session_duration: str | None = None, access_policy: AccessPolicy | None = None, + custom_domain: str | None = None, ) -> CreateOutput: """Create a new QURL. @@ -123,29 +138,59 @@ async def create( ``qurl_site``, and ``expires_at``. Use :meth:`get` to fetch the full :class:`QURL` object with status, timestamps, and policy details. + Note: ``tags`` and ``description`` are not accepted on create — they + live on the resource and must be set via :meth:`update` after + creation. The API uses different field names for the create-time + token label (``label``) and the resource-level description on + update/get responses. + 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. + target_url: The URL to protect. Max length 2048. + expires_in: Duration string (e.g. ``"24h"``, ``"7d"``). The API + uses ``expires_in`` on create; use :meth:`update` with + ``expires_at`` if you need an absolute expiry afterwards. + label: Human-readable label for the QURL. Max length 500. + one_time_use: If True, the QURL is consumed on first access. + max_sessions: Maximum concurrent sessions (0 = unlimited). + Must be between 0 and 1000 inclusive. + session_duration: Duration string for sessions (e.g. ``"1h"``). access_policy: IP/geo/user-agent access restrictions. + custom_domain: Custom domain for the QURL link. Max length 253. + + Raises: + ValueError: If any field violates the documented API constraints. """ - 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) + validate_create_input( + target_url=target_url, + label=label, + max_sessions=max_sessions, + custom_domain=custom_domain, + ) + body = build_body( + { + "target_url": target_url, + "expires_in": expires_in, + "label": label, + "one_time_use": one_time_use, + "max_sessions": max_sessions, + "session_duration": session_duration, + "access_policy": access_policy, + "custom_domain": custom_domain, + } + ) + resp = await self._request("POST", "/v1/qurls", body=body) return parse_create_output(resp) async def get(self, resource_id: str) -> QURL: - """Get a QURL by ID.""" + """Get a QURL resource and its access tokens. + + Accepts either a resource ID (``r_`` prefix) or a QURL display ID + (``q_`` prefix); the API resolves ``q_`` IDs to the parent resource + automatically. + + Args: + resource_id: The resource or QURL display ID. + """ validate_id(resource_id) resp = await self._request("GET", f"/v1/qurls/{resource_id}") return parse_qurl(resp) @@ -158,18 +203,43 @@ async def list( status: QURLStatus | None = None, q: str | None = None, sort: str | None = None, + created_after: datetime | str | None = None, + created_before: datetime | str | None = None, + expires_before: datetime | str | None = None, + expires_after: datetime | 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"``). + status: Filter by QURL status (``"active"``, ``"revoked"``). q: Search query string. sort: Sort order (e.g. ``"created_at"``, ``"-created_at"``). + created_after: Filter QURLs created after this timestamp. + Accepts a :class:`datetime` (serialized via ``.isoformat()``) + or a string. String values must be ISO 8601 / RFC 3339 + format (e.g. ``"2026-04-01T00:00:00Z"``) and are passed + through to the API unvalidated — the server rejects + malformed timestamps with a 400. + created_before: Filter QURLs created before this timestamp. + Same format rules as ``created_after``. + expires_before: Filter QURLs expiring before this timestamp. + Same format rules as ``created_after``. + expires_after: Filter QURLs expiring after this timestamp. + Same format rules as ``created_after``. """ - params = build_list_params(limit, cursor, status, q, sort) + params = build_list_params( + limit, + cursor, + status, + q, + sort, + created_after=created_after, + created_before=created_before, + expires_before=expires_before, + expires_after=expires_after, + ) data, meta = await self._raw_request("GET", "/v1/qurls", params=params) return parse_list_output(data, meta) @@ -180,15 +250,45 @@ async def list_all( q: str | None = None, sort: str | None = None, page_size: int = 50, + created_after: datetime | str | None = None, + created_before: datetime | str | None = None, + expires_before: datetime | str | None = None, + expires_after: datetime | str | None = None, ) -> AsyncIterator[QURL]: """Iterate over all QURLs, automatically paginating. Yields individual :class:`QURL` objects, fetching pages transparently. + + Args: + status: Filter by status (``"active"``, ``"revoked"``). + q: Search query string. + sort: Sort order. + page_size: Number of items per page (default 50). + created_after: Filter QURLs created after this timestamp. + Accepts a :class:`datetime` (serialized via ``.isoformat()``) + or a string. String values must be ISO 8601 / RFC 3339 + format (e.g. ``"2026-04-01T00:00:00Z"``) and are passed + through to the API unvalidated — the server rejects + malformed timestamps with a 400. + created_before: Filter QURLs created before this timestamp. + Same format rules as ``created_after``. + expires_before: Filter QURLs expiring before this timestamp. + Same format rules as ``created_after``. + expires_after: Filter QURLs expiring after this timestamp. + Same format rules as ``created_after``. """ cursor: str | None = None while True: page = await self.list( - limit=page_size, cursor=cursor, status=status, q=q, sort=sort, + limit=page_size, + cursor=cursor, + status=status, + q=q, + sort=sort, + created_after=created_after, + created_before=created_before, + expires_before=expires_before, + expires_after=expires_after, ) for qurl in page.qurls: yield qurl @@ -197,17 +297,37 @@ async def list_all( cursor = page.next_cursor async def delete(self, resource_id: str) -> None: - """Delete (revoke) a QURL.""" + """Delete (revoke) a QURL resource and all its access tokens. + + Only accepts a resource ID (``r_`` prefix), not a QURL display ID + (``q_`` prefix). Per the OpenAPI spec: + *"Requires a resource ID (r_ prefix). To revoke a single token, + use DELETE /v1/resources/:id/qurls/:qurl_id"*. + + A client-side prefix check catches the mistake before the API + round-trip. + + Args: + resource_id: The resource ID (must start with ``r_``). + + Raises: + ValueError: If ``resource_id`` is malformed or does not start + with ``r_``. + """ validate_id(resource_id) + require_resource_id_prefix(resource_id, "delete") 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)``. + Accepts either a resource ID (``r_`` prefix) or a QURL display ID + (``q_`` prefix); the API resolves ``q_`` IDs to the parent resource + automatically. Args: - resource_id: QURL resource ID. + resource_id: Resource or QURL display ID. duration: Duration to add (e.g. ``"7d"``, ``"24h"``). """ return await self.update(resource_id, extend_by=duration) @@ -219,26 +339,72 @@ async def update( extend_by: str | None = None, expires_at: datetime | str | None = None, description: str | None = None, - access_policy: AccessPolicy | None = None, + tags: builtins.list[str] | None = None, ) -> QURL: - """Update a QURL — extend expiration, change description, etc. + """Update a QURL — extend expiration, change description, set tags. + + Accepts either a resource ID (``r_`` prefix) or a QURL display ID + (``q_`` prefix). All fields are optional, but at least one must be + provided. ``extend_by`` and ``expires_at`` are mutually exclusive. - All fields are optional; only provided fields are sent. + **Cannot change `access_policy`** — the policy is immutable after + create, per the OpenAPI spec's ``UpdateQurlRequest``. To change + access policy, either create a new resource via :meth:`create` + with the new policy, or mint a new access token on the existing + resource via :meth:`mint_link` with a policy override (per-token + scope, base resource policy unchanged). 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. + resource_id: Resource or QURL display ID. + extend_by: Duration to add (e.g. ``"7d"``). Mutually exclusive + with ``expires_at``. + expires_at: New absolute expiry. Mutually exclusive with + ``extend_by``. + description: New resource description. Pass an empty string to + clear. Max length 500. + tags: Replacement tag list — this is always a REPLACE, never + a merge. Pass ``tags=[]`` to clear all tags (always a + real clear operation, even if no tags were set); pass + ``None`` (the default) to leave the existing tags + unchanged. Max 10 items, each 1-50 chars matching + ``^[a-zA-Z0-9][a-zA-Z0-9 _-]*$``. + + Raises: + ValueError: If ``extend_by`` and ``expires_at`` are both set, if + no update fields are provided, or if any field violates the + documented API constraints. """ validate_id(resource_id) - body = build_body({ - "extend_by": extend_by, - "expires_at": expires_at, - "description": description, - "access_policy": access_policy, - }) + if extend_by is not None and expires_at is not None: + raise ValueError( + "update: `extend_by` and `expires_at` are mutually exclusive " + "— provide at most one" + ) + if ( + extend_by is None + and expires_at is None + and description is None + and tags is None + ): + raise ValueError( + "update: at least one field (extend_by, expires_at, description, " + "tags) must be provided" + ) + validate_update_input(description=description, tags=tags) + # `build_body` strips top-level ``None`` only — falsy values like + # ``tags=[]`` and ``description=""`` are preserved. This is + # load-bearing: ``tags=[]`` is an intentional "clear all tags" + # API operation and ``description=""`` clears the description. + # A future refactor that adds a truthiness check here would + # silently drop both. + body = build_body( + { + "extend_by": extend_by, + "expires_at": expires_at, + "description": description, + "tags": tags, + } + ) resp = await self._request("PATCH", f"/v1/qurls/{resource_id}", body=body) return parse_qurl(resp) @@ -247,18 +413,137 @@ async def mint_link( resource_id: str, *, expires_at: datetime | str | None = None, + expires_in: str | None = None, + label: str | None = None, + one_time_use: bool | None = None, + max_sessions: int | None = None, + session_duration: str | None = None, + access_policy: AccessPolicy | None = None, ) -> MintOutput: """Mint a new access link for a QURL. + Accepts either a resource ID (``r_`` prefix) or a QURL display ID + (``q_`` prefix). ``expires_in`` and ``expires_at`` are mutually + exclusive — if neither is set, the link defaults to 24 hours. + Args: - resource_id: QURL resource ID. - expires_at: Optional expiry override for the minted link. + resource_id: Resource or QURL display ID. + expires_at: Absolute expiry for the minted link. Mutually + exclusive with ``expires_in``. + expires_in: Duration string for the link (e.g. ``"24h"``). + Mutually exclusive with ``expires_at``. + label: Human-readable label for the link. Max length 500. + one_time_use: If True, the link can only be used once. + max_sessions: Maximum concurrent sessions allowed. + Must be between 0 and 1000 inclusive. + session_duration: Duration string for sessions (e.g. ``"1h"``). + access_policy: IP/geo/user-agent access restrictions. + + Raises: + ValueError: If ``expires_in`` and ``expires_at`` are both set + or if any field violates the documented API constraints. """ validate_id(resource_id) - body = build_body({"expires_at": expires_at}) + if expires_in is not None and expires_at is not None: + raise ValueError( + "mint_link: `expires_in` and `expires_at` are mutually exclusive " + "— provide at most one" + ) + validate_mint_input(label=label, max_sessions=max_sessions) + body = build_body( + { + "expires_at": expires_at, + "expires_in": expires_in, + "label": label, + "one_time_use": one_time_use, + "max_sessions": max_sessions, + "session_duration": session_duration, + "access_policy": access_policy, + } + ) resp = await self._request("POST", f"/v1/qurls/{resource_id}/mint_link", body=body) return parse_mint_output(resp) + async def batch_create( + self, + items: Sequence[BatchCreateItem], + ) -> BatchCreateOutput: + """Create multiple QURLs at once (1-100 items). + + Each item is validated against the same spec constraints as + :meth:`create` before the request is sent, with per-item errors + attributed by index (``items[N]: ...``). + + **Partial failures do not raise.** Two API status codes resolve + normally with structured per-item results: + + - **HTTP 207 Multi-Status** (some succeeded, some failed). + - **HTTP 400** (every item failed validation) — the API returns a + populated ``BatchCreateOutput`` body on this path, so the SDK + whitelists 400 and surfaces the per-item errors instead of + raising a generic :class:`ValidationError`. + + Other error statuses (401, 403, 429, 5xx) still raise the + appropriate :class:`QURLError` subclass. Inspect + ``result.failed > 0`` and iterate ``result.results`` to see + which items succeeded and which errored. + + Args: + items: List of dicts, each with at least ``target_url``. + + Raises: + ValueError: If ``items`` is empty, exceeds 100 items, or any + item violates the documented API constraints. + """ + if not items: + raise ValueError("batch_create requires at least 1 item") + if len(items) > 100: + raise ValueError( + f"batch_create accepts at most 100 items (got {len(items)})" + ) + # Validate each item against the same spec constraints as single + # create() so obvious mistakes fail fast with the offending index. + for i, item in enumerate(items): + try: + validate_create_input( + target_url=item["target_url"], + label=item.get("label"), + max_sessions=item.get("max_sessions"), + custom_domain=item.get("custom_domain"), + ) + except KeyError as exc: + raise ValueError( + f"batch_create items[{i}]: missing required field 'target_url'" + ) from exc + except ValueError as exc: + raise ValueError(f"batch_create items[{i}]: {exc}") from exc + # `BatchCreateItem` is structurally a `dict[str, Any]` at runtime — + # TypedDicts compile to plain dicts and carry no runtime overhead. + # The `cast` narrows the type for `build_body` without any runtime + # conversion. + serialized = [build_body(cast("dict[str, Any]", item)) for item in items] + # HTTP 400 carries structured per-item errors on this endpoint — + # whitelist it so the generic error path doesn't swallow the body. + # `allow_statuses=(400,)` only — HTTP 207 Multi-Status (partial + # success) flows through the normal `status_code < 400` success + # path automatically, so it doesn't need to be whitelisted. Only + # the total-failure 400 needs the opt-in, because the API + # populates a `BatchCreateOutput` body there that the generic + # error path would otherwise swallow. + resp = await self._request( + "POST", + "/v1/qurls/batch", + body={"items": serialized}, + allow_statuses=(400,), + ) + # `parse_batch_create_output` runs the shape guard internally + # (see its docstring) — so if the API returns 400 with an + # unexpected body shape, the parser raises ValidationError + # rather than silently producing `(succeeded=0, failed=0, + # results=[])`. The guard is enforced at the parser boundary, + # not documented by convention at every call site. + return parse_batch_create_output(resp) + async def resolve(self, access_token: str) -> ResolveOutput: """Resolve a QURL access token (headless). @@ -286,8 +571,11 @@ async def _request( *, body: dict[str, Any] | None = None, params: dict[str, str] | None = None, + allow_statuses: tuple[int, ...] = (), ) -> Any: - data, _ = await self._raw_request(method, path, body=body, params=params) + data, _ = await self._raw_request( + method, path, body=body, params=params, allow_statuses=allow_statuses + ) return data async def _raw_request( @@ -297,7 +585,40 @@ async def _raw_request( *, body: dict[str, Any] | None = None, params: dict[str, str] | None = None, + allow_statuses: tuple[int, ...] = (), ) -> tuple[Any, dict[str, Any] | None]: + """Issue an HTTP request and parse the JSON envelope. + + ``allow_statuses`` lets a caller opt specific non-2xx codes out of + the default raise-on-error path and receive the parsed body + instead. This is used by :meth:`batch_create`, where the API + returns a structured ``BatchCreateOutput`` on HTTP 400 (all items + rejected) — raising would drop the per-item errors. + + **`allow_statuses` takes precedence over retries.** The check + order in the response-handling loop is: + + 1. ``response.status_code < 400 or in allow_statuses`` → + return the parsed body immediately as a success. + 2. Otherwise, build an error and check the retry filter + (``RETRYABLE_STATUS_POST`` for POST, ``RETRYABLE_STATUS`` + for everything else). + + This means a status listed in ``allow_statuses`` is returned + to the caller **without ever running through the retry + filter**, even if that status would normally be retried. For + the only current use case (``batch_create`` with + ``allow_statuses=(400,)``) the interaction is harmless because + 400 isn't in any retry set — a 400 carries the authoritative + per-item errors and retrying would just reproduce them. + + Callers adding a *retryable* status (e.g. 429 or 5xx) to + ``allow_statuses`` should be aware this bypasses the SDK's + retry path entirely: the status is surfaced on the first + attempt with no transparent backoff. If that's not what you + want, leave the status out of ``allow_statuses`` and let the + normal retry logic handle it. + """ url = f"{self._base_url}{path}" last_error: Exception | None = None @@ -332,10 +653,24 @@ async def _raw_request( logger.debug("%s %s → %d", method, url, response.status_code) - if response.status_code < 400: + if response.status_code < 400 or response.status_code in allow_statuses: if response.status_code == 204 or not response.content: return None, None - envelope = response.json() + try: + envelope = response.json() + except (json.JSONDecodeError, ValueError): + # If a whitelisted status (e.g. 400 on batch_create) + # comes back with a non-JSON body — a proxy HTML + # error page, a truncated response, a gateway's own + # plaintext error — we CAN'T surface it as success. + # Fall through to `parse_error`, which handles + # non-JSON error bodies gracefully and returns a + # well-formed QURLError using the response status + # and reason phrase. `raise ... from None` hides + # the JSONDecodeError chain since it's noise: the + # QURLError already captures "the body wasn't a + # parseable envelope" as its detail. + raise parse_error(response) from None return envelope.get("data"), envelope.get("meta") err = parse_error(response) diff --git a/src/layerv_qurl/client.py b/src/layerv_qurl/client.py index b63b9e1..cd8a885 100644 --- a/src/layerv_qurl/client.py +++ b/src/layerv_qurl/client.py @@ -1,9 +1,14 @@ -"""Synchronous QURL API client.""" +"""Synchronous QURL API client. + +NOTE: Business logic mirrors async_client.py — keep both in sync. Input +validation, body construction, and error handling must match exactly. +""" from __future__ import annotations +import json import time -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast import httpx @@ -18,6 +23,7 @@ default_user_agent, logger, mask_key, + parse_batch_create_output, parse_create_output, parse_error, parse_list_output, @@ -25,18 +31,29 @@ parse_quota, parse_qurl, parse_resolve_output, + require_resource_id_prefix, retry_delay, + validate_create_input, validate_id, + validate_mint_input, + validate_update_input, ) from layerv_qurl.errors import QURLError, QURLNetworkError, QURLTimeoutError if TYPE_CHECKING: - from collections.abc import Iterator + # The `list()` method on QURLClient shadows the `list` type inside the + # class body, so parameter/return annotations that need the builtin + # must reference `builtins.list[...]` explicitly. The import lives in + # a TYPE_CHECKING block because it's only needed for type annotations. + import builtins + from collections.abc import Iterator, Sequence from datetime import datetime from layerv_qurl.types import ( QURL, AccessPolicy, + BatchCreateItem, + BatchCreateOutput, CreateOutput, ListOutput, MintOutput, @@ -123,11 +140,12 @@ def create( target_url: str, *, expires_in: str | None = None, - expires_at: datetime | str | None = None, - description: str | None = None, + label: str | None = None, one_time_use: bool | None = None, max_sessions: int | None = None, + session_duration: str | None = None, access_policy: AccessPolicy | None = None, + custom_domain: str | None = None, ) -> CreateOutput: """Create a new QURL. @@ -135,29 +153,59 @@ def create( ``qurl_site``, and ``expires_at``. Use :meth:`get` to fetch the full :class:`QURL` object with status, timestamps, and policy details. + Note: ``tags`` and ``description`` are not accepted on create — they + live on the resource and must be set via :meth:`update` after + creation. The API uses different field names for the create-time + token label (``label``) and the resource-level description on + update/get responses. + 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. + target_url: The URL to protect. Max length 2048. + expires_in: Duration string (e.g. ``"24h"``, ``"7d"``). The API + uses ``expires_in`` on create; use :meth:`update` with + ``expires_at`` if you need an absolute expiry afterwards. + label: Human-readable label for the QURL. Max length 500. + one_time_use: If True, the QURL is consumed on first access. + max_sessions: Maximum concurrent sessions (0 = unlimited). + Must be between 0 and 1000 inclusive. + session_duration: Duration string for sessions (e.g. ``"1h"``). access_policy: IP/geo/user-agent access restrictions. + custom_domain: Custom domain for the QURL link. Max length 253. + + Raises: + ValueError: If any field violates the documented API constraints. """ - 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) + validate_create_input( + target_url=target_url, + label=label, + max_sessions=max_sessions, + custom_domain=custom_domain, + ) + body = build_body( + { + "target_url": target_url, + "expires_in": expires_in, + "label": label, + "one_time_use": one_time_use, + "max_sessions": max_sessions, + "session_duration": session_duration, + "access_policy": access_policy, + "custom_domain": custom_domain, + } + ) + resp = self._request("POST", "/v1/qurls", body=body) return parse_create_output(resp) def get(self, resource_id: str) -> QURL: - """Get a QURL by ID.""" + """Get a QURL resource and its access tokens. + + Accepts either a resource ID (``r_`` prefix) or a QURL display ID + (``q_`` prefix); the API resolves ``q_`` IDs to the parent resource + automatically. + + Args: + resource_id: The resource or QURL display ID. + """ validate_id(resource_id) resp = self._request("GET", f"/v1/qurls/{resource_id}") return parse_qurl(resp) @@ -170,18 +218,43 @@ def list( status: QURLStatus | None = None, q: str | None = None, sort: str | None = None, + created_after: datetime | str | None = None, + created_before: datetime | str | None = None, + expires_before: datetime | str | None = None, + expires_after: datetime | 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"``). + status: Filter by QURL status (``"active"``, ``"revoked"``). q: Search query string. sort: Sort order (e.g. ``"created_at"``, ``"-created_at"``). + created_after: Filter QURLs created after this timestamp. + Accepts a :class:`datetime` (serialized via ``.isoformat()``) + or a string. String values must be ISO 8601 / RFC 3339 + format (e.g. ``"2026-04-01T00:00:00Z"``) and are passed + through to the API unvalidated — the server rejects + malformed timestamps with a 400. + created_before: Filter QURLs created before this timestamp. + Same format rules as ``created_after``. + expires_before: Filter QURLs expiring before this timestamp. + Same format rules as ``created_after``. + expires_after: Filter QURLs expiring after this timestamp. + Same format rules as ``created_after``. """ - params = build_list_params(limit, cursor, status, q, sort) + params = build_list_params( + limit, + cursor, + status, + q, + sort, + created_after=created_after, + created_before=created_before, + expires_before=expires_before, + expires_after=expires_after, + ) data, meta = self._raw_request("GET", "/v1/qurls", params=params) return parse_list_output(data, meta) @@ -192,21 +265,45 @@ def list_all( q: str | None = None, sort: str | None = None, page_size: int = 50, + created_after: datetime | str | None = None, + created_before: datetime | str | None = None, + expires_before: datetime | str | None = None, + expires_after: datetime | str | None = None, ) -> Iterator[QURL]: """Iterate over all QURLs, automatically paginating. Yields individual :class:`QURL` objects, fetching pages transparently. Args: - status: Filter by status (``"active"``, ``"expired"``, etc.). + status: Filter by status (``"active"``, ``"revoked"``). q: Search query string. sort: Sort order. page_size: Number of items per page (default 50). + created_after: Filter QURLs created after this timestamp. + Accepts a :class:`datetime` (serialized via ``.isoformat()``) + or a string. String values must be ISO 8601 / RFC 3339 + format (e.g. ``"2026-04-01T00:00:00Z"``) and are passed + through to the API unvalidated — the server rejects + malformed timestamps with a 400. + created_before: Filter QURLs created before this timestamp. + Same format rules as ``created_after``. + expires_before: Filter QURLs expiring before this timestamp. + Same format rules as ``created_after``. + expires_after: Filter QURLs expiring after this timestamp. + Same format rules as ``created_after``. """ cursor: str | None = None while True: page = self.list( - limit=page_size, cursor=cursor, status=status, q=q, sort=sort, + limit=page_size, + cursor=cursor, + status=status, + q=q, + sort=sort, + created_after=created_after, + created_before=created_before, + expires_before=expires_before, + expires_after=expires_after, ) yield from page.qurls if not page.has_more or not page.next_cursor: @@ -214,17 +311,37 @@ def list_all( cursor = page.next_cursor def delete(self, resource_id: str) -> None: - """Delete (revoke) a QURL.""" + """Delete (revoke) a QURL resource and all its access tokens. + + Only accepts a resource ID (``r_`` prefix), not a QURL display ID + (``q_`` prefix). Per the OpenAPI spec: + *"Requires a resource ID (r_ prefix). To revoke a single token, + use DELETE /v1/resources/:id/qurls/:qurl_id"*. + + A client-side prefix check catches the mistake before the API + round-trip. + + Args: + resource_id: The resource ID (must start with ``r_``). + + Raises: + ValueError: If ``resource_id`` is malformed or does not start + with ``r_``. + """ validate_id(resource_id) + require_resource_id_prefix(resource_id, "delete") 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)``. + Accepts either a resource ID (``r_`` prefix) or a QURL display ID + (``q_`` prefix); the API resolves ``q_`` IDs to the parent resource + automatically. Args: - resource_id: QURL resource ID. + resource_id: Resource or QURL display ID. duration: Duration to add (e.g. ``"7d"``, ``"24h"``). """ return self.update(resource_id, extend_by=duration) @@ -236,26 +353,72 @@ def update( extend_by: str | None = None, expires_at: datetime | str | None = None, description: str | None = None, - access_policy: AccessPolicy | None = None, + tags: builtins.list[str] | None = None, ) -> QURL: - """Update a QURL — extend expiration, change description, etc. + """Update a QURL — extend expiration, change description, set tags. + + Accepts either a resource ID (``r_`` prefix) or a QURL display ID + (``q_`` prefix). All fields are optional, but at least one must be + provided. ``extend_by`` and ``expires_at`` are mutually exclusive. - All fields are optional; only provided fields are sent. + **Cannot change `access_policy`** — the policy is immutable after + create, per the OpenAPI spec's ``UpdateQurlRequest``. To change + access policy, either create a new resource via :meth:`create` + with the new policy, or mint a new access token on the existing + resource via :meth:`mint_link` with a policy override (per-token + scope, base resource policy unchanged). 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. + resource_id: Resource or QURL display ID. + extend_by: Duration to add (e.g. ``"7d"``). Mutually exclusive + with ``expires_at``. + expires_at: New absolute expiry. Mutually exclusive with + ``extend_by``. + description: New resource description. Pass an empty string to + clear. Max length 500. + tags: Replacement tag list — this is always a REPLACE, never + a merge. Pass ``tags=[]`` to clear all tags (always a + real clear operation, even if no tags were set); pass + ``None`` (the default) to leave the existing tags + unchanged. Max 10 items, each 1-50 chars matching + ``^[a-zA-Z0-9][a-zA-Z0-9 _-]*$``. + + Raises: + ValueError: If ``extend_by`` and ``expires_at`` are both set, if + no update fields are provided, or if any field violates the + documented API constraints. """ validate_id(resource_id) - body = build_body({ - "extend_by": extend_by, - "expires_at": expires_at, - "description": description, - "access_policy": access_policy, - }) + if extend_by is not None and expires_at is not None: + raise ValueError( + "update: `extend_by` and `expires_at` are mutually exclusive " + "— provide at most one" + ) + if ( + extend_by is None + and expires_at is None + and description is None + and tags is None + ): + raise ValueError( + "update: at least one field (extend_by, expires_at, description, " + "tags) must be provided" + ) + validate_update_input(description=description, tags=tags) + # `build_body` strips top-level ``None`` only — falsy values like + # ``tags=[]`` and ``description=""`` are preserved. This is + # load-bearing: ``tags=[]`` is an intentional "clear all tags" + # API operation and ``description=""`` clears the description. + # A future refactor that adds a truthiness check here would + # silently drop both. + body = build_body( + { + "extend_by": extend_by, + "expires_at": expires_at, + "description": description, + "tags": tags, + } + ) resp = self._request("PATCH", f"/v1/qurls/{resource_id}", body=body) return parse_qurl(resp) @@ -264,18 +427,137 @@ def mint_link( resource_id: str, *, expires_at: datetime | str | None = None, + expires_in: str | None = None, + label: str | None = None, + one_time_use: bool | None = None, + max_sessions: int | None = None, + session_duration: str | None = None, + access_policy: AccessPolicy | None = None, ) -> MintOutput: """Mint a new access link for a QURL. + Accepts either a resource ID (``r_`` prefix) or a QURL display ID + (``q_`` prefix). ``expires_in`` and ``expires_at`` are mutually + exclusive — if neither is set, the link defaults to 24 hours. + Args: - resource_id: QURL resource ID. - expires_at: Optional expiry override for the minted link. + resource_id: Resource or QURL display ID. + expires_at: Absolute expiry for the minted link. Mutually + exclusive with ``expires_in``. + expires_in: Duration string for the link (e.g. ``"24h"``). + Mutually exclusive with ``expires_at``. + label: Human-readable label for the link. Max length 500. + one_time_use: If True, the link can only be used once. + max_sessions: Maximum concurrent sessions allowed. + Must be between 0 and 1000 inclusive. + session_duration: Duration string for sessions (e.g. ``"1h"``). + access_policy: IP/geo/user-agent access restrictions. + + Raises: + ValueError: If ``expires_in`` and ``expires_at`` are both set + or if any field violates the documented API constraints. """ validate_id(resource_id) - body = build_body({"expires_at": expires_at}) + if expires_in is not None and expires_at is not None: + raise ValueError( + "mint_link: `expires_in` and `expires_at` are mutually exclusive " + "— provide at most one" + ) + validate_mint_input(label=label, max_sessions=max_sessions) + body = build_body( + { + "expires_at": expires_at, + "expires_in": expires_in, + "label": label, + "one_time_use": one_time_use, + "max_sessions": max_sessions, + "session_duration": session_duration, + "access_policy": access_policy, + } + ) resp = self._request("POST", f"/v1/qurls/{resource_id}/mint_link", body=body) return parse_mint_output(resp) + def batch_create( + self, + items: Sequence[BatchCreateItem], + ) -> BatchCreateOutput: + """Create multiple QURLs at once (1-100 items). + + Each item is validated against the same spec constraints as + :meth:`create` before the request is sent, with per-item errors + attributed by index (``items[N]: ...``). + + **Partial failures do not raise.** Two API status codes resolve + normally with structured per-item results: + + - **HTTP 207 Multi-Status** (some succeeded, some failed). + - **HTTP 400** (every item failed validation) — the API returns a + populated ``BatchCreateOutput`` body on this path, so the SDK + whitelists 400 and surfaces the per-item errors instead of + raising a generic :class:`ValidationError`. + + Other error statuses (401, 403, 429, 5xx) still raise the + appropriate :class:`QURLError` subclass. Inspect + ``result.failed > 0`` and iterate ``result.results`` to see + which items succeeded and which errored. + + Args: + items: List of dicts, each with at least ``target_url``. + + Raises: + ValueError: If ``items`` is empty, exceeds 100 items, or any + item violates the documented API constraints. + """ + if not items: + raise ValueError("batch_create requires at least 1 item") + if len(items) > 100: + raise ValueError( + f"batch_create accepts at most 100 items (got {len(items)})" + ) + # Validate each item against the same spec constraints as single + # create() so obvious mistakes fail fast with the offending index. + for i, item in enumerate(items): + try: + validate_create_input( + target_url=item["target_url"], + label=item.get("label"), + max_sessions=item.get("max_sessions"), + custom_domain=item.get("custom_domain"), + ) + except KeyError as exc: + raise ValueError( + f"batch_create items[{i}]: missing required field 'target_url'" + ) from exc + except ValueError as exc: + raise ValueError(f"batch_create items[{i}]: {exc}") from exc + # `BatchCreateItem` is structurally a `dict[str, Any]` at runtime — + # TypedDicts compile to plain dicts and carry no runtime overhead. + # The `cast` narrows the type for `build_body` without any runtime + # conversion. + serialized = [build_body(cast("dict[str, Any]", item)) for item in items] + # HTTP 400 carries structured per-item errors on this endpoint — + # whitelist it so the generic error path doesn't swallow the body. + # `allow_statuses=(400,)` only — HTTP 207 Multi-Status (partial + # success) flows through the normal `status_code < 400` success + # path automatically, so it doesn't need to be whitelisted. Only + # the total-failure 400 needs the opt-in, because the API + # populates a `BatchCreateOutput` body there that the generic + # error path would otherwise swallow. + resp = self._request( + "POST", + "/v1/qurls/batch", + body={"items": serialized}, + allow_statuses=(400,), + ) + # `parse_batch_create_output` runs the shape guard internally + # (see its docstring) — so if the API returns 400 with an + # unexpected body shape, the parser raises ValidationError + # rather than silently producing `(succeeded=0, failed=0, + # results=[])`. The guard is enforced at the parser boundary, + # not documented by convention at every call site. + return parse_batch_create_output(resp) + def resolve(self, access_token: str) -> ResolveOutput: """Resolve a QURL access token (headless). @@ -303,8 +585,11 @@ def _request( *, body: dict[str, Any] | None = None, params: dict[str, str] | None = None, + allow_statuses: tuple[int, ...] = (), ) -> Any: - data, _ = self._raw_request(method, path, body=body, params=params) + data, _ = self._raw_request( + method, path, body=body, params=params, allow_statuses=allow_statuses + ) return data def _raw_request( @@ -314,7 +599,40 @@ def _raw_request( *, body: dict[str, Any] | None = None, params: dict[str, str] | None = None, + allow_statuses: tuple[int, ...] = (), ) -> tuple[Any, dict[str, Any] | None]: + """Issue an HTTP request and parse the JSON envelope. + + ``allow_statuses`` lets a caller opt specific non-2xx codes out of + the default raise-on-error path and receive the parsed body + instead. This is used by :meth:`batch_create`, where the API + returns a structured ``BatchCreateOutput`` on HTTP 400 (all items + rejected) — raising would drop the per-item errors. + + **`allow_statuses` takes precedence over retries.** The check + order in the response-handling loop is: + + 1. ``response.status_code < 400 or in allow_statuses`` → + return the parsed body immediately as a success. + 2. Otherwise, build an error and check the retry filter + (``RETRYABLE_STATUS_POST`` for POST, ``RETRYABLE_STATUS`` + for everything else). + + This means a status listed in ``allow_statuses`` is returned + to the caller **without ever running through the retry + filter**, even if that status would normally be retried. For + the only current use case (``batch_create`` with + ``allow_statuses=(400,)``) the interaction is harmless because + 400 isn't in any retry set — a 400 carries the authoritative + per-item errors and retrying would just reproduce them. + + Callers adding a *retryable* status (e.g. 429 or 5xx) to + ``allow_statuses`` should be aware this bypasses the SDK's + retry path entirely: the status is surfaced on the first + attempt with no transparent backoff. If that's not what you + want, leave the status out of ``allow_statuses`` and let the + normal retry logic handle it. + """ url = f"{self._base_url}{path}" last_error: Exception | None = None @@ -349,10 +667,24 @@ def _raw_request( logger.debug("%s %s → %d", method, url, response.status_code) - if response.status_code < 400: + if response.status_code < 400 or response.status_code in allow_statuses: if response.status_code == 204 or not response.content: return None, None - envelope = response.json() + try: + envelope = response.json() + except (json.JSONDecodeError, ValueError): + # If a whitelisted status (e.g. 400 on batch_create) + # comes back with a non-JSON body — a proxy HTML + # error page, a truncated response, a gateway's own + # plaintext error — we CAN'T surface it as success. + # Fall through to `parse_error`, which handles + # non-JSON error bodies gracefully and returns a + # well-formed QURLError using the response status + # and reason phrase. `raise ... from None` hides + # the JSONDecodeError chain since it's noise: the + # QURLError already captures "the body wasn't a + # parseable envelope" as its detail. + raise parse_error(response) from None return envelope.get("data"), envelope.get("meta") err = parse_error(response) diff --git a/src/layerv_qurl/errors.py b/src/layerv_qurl/errors.py index 5dc1ddd..1804ea4 100644 --- a/src/layerv_qurl/errors.py +++ b/src/layerv_qurl/errors.py @@ -6,6 +6,28 @@ class QURLError(Exception): """Error raised for API-level errors (4xx/5xx responses). + Carries the full RFC 7807 Problem Details shape when the API provides + it: ``status``, ``code``, ``title``, ``detail``, plus the optional + ``type`` (problem-type URI) and ``instance`` (occurrence URI). + + .. note:: + + ``detail`` is **always non-empty** on the instance — the + constructor falls back to ``title`` when the API omits detail + (RFC 7807 allows this). Use ``code`` / ``status`` / ``type`` to + distinguish between error cases rather than inspecting ``detail`` + for the "was it absent?" signal. + + .. note:: + + ``type`` shadows Python's built-in ``type()`` inside method + bodies. This is intentional — the name mirrors the RFC 7807 field + name and matches the other SDKs (``qurl-typescript``, + ``qurl-mcp``). The shadowing only matters inside ``QURLError`` + method definitions; external code can still use ``type(err)`` + safely since attribute access doesn't shadow the builtin in that + scope. + Catch specific subclasses for fine-grained handling:: try: @@ -18,6 +40,8 @@ class QURLError(Exception): print(f"Rate limited — retry in {e.retry_after}s") except QURLError as e: print(f"API error: {e.status} {e.code}") + if e.type: + print(f" problem type: {e.type}") """ def __init__( @@ -26,16 +50,27 @@ def __init__( status: int, code: str, title: str, - detail: str, + detail: str | None = None, + type: str | None = None, + instance: str | None = None, invalid_fields: dict[str, str] | None = None, request_id: str | None = None, retry_after: int | None = None, ) -> None: - super().__init__(f"{title} ({status}): {detail}") + # RFC 7807 leaves `detail` optional, and `title` is always present. + # When `detail` is `None` (omitted), fall back to `title` so the + # Exception message stays meaningful instead of producing + # "Title (403): None". An explicit empty string is stored as-is — + # the caller opted in. Uses `is not None` rather than truthiness + # so `detail=""` is distinguishable from "not provided". + message_detail = detail if detail is not None else title + super().__init__(f"{title} ({status}): {message_detail}") self.status = status self.code = code self.title = title - self.detail = detail + self.detail = message_detail + self.type = type + self.instance = instance self.invalid_fields = invalid_fields self.request_id = request_id self.retry_after = retry_after diff --git a/src/layerv_qurl/langchain.py b/src/layerv_qurl/langchain.py index f6f7849..500d93d 100644 --- a/src/layerv_qurl/langchain.py +++ b/src/layerv_qurl/langchain.py @@ -9,6 +9,7 @@ try: from langchain_core.tools import BaseTool + _HAS_LANGCHAIN = True except ImportError: _HAS_LANGCHAIN = False @@ -35,8 +36,7 @@ class CreateQURLTool(BaseTool): 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)." + "and optionally 'expires_in' (e.g. '24h', '7d'), 'label'." ) client: Any = None # QURLClient, typed as Any for Pydantic compatibility @@ -44,17 +44,13 @@ 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, + label: str | 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, + label=label, ) return ( f"Created QURL {result.resource_id}\n" @@ -95,9 +91,7 @@ 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)." - ) + description: str = "List active QURL links. Optionally filter by status (active, revoked)." client: Any = None def _run( diff --git a/src/layerv_qurl/types.py b/src/layerv_qurl/types.py index d793b9b..5a5bf7b 100644 --- a/src/layerv_qurl/types.py +++ b/src/layerv_qurl/types.py @@ -4,11 +4,22 @@ from dataclasses import dataclass, field from datetime import datetime -from typing import Literal +from typing import Any, Literal, TypedDict -#: 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 +#: Valid resource-level status values. Resources only have two states per +#: the OpenAPI spec (``QurlData.status``) and the server code. Accepts known +#: values for IDE autocomplete, plus ``str`` for forward compatibility. +QURLStatus = Literal["active", "revoked"] | str + +#: Valid per-token status values. Wider than :data:`QURLStatus` because +#: individual access tokens can additionally be ``consumed`` (one-time use) +#: or ``expired``, per the OpenAPI spec (``QurlSummary.status``). +TokenStatus = Literal["active", "consumed", "expired", "revoked"] | str + +#: Valid subscription plan values. Matches the ``QuotaData.plan`` enum in +#: the OpenAPI spec (``[free, growth, enterprise]``). Accepts arbitrary +#: strings so the API can add new plans without a breaking SDK change. +QuotaPlan = Literal["free", "growth", "enterprise"] | str def _parse_dt(s: str | None) -> datetime | None: @@ -20,6 +31,15 @@ def _parse_dt(s: str | None) -> datetime | None: return datetime.fromisoformat(s) +@dataclass +class AIAgentPolicy: + """Structured policy for controlling AI agent access.""" + + block_all: bool | None = None + deny_categories: list[str] | None = None + allow_categories: list[str] | None = None + + @dataclass class AccessPolicy: """Access control policy for a QURL.""" @@ -30,14 +50,20 @@ class AccessPolicy: geo_denylist: list[str] | None = None user_agent_allow_regex: str | None = None user_agent_deny_regex: str | None = None + ai_agent_policy: AIAgentPolicy | None = None @dataclass class AccessToken: - """An individual access token within a QURL.""" + """An individual access token within a QURL. + + ``status`` uses the wider :data:`TokenStatus` alias — tokens can be + ``active``/``consumed``/``expired``/``revoked`` (per ``QurlSummary.status`` + in the spec), while resources are only ``active``/``revoked``. + """ qurl_id: str - status: QURLStatus + status: TokenStatus one_time_use: bool = False max_sessions: int = 0 session_duration: int = 0 @@ -59,19 +85,28 @@ class QURL: created_at: datetime | None = None expires_at: datetime | None = None description: str | None = None + tags: list[str] = field(default_factory=list) qurl_site: str | None = None + custom_domain: str | None = None qurl_count: int | None = None access_tokens: list[AccessToken] | None = None @dataclass class CreateOutput: - """Response from creating a QURL.""" + """Response from creating a QURL. + + ``resource_id`` identifies the resource container (grouped by target URL). + ``qurl_id`` identifies the specific access token created (``q_`` prefix). + Multiple QURLs for the same target URL share one ``resource_id``. + """ resource_id: str qurl_link: str qurl_site: str expires_at: datetime | None = None + qurl_id: str | None = None + label: str | None = None @dataclass @@ -119,6 +154,7 @@ class RateLimits: resolve_per_minute: int = 0 max_active_qurls: int = 0 max_tokens_per_qurl: int = 0 + max_expiry_seconds: int = 0 @dataclass @@ -127,16 +163,94 @@ class Usage: qurls_created: int = 0 active_qurls: int = 0 - active_qurls_percent: float = 0.0 + # Changed from float=0.0 — callers must None-check before arithmetic. + active_qurls_percent: float | None = None total_accesses: int = 0 @dataclass class Quota: - """Quota and usage information.""" + """Quota and usage information. - plan: str = "" + ``plan`` is typed via :data:`QuotaPlan` (Literal enum + ``str`` + escape hatch for forward compat). Defaults to the sentinel + ``"unknown"`` — only hit by tests/bootstrap paths, since the + ``/v1/quota`` endpoint always returns a populated plan string. + """ + + plan: QuotaPlan = "unknown" period_start: datetime | None = None period_end: datetime | None = None rate_limits: RateLimits | None = None usage: Usage | None = None + + +@dataclass +class BatchItemError: + """Error details for a failed batch item.""" + + code: str = "" + message: str = "" + + +@dataclass +class BatchItemResult: + """Result for a single item in a batch create.""" + + index: int = 0 + success: bool = False + resource_id: str | None = None + qurl_link: str | None = None + qurl_site: str | None = None + expires_at: datetime | None = None + error: BatchItemError | None = None + + +@dataclass +class BatchCreateOutput: + """Response from batch creating QURLs.""" + + succeeded: int = 0 + failed: int = 0 + results: list[BatchItemResult] = field(default_factory=list) + + +# ---- batch_create input shape ------------------------------------------- +# TypedDicts for :meth:`QURLClient.batch_create` items. Split into a +# required-fields base class and an `total=False` subclass so callers on +# Python 3.10 don't need ``typing.Required`` (added in 3.11) or a +# ``typing_extensions`` dependency. Fields mirror the corresponding +# keyword arguments on :meth:`QURLClient.create` one-for-one; the +# single-create endpoint and the batch endpoint share the same +# ``CreateQurlRequest`` schema in the OpenAPI spec. +# +# The runtime behavior is unchanged — ``batch_create`` still iterates +# items as plain dicts and passes them through ``build_body`` / +# ``_serialize_value``. The TypedDict is purely for IDE autocomplete and +# static type checking. + + +class _BatchCreateItemRequired(TypedDict): + target_url: str + + +class BatchCreateItem(_BatchCreateItemRequired, total=False): + """Input shape for a single item in :meth:`QURLClient.batch_create`. + + ``target_url`` is required; every other field is optional and mirrors + the corresponding keyword argument on :meth:`QURLClient.create`. + + ``access_policy`` accepts either an :class:`AccessPolicy` dataclass + (recommended for type safety and IDE autocomplete) or a plain + ``dict[str, Any]`` (for callers who prefer dicts or are working from + dynamic config). Both forms are converted to the same JSON body at + request time via ``_serialize_value``. + """ + + expires_in: str + label: str + one_time_use: bool + max_sessions: int + session_duration: str + access_policy: AccessPolicy | dict[str, Any] + custom_domain: str diff --git a/tests/test_client.py b/tests/test_client.py index f4b9020..1e38638 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -4,12 +4,16 @@ import json from datetime import datetime, timezone +from typing import TYPE_CHECKING, Any from unittest.mock import patch import httpx import pytest import respx +if TYPE_CHECKING: + from collections.abc import AsyncGenerator + from layerv_qurl import ( AsyncQURLClient, QURLClient, @@ -25,20 +29,24 @@ ServerError, ValidationError, ) -from layerv_qurl.types import AccessPolicy, AccessToken +from layerv_qurl.types import AccessPolicy, AccessToken, AIAgentPolicy, BatchCreateItem BASE_URL = "https://api.test.layerv.ai" _ERR_429 = { "error": { - "status": 429, "code": "rate_limited", - "title": "Rate Limited", "detail": "Slow down", + "status": 429, + "code": "rate_limited", + "title": "Rate Limited", + "detail": "Slow down", }, } _ERR_503 = { "error": { - "status": 503, "code": "unavailable", - "title": "Unavailable", "detail": "Down", + "status": 503, + "code": "unavailable", + "title": "Unavailable", + "detail": "Down", }, } _QUOTA_OK = { @@ -50,12 +58,13 @@ } -def _qurl_item(rid: str, url: str) -> dict: +def _qurl_item(rid: str, url: str) -> dict[str, Any]: return { "resource_id": rid, "target_url": url, "status": "active", "created_at": "2026-03-10T10:00:00Z", + "tags": [], } @@ -65,9 +74,9 @@ def client() -> QURLClient: @pytest.fixture -async def async_client() -> AsyncQURLClient: +async def async_client() -> AsyncGenerator[AsyncQURLClient, None]: client = AsyncQURLClient(api_key="lv_live_test", base_url=BASE_URL, max_retries=0) - yield client # type: ignore[misc] + yield client await client.close() @@ -123,7 +132,7 @@ def test_repr_short_api_key() -> None: @respx.mock def test_create(client: QURLClient) -> None: - respx.post(f"{BASE_URL}/v1/qurl").mock( + respx.post(f"{BASE_URL}/v1/qurls").mock( return_value=httpx.Response( 201, json={ @@ -132,6 +141,7 @@ def test_create(client: QURLClient) -> None: "qurl_link": "https://qurl.link/#at_test", "qurl_site": "https://r_abc123def45.qurl.site", "expires_at": "2026-03-15T10:00:00Z", + "qurl_id": "q_abc", }, "meta": {"request_id": "req_1"}, }, @@ -143,11 +153,18 @@ def test_create(client: QURLClient) -> None: assert result.qurl_link == "https://qurl.link/#at_test" assert result.qurl_site == "https://r_abc123def45.qurl.site" assert isinstance(result.expires_at, datetime) + assert result.qurl_id == "q_abc" @respx.mock def test_create_sends_correct_body(client: QURLClient) -> None: - route = respx.post(f"{BASE_URL}/v1/qurl").mock( + """create() serializes the full new-spec input shape into the request body. + + Covers the fields added in the v2 API alignment (label, one_time_use, + max_sessions, session_duration) so each has at least one regression + guard beyond the access_policy test. + """ + route = respx.post(f"{BASE_URL}/v1/qurls").mock( return_value=httpx.Response( 201, json={ @@ -155,6 +172,7 @@ def test_create_sends_correct_body(client: QURLClient) -> None: "resource_id": "r_abc", "qurl_link": "https://qurl.link/#at_test", "qurl_site": "https://r_abc.qurl.site", + "qurl_id": "q_abc", }, }, ) @@ -163,21 +181,25 @@ def test_create_sends_correct_body(client: QURLClient) -> None: client.create( target_url="https://example.com", expires_in="24h", - description="test", + label="Alice from Acme", one_time_use=True, + max_sessions=5, + session_duration="1h", ) body = json.loads(route.calls[0].request.content) assert body == { "target_url": "https://example.com", "expires_in": "24h", - "description": "test", + "label": "Alice from Acme", "one_time_use": True, + "max_sessions": 5, + "session_duration": "1h", } @respx.mock def test_create_omits_none_values(client: QURLClient) -> None: - route = respx.post(f"{BASE_URL}/v1/qurl").mock( + route = respx.post(f"{BASE_URL}/v1/qurls").mock( return_value=httpx.Response( 201, json={ @@ -185,6 +207,7 @@ def test_create_omits_none_values(client: QURLClient) -> None: "resource_id": "r_abc", "qurl_link": "https://qurl.link/#at_test", "qurl_site": "https://r_abc.qurl.site", + "qurl_id": "", }, }, ) @@ -194,7 +217,7 @@ def test_create_omits_none_values(client: QURLClient) -> None: 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 + assert "label" not in body @respx.mock @@ -209,6 +232,7 @@ def test_get(client: QURLClient) -> None: "status": "active", "created_at": "2026-03-10T10:00:00Z", "expires_at": "2026-03-15T10:00:00Z", + "tags": [], "qurl_count": 2, # API wire format uses "qurls"; parse_qurl maps to access_tokens "qurls": [ @@ -329,6 +353,7 @@ def test_list(client: QURLClient) -> None: "target_url": "https://example.com", "status": "active", "created_at": "2026-03-10T10:00:00Z", + "tags": [], } ], "meta": {"has_more": False, "page_size": 20}, @@ -346,14 +371,20 @@ def test_list(client: QURLClient) -> None: 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}, - }), + 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)) @@ -363,10 +394,53 @@ def test_list_all_paginates(client: QURLClient) -> None: @respx.mock -def test_delete(client: QURLClient) -> None: - respx.delete(f"{BASE_URL}/v1/qurls/r_abc123def45").mock( - return_value=httpx.Response(204) +def test_list_all_propagates_date_filters_to_every_page(client: QURLClient) -> None: + """Regression guard: ``list_all`` delegates to ``list`` on each + page fetch and must pass the date filter params through to the + underlying HTTP request on EVERY iteration, not just the first. + If a future refactor hoisted the filter params out of the loop + body, pagination would silently drop the filter on page 2+. + """ + 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}, + }, + ), + ] + + created_after = datetime(2026, 3, 1, 0, 0, 0, tzinfo=timezone.utc) + expires_before = datetime(2026, 4, 1, 0, 0, 0, tzinfo=timezone.utc) + all_qurls = list( + client.list_all( + page_size=1, + created_after=created_after, + expires_before=expires_before, + ) ) + assert len(all_qurls) == 2 + assert route.call_count == 2 + + # Every HTTP call must carry the date filters in the query string. + for call in route.calls: + url = str(call.request.url) + assert "created_after=2026-03-01T00%3A00%3A00" in url + assert "expires_before=2026-04-01T00%3A00%3A00" in url + + +@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 @@ -382,6 +456,7 @@ def test_update_with_extend(client: QURLClient) -> None: "status": "active", "created_at": "2026-03-10T10:00:00Z", "expires_at": "2026-03-20T10:00:00Z", + "tags": [], }, }, ) @@ -405,6 +480,7 @@ def test_update_with_description(client: QURLClient) -> None: "status": "active", "created_at": "2026-03-10T10:00:00Z", "description": "new desc", + "tags": [], }, }, ) @@ -430,6 +506,7 @@ def test_update_combined(client: QURLClient) -> None: "created_at": "2026-03-10T10:00:00Z", "expires_at": "2026-03-20T10:00:00Z", "description": "updated", + "tags": [], }, }, ) @@ -440,6 +517,37 @@ def test_update_combined(client: QURLClient) -> None: assert body == {"extend_by": "7d", "description": "updated"} +@respx.mock +def test_update_serializes_datetime_expires_at(client: QURLClient) -> None: + """Integration test: a ``datetime`` object passed as ``expires_at`` + is serialized to an ISO 8601 string in the wire body via the + ``build_body → _serialize_value`` pipeline. The unit test + ``test_serialize_value_datetime`` covers the function directly; + this closes the loop through the full client method path. + """ + 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-04-01T00:00:00+00:00", + }, + }, + ) + ) + + client.update( + "r_abc", + expires_at=datetime(2026, 4, 1, 0, 0, 0, tzinfo=timezone.utc), + ) + body = json.loads(route.calls[0].request.content) + assert body["expires_at"] == "2026-04-01T00:00:00+00:00" + + @respx.mock def test_mint_link(client: QURLClient) -> None: respx.post(f"{BASE_URL}/v1/qurls/r_abc123def45/mint_link").mock( @@ -550,6 +658,11 @@ def test_quota_typed(client: QURLClient) -> None: "resolve_per_minute": 300, "max_active_qurls": 5000, "max_tokens_per_qurl": 10, + # Populated to exercise the parse path — earlier + # revisions of this test let `max_expiry_seconds` + # fall through the `.get(..., 0)` default, which + # meant the field wasn't actually tested. + "max_expiry_seconds": 604800, # 7 days }, "usage": { "qurls_created": 10, @@ -570,12 +683,82 @@ def test_quota_typed(client: QURLClient) -> None: assert result.rate_limits is not None assert result.rate_limits.create_per_minute == 60 assert result.rate_limits.max_active_qurls == 5000 + assert result.rate_limits.max_expiry_seconds == 604800 # 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 + assert result.usage.active_qurls_percent == 0.1 + + +@respx.mock +def test_quota_active_qurls_percent_null(client: QURLClient) -> None: + """`active_qurls_percent` is nullable per the API spec — when the + plan's `max_active_qurls` is unlimited, the field comes back as + `null`. Lock in that the parser preserves `None` (rather than + defaulting to `0.0` like the pre-alignment behavior did) so + callers doing arithmetic on the field get a proper TypeError + instead of silently treating unlimited as 0%. + """ + respx.get(f"{BASE_URL}/v1/quota").mock( + return_value=httpx.Response( + 200, + json={ + "data": { + "plan": "enterprise", + "period_start": "2026-03-01T00:00:00Z", + "period_end": "2026-04-01T00:00:00Z", + "rate_limits": { + "create_per_minute": 1000, + "max_active_qurls": 0, # unlimited on enterprise + }, + "usage": { + "qurls_created": 500, + "active_qurls": 200, + # The load-bearing field under test: null when + # max_active_qurls is unlimited. + "active_qurls_percent": None, + "total_accesses": 12345, + }, + }, + }, + ) + ) + + result = client.get_quota() + assert result.usage is not None + assert result.usage.active_qurls_percent is None + # Sanity: the other nullable-adjacent fields still parse normally. + assert result.usage.active_qurls == 200 + assert result.usage.total_accesses == 12345 + + +@respx.mock +def test_quota_plan_missing_falls_back_to_unknown(client: QURLClient) -> None: + """Regression guard for the `parse_quota` plan fallback being + aligned with the `Quota.plan` dataclass default. A malformed API + response that omits `plan` should produce `quota.plan == "unknown"`, + not `""` — consistent with the dataclass default and the docstring. + In practice the /v1/quota endpoint always returns a populated plan, + so this exercises the defensive fallback path. + """ + respx.get(f"{BASE_URL}/v1/quota").mock( + return_value=httpx.Response( + 200, + json={ + "data": { + # Deliberately missing `plan` + "period_start": "2026-03-01T00:00:00Z", + "period_end": "2026-04-01T00:00:00Z", + }, + }, + ) + ) + + result = client.get_quota() + assert result.plan == "unknown" # --- Injected http_client --- @@ -647,7 +830,9 @@ 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, + 429, + headers={"Retry-After": "5"}, + json=_ERR_429, ), httpx.Response(200, json=_QUOTA_OK), ] @@ -664,7 +849,9 @@ 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, + 429, + headers={"Retry-After": "120"}, + json=_ERR_429, ), httpx.Response(200, json=_QUOTA_OK), ] @@ -675,6 +862,161 @@ def test_retry_after_capped_at_30s(retry_client: QURLClient) -> None: mock_sleep.assert_called_once_with(30.0) +@respx.mock +def test_retry_after_http_date_falls_back_to_exponential_backoff( + retry_client: QURLClient, +) -> None: + """Per RFC 7231 §7.1.3, ``Retry-After`` can be either a delay-seconds + integer OR an HTTP-date. The SDK's ``.isdigit()`` check accepts only + the integer form — HTTP-date strings (which contain letters/spaces/ + commas) fall through and the retry uses exponential backoff instead. + + This is a safe fallback: we don't honor the server's exact hint, + but we also don't hang waiting for a parsed date value or crash on + the unexpected header format. Locks in the intentional behavior + against a future refactor that might try to parse HTTP-dates + eagerly and introduce a new bug class. + + Mirrors the qurl-typescript SDK's same-named test for cross-SDK + parity. + """ + route = respx.get(f"{BASE_URL}/v1/quota") + route.side_effect = [ + httpx.Response( + 429, + # Valid RFC 7231 HTTP-date format — the SDK should NOT parse this. + headers={"Retry-After": "Wed, 21 Oct 2026 07:28:00 GMT"}, + json=_ERR_429, + ), + httpx.Response(200, json=_QUOTA_OK), + ] + + with patch("layerv_qurl.client.time.sleep") as mock_sleep: + result = retry_client.get_quota() + + # The retry still fires and succeeds — the HTTP-date header + # doesn't crash anything. We don't assert the exact sleep value + # because that's exponential-backoff territory (with jitter), + # but we do assert that sleep was called (meaning the retry + # path ran) with a positive float. + assert result.plan == "growth" + mock_sleep.assert_called_once() + (call_arg,) = mock_sleep.call_args.args + assert isinstance(call_arg, float) + assert call_arg > 0 + # Must NOT be the literal 0 or any absurdly large value — just + # bounded by the retry_delay() cap at 30s. + assert 0 < call_arg <= 30.0 + + +@respx.mock +@pytest.mark.asyncio +async def test_async_retry_after_http_date_falls_back_to_exponential_backoff() -> None: + """Async mirror of the sync HTTP-date fallback test. Locks in + sync/async parity for the RFC 7231 ``Retry-After: `` + fallback behavior. + + Constructs a fresh AsyncQURLClient instead of using the + async_client fixture because the fixture is configured with + max_retries=0, and this test specifically needs retries enabled + to exercise the retry-after fallback path. + """ + client = AsyncQURLClient(api_key="lv_live_test", base_url=BASE_URL, max_retries=2) + try: + route = respx.get(f"{BASE_URL}/v1/quota") + route.side_effect = [ + httpx.Response( + 429, + headers={"Retry-After": "Wed, 21 Oct 2026 07:28:00 GMT"}, + json=_ERR_429, + ), + httpx.Response(200, json=_QUOTA_OK), + ] + + with patch("layerv_qurl.async_client.asyncio.sleep") as mock_sleep: + result = await client.get_quota() + + assert result.plan == "growth" + mock_sleep.assert_called_once() + (call_arg,) = mock_sleep.call_args.args + assert isinstance(call_arg, float) + assert 0 < call_arg <= 30.0 + finally: + await client.close() + + +# --- POST retry safety --- +# POST is non-idempotent (create() might actually hit the DB even if the +# response is 5xx), so the client deliberately restricts POST retries to +# {429} only — retrying a 5xx on a create request risks duplicate records. +# These tests lock that decision in so a future refactor that naively +# unifies retry sets across methods will trip the guard. + + +@respx.mock +def test_post_does_not_retry_on_503(retry_client: QURLClient) -> None: + """POST /v1/qurls must not retry on 5xx even when retries are configured.""" + route = respx.post(f"{BASE_URL}/v1/qurls").mock( + return_value=httpx.Response(503, json=_ERR_503), + ) + + with patch("layerv_qurl.client.time.sleep"), pytest.raises(QURLError) as exc_info: + retry_client.create(target_url="https://example.com", expires_in="24h") + + assert exc_info.value.status == 503 + # retry_client has max_retries=2, so a retry-on-503 regression would + # produce 3 attempts. Assert exactly one — no retries for POST 5xx. + assert route.call_count == 1 + + +@respx.mock +def test_post_still_retries_on_429(retry_client: QURLClient) -> None: + """POST retries on 429 specifically (rate limits are safe to retry).""" + route = respx.post(f"{BASE_URL}/v1/qurls") + route.side_effect = [ + httpx.Response(429, json=_ERR_429), + httpx.Response( + 201, + json={ + "data": { + "resource_id": "r_abc123def45", + "qurl_link": "https://qurl.link/#at_test", + "qurl_site": "https://r_abc123def45.qurl.site", + "qurl_id": "q_abc", + }, + }, + ), + ] + + with patch("layerv_qurl.client.time.sleep"): + result = retry_client.create(target_url="https://example.com", expires_in="24h") + + assert result.resource_id == "r_abc123def45" + assert route.call_count == 2 + + +@respx.mock +@pytest.mark.asyncio +async def test_async_post_does_not_retry_on_503() -> None: + """Async POST must also not retry on 5xx — sync/async parity guard.""" + client = AsyncQURLClient(api_key="lv_live_test", base_url=BASE_URL, max_retries=2) + try: + route = respx.post(f"{BASE_URL}/v1/qurls").mock( + return_value=httpx.Response(503, json=_ERR_503), + ) + + with ( + patch("layerv_qurl.async_client.asyncio.sleep"), + pytest.raises(QURLError) as exc_info, + ): + await client.create(target_url="https://example.com", expires_in="24h") + + assert exc_info.value.status == 503 + assert route.call_count == 1 + finally: + await client.close() + + # --- Non-JSON error --- @@ -703,9 +1045,7 @@ def test_non_json_error_response(client: QURLClient) -> None: @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") - ) + respx.get(f"{BASE_URL}/v1/quota").mock(side_effect=httpx.ConnectError("Connection refused")) with pytest.raises(QURLNetworkError, match="Connection refused"): client.get_quota() @@ -714,9 +1054,7 @@ def test_network_error_wrapped(client: QURLClient) -> None: @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") - ) + 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() @@ -727,9 +1065,7 @@ def test_network_error_preserves_cause(client: QURLClient) -> None: @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") - ) + 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() @@ -738,9 +1074,7 @@ def test_timeout_error_wrapped(client: QURLClient) -> None: @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") - ) + respx.get(f"{BASE_URL}/v1/quota").mock(side_effect=httpx.ReadTimeout("Read timed out")) with pytest.raises(QURLNetworkError): client.get_quota() @@ -826,6 +1160,7 @@ def test_get_request_has_no_content_type(client: QURLClient) -> None: "target_url": "https://example.com", "status": "active", "created_at": "2026-03-10T10:00:00Z", + "tags": [], }, }, ) @@ -839,7 +1174,7 @@ def test_get_request_has_no_content_type(client: QURLClient) -> None: @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( + route = respx.post(f"{BASE_URL}/v1/qurls").mock( return_value=httpx.Response( 201, json={ @@ -847,6 +1182,7 @@ def test_post_request_has_content_type(client: QURLClient) -> None: "resource_id": "r_abc", "qurl_link": "https://qurl.link/#at_test", "qurl_site": "https://r_abc.qurl.site", + "qurl_id": "", }, }, ) @@ -863,7 +1199,7 @@ def test_post_request_has_content_type(client: QURLClient) -> None: @respx.mock @pytest.mark.asyncio async def test_async_create(async_client: AsyncQURLClient) -> None: - respx.post(f"{BASE_URL}/v1/qurl").mock( + respx.post(f"{BASE_URL}/v1/qurls").mock( return_value=httpx.Response( 201, json={ @@ -872,6 +1208,7 @@ async def test_async_create(async_client: AsyncQURLClient) -> None: "qurl_link": "https://qurl.link/#at_async", "qurl_site": "https://r_async.qurl.site", "expires_at": "2026-03-15T10:00:00Z", + "qurl_id": "q_async", }, }, ) @@ -913,14 +1250,20 @@ async def test_async_resolve(async_client: AsyncQURLClient) -> None: 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}, - }), + 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)] @@ -928,12 +1271,57 @@ async def test_async_list_all(async_client: AsyncQURLClient) -> None: assert [q.resource_id for q in all_qurls] == ["r_1", "r_2"] +@respx.mock +@pytest.mark.asyncio +async def test_async_list_all_propagates_date_filters_to_every_page( + async_client: AsyncQURLClient, +) -> None: + """Async mirror of test_list_all_propagates_date_filters_to_every_page. + Locks in sync/async parity for the list_all date-filter passthrough + behavior — both clients must carry the filter params on every + paginated HTTP call, not just the first. + """ + 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}, + }, + ), + ] + + created_after = datetime(2026, 3, 1, 0, 0, 0, tzinfo=timezone.utc) + expires_before = datetime(2026, 4, 1, 0, 0, 0, tzinfo=timezone.utc) + all_qurls = [ + q + async for q in async_client.list_all( + page_size=1, + created_after=created_after, + expires_before=expires_before, + ) + ] + assert len(all_qurls) == 2 + assert route.call_count == 2 + + for call in route.calls: + url = str(call.request.url) + assert "created_after=2026-03-01T00%3A00%3A00" in url + assert "expires_before=2026-04-01T00%3A00%3A00" in url + + @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") - ) + 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() @@ -943,9 +1331,7 @@ async def test_async_network_error_wrapped(async_client: AsyncQURLClient) -> Non @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") - ) + 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() @@ -955,9 +1341,7 @@ async def test_async_timeout_error_wrapped(async_client: AsyncQURLClient) -> Non @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") - ) + respx.get(f"{BASE_URL}/v1/quota").mock(side_effect=httpx.ReadTimeout("Read timed out")) with pytest.raises(QURLNetworkError): await async_client.get_quota() @@ -982,8 +1366,10 @@ def test_401_raises_authentication_error(client: QURLClient) -> None: 401, json={ "error": { - "status": 401, "code": "unauthorized", - "title": "Unauthorized", "detail": "Invalid API key", + "status": 401, + "code": "unauthorized", + "title": "Unauthorized", + "detail": "Invalid API key", }, }, ) @@ -1002,8 +1388,10 @@ def test_403_raises_authorization_error(client: QURLClient) -> None: 403, json={ "error": { - "status": 403, "code": "forbidden", - "title": "Forbidden", "detail": "Insufficient scope", + "status": 403, + "code": "forbidden", + "title": "Forbidden", + "detail": "Insufficient scope", }, }, ) @@ -1021,8 +1409,10 @@ def test_404_raises_not_found_error(client: QURLClient) -> None: 404, json={ "error": { - "status": 404, "code": "not_found", - "title": "Not Found", "detail": "QURL not found", + "status": 404, + "code": "not_found", + "title": "Not Found", + "detail": "QURL not found", }, "meta": {"request_id": "req_err"}, }, @@ -1037,13 +1427,19 @@ def test_404_raises_not_found_error(client: QURLClient) -> None: @respx.mock def test_422_raises_validation_error(client: QURLClient) -> None: - respx.post(f"{BASE_URL}/v1/qurl").mock( + """The API's 422 path is exercised by sending a syntactically valid URL + that passes client-side checks but is rejected by the API (e.g. a + host that fails SSRF protection). The mock returns 422 regardless of + the request body, so any valid-scheme URL works here.""" + respx.post(f"{BASE_URL}/v1/qurls").mock( return_value=httpx.Response( 422, json={ "error": { - "status": 422, "code": "validation_error", - "title": "Validation Error", "detail": "Invalid target_url", + "status": 422, + "code": "validation_error", + "title": "Validation Error", + "detail": "Invalid target_url", "invalid_fields": {"target_url": "must be a valid URL"}, }, }, @@ -1051,7 +1447,7 @@ def test_422_raises_validation_error(client: QURLClient) -> None: ) with pytest.raises(ValidationError) as exc_info: - client.create(target_url="not-a-url") + client.create(target_url="https://localhost/rejected-by-ssrf-protection") assert exc_info.value.invalid_fields == {"target_url": "must be a valid URL"} @@ -1063,8 +1459,10 @@ def test_429_raises_rate_limit_error(client: QURLClient) -> None: headers={"Retry-After": "10"}, json={ "error": { - "status": 429, "code": "rate_limited", - "title": "Rate Limited", "detail": "Slow down", + "status": 429, + "code": "rate_limited", + "title": "Rate Limited", + "detail": "Slow down", }, }, ) @@ -1082,8 +1480,10 @@ def test_500_raises_server_error(client: QURLClient) -> None: 500, json={ "error": { - "status": 500, "code": "internal", - "title": "Internal Server Error", "detail": "Something broke", + "status": 500, + "code": "internal", + "title": "Internal Server Error", + "detail": "Something broke", }, }, ) @@ -1096,20 +1496,24 @@ def test_500_raises_server_error(client: QURLClient) -> None: @respx.mock def test_400_raises_validation_error(client: QURLClient) -> None: - respx.post(f"{BASE_URL}/v1/qurl").mock( + """Exercise the API's 400 path with a URL that passes client-side + validation — the mock returns 400 regardless of the request body.""" + respx.post(f"{BASE_URL}/v1/qurls").mock( return_value=httpx.Response( 400, json={ "error": { - "status": 400, "code": "bad_request", - "title": "Bad Request", "detail": "Missing target_url", + "status": 400, + "code": "bad_request", + "title": "Bad Request", + "detail": "Missing target_url", }, }, ) ) with pytest.raises(ValidationError): - client.create(target_url="") + client.create(target_url="https://example.com/triggers-mocked-400") # --- extend() convenience method --- @@ -1128,6 +1532,7 @@ def test_extend(client: QURLClient) -> None: "status": "active", "created_at": "2026-03-10T10:00:00Z", "expires_at": "2026-03-20T10:00:00Z", + "tags": [], }, }, ) @@ -1153,6 +1558,7 @@ async def test_async_extend(async_client: AsyncQURLClient) -> None: "status": "active", "created_at": "2026-03-10T10:00:00Z", "expires_at": "2026-03-20T10:00:00Z", + "tags": [], }, }, ) @@ -1164,59 +1570,1855 @@ async def test_async_extend(async_client: AsyncQURLClient) -> None: assert body == {"extend_by": "24h"} -# --- AccessPolicy serialization --- +# --- Batch create --- @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( +def test_batch_create_all_succeed(client: QURLClient) -> None: + """batch_create() parses a fully successful batch response.""" + respx.post(f"{BASE_URL}/v1/qurls/batch").mock( return_value=httpx.Response( - 201, + 200, json={ "data": { - "resource_id": "r_policy", - "qurl_link": "https://qurl.link/#at_test", - "qurl_site": "https://r_policy.qurl.site", + "succeeded": 2, + "failed": 0, + "results": [ + { + "index": 0, + "success": True, + "resource_id": "r_batch1", + "qurl_link": "https://qurl.link/#at_b1", + "qurl_site": "https://r_batch1.qurl.site", + "expires_at": "2026-04-01T00:00:00Z", + }, + { + "index": 1, + "success": True, + "resource_id": "r_batch2", + "qurl_link": "https://qurl.link/#at_b2", + "qurl_site": "https://r_batch2.qurl.site", + }, + ], }, }, ) ) - policy = AccessPolicy( - ip_allowlist=["10.0.0.0/8"], - geo_denylist=["CN", "RU"], + result = client.batch_create( + [ + {"target_url": "https://a.com", "expires_in": "24h"}, + {"target_url": "https://b.com"}, + ] ) - 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"] + assert result.succeeded == 2 + assert result.failed == 0 + assert len(result.results) == 2 + assert result.results[0].resource_id == "r_batch1" + assert result.results[0].success is True + assert isinstance(result.results[0].expires_at, datetime) + assert result.results[1].resource_id == "r_batch2" + assert result.results[1].expires_at is None @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( +def test_batch_create_207_multi_status(client: QURLClient) -> None: + """HTTP 207 Multi-Status routes through the success path (< 400). + + The success path in ``_raw_request`` is gated by + ``response.status_code < 400``, so 207 flows through naturally like + 200/201. This test locks in the status routing against a future + refactor that might accidentally narrow the range (e.g. ``== 200`` + or ``< 300``). Without this, a partial-success 207 response + could be misclassified as an error. + """ + respx.post(f"{BASE_URL}/v1/qurls/batch").mock( return_value=httpx.Response( - 200, + 207, json={ "data": { - "resource_id": "r_abc", - "target_url": "https://example.com", - "status": "active", - "created_at": "2026-03-10T10:00:00Z", + "succeeded": 1, + "failed": 1, + "results": [ + { + "index": 0, + "success": True, + "resource_id": "r_multi", + "qurl_link": "https://qurl.link/#at_multi", + "qurl_site": "https://r_multi.qurl.site", + }, + { + "index": 1, + "success": False, + "error": { + "code": "validation_error", + "message": "items[1]: target_url must be HTTPS", + }, + }, + ], }, }, ) ) - policy = AccessPolicy(user_agent_deny_regex="curl.*") - result = client.update("r_abc", access_policy=policy) - assert result.resource_id == "r_abc" - body = json.loads(route.calls[0].request.content) - assert body["access_policy"] == {"user_agent_deny_regex": "curl.*"} + result = client.batch_create( + [ + {"target_url": "https://a.com"}, + {"target_url": "https://b.com"}, + ] + ) + assert result.succeeded == 1 + assert result.failed == 1 + assert result.results[0].success is True + assert result.results[0].resource_id == "r_multi" + assert result.results[1].success is False + + +@respx.mock +async def test_async_batch_create_207_multi_status( + async_client: AsyncQURLClient, +) -> None: + """Async mirror: HTTP 207 Multi-Status routes through success path.""" + respx.post(f"{BASE_URL}/v1/qurls/batch").mock( + return_value=httpx.Response( + 207, + json={ + "data": { + "succeeded": 1, + "failed": 0, + "results": [ + { + "index": 0, + "success": True, + "resource_id": "r_async207", + "qurl_link": "https://qurl.link/#at_a207", + "qurl_site": "https://r_async207.qurl.site", + }, + ], + }, + }, + ) + ) + + result = await async_client.batch_create( + [{"target_url": "https://a.com"}] + ) + assert result.succeeded == 1 + assert result.results[0].resource_id == "r_async207" + + +@respx.mock +def test_batch_create_partial_failure(client: QURLClient) -> None: + """batch_create() correctly parses partial failures.""" + respx.post(f"{BASE_URL}/v1/qurls/batch").mock( + return_value=httpx.Response( + 200, + json={ + "data": { + "succeeded": 1, + "failed": 1, + "results": [ + { + "index": 0, + "success": True, + "resource_id": "r_ok", + "qurl_link": "https://qurl.link/#at_ok", + "qurl_site": "https://r_ok.qurl.site", + }, + { + "index": 1, + "success": False, + "error": { + "code": "validation_error", + "message": "Invalid target_url", + }, + }, + ], + }, + }, + ) + ) + + # Both URLs must pass client-side validation (syntactically valid + # https://) — the mock returns a partial-failure payload regardless + # of what's in the request body, so we're exercising the parser. + result = client.batch_create( + [ + {"target_url": "https://good.example.com"}, + {"target_url": "https://bad.example.com"}, + ] + ) + assert result.succeeded == 1 + assert result.failed == 1 + assert result.results[0].success is True + assert result.results[1].success is False + assert result.results[1].error is not None + assert result.results[1].error.code == "validation_error" + assert result.results[1].error.message == "Invalid target_url" + + +# --- Date filter tests --- + + +@respx.mock +def test_list_with_date_filters(client: QURLClient) -> None: + """list() passes date filter params to the request.""" + route = respx.get(f"{BASE_URL}/v1/qurls").mock( + return_value=httpx.Response( + 200, + json={ + "data": [_qurl_item("r_filtered", "https://filtered.com")], + "meta": {"has_more": False}, + }, + ) + ) + + result = client.list( + status="active", + created_after="2026-03-01T00:00:00Z", + expires_before="2026-04-01T00:00:00Z", + ) + assert len(result.qurls) == 1 + req = route.calls[0].request + assert "created_after=2026-03-01T00%3A00%3A00Z" in str(req.url) + assert "expires_before=2026-04-01T00%3A00%3A00Z" in str(req.url) + + +# --- Mint link full input --- + + +@respx.mock +def test_mint_link_full_input(client: QURLClient) -> None: + """mint_link() sends all expanded params.""" + route = respx.post(f"{BASE_URL}/v1/qurls/r_abc/mint_link").mock( + return_value=httpx.Response( + 200, + json={ + "data": { + "qurl_link": "https://qurl.link/#at_full", + "expires_at": "2026-04-01T00:00:00Z", + }, + }, + ) + ) + + policy = AccessPolicy(ip_allowlist=["10.0.0.0/8"]) + result = client.mint_link( + "r_abc", + expires_in="12h", + label="my-link", + one_time_use=True, + max_sessions=5, + session_duration="30m", + access_policy=policy, + ) + assert result.qurl_link == "https://qurl.link/#at_full" + body = json.loads(route.calls[0].request.content) + assert body["expires_in"] == "12h" + assert body["label"] == "my-link" + assert body["one_time_use"] is True + assert body["max_sessions"] == 5 + assert body["session_duration"] == "30m" + assert body["access_policy"] == {"ip_allowlist": ["10.0.0.0/8"]} + + +@respx.mock +def test_mint_link_nested_ai_agent_policy(client: QURLClient) -> None: + """mint_link() correctly serializes nested AIAgentPolicy inside AccessPolicy.""" + route = respx.post(f"{BASE_URL}/v1/qurls/r_abc/mint_link").mock( + return_value=httpx.Response( + 200, + json={ + "data": { + "qurl_link": "https://qurl.link/#at_ai", + "expires_at": "2026-04-01T00:00:00Z", + }, + }, + ) + ) + + policy = AccessPolicy( + ip_allowlist=["10.0.0.0/8"], + ai_agent_policy=AIAgentPolicy( + block_all=True, + deny_categories=["scraping"], + ), + ) + result = client.mint_link("r_abc", access_policy=policy) + assert result.qurl_link == "https://qurl.link/#at_ai" + body = json.loads(route.calls[0].request.content) + assert body["access_policy"] == { + "ip_allowlist": ["10.0.0.0/8"], + "ai_agent_policy": { + "block_all": True, + "deny_categories": ["scraping"], + }, + } + + +# --- Update with tags --- + + +@respx.mock +def test_update_with_tags(client: QURLClient) -> None: + """update() sends tags in the request body.""" + 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", + "tags": ["team engineering", "env-prod"], + }, + }, + ) + ) + + # Tags must match the API regex ^[a-zA-Z0-9][a-zA-Z0-9 _-]*$ — + # alphanumerics, spaces, underscores, and hyphens only. + result = client.update("r_abc", tags=["team engineering", "env-prod"]) + assert result.tags == ["team engineering", "env-prod"] + body = json.loads(route.calls[0].request.content) + assert body == {"tags": ["team engineering", "env-prod"]} + + +# --- Create with label and session_duration --- + + +@respx.mock +def test_create_with_label_and_session_duration(client: QURLClient) -> None: + """create() sends label and session_duration in the body.""" + route = respx.post(f"{BASE_URL}/v1/qurls").mock( + return_value=httpx.Response( + 201, + json={ + "data": { + "resource_id": "r_new", + "qurl_link": "https://qurl.link/#at_new", + "qurl_site": "https://r_new.qurl.site", + "qurl_id": "q_new", + "label": "my label", + "expires_at": "2026-04-01T00:00:00Z", + }, + }, + ) + ) + + result = client.create( + target_url="https://example.com", + expires_in="7d", + label="my label", + session_duration="1h", + ) + assert result.resource_id == "r_new" + assert result.label == "my label" + assert result.qurl_id == "q_new" + body = json.loads(route.calls[0].request.content) + assert body["label"] == "my label" + assert body["session_duration"] == "1h" + + +# --- Create with custom_domain --- + + +@respx.mock +def test_create_with_custom_domain(client: QURLClient) -> None: + """create() sends custom_domain in the body.""" + route = respx.post(f"{BASE_URL}/v1/qurls").mock( + return_value=httpx.Response( + 201, + json={ + "data": { + "resource_id": "r_cd", + "qurl_link": "https://links.example.com/#at_cd", + "qurl_site": "https://r_cd.qurl.site", + "qurl_id": "q_cd", + "expires_at": "2026-04-01T00:00:00Z", + }, + }, + ) + ) + + result = client.create( + target_url="https://example.com", + expires_in="7d", + custom_domain="links.example.com", + ) + assert result.resource_id == "r_cd" + body = json.loads(route.calls[0].request.content) + assert body["custom_domain"] == "links.example.com" + + +# --- Async batch create --- + + +@respx.mock +@pytest.mark.asyncio +async def test_async_batch_create(async_client: AsyncQURLClient) -> None: + """Async batch_create() works correctly.""" + respx.post(f"{BASE_URL}/v1/qurls/batch").mock( + return_value=httpx.Response( + 200, + json={ + "data": { + "succeeded": 1, + "failed": 0, + "results": [ + { + "index": 0, + "success": True, + "resource_id": "r_async_batch", + "qurl_link": "https://qurl.link/#at_ab", + "qurl_site": "https://r_async_batch.qurl.site", + }, + ], + }, + }, + ) + ) + + result = await async_client.batch_create( + [ + {"target_url": "https://async.com"}, + ] + ) + assert result.succeeded == 1 + assert result.results[0].resource_id == "r_async_batch" + + +# --- Async date filters --- + + +@respx.mock +@pytest.mark.asyncio +async def test_async_list_with_date_filters(async_client: AsyncQURLClient) -> None: + """Async list() passes date filter params.""" + route = respx.get(f"{BASE_URL}/v1/qurls").mock( + return_value=httpx.Response( + 200, + json={ + "data": [_qurl_item("r_af", "https://af.com")], + "meta": {"has_more": False}, + }, + ) + ) + + result = await async_client.list( + created_before="2026-03-15T00:00:00Z", + expires_after="2026-03-01T00:00:00Z", + ) + assert len(result.qurls) == 1 + req = route.calls[0].request + assert "created_before=2026-03-15T00%3A00%3A00Z" in str(req.url) + assert "expires_after=2026-03-01T00%3A00%3A00Z" in str(req.url) + + +# --- Async mint link full input --- + + +@respx.mock +@pytest.mark.asyncio +async def test_async_mint_link_full_input(async_client: AsyncQURLClient) -> None: + """Async mint_link() sends all expanded params.""" + route = respx.post(f"{BASE_URL}/v1/qurls/r_abc/mint_link").mock( + return_value=httpx.Response( + 200, + json={ + "data": { + "qurl_link": "https://qurl.link/#at_afull", + "expires_at": "2026-04-01T00:00:00Z", + }, + }, + ) + ) + + result = await async_client.mint_link( + "r_abc", + expires_in="6h", + label="async-link", + one_time_use=False, + max_sessions=3, + session_duration="15m", + ) + assert result.qurl_link == "https://qurl.link/#at_afull" + body = json.loads(route.calls[0].request.content) + assert body["expires_in"] == "6h" + assert body["label"] == "async-link" + assert body["one_time_use"] is False + assert body["max_sessions"] == 3 + assert body["session_duration"] == "15m" + + +# --- Async update with tags --- + + +@respx.mock +@pytest.mark.asyncio +async def test_async_update_with_tags(async_client: AsyncQURLClient) -> None: + """Async update() sends tags.""" + 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", + "tags": ["internal"], + }, + }, + ) + ) + + result = await async_client.update("r_abc", tags=["internal"]) + assert result.tags == ["internal"] + body = json.loads(route.calls[0].request.content) + assert body == {"tags": ["internal"]} + + +async def test_async_update_rejects_access_policy_kwarg( + async_client: AsyncQURLClient, +) -> None: + """Async mirror of `test_update_rejects_access_policy_kwarg`. + + Locks in the immutability invariant on the async surface. The + reasoning is identical: a future refactor adding `**kwargs` to + `async_client.update()` would silently accept `access_policy`, + breaking the spec-enforced contract. This guards against that + independently of the function signature. + """ + with pytest.raises(TypeError, match="access_policy"): + await async_client.update( + "r_abc", + access_policy={"ai_agent_policy": "allow"}, # type: ignore[call-arg] + ) + + +# --- batch_create validation --- + + +def test_batch_create_empty_raises(client: QURLClient) -> None: + with pytest.raises(ValueError, match="requires at least 1 item"): + client.batch_create([]) + + +def test_batch_create_over_100_raises(client: QURLClient) -> None: + items: list[BatchCreateItem] = [{"target_url": f"https://{i}.com"} for i in range(101)] + with pytest.raises(ValueError, match="at most 100"): + client.batch_create(items) + + +# --- create() with access_policy serialization --- + + +@respx.mock +def test_create_with_access_policy(client: QURLClient) -> None: + """create() correctly serializes AccessPolicy including nested AIAgentPolicy.""" + route = respx.post(f"{BASE_URL}/v1/qurls").mock( + return_value=httpx.Response( + 201, + json={ + "data": { + "resource_id": "r_pol", + "qurl_link": "https://qurl.link/#at_pol", + "qurl_site": "https://r_pol.qurl.site", + "qurl_id": "q_pol", + }, + }, + ) + ) + + policy = AccessPolicy( + ip_allowlist=["10.0.0.0/8"], + ai_agent_policy=AIAgentPolicy( + block_all=True, + deny_categories=["scraping"], + ), + ) + result = client.create( + target_url="https://example.com", + one_time_use=True, + max_sessions=3, + access_policy=policy, + ) + assert result.resource_id == "r_pol" + body = json.loads(route.calls[0].request.content) + assert body["target_url"] == "https://example.com" + assert body["one_time_use"] is True + assert body["max_sessions"] == 3 + assert body["access_policy"] == { + "ip_allowlist": ["10.0.0.0/8"], + "ai_agent_policy": { + "block_all": True, + "deny_categories": ["scraping"], + }, + } + + +@respx.mock +def test_create_with_minimal_ai_agent_policy_only(client: QURLClient) -> None: + """Regression guard for the reviewer-noted gap: an AccessPolicy + that contains ONLY ``ai_agent_policy`` (with every other policy + field left None) must still serialize correctly. The existing + test pairs ai_agent_policy with ip_allowlist, so the None-drop + rule for the other policy fields wasn't exercised in isolation. + + This test verifies two things: + 1. `_serialize_value`'s dataclass None-drop works across ALL + other `AccessPolicy` fields (ip_allowlist, ip_denylist, + geo_allowlist, geo_denylist, user_agent_allow_regex, + user_agent_deny_regex) — the serialized body contains ONLY + `ai_agent_policy` under the `access_policy` key. + 2. `ai_agent_policy`'s own None fields (allow_categories, + deny_categories) are also dropped, leaving ONLY `block_all`. + """ + route = respx.post(f"{BASE_URL}/v1/qurls").mock( + return_value=httpx.Response( + 201, + json={ + "data": { + "resource_id": "r_minpol", + "qurl_link": "https://qurl.link/#at_minpol", + "qurl_site": "https://r_minpol.qurl.site", + "qurl_id": "q_minpol", + }, + }, + ) + ) + + # Only ai_agent_policy set; every other AccessPolicy field left None. + # And within ai_agent_policy, only block_all is set. + policy = AccessPolicy(ai_agent_policy=AIAgentPolicy(block_all=True)) + client.create(target_url="https://example.com", access_policy=policy) + + body = json.loads(route.calls[0].request.content) + # The entire access_policy payload should be just the nested + # ai_agent_policy, nothing else. + assert body["access_policy"] == {"ai_agent_policy": {"block_all": True}} + # Explicit checks that the other policy fields do NOT appear at all. + assert "ip_allowlist" not in body["access_policy"] + assert "ip_denylist" not in body["access_policy"] + assert "geo_allowlist" not in body["access_policy"] + assert "geo_denylist" not in body["access_policy"] + assert "user_agent_allow_regex" not in body["access_policy"] + assert "user_agent_deny_regex" not in body["access_policy"] + # And None fields on AIAgentPolicy itself are dropped too. + assert "allow_categories" not in body["access_policy"]["ai_agent_policy"] + assert "deny_categories" not in body["access_policy"]["ai_agent_policy"] + + +# --- _parse_access_policy deserialization edge cases --- + + +def test_parse_access_policy_null_ai_agent_policy() -> None: + """``ai_agent_policy: null`` / missing yields ``ai_agent_policy=None``. + + The happy-path test (test_create_with_access_policy) covers a + populated AIAgentPolicy, but the None branch wasn't directly + asserted. This locks in that null and missing are both normalised + to ``None``, not to an empty ``AIAgentPolicy()``. + """ + import layerv_qurl._utils as utils + + # Explicit null + policy = utils._parse_access_policy( + {"ip_allowlist": ["10.0.0.0/8"], "ai_agent_policy": None} + ) + assert policy.ai_agent_policy is None + assert policy.ip_allowlist == ["10.0.0.0/8"] + + # Key missing entirely + policy2 = utils._parse_access_policy({"ip_denylist": ["192.168.0.0/16"]}) + assert policy2.ai_agent_policy is None + assert policy2.ip_denylist == ["192.168.0.0/16"] + + +def test_parse_access_policy_non_dict_ai_agent_policy_is_ignored() -> None: + """Non-dict ``ai_agent_policy`` (e.g. bare string or bool) is ignored. + + Without the ``isinstance(ap, dict)`` guard in ``_parse_access_policy``, + ``.get("block_all")`` would raise ``AttributeError`` on a non-dict + value. This locks in the defensive posture against unexpected API + shapes — a string or boolean should be treated as "no policy" + rather than crashing the entire response parser. + """ + import layerv_qurl._utils as utils + + # Bare string + policy = utils._parse_access_policy({"ai_agent_policy": "unexpected_string"}) + assert policy.ai_agent_policy is None + + # Boolean + policy2 = utils._parse_access_policy({"ai_agent_policy": True}) + assert policy2.ai_agent_policy is None + + # Integer + policy3 = utils._parse_access_policy({"ai_agent_policy": 42}) + assert policy3.ai_agent_policy is None + + +# --- _serialize_value end-to-end with nested dataclasses --- + + +@respx.mock +def test_mint_link_nested_serialization_e2e(client: QURLClient) -> None: + """Verifies _serialize_value recursively handles AccessPolicy > AIAgentPolicy.""" + route = respx.post(f"{BASE_URL}/v1/qurls/r_abc/mint_link").mock( + return_value=httpx.Response( + 200, + json={ + "data": { + "qurl_link": "https://qurl.link/#at_e2e", + "expires_at": "2026-04-01T00:00:00Z", + }, + }, + ) + ) + + policy = AccessPolicy( + geo_denylist=["CN", "RU"], + ai_agent_policy=AIAgentPolicy( + allow_categories=["claude", "chatgpt"], + ), + ) + client.mint_link("r_abc", access_policy=policy, label="e2e-test") + body = json.loads(route.calls[0].request.content) + assert body["access_policy"]["geo_denylist"] == ["CN", "RU"] + assert body["access_policy"]["ai_agent_policy"]["allow_categories"] == ["claude", "chatgpt"] + assert "block_all" not in body["access_policy"]["ai_agent_policy"] # None fields omitted + + +def test_serialize_value_none_asymmetry_dataclass_vs_dict() -> None: + """Regression guard for the deliberate None-handling asymmetry in + :func:`_serialize_value`: + + * Dataclass fields with ``None`` values are **dropped** — the + dataclass distinguishes "unset" from "explicitly null." + * ``None`` values inside nested dicts/lists are **preserved** — + some API fields use explicit ``null`` as a signalling value + (e.g. ``"ai_agent_policy": null`` to clear a policy). + + This test exercises both rules in a single call so a future + refactor that unifies the behavior would trip immediately. + """ + import layerv_qurl._utils as utils + + # AIAgentPolicy is a real dataclass from the types module. block_all + # stays None — dataclass rule should drop it. + policy = AIAgentPolicy(allow_categories=["claude"]) + + value = { + "explicit_null": None, # dict None: must survive + "dataclass": policy, # dataclass with a None field: field must be dropped + "nested": { + "also_null": None, # nested dict None: must survive + "real": "value", + }, + "list_with_nulls": [None, "x", None], # list Nones: must survive + } + + serialized = utils._serialize_value(value) + + # Dict-level None is preserved + assert serialized["explicit_null"] is None + assert serialized["nested"]["also_null"] is None + # List-level None is preserved + assert serialized["list_with_nulls"] == [None, "x", None] + # Dataclass None field is dropped (block_all was None on the policy) + assert "block_all" not in serialized["dataclass"] + assert "deny_categories" not in serialized["dataclass"] # also None + # Non-None dataclass field survives + assert serialized["dataclass"]["allow_categories"] == ["claude"] + + +# ---- Spec-derived input validation (create) -------------------------------- + + +def test_create_rejects_target_url_longer_than_2048(client: QURLClient) -> None: + with pytest.raises(ValueError, match="target_url"): + client.create(target_url="https://a.com/" + "x" * 2048) + + +def test_create_rejects_label_longer_than_500(client: QURLClient) -> None: + with pytest.raises(ValueError, match="label"): + client.create(target_url="https://example.com", label="x" * 501) + + +def test_create_rejects_custom_domain_longer_than_253(client: QURLClient) -> None: + with pytest.raises(ValueError, match="custom_domain"): + client.create( + target_url="https://example.com", + custom_domain="a" * 254, + ) + + +def test_create_rejects_max_sessions_above_1000(client: QURLClient) -> None: + with pytest.raises(ValueError, match="max_sessions"): + client.create(target_url="https://example.com", max_sessions=1001) + + +def test_create_rejects_negative_max_sessions(client: QURLClient) -> None: + with pytest.raises(ValueError, match="max_sessions"): + client.create(target_url="https://example.com", max_sessions=-1) + + +def test_require_max_sessions_in_range_rejects_bool() -> None: + """Direct unit test for the ``bool`` rejection in + :func:`_require_max_sessions_in_range`. + + ``bool`` is a subclass of ``int`` in Python (``True == 1``, + ``False == 0``), so a naive ``isinstance(value, int)`` check + would silently accept ``max_sessions=True``. The validator has + an explicit ``isinstance(value, bool)`` rejection to catch this. + Reviewer's argument for this test: a future simplification that + drops the bool guard would trip this regression immediately. + + The rejection is already exercised indirectly via batch bool- + counts tests, but a direct unit test makes the intent explicit. + """ + import layerv_qurl._utils as utils + + # `True` and `False` are type-compatible with `int` in Python (bool + # is a subclass of int), so mypy doesn't need a type ignore — the + # test exercises a RUNTIME check that catches what the type system + # can't. + with pytest.raises(ValueError, match="max_sessions"): + utils._require_max_sessions_in_range(True) + with pytest.raises(ValueError, match="max_sessions"): + utils._require_max_sessions_in_range(False) + # Sanity: plain ints still pass through. + utils._require_max_sessions_in_range(0) + utils._require_max_sessions_in_range(500) + utils._require_max_sessions_in_range(1000) + # And None (the "not provided" sentinel) is still a no-op. + utils._require_max_sessions_in_range(None) + + +@respx.mock +def test_create_accepts_max_sessions_boundaries(client: QURLClient) -> None: + """max_sessions=0 (unlimited) and max_sessions=1000 (hard limit) are both valid.""" + respx.post(f"{BASE_URL}/v1/qurls").mock( + return_value=httpx.Response( + 201, + json={ + "data": { + "resource_id": "r_x", + "qurl_link": "https://qurl.link/#x", + "qurl_site": "https://x.qurl.site", + "qurl_id": "q_x", + }, + }, + ) + ) + client.create(target_url="https://example.com", max_sessions=0) + client.create(target_url="https://example.com", max_sessions=1000) + + +# ---- Spec-derived input validation (update) -------------------------------- + + +def test_update_rejects_description_longer_than_500(client: QURLClient) -> None: + with pytest.raises(ValueError, match="description"): + client.update("r_abc", description="x" * 501) + + +def test_update_rejects_more_than_10_tags(client: QURLClient) -> None: + with pytest.raises(ValueError, match="tags"): + client.update("r_abc", tags=[f"tag{i}" for i in range(11)]) + + +def test_update_rejects_tags_longer_than_50_chars(client: QURLClient) -> None: + with pytest.raises(ValueError, match="1-50 characters"): + client.update("r_abc", tags=["x" * 51]) + + +def test_update_rejects_tags_that_dont_match_pattern(client: QURLClient) -> None: + # Tags must start with an alphanumeric character. + with pytest.raises(ValueError, match="alphanumeric"): + client.update("r_abc", tags=["-leading-dash"]) + + +@respx.mock +def test_update_accepts_empty_tags_to_clear(client: QURLClient) -> None: + """Empty tag list clears all tags per the API spec.""" + 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", + "tags": [], + "created_at": "2026-03-10T10:00:00Z", + }, + }, + ) + ) + result = client.update("r_abc", tags=[]) + assert result.tags == [] + + +@respx.mock +def test_update_wire_body_preserves_tags_empty_list(client: QURLClient) -> None: + """Regression guard for the load-bearing `build_body` contract: + `tags=[]` is a "clear all tags" API operation, not a "no change" + signal. The empty list must survive into the serialized request + body — `build_body` only strips top-level ``None``, never falsy + values. A future refactor that adds a truthiness check would + silently break tag-clearing, so this test asserts the wire body + explicitly rather than just the parsed response. + """ + 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", + "tags": [], + "created_at": "2026-03-10T10:00:00Z", + }, + }, + ) + ) + client.update("r_abc", tags=[]) + body = json.loads(route.calls[0].request.content) + assert "tags" in body + assert body["tags"] == [] + + +@respx.mock +def test_update_wire_body_preserves_description_empty_string( + client: QURLClient, +) -> None: + """Same contract as tags=[] but for `description=""` — an empty + string means "clear the description" per the API spec, and must + survive `build_body`'s top-level-None-only strip. No previous + regression test covered this path; this fills that gap. + """ + 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", + "description": "", + "created_at": "2026-03-10T10:00:00Z", + }, + }, + ) + ) + client.update("r_abc", description="") + body = json.loads(route.calls[0].request.content) + assert "description" in body + assert body["description"] == "" + + +def test_update_rejects_empty_input(client: QURLClient) -> None: + with pytest.raises(ValueError, match="at least one field"): + client.update("r_abc") + + +def test_update_rejects_both_extend_by_and_expires_at(client: QURLClient) -> None: + with pytest.raises(ValueError, match="mutually exclusive"): + client.update("r_abc", extend_by="24h", expires_at="2026-04-01T00:00:00Z") + + +def test_update_rejects_access_policy_kwarg(client: QURLClient) -> None: + """Lock in that `access_policy` is immutable on update(). + + Per the OpenAPI spec's `UpdateQurlRequest` schema, access policy is + set only at create time and cannot be modified on an existing + resource. The signature itself currently enforces this (Python + raises `TypeError` on unknown kwargs), but this test is an explicit + invariant guard: a future refactor adding `**kwargs` to `update()` + for forward-compat pass-through would silently accept + `access_policy` again, breaking the spec-enforced immutability + contract. Callers needing policy changes should create a new + resource or mint a per-token override via `mint_link()`. + """ + with pytest.raises(TypeError, match="access_policy"): + client.update("r_abc", access_policy={"ai_agent_policy": "allow"}) # type: ignore[call-arg] + + +def test_create_rejects_expires_at_kwarg(client: QURLClient) -> None: + """Lock in that ``expires_at`` was removed from ``create()``. + + Per the OpenAPI spec's ``CreateQurlRequest`` schema, creation accepts + only ``expires_in`` (relative duration), not ``expires_at`` (absolute + timestamp). The old ``expires_at`` parameter was removed in this PR. + Like the ``access_policy`` invariant guard on ``update()``, this test + is an explicit safety net: a future refactor adding ``**kwargs`` + would silently re-accept the removed parameter. Callers needing an + absolute expiry should use ``create(expires_in=...) + update(expires_at=...)``. + """ + with pytest.raises(TypeError, match="expires_at"): + client.create(target_url="https://example.com", expires_at="2026-04-01T00:00:00Z") # type: ignore[call-arg] + + +async def test_async_create_rejects_expires_at_kwarg( + async_client: AsyncQURLClient, +) -> None: + """Async mirror of ``test_create_rejects_expires_at_kwarg``.""" + with pytest.raises(TypeError, match="expires_at"): + await async_client.create( # type: ignore[call-arg] + target_url="https://example.com", + expires_at="2026-04-01T00:00:00Z", + ) + + +# ---- Spec-derived input validation (mint_link) ----------------------------- + + +def test_mint_link_rejects_label_longer_than_500(client: QURLClient) -> None: + with pytest.raises(ValueError, match="label"): + client.mint_link("r_abc", label="x" * 501) + + +def test_mint_link_rejects_max_sessions_above_1000(client: QURLClient) -> None: + with pytest.raises(ValueError, match="max_sessions"): + client.mint_link("r_abc", max_sessions=5000) + + +def test_mint_link_rejects_both_expires_in_and_expires_at(client: QURLClient) -> None: + with pytest.raises(ValueError, match="mutually exclusive"): + client.mint_link( + "r_abc", + expires_in="7d", + expires_at="2026-04-01T00:00:00Z", + ) + + +# ---- delete() r_ prefix enforcement ---------------------------------------- + + +def test_delete_rejects_q_prefix_client_side(client: QURLClient) -> None: + """DELETE /v1/qurls/:id only accepts r_ prefix per the API spec.""" + with pytest.raises(ValueError, match="r_ prefix"): + client.delete("q_3a7f2c8e91b") + + +def test_delete_error_does_not_leak_full_resource_id(client: QURLClient) -> None: + """Info-leak regression: the error message must echo only the 2-char + prefix, not the raw caller-supplied ID. Error strings may end up in + observability pipelines and the ID suffix could contain + caller-sensitive data. + """ + with pytest.raises(ValueError) as exc_info: + client.delete("q_3a7f2c8e91b_sensitive_suffix") + msg = str(exc_info.value) + assert "'q_'" in msg + # Must not echo any part of the caller-supplied ID beyond the prefix. + assert "3a7f2c8e91b" not in msg + assert "sensitive_suffix" not in msg + + +# ---- list() limit validation (OpenAPI spec: integer, 1-100) ------------------ + + +def test_list_rejects_limit_zero(client: QURLClient) -> None: + """Per OpenAPI (GET /v1/qurls → limit: minimum: 1, maximum: 100).""" + with pytest.raises(ValueError, match="1 and 100"): + client.list(limit=0) + + +def test_list_rejects_limit_above_100(client: QURLClient) -> None: + with pytest.raises(ValueError, match="1 and 100"): + client.list(limit=101) + + +def test_list_rejects_negative_limit(client: QURLClient) -> None: + with pytest.raises(ValueError, match="1 and 100"): + client.list(limit=-5) + + +def test_list_rejects_non_integer_limit(client: QURLClient) -> None: + """Floats pass Python's ``int | None`` type annotation at runtime + but violate the spec's ``type: integer``.""" + with pytest.raises(ValueError, match="integer"): + client.list(limit=2.5) # type: ignore[arg-type] + + +def test_list_rejects_bool_limit(client: QURLClient) -> None: + """``bool`` is a subclass of ``int`` — must be explicitly rejected + like ``_require_max_sessions_in_range``.""" + with pytest.raises(ValueError): + client.list(limit=True) # type: ignore[arg-type] + + +@respx.mock +def test_list_accepts_limit_at_boundaries(client: QURLClient) -> None: + """Boundary regression: 1 and 100 are both valid.""" + respx.get(f"{BASE_URL}/v1/qurls").mock( + return_value=httpx.Response( + 200, + json={"data": [], "meta": {"has_more": False}}, + ) + ) + result_1 = client.list(limit=1) + assert result_1.has_more is False + result_100 = client.list(limit=100) + assert result_100.has_more is False + + +@respx.mock +def test_list_omitted_limit_uses_server_default(client: QURLClient) -> None: + """Omitting ``limit`` entirely must not trip the validator and must + not produce a ``limit=`` query param.""" + route = respx.get(f"{BASE_URL}/v1/qurls").mock( + return_value=httpx.Response( + 200, + json={"data": [], "meta": {"has_more": False}}, + ) + ) + client.list() + assert "limit" not in str(route.calls[0].request.url) + + +# ---- batch_create per-item validation & async validators ------------------- + + +def test_batch_create_rejects_per_item_violation_with_index(client: QURLClient) -> None: + """Per-item validation failures include the offending index.""" + with pytest.raises(ValueError, match=r"items\[1\].*max_sessions"): + client.batch_create( + [ + {"target_url": "https://a.example.com"}, + {"target_url": "https://b.example.com", "max_sessions": 9999}, + ] + ) + + +def test_batch_create_rejects_items_missing_target_url(client: QURLClient) -> None: + with pytest.raises(ValueError, match=r"items\[0\].*target_url"): + # Deliberately passing an item missing the required `target_url` + # field to exercise the runtime validation. The type: ignore is + # expected because BatchCreateItem enforces `target_url` at the + # type level — this test guards against untyped callers + # (e.g. Python scripts without strict type-checking) accidentally + # sending incomplete items. + client.batch_create([{"label": "no url"}]) # type: ignore[typeddict-item] + + +@pytest.mark.asyncio +async def test_async_batch_create_empty_raises(async_client: AsyncQURLClient) -> None: + with pytest.raises(ValueError, match="requires at least 1 item"): + await async_client.batch_create([]) + + +@pytest.mark.asyncio +async def test_async_batch_create_over_100_raises(async_client: AsyncQURLClient) -> None: + items: list[BatchCreateItem] = [{"target_url": f"https://{i}.com"} for i in range(101)] + with pytest.raises(ValueError, match="at most 100"): + await async_client.batch_create(items) + + +# ---- batch_create HTTP 400 passthrough ------------------------------------- + + +@respx.mock +def test_batch_create_passes_through_400_with_per_item_errors( + client: QURLClient, +) -> None: + """HTTP 400 on batch/ carries a BatchCreateOutput body with per-item errors. + + The SDK whitelists 400 and surfaces the structured body instead of + raising a generic ValidationError — matching the qurl-mcp and + qurl-typescript behavior on this endpoint. + """ + respx.post(f"{BASE_URL}/v1/qurls/batch").mock( + return_value=httpx.Response( + 400, + json={ + "data": { + "succeeded": 0, + "failed": 2, + "results": [ + { + "index": 0, + "success": False, + "error": { + "code": "validation_error", + "message": "items[0]: target_url must be HTTPS", + }, + }, + { + "index": 1, + "success": False, + "error": { + "code": "validation_error", + "message": "items[1]: target_url must be HTTPS", + }, + }, + ], + }, + "meta": {"request_id": "req_allfail"}, + }, + ) + ) + result = client.batch_create( + [ + {"target_url": "https://ok1.example.com"}, + {"target_url": "https://ok2.example.com"}, + ] + ) + assert result.failed == 2 + assert result.succeeded == 0 + assert len(result.results) == 2 + assert result.results[0].success is False + assert result.results[0].error is not None + assert result.results[0].error.code == "validation_error" + + +@respx.mock +@pytest.mark.asyncio +async def test_async_batch_create_passes_through_400_with_per_item_errors( + async_client: AsyncQURLClient, +) -> None: + """Async mirror of `test_batch_create_passes_through_400_with_per_item_errors`. + The sync/async parity goal requires the async client to also + surface the structured 400 body as a normal return value rather + than raising — reviewer flagged this as a coverage gap. + """ + respx.post(f"{BASE_URL}/v1/qurls/batch").mock( + return_value=httpx.Response( + 400, + json={ + "data": { + "succeeded": 0, + "failed": 2, + "results": [ + { + "index": 0, + "success": False, + "error": { + "code": "validation_error", + "message": "items[0]: target_url must be HTTPS", + }, + }, + { + "index": 1, + "success": False, + "error": { + "code": "validation_error", + "message": "items[1]: target_url must be HTTPS", + }, + }, + ], + }, + "meta": {"request_id": "req_allfail_async"}, + }, + ) + ) + result = await async_client.batch_create( + [ + {"target_url": "https://ok1.example.com"}, + {"target_url": "https://ok2.example.com"}, + ] + ) + assert result.failed == 2 + assert result.succeeded == 0 + assert len(result.results) == 2 + assert result.results[0].success is False + assert result.results[0].error is not None + assert result.results[0].error.code == "validation_error" + assert result.results[1].success is False + assert result.results[1].error is not None + assert result.results[1].error.code == "validation_error" + + +@respx.mock +def test_batch_create_still_raises_on_401(client: QURLClient) -> None: + """Non-400 error statuses still raise — the 400 passthrough is surgical.""" + respx.post(f"{BASE_URL}/v1/qurls/batch").mock( + return_value=httpx.Response( + 401, + json={ + "error": { + "type": "https://api.qurl.link/problems/unauthorized", + "title": "Unauthorized", + "status": 401, + "code": "unauthorized", + "detail": "Invalid API key", + }, + }, + ) + ) + with pytest.raises(AuthenticationError): + client.batch_create([{"target_url": "https://example.com"}]) + + +# ---- Error type/instance exposure, detail fallback, legacy message --------- + + +@respx.mock +def test_error_surfaces_rfc7807_type_and_instance(client: QURLClient) -> None: + respx.get(f"{BASE_URL}/v1/qurls/r_nf0000000000").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", + "instance": "/v1/qurls/r_nf0000000000", + "code": "not_found", + }, + "meta": {"request_id": "req_nf"}, + }, + ) + ) + with pytest.raises(NotFoundError) as excinfo: + client.get("r_nf0000000000") + err = excinfo.value + assert err.type == "https://api.qurl.link/problems/not_found" + assert err.instance == "/v1/qurls/r_nf0000000000" + assert err.request_id == "req_nf" + + +@respx.mock +def test_error_handles_explicit_null_error_envelope(client: QURLClient) -> None: + """Regression guard for the `envelope.get("error") or {}` pattern. + + Some APIs return ``{"error": null, ...}`` explicitly instead of + omitting the ``error`` key entirely. The standard + ``.get("error", {})`` would only handle the missing-key case — + an explicit ``null`` would pass through and then crash on the + subsequent ``err.get(...)`` chain with ``AttributeError: 'NoneType' + object has no attribute 'get'``. The inline comment on ``parse_error`` + documents the intentional ``or {}`` form; this test locks it in + against a future refactor that might "simplify" it back to the + broken ``, {}`` form. + """ + respx.get(f"{BASE_URL}/v1/quota").mock( + return_value=httpx.Response( + 500, + json={ + # Explicit null, not a missing key. + "error": None, + "meta": {"request_id": "req_null_err"}, + }, + ) + ) + with pytest.raises(ServerError) as excinfo: + client.get_quota() + err = excinfo.value + # The `or {}` fallback collapses `err` to an empty dict, which + # means none of the RFC 7807 fields are populated — we get the + # pure fallback behavior: status from the HTTP response, + # reason_phrase as the title, and "HTTP 500" as the detail. + assert err.status == 500 + assert err.code == "unknown" + # Detail falls back to title → reason_phrase → "HTTP {status}". + # httpx's reason phrase for 500 is "Internal Server Error" or + # empty depending on the transport; either way the detail is + # not None and doesn't contain "None"/"undefined" placeholders. + assert err.detail is not None + assert "None" not in str(err) + assert "undefined" not in str(err) + + +@respx.mock +def test_error_falls_back_to_title_when_detail_missing(client: QURLClient) -> None: + """RFC 7807 `detail` is optional — fall back to title.""" + respx.get(f"{BASE_URL}/v1/quota").mock( + return_value=httpx.Response( + 403, + json={ + "error": { + "type": "https://api.qurl.link/problems/forbidden", + "title": "Forbidden", + "status": 403, + "code": "forbidden", + # no detail + }, + }, + ) + ) + with pytest.raises(AuthorizationError) as excinfo: + client.get_quota() + err = excinfo.value + assert err.detail == "Forbidden" + assert "None" not in str(err) + assert "undefined" not in str(err) + + +@respx.mock +def test_error_falls_back_to_legacy_message_field(client: QURLClient) -> None: + """Pre-RFC-7807 `{error: {code, message}}` envelope still works.""" + respx.get(f"{BASE_URL}/v1/quota").mock( + return_value=httpx.Response( + 400, + json={ + "error": { + "code": "invalid_request", + "message": "legacy-format detail string", + }, + }, + ) + ) + with pytest.raises(ValidationError) as excinfo: + client.get_quota() + err = excinfo.value + assert err.detail == "legacy-format detail string" + + +# ---- parse_create_output normalizes empty qurl_id -------------------------- + + +@respx.mock +def test_create_normalizes_empty_qurl_id_to_none(client: QURLClient) -> None: + """Empty-string qurl_id is normalized to None so `if result.qurl_id:` works.""" + respx.post(f"{BASE_URL}/v1/qurls").mock( + return_value=httpx.Response( + 201, + json={ + "data": { + "resource_id": "r_abc", + "qurl_link": "https://qurl.link/#at_x", + "qurl_site": "https://r_abc.qurl.site", + "qurl_id": "", + }, + }, + ) + ) + result = client.create(target_url="https://example.com") + assert result.qurl_id is None + + +# ---- Target URL scheme validation (create) -------------------------------- + + +def test_create_rejects_target_url_without_scheme(client: QURLClient) -> None: + """Client-side URL scheme check catches the common 'forgot http://' mistake.""" + with pytest.raises(ValueError, match="http:// or https://"): + client.create(target_url="example.com") + + +def test_create_rejects_target_url_with_unsupported_scheme(client: QURLClient) -> None: + with pytest.raises(ValueError, match="http:// or https://"): + client.create(target_url="ftp://files.example.com") + + +def test_create_accepts_http_and_https_schemes(client: QURLClient) -> None: + """Both http:// and https:// pass the client-side check.""" + import layerv_qurl._utils as utils + + # Direct check — doesn't need a mocked HTTP response. + utils.validate_create_input(target_url="http://example.com") + utils.validate_create_input(target_url="https://example.com") + + +def test_create_rejects_non_string_target_url_with_value_error() -> None: + """Non-string target_url inputs (None, int, bool, bytes) must raise + ``ValueError`` — not a cryptic ``TypeError`` from slicing inside the + error message. Regression guard: the previous ``target_url[:32]!r`` + form would raise ``TypeError`` on any non-subscriptable input before + the ValueError could surface. + """ + import layerv_qurl._utils as utils + + for bad in (None, 42, True, b"https://example.com"): + with pytest.raises(ValueError, match="http:// or https://"): + utils.validate_create_input(target_url=bad) # type: ignore[arg-type] + + +# ---- _parse_access_policy deserialization (reviewer gap #8) ---------------- + + +@respx.mock +def test_get_response_parses_nested_ai_agent_policy(client: QURLClient) -> None: + """GET responses with a populated ai_agent_policy inside an access_policy + on a token should deserialize cleanly. Serialization of this shape is + covered elsewhere; this test closes the deserialization loop for the + _parse_access_policy changes in this PR. + """ + 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", + "qurl_count": 1, + "qurls": [ + { + "qurl_id": "q_3a7f2c8e91b", + "status": "active", + "one_time_use": False, + "max_sessions": 5, + "session_duration": 3600, + "use_count": 0, + "created_at": "2026-03-10T10:00:00Z", + "expires_at": "2026-03-17T10:00:00Z", + "access_policy": { + "ip_allowlist": ["10.0.0.0/8"], + "geo_allowlist": ["US", "CA"], + "ai_agent_policy": { + "block_all": False, + "deny_categories": ["gptbot", "commoncrawl"], + "allow_categories": ["claude", "perplexity"], + }, + }, + }, + ], + }, + }, + ) + ) + qurl = client.get("r_abc123def45") + assert qurl.access_tokens is not None + assert len(qurl.access_tokens) == 1 + token = qurl.access_tokens[0] + assert token.access_policy is not None + assert token.access_policy.ip_allowlist == ["10.0.0.0/8"] + assert token.access_policy.geo_allowlist == ["US", "CA"] + # The big one: ai_agent_policy nested inside access_policy must parse. + assert token.access_policy.ai_agent_policy is not None + ai_policy = token.access_policy.ai_agent_policy + assert ai_policy.block_all is False + assert ai_policy.deny_categories == ["gptbot", "commoncrawl"] + assert ai_policy.allow_categories == ["claude", "perplexity"] + + +# ---- datetime list filter params (reviewer gap #9) ------------------------ + + +@respx.mock +def test_list_serializes_datetime_filter_params_as_isoformat( + client: QURLClient, +) -> None: + """build_list_params handles datetime via .isoformat() — exercise that + branch explicitly (existing tests only pass string timestamps).""" + route = respx.get(f"{BASE_URL}/v1/qurls").mock( + return_value=httpx.Response( + 200, + json={"data": [], "meta": {"has_more": False}}, + ) + ) + cutoff = datetime(2026, 3, 1, 0, 0, 0) + client.list(created_after=cutoff) + + called_url = str(route.calls[0].request.url) + # urlencode-safe ISO 8601 — check the raw URL for the encoded value. + assert "created_after=2026-03-01T00%3A00%3A00" in called_url + + +# ---- Async delete q_ prefix rejection (reviewer gap #10) ------------------ + + +@pytest.mark.asyncio +async def test_async_delete_rejects_q_prefix_client_side( + async_client: AsyncQURLClient, +) -> None: + """Sync test exists; async symmetry gap closed.""" + with pytest.raises(ValueError, match="r_ prefix"): + await async_client.delete("q_3a7f2c8e91b") + + +# ---- batch_create response shape guard (defense-in-depth) ----------------- + + +@respx.mock +def test_batch_create_rejects_unexpected_400_body_shape(client: QURLClient) -> None: + """The 400 passthrough trusts the BatchCreateOutput shape. If the API + ever returns 400 with a different body (e.g. a plain error envelope + or a proxy error), the SDK must raise a clear error instead of + silently producing `(succeeded=0, failed=0, results=[])`. Defense + in depth — matches qurl-typescript and qurl-mcp. + """ + respx.post(f"{BASE_URL}/v1/qurls/batch").mock( + return_value=httpx.Response( + 400, + json={"data": {"unexpected": "not a batch envelope"}}, + ) + ) + with pytest.raises(QURLError, match="Unexpected response shape"): + client.batch_create([{"target_url": "https://example.com"}]) + + +@respx.mock +def test_batch_create_rejects_400_body_with_non_boolean_success( + client: QURLClient, +) -> None: + """Per-entry shape guard: results[i].success must be a bool for the + BatchItemResult discriminated-union contract to hold.""" + respx.post(f"{BASE_URL}/v1/qurls/batch").mock( + return_value=httpx.Response( + 400, + json={ + "data": { + "succeeded": 0, + "failed": 1, + "results": [ + {"index": 0, "success": "oops"}, # should be bool + ], + }, + }, + ) + ) + with pytest.raises(QURLError, match="Unexpected response shape"): + client.batch_create([{"target_url": "https://example.com"}]) + + +@respx.mock +def test_batch_create_rejects_400_body_with_bool_counts( + client: QURLClient, +) -> None: + """`bool` is a subclass of `int` in Python, so a naive + ``isinstance(..., int)`` check would silently accept + ``"succeeded": True``. The shape guard explicitly rejects bool in + the counts — this test locks that in against a future simplification + that might drop the explicit bool check.""" + respx.post(f"{BASE_URL}/v1/qurls/batch").mock( + return_value=httpx.Response( + 400, + json={ + "data": { + "succeeded": True, # bool should be rejected + "failed": False, + "results": [], + }, + }, + ) + ) + with pytest.raises(QURLError, match="Unexpected response shape"): + client.batch_create([{"target_url": "https://example.com"}]) + + +@respx.mock +def test_batch_create_rejects_counts_arithmetic_mismatch( + client: QURLClient, +) -> None: + """The shape guard asserts `succeeded + failed == len(results)`. + A mismatch suggests a proxy or middleware mangled the response, + or the API had a counting bug — either case warrants raising + rather than trusting the data.""" + respx.post(f"{BASE_URL}/v1/qurls/batch").mock( + return_value=httpx.Response( + 400, + json={ + "data": { + "succeeded": 5, # claims 5 succeeded + "failed": 0, + "results": [ + # …but only 1 entry + {"index": 0, "success": True, "resource_id": "r_only1"}, + ], + }, + }, + ) + ) + with pytest.raises(QURLError, match="Unexpected response shape"): + client.batch_create([{"target_url": "https://example.com"}]) + + +# ---- Async mirrors of the batch shape-guard tests ------------------------- +# Sync/async parity: every sync shape-guard test above has an async twin. +# Without these, an async-specific regression (e.g. a refactor that diverged +# the two code paths) could slip past CI. + + +@respx.mock +@pytest.mark.asyncio +async def test_async_batch_create_rejects_unexpected_400_body_shape( + async_client: AsyncQURLClient, +) -> None: + """Async mirror of test_batch_create_rejects_unexpected_400_body_shape.""" + respx.post(f"{BASE_URL}/v1/qurls/batch").mock( + return_value=httpx.Response( + 400, + json={"data": {"unexpected": "not a batch envelope"}}, + ) + ) + with pytest.raises(QURLError, match="Unexpected response shape"): + await async_client.batch_create([{"target_url": "https://example.com"}]) + + +@respx.mock +@pytest.mark.asyncio +async def test_async_batch_create_rejects_400_body_with_non_boolean_success( + async_client: AsyncQURLClient, +) -> None: + """Async mirror of test_batch_create_rejects_400_body_with_non_boolean_success.""" + respx.post(f"{BASE_URL}/v1/qurls/batch").mock( + return_value=httpx.Response( + 400, + json={ + "data": { + "succeeded": 0, + "failed": 1, + "results": [ + {"index": 0, "success": "oops"}, # should be bool + ], + }, + }, + ) + ) + with pytest.raises(QURLError, match="Unexpected response shape"): + await async_client.batch_create([{"target_url": "https://example.com"}]) + + +@respx.mock +@pytest.mark.asyncio +async def test_async_batch_create_rejects_400_body_with_bool_counts( + async_client: AsyncQURLClient, +) -> None: + """Async mirror of test_batch_create_rejects_400_body_with_bool_counts.""" + respx.post(f"{BASE_URL}/v1/qurls/batch").mock( + return_value=httpx.Response( + 400, + json={ + "data": { + "succeeded": True, # bool should be rejected + "failed": False, + "results": [], + }, + }, + ) + ) + with pytest.raises(QURLError, match="Unexpected response shape"): + await async_client.batch_create([{"target_url": "https://example.com"}]) + + +@respx.mock +@pytest.mark.asyncio +async def test_async_batch_create_rejects_counts_arithmetic_mismatch( + async_client: AsyncQURLClient, +) -> None: + """Async mirror of test_batch_create_rejects_counts_arithmetic_mismatch.""" + respx.post(f"{BASE_URL}/v1/qurls/batch").mock( + return_value=httpx.Response( + 400, + json={ + "data": { + "succeeded": 5, # claims 5 succeeded + "failed": 0, + "results": [ + # …but only 1 entry + {"index": 0, "success": True, "resource_id": "r_only1"}, + ], + }, + }, + ) + ) + with pytest.raises(QURLError, match="Unexpected response shape"): + await async_client.batch_create([{"target_url": "https://example.com"}]) + + +@respx.mock +def test_batch_create_falls_through_to_parse_error_on_non_json_body( + client: QURLClient, +) -> None: + """Defense-in-depth: if a whitelisted 400 (or other status in + ``allow_statuses``) comes back with non-JSON content — e.g. a proxy + HTML error page, a CDN captive portal, a gateway plaintext error — + the SDK must NOT raise a raw ``JSONDecodeError`` from inside + ``response.json()``. Instead it should fall through to + ``parse_error``, which handles non-JSON error bodies gracefully + and returns a well-formed ``QURLError`` with the response status. + + Without this guard, a JSONDecodeError would propagate raw to the + caller, bypassing both the batch shape guard and the standard + error path — confusing and hard to handle. Note: this test does + NOT assert on detail content, because `parse_error` intentionally + echoes non-JSON response body text into the detail field (so + callers can see plaintext gateway errors); that behavior is + scoped to this bug fix only via the early-return path. + """ + respx.post(f"{BASE_URL}/v1/qurls/batch").mock( + return_value=httpx.Response( + 400, + text="Bad Gateway", + ) + ) + # Load-bearing assertion: raises a QURLError (not raw + # JSONDecodeError/ValueError from response.json()) with the + # correct HTTP status. The class hierarchy dispatch in parse_error + # maps 400 → ValidationError, and we accept any QURLError subclass + # here since the specific class isn't the contract under test. + with pytest.raises(QURLError) as exc_info: + client.batch_create([{"target_url": "https://example.com"}]) + assert exc_info.value.status == 400 + + +@respx.mock +@pytest.mark.asyncio +async def test_async_batch_create_falls_through_to_parse_error_on_non_json_body( + async_client: AsyncQURLClient, +) -> None: + """Async mirror of the non-JSON body fall-through test.""" + respx.post(f"{BASE_URL}/v1/qurls/batch").mock( + return_value=httpx.Response( + 400, + text="Bad Gateway", + ) + ) + with pytest.raises(QURLError) as exc_info: + await async_client.batch_create([{"target_url": "https://example.com"}]) + assert exc_info.value.status == 400 + + +@respx.mock +def test_batch_create_accepts_access_policy_dataclass( + client: QURLClient, +) -> None: + """End-to-end coverage for passing an ``AccessPolicy`` dataclass + (rather than a plain dict) in a batch item. The bridge between + typed-caller convenience and the serialized request body is + ``_serialize_value`` — this test locks in that the dataclass path + produces the same nested JSON as a plain-dict caller would.""" + route = respx.post(f"{BASE_URL}/v1/qurls/batch").mock( + return_value=httpx.Response( + 201, + json={ + "data": { + "succeeded": 1, + "failed": 0, + "results": [ + { + "index": 0, + "success": True, + "resource_id": "r_dc_policy", + "qurl_link": "https://qurl.link/#at_dc", + "qurl_site": "https://r_dc_policy.qurl.site", + }, + ], + }, + }, + ) + ) + + policy = AccessPolicy( + geo_denylist=["CN", "RU"], + ai_agent_policy=AIAgentPolicy(allow_categories=["claude", "chatgpt"]), + ) + item: BatchCreateItem = { + "target_url": "https://example.com", + "label": "dc-test", + "access_policy": policy, + } + client.batch_create([item]) + + body = json.loads(route.calls[0].request.content) + assert body["items"][0]["target_url"] == "https://example.com" + assert body["items"][0]["access_policy"]["geo_denylist"] == ["CN", "RU"] + assert body["items"][0]["access_policy"]["ai_agent_policy"]["allow_categories"] == [ + "claude", + "chatgpt", + ] + # None fields on AIAgentPolicy (block_all, deny_categories) must be + # dropped by _serialize_value's dataclass rule, not preserved. + assert "block_all" not in body["items"][0]["access_policy"]["ai_agent_policy"] + assert "deny_categories" not in body["items"][0]["access_policy"]["ai_agent_policy"] diff --git a/tests/test_langchain.py b/tests/test_langchain.py index 492aa04..e1eaf35 100644 --- a/tests/test_langchain.py +++ b/tests/test_langchain.py @@ -1,11 +1,18 @@ -"""Tests for the LangChain tool integration.""" +"""Tests for the LangChain tool integration. + +Requires the ``langchain`` extra: ``pip install layerv-qurl[langchain]``. +All tests are skipped when ``langchain-core`` is not installed. +""" from __future__ import annotations from datetime import datetime, timezone from unittest.mock import MagicMock +import pytest + from layerv_qurl.langchain import ( + _HAS_LANGCHAIN, CreateQURLTool, DeleteQURLTool, ListQURLsTool, @@ -20,6 +27,8 @@ ResolveOutput, ) +pytestmark = pytest.mark.skipif(not _HAS_LANGCHAIN, reason="langchain-core not installed") + def _mock_client() -> MagicMock: return MagicMock() @@ -42,9 +51,7 @@ def test_create_qurl_tool() -> None: client.create.assert_called_once_with( target_url="https://example.com", expires_in="24h", - description=None, - one_time_use=None, - max_sessions=None, + label=None, )