Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down
49 changes: 49 additions & 0 deletions insforge/auth/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
*,
Expand Down
14 changes: 14 additions & 0 deletions insforge/auth/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
4 changes: 4 additions & 0 deletions insforge/storage/__init__.py
Original file line number Diff line number Diff line change
@@ -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",
]
24 changes: 24 additions & 0 deletions insforge/storage/client.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down
15 changes: 15 additions & 0 deletions insforge/storage/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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")

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
141 changes: 141 additions & 0 deletions tests/auth/test_auth_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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")
Expand Down
Loading