diff --git a/README.md b/README.md index 0a2184d8..b4c95df0 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,14 @@ client = VolcanoClient( service_key="your-service-key", ) +sign_up = client.auth.sign_up( + email="new-user@example.com", + password="secret", + metadata={"display_name": "New User"}, +) +if sign_up.confirmation_required: + print(sign_up.message) + session = client.auth.sign_in(email="user@example.com", password="secret") current_session = client.auth.get_session() assert current_session == session @@ -32,6 +40,10 @@ lease = client.locks.acquire("build", ttl=30) client.locks.release("build", lease) ``` +`sign_up()` returns an immutable acknowledgement and never creates or replaces a session. The +response is identical for new and existing email addresses. Call `sign_in()` separately after the +account is ready to establish a session. + `get_session()` reads immutable local state. It does not refresh or validate the token. Copy a complete native session into another client's memory: diff --git a/src/volcano_sdk/__init__.py b/src/volcano_sdk/__init__.py index d6e75f26..e1f962df 100644 --- a/src/volcano_sdk/__init__.py +++ b/src/volcano_sdk/__init__.py @@ -12,7 +12,7 @@ ValidationError, VolcanoError, ) -from .models import LockLease, Session +from .models import LockLease, Session, SignUpResult __all__ = [ "AuthenticationError", @@ -23,6 +23,7 @@ "ServerError", "Session", "SessionChangedError", + "SignUpResult", "TransportError", "ValidationError", "VolcanoClient", diff --git a/src/volcano_sdk/_transport.py b/src/volcano_sdk/_transport.py index aec5beae..2e9cfca8 100644 --- a/src/volcano_sdk/_transport.py +++ b/src/volcano_sdk/_transport.py @@ -11,7 +11,12 @@ import httpx -from ._generated.api.authentication import auth_logout, auth_refresh, auth_signin +from ._generated.api.authentication import ( + auth_logout, + auth_refresh, + auth_signin, + auth_signup, +) from ._generated.api.database_queries import query_database_select from ._generated.api.locks import acquire_project_lock, release_project_lock from ._generated.api.storage_objects import ( @@ -22,6 +27,10 @@ from ._generated.models.auth_logout_body import AuthLogoutBody from ._generated.models.auth_refresh_body import AuthRefreshBody from ._generated.models.auth_signin_body import AuthSigninBody +from ._generated.models.auth_signup_body import AuthSignupBody +from ._generated.models.auth_signup_body_user_metadata import ( + AuthSignupBodyUserMetadata, +) from ._generated.models.database_select_request import DatabaseSelectRequest from ._generated.models.project_lock_lease_request import ProjectLockLeaseRequest from ._generated.models.upload_storage_object_files_body import ( @@ -98,6 +107,17 @@ def auth_logout( ) -> TransportResponse: ... +class AuthSignUpTransport(Protocol): + def auth_signup( + self, + *, + authorization: str, + email: str, + password: str, + metadata: dict[str, object], + ) -> TransportResponse: ... + + class Transport(Protocol): def auth_signin( self, @@ -263,6 +283,23 @@ def auth_signin( ) return self._response(response) + def auth_signup( + self, + *, + authorization: str, + email: str, + password: str, + metadata: dict[str, object], + ) -> TransportResponse: + body = AuthSignupBody( + email=email, + password=password, + user_metadata=AuthSignupBodyUserMetadata.from_dict(metadata), + ) + with self._client(authorization) as client: + response = auth_signup.sync_detailed(client=client, body=body) + return self._response(response) + def auth_refresh( self, *, diff --git a/src/volcano_sdk/auth.py b/src/volcano_sdk/auth.py index c4f3cec5..efc69d40 100644 --- a/src/volcano_sdk/auth.py +++ b/src/volcano_sdk/auth.py @@ -8,14 +8,16 @@ from ._transport import ( AuthLogoutTransport, AuthRefreshTransport, + AuthSignUpTransport, Transport, invoke, response_payload, ) from .errors import AuthenticationError, SessionChangedError, VolcanoError -from .models import Session +from .models import Session, SignUpResult _INCOMPLETE_SESSION = "Expected a complete Session" +_INVALID_SIGN_UP_RESULT = "Expected a complete sign-up acknowledgement" _NO_ACTIVE_SESSION = "No active session" @@ -63,6 +65,20 @@ def _session_from_payload(payload: object) -> Session: ) +def _sign_up_result_from_payload(payload: object) -> SignUpResult: + values: Mapping[object, object] = ( + cast("Mapping[object, object]", payload) if isinstance(payload, Mapping) else {} + ) + confirmation_required = values.get("confirmation_required") + message = values.get("message") + if not isinstance(confirmation_required, bool) or not isinstance(message, str): + raise TypeError(_INVALID_SIGN_UP_RESULT) + return SignUpResult( + confirmation_required=confirmation_required, + message=message, + ) + + class AuthContext(Protocol): """Client capabilities required by the authentication facade.""" @@ -101,6 +117,24 @@ def set_session(self, session: Session) -> Session: self._client._set_session(owned) return owned + def sign_up( + self, + *, + email: str, + password: str, + metadata: Mapping[str, object] | None = None, + ) -> SignUpResult: + """Create an account without creating or replacing a local session.""" + transport = cast("AuthSignUpTransport", self._client._transport) + response = invoke( + transport.auth_signup, + authorization=self._client._anon_token(), + email=email, + password=password, + metadata=dict(metadata or {}), + ) + return _sign_up_result_from_payload(response_payload(response, 201)) + def sign_in(self, *, email: str, password: str) -> Session: """Sign in a user and store the returned session.""" response = invoke( diff --git a/src/volcano_sdk/models.py b/src/volcano_sdk/models.py index 8ff7275b..1c56bca4 100644 --- a/src/volcano_sdk/models.py +++ b/src/volcano_sdk/models.py @@ -18,6 +18,14 @@ class Session: user_id: str +@dataclass(frozen=True, slots=True) +class SignUpResult: + """Session-less acknowledgement returned after sign-up.""" + + confirmation_required: bool + message: str + + @dataclass(frozen=True, slots=True) class LockLease: """Lease returned for an acquired distributed lock.""" diff --git a/tests/unit/test_generated_transport.py b/tests/unit/test_generated_transport.py index f6d268f3..355bdb7b 100644 --- a/tests/unit/test_generated_transport.py +++ b/tests/unit/test_generated_transport.py @@ -7,6 +7,46 @@ from volcano_sdk._transport import GeneratedTransport +def test_generated_transport_signs_up_with_the_anon_key_and_metadata() -> None: + requests: list[httpx.Request] = [] + + def handle(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response( + 201, + json={ + "confirmation_required": True, + "message": "Check your email to confirm your account", + }, + ) + + transport = GeneratedTransport( + api_url="https://api.test.volcano.dev", + httpx_transport=httpx.MockTransport(handle), + ) + + response = transport.auth_signup( + authorization="anon-key", + email="user@example.com", + password="secret", + metadata={"display_name": "New User"}, + ) + + assert response.status_code == 201 + assert response.payload == { + "confirmation_required": True, + "message": "Check your email to confirm your account", + } + assert requests[0].method == "POST" + assert requests[0].url.path == "/auth/signup" + assert requests[0].headers["authorization"] == "Bearer anon-key" + assert json.loads(requests[0].content) == { + "email": "user@example.com", + "password": "secret", + "user_metadata": {"display_name": "New User"}, + } + + def test_generated_transport_logs_out_with_the_anon_key_and_refresh_token() -> None: requests: list[httpx.Request] = [] diff --git a/tests/unit/test_import.py b/tests/unit/test_import.py index 478ebd90..9624ae22 100644 --- a/tests/unit/test_import.py +++ b/tests/unit/test_import.py @@ -1,5 +1,12 @@ -from volcano_sdk import VolcanoClient +from volcano_sdk import SignUpResult, VolcanoClient def test_package_exports_client() -> None: assert VolcanoClient.__name__ == "VolcanoClient" + + +def test_package_exports_sign_up_result() -> None: + result = SignUpResult(confirmation_required=True, message="Check your email") + + assert result.confirmation_required is True + assert result.message == "Check your email" diff --git a/tests/unit/test_state.py b/tests/unit/test_state.py index 447bf281..e23dbf79 100644 --- a/tests/unit/test_state.py +++ b/tests/unit/test_state.py @@ -1,6 +1,6 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import FrozenInstanceError, dataclass from typing import TYPE_CHECKING, Any import pytest @@ -28,6 +28,14 @@ class Response: class StateTransport: def __init__(self) -> None: self.next_access_token = "access-1" + self.signup_response = Response( + 201, + { + "confirmation_required": True, + "message": "Check your email to confirm your account", + }, + ) + self.signup_calls: list[dict[str, Any]] = [] self.refresh_response = Response( 200, { @@ -53,6 +61,11 @@ def auth_signin(self, **kwargs: Any) -> Response: }, ) + def auth_signup(self, **kwargs: Any) -> Response: + self.authorizations.append(("signup", kwargs["authorization"])) + self.signup_calls.append(kwargs) + return self.signup_response + def auth_refresh(self, **kwargs: Any) -> Response: self.authorizations.append(("refresh", kwargs["authorization"])) if self.on_refresh is not None: @@ -151,6 +164,55 @@ def test_auth_facade_reads_an_empty_session_without_transport() -> None: assert transport.authorizations == [] +def test_sign_up_returns_immutable_acknowledgement_without_session_change() -> None: + transport = StateTransport() + client = VolcanoClient(anon_key="anon", _transport=transport) + established = client.auth.sign_in(email="user@example.com", password="secret") + + result = client.auth.sign_up( + email="new@example.com", + password="secret", + metadata={"display_name": "New User"}, + ) + + assert result.confirmation_required is True + assert result.message == "Check your email to confirm your account" + assert client.auth.get_session() is established + assert transport.signup_calls == [ + { + "authorization": "anon", + "email": "new@example.com", + "password": "secret", + "metadata": {"display_name": "New User"}, + } + ] + mutable_result: Any = result + with pytest.raises(FrozenInstanceError): + mutable_result.message = "changed" + + +def test_sign_up_uses_empty_metadata_without_creating_a_session() -> None: + transport = StateTransport() + client = VolcanoClient(anon_key="anon", _transport=transport) + + client.auth.sign_up(email="new@example.com", password="secret") + + assert client.auth.get_session() is None + assert transport.signup_calls[0]["metadata"] == {} + + +def test_sign_up_raises_typed_errors_without_changing_session() -> None: + transport = StateTransport() + client = VolcanoClient(anon_key="anon", _transport=transport) + established = client.auth.sign_in(email="user@example.com", password="secret") + transport.signup_response = Response(403, {"error": "Signups are disabled"}) + + with pytest.raises(AuthenticationError, match="Signups are disabled"): + client.auth.sign_up(email="new@example.com", password="secret") + + assert client.auth.get_session() is established + + def test_auth_facade_reads_established_immutable_session_without_transport() -> None: transport = StateTransport() client = VolcanoClient(anon_key="anon", _transport=transport)