Skip to content
Merged
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
34 changes: 32 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,14 @@ When to use what:
# API surface (summary)
- POST /api/users/ — Create a user
- GET /api/users/{id}/ — Retrieve user by ID
- GET /api/users/ — List all user IDs
- GET /api/users/ — List users
- PUT /api/users/{id}/ — Update a user (id is immutable; if provided in body it must equal the path id)
- PATCH /api/users/{id}/ — Partially update a user (id in body is forbidden)
- DELETE /api/users/{id}/ — Delete a user
- GET /api/health/ — Public health check (no auth)

Validation rules:
- id: Valid Israeli ID (checksum validated)
- id: Valid Israeli ID (checksum validated). Primary key is immutable after creation.
- phone: International format starting with `+`
- name: Required
- address: Required
Expand Down Expand Up @@ -189,3 +192,30 @@ Pipeline summary:
- Introduce semantic versioning for all deliverables.
- Publish metadata_client and dp-client to a registry and pin versions in tests and downstream projects.
- Keep dp-client in a separate repository with its own SDLC, releases, and changelog.


# Test matrix

The repository contains unit tests (using the generated metadata_client) and component tests (using the higher-level dp-client with optional DB validations). Below is an overview of the current tests and what they verify.

| Suite | Test function | Parametrized case IDs | What it verifies | DB checks |
|------------|---------------------------------------------------|------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------|-----------|
| Unit | test_health_check | — | GET /api/health/ returns 200 OK | No |
| Unit | test_users_create_parametrized | create:ok, create:bad-id, create:bad-phone | POST /api/users/ happy path and validation failures | No |
| Unit | test_users_partial_update_parametrized | patch-address:ok, patch-name:ok, patch-phone:ok-valid, patch-phone:bad-format, patch-id:forbidden | PATCH /api/users/{id}/; validators apply; id in body forbidden | No |
| Unit | test_users_update_put_parametrized | put-with-same-id:ok, put-with-different-id:forbidden, put-invalid-phone:bad-format | PUT /api/users/{id}/; id immutable (must match path); validators apply | No |
| Component | test_health_check_component | — | GET /api/health/ returns 200 OK | No |
| Component | test_create_user_valid_component | — | Create user via API and verify response; then verify row exists and delete it | Yes |
| Component | test_create_user_invalid_id_component | — | Invalid ID rejected by API; ensure no row inserted | Yes |
| Component | test_create_user_invalid_phone_component | — | Invalid phone rejected by API; ensure no row inserted | Yes |
| Component | test_retrieve_user_component | — | Create and then GET /api/users/{id}/ reflects fields; cleanup and DB removal | Yes |
| Component | test_list_users_component | — | Create two users, list includes their IDs; cleanup and DB removal | Yes |
| Component | test_component_users_partial_update_parametrized | component:patch-address:ok, component:patch-name:ok, component:patch-phone:ok, component:patch-phone:bad-format, component:patch-id:forbidden | PATCH /api/users/{id}/ happy and failure paths; DB reflects successful changes; id in body forbidden | Yes |
| Component | test_component_users_update_put_parametrized | component:put-no-id:ok, component:put-with-id:forbidden, component:put-invalid-phone:bad-format | PUT /api/users/{id}/ happy and failure paths; server enforces immutable id; DB reflects successful changes | Yes |

How to run tests:
- Unit tests: `pytest tests/unit -s -v`
- Component tests (with DB validations):
- Optionally enable fallback if running outside Docker and `.env` uses POSTGRES_HOST=db: `export POSTGRES_ALLOW_LOCAL_FALLBACK=true`
- Build and install dp-client: `./scripts/build_and_install_dp_client.sh`
- Run: `pytest tests/component -s -v`
19 changes: 17 additions & 2 deletions dp-client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ A higher-level, test-friendly Python client for the Metadata Server, designed fo
`DPClient` composes distinct parts with strict separation of concerns:
- MetaDataServerAPIClient — factory‑built HTTP client for the Metadata Server (authenticated or not).
- HealthAPI — methods for `/api/health/`.
- UsersApi — methods for `/api/users/` (create/get/list), aligned with unit tests usage.
- UsersApi — methods for `/api/users/` (create/get/list/update/partial_update/destroy) with strict id immutability enforced by the server. The client injects the path id into PUT bodies if omitted, and rejects PATCH bodies containing `id`.
- PGDBClient — optional Postgres DB helper used in tests to verify persistence and cleanup. It is decoupled from Django ORM and uses driver(s) under `dp_client.db.drivers`.

This design keeps HTTP API usage and DB verification separate, while allowing simple orchestration from tests.
Expand Down Expand Up @@ -93,10 +93,25 @@ if client.PGDBClient is not None:
Backwards‑compatible helpers (delegating to structured APIs):

```python
from dp_client import DPClient
client = DPClient(base_url="http://localhost:8000", token="<ACCESS_TOKEN>")

# Health
client.health_check()

# Users CRUD
client.create_user({"id": "...", "name": "...", "phone": "+972...", "address": "..."})
client.get_user("...")
client.get_user("<id>")
client.list_users()

# Update (PUT): body must represent the same id as in the path; if omitted, dp-client injects the path id
client.update_user("<id>", {"name": "New", "phone": "+9728...", "address": "..."})

# Partial update (PATCH): must NOT include 'id' in body
client.partial_update_user("<id>", {"address": "Changed"})

# Delete
client.delete_user("<id>")
```

## Using dp-client in component tests
Expand Down
9 changes: 9 additions & 0 deletions dp-client/dp_client/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,12 @@
from .client import DPClient # noqa: F401
from .clients import MetaDataServerAPIClientFactory # noqa: F401
from .db import PGDBClient # noqa: F401

__all__ = [
"__version__",
"DPClient",
"HealthAPI",
"UsersAPI",
"MetaDataServerAPIClientFactory",
"PGDBClient",
]
25 changes: 21 additions & 4 deletions dp-client/dp_client/api/health.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,24 @@
from __future__ import annotations

from typing import Any
from typing import Union, cast

from metadata_client import AuthenticatedClient, Client
from metadata_client.types import Response


class HealthAPI:
"""Health endpoints wrapper using generated metadata_client api module."""

def __init__(self, client: Any) -> None:
def __init__(self, client: Union[Client, AuthenticatedClient]) -> None:
"""Initialize the HealthAPI wrapper.

Args:
client: An instance of the generated metadata_client Client or
AuthenticatedClient used to perform HTTP requests.

Raises:
RuntimeError: If the generated health endpoint cannot be imported.
"""
try:
from metadata_client.api.health import health_retrieve as _health_retrieve
except Exception as exc:
Expand All @@ -17,5 +29,10 @@ def __init__(self, client: Any) -> None:
self._client = client
self._health_retrieve = _health_retrieve

def health_check(self):
return self._health_retrieve.sync_detailed(client=self._client)
def health_check(self) -> Response[dict[str, str]]:
"""Call GET /api/health/ and return the detailed response.

Returns:
The detailed response object from the generated client.
"""
return cast(Response[dict[str, str]], self._health_retrieve.sync_detailed(client=self._client))
167 changes: 152 additions & 15 deletions dp-client/dp_client/api/users.py
Original file line number Diff line number Diff line change
@@ -1,34 +1,171 @@
from __future__ import annotations

from typing import Any, Union
from types import ModuleType
from typing import TYPE_CHECKING, Dict, Union

from metadata_client import AuthenticatedClient, Client
from metadata_client.types import Response

if TYPE_CHECKING: # import only for type checkers; runtime uses lazy-loaded attributes
from metadata_client.models import User as _User
from metadata_client.models import UserUpdate as _UserUpdate

from typing import cast


class UsersAPI:
"""Users endpoints wrapper using generated metadata_client api module."""
"""Users endpoints wrapper using generated metadata_client api module.

Implements a generalized approach to load and validate all user endpoints
at initialization time to avoid per-call checks and asserts.
"""

# Mapping of logical names to attributes provided by metadata_client.api.users
_ENDPOINT_ATTRS: Dict[str, str] = {
"create": "users_create",
"list": "users_list",
"retrieve": "users_retrieve",
"update": "users_update",
"partial_update": "users_partial_update",
"destroy": "users_destroy",
}

def __init__(self, client: Union[Client, AuthenticatedClient]) -> None:
"""Initialize the UsersAPI wrapper.

Args:
client: An instance of the generated metadata_client Client or
AuthenticatedClient that will be used to perform HTTP requests.

def __init__(self, client: Any) -> None:
Raises:
RuntimeError: If the metadata_client package or required modules
cannot be imported, or if any of the expected endpoints are
missing from metadata_client.api.users.
"""
try:
from metadata_client.api.users import users_create as _users_create
from metadata_client.api.users import users_list as _users_list
from metadata_client.api.users import users_retrieve as _users_retrieve
from metadata_client.models import PatchedUserUpdate as _PatchedUserUpdate
from metadata_client.models import User as _User
from metadata_client.models import UserUpdate as _UserUpdate
except Exception as exc:
raise RuntimeError(
"metadata-client is required but not installed or failed to import. "
f"Original error: {exc}. Install it or run: pip install -r dp-client/requirements.txt"
) from exc

self._client = client
self._users_create = _users_create
self._users_list = _users_list
self._users_retrieve = _users_retrieve
self._User = _User
self._UserUpdate = _UserUpdate
self._PatchedUserUpdate = _PatchedUserUpdate

# Load all endpoints by importing submodules explicitly since
# metadata_client.api.users may not expose submodules as attributes.
import importlib

tmp_ep: Dict[str, ModuleType | None] = {}
for key, attr_name in self._ENDPOINT_ATTRS.items():
module_name = f"metadata_client.api.users.{attr_name}"
try:
mod = importlib.import_module(module_name)
except Exception:
mod = None
tmp_ep[key] = mod

# Validate presence of ALL endpoints (include all options)
missing = [self._ENDPOINT_ATTRS[k] for k, v in tmp_ep.items() if v is None]
if missing:
raise RuntimeError(
"metadata_client.api.users is missing required endpoints: "
+ ", ".join(missing)
+ ". Regenerate the client from the updated API spec."
)

self._ep: Dict[str, ModuleType] = cast(Dict[str, ModuleType], tmp_ep)

def create_user(self, user: Union[_User, dict[str, object]]) -> Response[_User]:
"""Create a user via POST /api/users/.

def create_user(self, user: Union[Any, dict]):
Args:
user: Either a metadata_client.models.User instance or a dict that
can be converted to one via User.from_dict.

Returns:
The detailed response object from the generated client containing
status_code, content, headers, and parsed payload.
"""
body = self._User.from_dict(user) if isinstance(user, dict) else user
return self._users_create.sync_detailed(client=self._client, body=body)
res: Response[_User] = self._ep["create"].sync_detailed(client=self._client, body=body)
return res

def get_user(self, user_id: str) -> Response[_User]:
"""Retrieve a user by ID via GET /api/users/{id}/.

Args:
user_id: The primary key of the user to retrieve.

Returns:
The detailed response object from the generated client.
"""
res: Response[_User] = self._ep["retrieve"].sync_detailed(client=self._client, id=user_id)
return res

def list_users(self) -> Response[list[_User]]:
"""List all users via GET /api/users/.

Returns:
The detailed response object from the generated client.
"""
res: Response[list[_User]] = self._ep["list"].sync_detailed(client=self._client)
return res

def update_user(self, user_id: str, body: Union[_UserUpdate, dict[str, object]]) -> Response[_User]:
"""Update a user via PUT /api/users/{id}/.

Args:
user_id: The primary key of the user to update.
body: Either a metadata_client.models.UserUpdate instance or a dict
that will be converted with UserUpdate.from_dict. If the dict
omits an "id" field, this client will inject the path id to
satisfy the generated model requirements. Attempting to change
the id (mismatch between body and path) will result in 400 from
the server.

Returns:
The detailed response object from the generated client.
"""
if isinstance(body, dict):
if "id" not in body:
body = {**body, "id": user_id}
body_obj = self._UserUpdate.from_dict(body)
else:
body_obj = body

res: Response[_User] = self._ep["update"].sync_detailed(client=self._client, id=user_id, body=body_obj)
return res

def partial_update_user(self, user_id: str, body: dict[str, object]) -> Response[_User]:
"""Partially update a user via PATCH /api/users/{id}/.

Args:
user_id: The primary key of the user to update.
body: A partial JSON dictionary of fields to update. Must not
include "id" (server enforces immutability). Converted to
PatchedUserUpdate model for the generated client.

Returns:
The detailed response object from the generated client.
"""
m_body = self._PatchedUserUpdate.from_dict(body)
res: Response[_User] = self._ep["partial_update"].sync_detailed(client=self._client, id=user_id, body=m_body)
return res

def delete_user(self, user_id: str) -> Response[None]:
"""Delete a user via DELETE /api/users/{id}/.

def get_user(self, user_id: str):
return self._users_retrieve.sync_detailed(client=self._client, id=user_id)
Args:
user_id: The primary key of the user to delete.

def list_users(self):
return self._users_list.sync_detailed(client=self._client)
Returns:
The detailed response object from the generated client.
"""
res: Response[None] = self._ep["destroy"].sync_detailed(client=self._client, id=user_id)
return res
Loading
Loading