diff --git a/README.md b/README.md index 6c56031..010b4e4 100644 --- a/README.md +++ b/README.md @@ -29,23 +29,14 @@ Python client for the Userverse HTTP server. ## Installation -Create and activate a virtual environment, then install the project in editable mode: - -## linux configuration +Install from PyPI: ```bash -uv venv -source .venv\Scripts\activate -uv pip install -e . +python -m pip install userverse-python-client ``` -## windows configuration - -```bash -uv venv -.venv\Scripts\activate -uv pip install -e . -``` +For editable installs from source, see the repository README: +https://github.com/SoftwareVerse/userverse-python-client#installation ## Usage @@ -59,12 +50,17 @@ client = UverseUserClient(base_url="https://api.example.com") ## Demo -The runnable demo lives in `examples/user_demo.py`. See `examples/user_demo_README.md` -for flags and environment variables: +The runnable demo lives in: +https://github.com/SoftwareVerse/userverse-python-client/blob/main/examples/user_demo.py -```bash -uv run -m examples.user_demo --help -``` +See the demo README for flags and environment variables: +https://github.com/SoftwareVerse/userverse-python-client/blob/main/examples/user_demo_README.md + +## Developing Clients in Other Languages + +See the guide for a summary of the Python client architecture and a plan for +implementing SDKs in other languages: +https://github.com/SoftwareVerse/userverse-python-client/blob/main/docs/other-language-clients.md ## Tests @@ -73,4 +69,3 @@ Run the unit tests with: ```bash pytest ``` - diff --git a/docs/other-language-clients.md b/docs/other-language-clients.md new file mode 100644 index 0000000..6e5c03d --- /dev/null +++ b/docs/other-language-clients.md @@ -0,0 +1,118 @@ +# Developing Userverse Clients in Other Languages + +This document summarizes the Python client architecture and provides a plan for +building equivalent clients in other languages (e.g., Dart, JavaScript, Go). + +## Summary of the Python Client + +### Core HTTP design +- **Base URL normalization**: The Python client trims trailing slashes from the + base URL and requires all request paths to start with `/`. +- **Session headers**: Every request sets `Accept: application/json` and + `Content-Type: application/json`. +- **Timeouts**: The `BaseClient` accepts a configurable timeout (default: 30 + seconds). +- **Authorization**: + - **Bearer** token for authenticated endpoints via + `Authorization: Bearer `. + - **Basic Auth** for user login and account creation with email/password + encoded as `Basic `. +- **Error handling**: + - HTTP errors are parsed into a structured error payload if possible. + - Network/timeouts are surfaced as client errors with a generic "invalid + request" message. + - Empty bodies (e.g., 204 responses) return `None`. + - Successful responses that are not JSON fall back to text. + +### Client surface area +The Python package exposes dedicated client classes that map to API domains and +reuse the shared `BaseClient`: +- `UverseUserClient` (login, create user, get/update user, verification, password + reset). +- `UverseCompanyClient` (list companies, get company by ID/email, create/update + company). +- `UverseCompanyUserManagementClient` (add/list/delete users in a company). +- `UverseCompanyUserRolesManagement` (list/create/update/delete company roles). + +### Models and response shapes +- Responses are wrapped in a generic response shape (`GenericResponseModel`) + with a `data` field that holds the typed payload. +- Request and response schemas are defined in the shared model packages + (`userverse_models`, `sverse_generic_models`), which the client references + directly. + +### Usage patterns +Examples show a consistent flow: +1. Create a `UverseUserClient` with `base_url`. +2. Login with Basic Auth to obtain a JWT. +3. Set `Authorization: Bearer ` on other clients for protected endpoints. +4. Call methods that mirror API routes and validate responses. + +## Plan to Build Clients in Other Languages + +### 1. Establish a shared API contract +- Extract the request/response schemas (from `userverse_models` and + `sverse_generic_models`) into an OpenAPI or JSON schema source of truth. +- Ensure the response wrapper and error formats match the server responses. + +### 2. Implement a base HTTP client +- Mirror the Python `BaseClient` behavior: + - Normalize `base_url` and enforce path formatting. + - Default headers: `Accept` + `Content-Type` set to JSON. + - Configurable timeout. + - Helper for setting Bearer tokens. + - Return `None` for empty bodies. + - Prefer explicit query parameter handling instead of manual string building. +- Provide a centralized request method that every endpoint uses. + +### 3. Port authentication flows +- Implement Basic Auth encoding for login and create-user endpoints. +- Implement bearer token injection after login for all protected endpoints. +- Preserve the same endpoints/methods: + - `PATCH /user/login` + - `POST /user` + - `GET /user/get` + - `PATCH /user/update` + - `POST /user/resend-verification` + - `GET /user/verify?token=` + - `PATCH /password-reset/request?email=` + - `PATCH /password-reset/validate-otp` + - `GET /user/companies` + - `GET /company?company_id=` or `GET /company?email=` + - `PATCH /company/{id}` + - `POST /company` + - Company user/role management endpoints as in the Python client. + +### 4. Generate or hand-write typed models +- If your language supports OpenAPI codegen (e.g., Dart `openapi-generator`, + TypeScript `openapi-typescript`), generate data models and response wrappers. +- Otherwise, hand-write models matching the Python client types and ensure + required/optional fields align with the API. + +### 5. Error handling and response validation +- Match the Python client’s strategy: + - On non-2xx responses, parse the error body into a structured error type. + - If the server returns `detail.message` and `detail.error`, map them directly. + - If parsing fails, fall back to a generic error message and raw body text. +- Provide a top-level error type (similar to `ClientErrorModel`) that includes + HTTP status code and a detail payload. + +### 6. Organize the client by domain +Mirror the Python structure for discoverability: +- `user` module/class for authentication + user profile. +- `company` module/class for company CRUD. +- `company_user_management` module/class for user membership in companies. +- `company_user_roles_management` module/class for role management. + +### 7. Tests and example scripts +- Use the Python unit tests as reference for expected behaviors: + - Enforce path requirements. + - Validate error handling in HTTP failures. + - Verify response shape parsing and Basic/Bearer auth handling. +- Provide runnable examples similar to the Python `examples/` to document flows + for login, list companies, etc. + +### 8. Packaging and documentation +- Provide a README that mirrors the Python client usage. +- Publish artifacts to the target ecosystem (npm, pub.dev, etc.). +- Version releases consistently with the API behavior. diff --git a/src/userverse_python_client/clients/company.py b/src/userverse_python_client/clients/company.py index d2eed04..a1c20f3 100644 --- a/src/userverse_python_client/clients/company.py +++ b/src/userverse_python_client/clients/company.py @@ -47,14 +47,15 @@ def get_company_by_id_or_email( email: Optional[EmailStr] = None, ) -> GenericResponseModel[CompanyReadModel]: """Fetches a company by its ID or email.""" + params = None if company_id is not None: - path = f"/company?company_id={company_id}" + params = {"company_id": company_id} elif email is not None: - path = f"/company?email={email}" + params = {"email": email} else: raise ValueError("Either company_id or email must be provided") - response = self._request("GET", path) + response = self._request("GET", "/company", params=params) if not response or "data" not in response: raise ValueError("Invalid response from get company endpoint") diff --git a/src/userverse_python_client/clients/user.py b/src/userverse_python_client/clients/user.py index 9585c02..0677ae4 100644 --- a/src/userverse_python_client/clients/user.py +++ b/src/userverse_python_client/clients/user.py @@ -47,7 +47,12 @@ def create_user( ) headers = {"Authorization": basic_auth} - response = self._request("POST", "/user", json=user_data, headers=headers) + response = self._request( + "POST", + "/user", + json=user_data.model_dump(exclude_none=True), + headers=headers, + ) if not response or "data" not in response: raise ValueError("Invalid response from create user endpoint") @@ -71,7 +76,9 @@ def update_user( self, user_update: UserUpdateModel ) -> GenericResponseModel[UserReadModel]: """Updates the current user's details. JWT token must be set in the client.""" - response = self._request("PATCH", "/user/update", json=user_update) + response = self._request( + "PATCH", "/user/update", json=user_update.model_dump(exclude_none=True) + ) if not response: raise ValueError("No user data found in response") if not isinstance(response, dict): @@ -92,7 +99,7 @@ def resend_verification_email(self) -> GenericResponseModel[None]: def verify_user(self, token: str) -> GenericResponseModel[None]: """Verifies the current user's email. Token sent via email.""" - response = self._request("GET", "/user/verify?token=" + token) + response = self._request("GET", "/user/verify", params={"token": token}) if not response: raise ValueError("No data found in response") if not isinstance(response, dict): @@ -104,7 +111,9 @@ def verify_user(self, token: str) -> GenericResponseModel[None]: # Password reset methods def request_password_reset(self, email: EmailStr) -> GenericResponseModel[None]: """Requests a password reset email to be sent to the user.""" - response = self._request("PATCH", "/password-reset/request?email=" + email) + response = self._request( + "PATCH", "/password-reset/request", params={"email": email} + ) if not response: raise ValueError("No data found in response") if not isinstance(response, dict): diff --git a/src/userverse_python_client/http_client_base.py b/src/userverse_python_client/http_client_base.py index 1f024c1..e0ab963 100644 --- a/src/userverse_python_client/http_client_base.py +++ b/src/userverse_python_client/http_client_base.py @@ -27,7 +27,7 @@ def __init__( self.set_access_token(access_token) def set_access_token(self, token: str) -> None: - self.session.headers["Authorization"] = f"bearer {token}" + self.session.headers["Authorization"] = f"Bearer {token}" def _request( self, @@ -57,7 +57,10 @@ def _request( if not resp.content: return None - return resp.json() + try: + return resp.json() + except ValueError: + return resp.text except requests.exceptions.HTTPError as http_err: status = http_err.response.status_code if http_err.response else 500 diff --git a/uv.lock b/uv.lock index b46fe7e..7612511 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.12" [[package]] @@ -453,7 +453,7 @@ wheels = [ [[package]] name = "userverse-python-client" -version = "0.1.6" +version = "0.1.8" source = { editable = "." } dependencies = [ { name = "black" },