From 639f4c74a5dae7f0c054dd7d0681d49ee8fb3336 Mon Sep 17 00:00:00 2001 From: "agent-zhang-beihai[bot]" <289036412+agent-zhang-beihai[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:29:07 -0700 Subject: [PATCH] Sync with InsForge-sdk-js v1.5.1: email OTP sign-in and batch object deletion Port the API additions from InsForge-sdk-js v1.5.0..v1.5.1: - auth: add sign_in_with_otp(email=...) to request a passwordless 6-digit sign-in code via POST /api/auth/email/send-otp (enumeration-safe generic response), and verify_otp(email=..., otp=..., name=None) to verify the code and create a session via POST /api/auth/sessions with method "otp". - storage: add delete_objects(bucket_name, object_keys) to delete up to 1000 objects in one DELETE /api/storage/buckets/{bucket}/objects request, returning per-key results (deleted | notFound | failed). - Export StorageDeleteObjectResult / StorageDeleteObjectsResponse, document the new methods in the README, and bump the package version to 0.2.0. Additive and backward-compatible; existing password/verify-email flows and single-object deletion are unchanged. Co-Authored-By: Claude Opus 4.8 --- README.md | 8 ++ insforge/auth/client.py | 49 ++++++++++ insforge/auth/models.py | 14 +++ insforge/storage/__init__.py | 4 + insforge/storage/client.py | 24 +++++ insforge/storage/models.py | 15 +++ pyproject.toml | 2 +- tests/auth/test_auth_client.py | 141 +++++++++++++++++++++++++++ tests/storage/test_storage_client.py | 80 +++++++++++++++ 9 files changed, 336 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index b96a290..ad046e1 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,10 @@ user = await client.auth.create_user(email="...", password="...") await client.auth.send_email_verification(email="...") await client.auth.verify_email(email="...", otp="123456") +# Passwordless email OTP sign-in +await client.auth.sign_in_with_otp(email="...") +session = await client.auth.verify_otp(email="...", otp="123456") + # Password reset await client.auth.send_reset_password_email(email="...") resp = await client.auth.exchange_reset_password_token(email="...", code="123456") @@ -110,6 +114,10 @@ await client.storage.upload_object("my-bucket", "photos/cat.jpg", image_bytes, c data = await client.storage.download_object("my-bucket", "photos/cat.jpg") await client.storage.delete_object("my-bucket", "photos/cat.jpg") + +# Delete multiple objects in one request (maximum 1000 keys) +response = await client.storage.delete_objects("my-bucket", ["photos/cat.jpg", "photos/dog.jpg"]) +# response.results: one entry per key with status "deleted" | "notFound" | "failed" ``` ### Functions diff --git a/insforge/auth/client.py b/insforge/auth/client.py index e926391..2b799de 100644 --- a/insforge/auth/client.py +++ b/insforge/auth/client.py @@ -16,8 +16,10 @@ from .models import AuthCurrentSessionResponse from .models import AuthDeleteUsersRequest from .models import AuthDeleteUsersResponse +from .models import AuthSendOtpRequest from .models import AuthSessionResponse from .models import AuthUserCreateRequest +from .models import AuthVerifyOtpRequest from .models import AuthResetPasswordExchangeRequest from .models import AuthResetPasswordExchangeResponse from .models import AuthResetPasswordRequest @@ -53,6 +55,53 @@ async def sign_in_with_password( ) return SignInResponse.model_validate(payload) + async def sign_in_with_otp( + self, + *, + email: str, + ) -> AuthEmailActionResponse: + """Send a one-time sign-in code to an email address. + + The response is intentionally generic whether or not an account + exists, to avoid account enumeration. Complete the flow with + ``verify_otp``. + """ + payload = AuthSendOtpRequest(email=email).model_dump(by_alias=True) + response = await self._client._request_json( + "POST", + "/api/auth/email/send-otp", + json=payload, + exception_cls=InsforgeAuthError, + ) + return AuthEmailActionResponse.model_validate(response) + + async def verify_otp( + self, + *, + email: str, + otp: str, + name: str | None = None, + ) -> AuthSessionResponse: + """Verify an email sign-in code and create a session. + + If the email is new, a verified passwordless user is created; ``name`` + sets the display name only on that first-time creation. + """ + payload = AuthVerifyOtpRequest(email=email, otp=otp, name=name).model_dump( + by_alias=True, + exclude_none=True, + ) + # method is set last so it can never be clobbered by the payload. + payload["method"] = "otp" + response = await self._client._request_json( + "POST", + "/api/auth/sessions", + params=SERVER_CLIENT_TYPE_PARAMS, + json=payload, + exception_cls=InsforgeAuthError, + ) + return AuthSessionResponse.model_validate(response) + async def send_email_verification( self, *, diff --git a/insforge/auth/models.py b/insforge/auth/models.py index ae9eb57..72026f3 100644 --- a/insforge/auth/models.py +++ b/insforge/auth/models.py @@ -35,6 +35,20 @@ class AuthEmailVerifyRequest(BaseModel): otp: str = Field(min_length=1) +class AuthSendOtpRequest(BaseModel): + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + email: str = Field(min_length=1) + + +class AuthVerifyOtpRequest(BaseModel): + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + email: str = Field(min_length=1) + otp: str = Field(min_length=1) + name: str | None = None + + class AuthResetPasswordExchangeRequest(BaseModel): model_config = ConfigDict(extra="ignore", populate_by_name=True) diff --git a/insforge/storage/__init__.py b/insforge/storage/__init__.py index cd47861..c556d22 100644 --- a/insforge/storage/__init__.py +++ b/insforge/storage/__init__.py @@ -1,11 +1,15 @@ from .client import StorageClient from .models import StorageBucketListResponse from .models import StorageDeleteObjectResponse +from .models import StorageDeleteObjectResult +from .models import StorageDeleteObjectsResponse from .models import StorageObjectResponse __all__ = [ "StorageBucketListResponse", "StorageDeleteObjectResponse", + "StorageDeleteObjectResult", + "StorageDeleteObjectsResponse", "StorageObjectResponse", "StorageClient", ] diff --git a/insforge/storage/client.py b/insforge/storage/client.py index a41402f..6b6760b 100644 --- a/insforge/storage/client.py +++ b/insforge/storage/client.py @@ -1,6 +1,7 @@ from __future__ import annotations from collections.abc import Mapping +from collections.abc import Sequence from typing import Any from typing import Iterable @@ -19,6 +20,7 @@ from .models import StorageBucketUpdateResponse from .models import StorageDeleteBucketResponse from .models import StorageDeleteObjectResponse +from .models import StorageDeleteObjectsResponse from .models import StorageDownloadResult from .models import StorageObjectResponse from .models import StoredFileList @@ -218,6 +220,28 @@ async def delete_object( ) return StorageDeleteObjectResponse.model_validate(payload) + async def delete_objects( + self, + bucket_name: str, + object_keys: Sequence[str], + *, + access_token: str | None = None, + extra_headers: Mapping[str, str] | None = None, + ) -> StorageDeleteObjectsResponse: + """Delete multiple objects in a single request. + + The batch endpoint accepts at most 1000 keys and returns one result + per key (``deleted``, ``notFound``, or ``failed``). + """ + payload = await self._client._request_json( + "DELETE", + f"/api/storage/buckets/{quote_path_segment(bucket_name)}/objects", + json={"keys": list(object_keys)}, + access_token=access_token, + extra_headers=extra_headers, + ) + return StorageDeleteObjectsResponse.model_validate(payload) + async def upload_object_auto( self, bucket_name: str, diff --git a/insforge/storage/models.py b/insforge/storage/models.py index 6546caf..f6d12ec 100644 --- a/insforge/storage/models.py +++ b/insforge/storage/models.py @@ -3,6 +3,7 @@ from datetime import datetime from dataclasses import dataclass from typing import Any +from typing import Literal from pydantic import BaseModel, ConfigDict, Field @@ -45,6 +46,20 @@ class StorageDeleteObjectResponse(BaseModel): message: str +class StorageDeleteObjectResult(BaseModel): + model_config = ConfigDict(extra="ignore") + + key: str + status: Literal["deleted", "notFound", "failed"] + message: str | None = None + + +class StorageDeleteObjectsResponse(BaseModel): + model_config = ConfigDict(extra="ignore") + + results: list[StorageDeleteObjectResult] = Field(default_factory=list) + + class StoragePagination(BaseModel): model_config = ConfigDict(extra="ignore") diff --git a/pyproject.toml b/pyproject.toml index 330788f..920cc6a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "insforge" -version = "0.1.0" +version = "0.2.0" description = "Python SDK for Insforge" readme = "README.md" requires-python = ">=3.11" diff --git a/tests/auth/test_auth_client.py b/tests/auth/test_auth_client.py index f91d31b..0adce06 100644 --- a/tests/auth/test_auth_client.py +++ b/tests/auth/test_auth_client.py @@ -6,7 +6,9 @@ from insforge import InsforgeClient from insforge.auth.models import AuthConfigUpdateRequest +from insforge.auth.models import AuthSendOtpRequest from insforge.auth.models import AuthUserCreateRequest +from insforge.auth.models import AuthVerifyOtpRequest from insforge.exceptions import InsforgeAuthError @@ -587,6 +589,145 @@ async def fake_request(method: str, url: httpx.URL, **kwargs: object) -> httpx.R assert captured_verify["kwargs"]["params"] == {"client_type": "server"} +def test_sign_in_with_otp_posts_email_and_returns_generic_payload() -> None: + async def scenario() -> tuple[object, dict[str, object]]: + captured: dict[str, object] = {} + + async def fake_request(method: str, url: httpx.URL, **kwargs: object) -> httpx.Response: + captured["method"] = method + captured["url"] = str(url) + captured["kwargs"] = kwargs + return httpx.Response( + 202, + json={ + "success": True, + "message": "If sign-in is available for this email, we have sent a verification code.", + }, + ) + + async with InsforgeClient( + base_url="https://example.com", + api_key="ins_test", + ) as client: + client.http_client.request = fake_request # type: ignore[method-assign] + result = await client.auth.sign_in_with_otp(email="user@example.com") + + return result, captured + + result, captured = asyncio.run(scenario()) + + assert captured["method"] == "POST" + assert captured["url"] == "https://example.com/api/auth/email/send-otp" + assert captured["kwargs"]["json"] == {"email": "user@example.com"} + assert captured["kwargs"]["headers"]["X-API-Key"] == "ins_test" + assert "Authorization" not in captured["kwargs"]["headers"] + assert result.success is True + assert result.message == "If sign-in is available for this email, we have sent a verification code." + + +def test_verify_otp_posts_method_otp_to_sessions_and_returns_session() -> None: + async def scenario() -> tuple[object, dict[str, object]]: + captured: dict[str, object] = {} + + async def fake_request(method: str, url: httpx.URL, **kwargs: object) -> httpx.Response: + captured["method"] = method + captured["url"] = str(url) + captured["kwargs"] = kwargs + return httpx.Response( + 200, + json={ + "user": { + "id": "u1", + "email": "user@example.com", + "emailVerified": True, + }, + "accessToken": "access", + "refreshToken": "refresh", + }, + ) + + async with InsforgeClient( + base_url="https://example.com", + api_key="ins_test", + ) as client: + client.http_client.request = fake_request # type: ignore[method-assign] + result = await client.auth.verify_otp( + email="user@example.com", + otp="123456", + name="Ada Lovelace", + ) + + return result, captured + + result, captured = asyncio.run(scenario()) + + assert captured["method"] == "POST" + assert captured["url"] == "https://example.com/api/auth/sessions" + assert captured["kwargs"]["params"] == {"client_type": "server"} + assert captured["kwargs"]["json"] == { + "email": "user@example.com", + "otp": "123456", + "name": "Ada Lovelace", + "method": "otp", + } + assert captured["kwargs"]["headers"]["X-API-Key"] == "ins_test" + assert "Authorization" not in captured["kwargs"]["headers"] + assert result.user.email == "user@example.com" + assert result.access_token == "access" + assert result.refresh_token == "refresh" + + +def test_verify_otp_omits_optional_name_and_raises_auth_error() -> None: + async def scenario() -> tuple[dict[str, object], dict[str, object]]: + captured_ok: dict[str, object] = {} + captured_fail: dict[str, object] = {} + + async def fake_request(method: str, url: httpx.URL, **kwargs: object) -> httpx.Response: + if not captured_ok: + captured_ok["method"] = method + captured_ok["url"] = str(url) + captured_ok["kwargs"] = kwargs + return httpx.Response(200, json={"accessToken": "access", "refreshToken": "refresh"}) + + captured_fail["method"] = method + captured_fail["url"] = str(url) + captured_fail["kwargs"] = kwargs + return httpx.Response(401, json={"error": "INVALID_OTP", "message": "Verification code expired"}) + + async with InsforgeClient( + base_url="https://example.com", + api_key="ins_test", + ) as client: + client.http_client.request = fake_request # type: ignore[method-assign] + await client.auth.verify_otp(email="user@example.com", otp="123456") + + try: + await client.auth.verify_otp(email="user@example.com", otp="000000") + except InsforgeAuthError as exc: + assert exc.error == "INVALID_OTP" + assert exc.message == "Verification code expired" + else: + raise AssertionError("verify_otp should raise InsforgeAuthError on 401") + + return captured_ok, captured_fail + + captured_ok, captured_fail = asyncio.run(scenario()) + + assert captured_ok["kwargs"]["json"] == { + "email": "user@example.com", + "otp": "123456", + "method": "otp", + } + assert captured_fail["kwargs"]["params"] == {"client_type": "server"} + + +def test_send_otp_and_verify_otp_reject_empty_fields() -> None: + with pytest.raises(ValidationError): + AuthSendOtpRequest(email="") + with pytest.raises(ValidationError): + AuthVerifyOtpRequest(email="user@example.com", otp="") + + def test_create_user_rejects_empty_email_and_password() -> None: with pytest.raises(ValidationError): AuthUserCreateRequest(email="", password="secret") diff --git a/tests/storage/test_storage_client.py b/tests/storage/test_storage_client.py index 96f27d5..0c6a2e4 100644 --- a/tests/storage/test_storage_client.py +++ b/tests/storage/test_storage_client.py @@ -245,6 +245,86 @@ async def fake_request(method: str, url: httpx.URL, **kwargs: object) -> httpx.R assert result.message == "Object deleted successfully" +def test_delete_objects_uses_batch_endpoint_and_returns_per_key_results() -> None: + async def scenario() -> tuple[object, dict[str, object]]: + captured: dict[str, object] = {} + + async def fake_request(method: str, url: httpx.URL, **kwargs: object) -> httpx.Response: + captured["method"] = method + captured["url"] = str(url) + captured["kwargs"] = kwargs + return httpx.Response( + 200, + json={ + "results": [ + {"key": "a.pdf", "status": "deleted"}, + {"key": "missing.pdf", "status": "notFound"}, + {"key": "locked.pdf", "status": "failed", "message": "Delete denied"}, + ], + }, + ) + + async with InsforgeClient( + base_url="https://example.com", + api_key="ins_test", + ) as client: + client.http_client.request = fake_request # type: ignore[method-assign] + result = await client.storage.delete_objects( + "docs", + ["a.pdf", "missing.pdf", "locked.pdf"], + ) + + return result, captured + + result, captured = asyncio.run(scenario()) + + assert captured["method"] == "DELETE" + assert captured["url"] == "https://example.com/api/storage/buckets/docs/objects" + assert captured["kwargs"]["json"] == {"keys": ["a.pdf", "missing.pdf", "locked.pdf"]} + assert captured["kwargs"]["headers"]["X-API-Key"] == "ins_test" + assert [(entry.key, entry.status) for entry in result.results] == [ + ("a.pdf", "deleted"), + ("missing.pdf", "notFound"), + ("locked.pdf", "failed"), + ] + assert result.results[2].message == "Delete denied" + + +def test_delete_objects_does_not_split_oversized_batches_and_raises_http_error() -> None: + captured: dict[str, object] = {} + call_count = 0 + + async def scenario() -> None: + async def fake_request(method: str, url: httpx.URL, **kwargs: object) -> httpx.Response: + nonlocal call_count + call_count += 1 + captured["kwargs"] = kwargs + return httpx.Response( + 400, + json={ + "error": "STORAGE_ERROR", + "message": "Cannot delete more than 1000 objects at once", + }, + ) + + async with InsforgeClient( + base_url="https://example.com", + api_key="ins_test", + ) as client: + client.http_client.request = fake_request # type: ignore[method-assign] + await client.storage.delete_objects( + "docs", + [f"file-{index}.txt" for index in range(1001)], + ) + + with pytest.raises(InsforgeHTTPError) as exc_info: + asyncio.run(scenario()) + + assert exc_info.value.status_code == 400 + assert call_count == 1 + assert len(captured["kwargs"]["json"]["keys"]) == 1001 + + def test_upload_object_raises_insforge_http_error_on_failure() -> None: async def scenario() -> None: async def fake_request(method: str, url: httpx.URL, **kwargs: object) -> httpx.Response: