Skip to content

Commit a90332c

Browse files
committed
feat: Introduce AsyncGavaConnect and CheckersClient for asynchronous PIN validation
- Added AsyncGavaConnect class for async operations with checkers API. - Implemented CheckersClient for KRA PIN validation with methods for validating PINs. - Created BasicTokenEndpointProvider for handling token retrieval with Basic auth. - Introduced BasicPair data class for managing client credentials. - Updated auth module to include new classes and methods. - Added smoke tests and unit tests for validation and error handling. - Updated dependencies in pyproject.toml to include pydantic. - Enhanced error handling for API responses, including rate limiting and unauthorized access.
1 parent 4741108 commit a90332c

18 files changed

Lines changed: 812 additions & 5 deletions

README.md

Lines changed: 66 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,74 @@ uv install
4040

4141
## Quick Start
4242

43+
### Basic SDK Usage
44+
45+
```python
46+
import gavaconnect
47+
48+
# Create configuration
49+
config = gavaconnect.SDKConfig(base_url="https://sbx.kra.go.ke")
50+
51+
# Work with errors and basic types
52+
try:
53+
# Your API calls here
54+
pass
55+
except gavaconnect.APIError as e:
56+
print(f"API Error: {e.status} - {e.message}")
57+
```
58+
59+
### PIN Validation (requires httpx and pydantic)
60+
4361
```python
44-
from gavaconnect import main
62+
from gavaconnect.facade_async import AsyncGavaConnect
63+
from gavaconnect.config import SDKConfig
64+
65+
async def validate_pin():
66+
config = SDKConfig(base_url="https://sbx.kra.go.ke")
67+
68+
async with AsyncGavaConnect(
69+
config,
70+
checkers_client_id="your_client_id",
71+
checkers_client_secret="your_client_secret"
72+
) as sdk:
73+
result = await sdk.checkers.validate_pin(pin="A000000000B")
74+
print(result.model_dump(by_alias=True))
75+
# Output: {"PIN": "A000000000B", "TaxPayerName": "...", "status": "VALID", "valid": true}
76+
77+
# Run the async function
78+
import asyncio
79+
asyncio.run(validate_pin())
80+
```
4581

46-
# Basic usage example
47-
main()
82+
### Advanced Usage
83+
84+
```python
85+
from gavaconnect.resources.checkers import CheckersClient, PinCheckResult
86+
from gavaconnect.auth import BasicTokenEndpointProvider, BasicPair, BearerAuthPolicy
87+
from gavaconnect.http.transport import AsyncTransport
88+
89+
# Manual client setup for advanced use cases
90+
async def advanced_usage():
91+
config = SDKConfig(base_url="https://sbx.kra.go.ke")
92+
transport = AsyncTransport(config)
93+
94+
# Setup authentication
95+
provider = BasicTokenEndpointProvider(
96+
token_url="https://sbx.kra.go.ke/v1/token/generate",
97+
basic=BasicPair("client_id", "client_secret"),
98+
method="GET"
99+
)
100+
auth = BearerAuthPolicy(provider)
101+
102+
# Create client
103+
client = CheckersClient(transport, auth)
104+
105+
# Use different validation methods
106+
result1 = await client.validate_pin(pin="A000000000B")
107+
result2 = await client.validate_pin_get(pin="A000000000B", query_key="PIN")
108+
result3 = await client.validate_pin_raw({"PIN": "A000000000B", "extra": "data"})
109+
110+
await transport.close()
48111
```
49112

50113
## Development

gavaconnect/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@
55
from .config import SDKConfig
66
from .errors import APIError, RateLimitError, SDKError, TransportError
77

8+
# Note: AsyncGavaConnect and CheckersClient require httpx and pydantic
9+
# Import them explicitly: from gavaconnect.facade_async import AsyncGavaConnect
10+
# or from gavaconnect.resources.checkers import CheckersClient
11+
812
__all__ = [
913
"__version__",
1014
"SDKConfig",

gavaconnect/auth/__init__.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,16 @@
22

33
from .basic import BasicAuthPolicy, BasicCredentials
44
from .bearer import AuthPolicy, BearerAuthPolicy, TokenProvider
5-
from .providers import ClientCredentialsProvider
5+
from .credentials import BasicPair
6+
from .providers import BasicTokenEndpointProvider, ClientCredentialsProvider
67

78
__all__ = [
89
"AuthPolicy",
9-
"BasicAuthPolicy",
10+
"BasicAuthPolicy",
1011
"BasicCredentials",
12+
"BasicPair",
1113
"BearerAuthPolicy",
1214
"TokenProvider",
15+
"BasicTokenEndpointProvider",
1316
"ClientCredentialsProvider",
1417
]

gavaconnect/auth/credentials.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
"""Basic credential types for authentication."""
2+
3+
from dataclasses import dataclass
4+
5+
6+
@dataclass(frozen=True, slots=True)
7+
class BasicPair:
8+
"""Basic auth credential pair for token endpoints."""
9+
10+
client_id: str
11+
client_secret: str

gavaconnect/auth/providers.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,12 @@
22

33
import asyncio
44
import time
5+
from typing import Literal
56

67
import httpx
78

9+
from .credentials import BasicPair
10+
811
# Minimum token TTL to prevent rapid refresh cycles
912
MIN_TOKEN_TTL_S = 30.0
1013

@@ -86,3 +89,74 @@ async def refresh(self) -> str:
8689
async with self._lock:
8790
self._token, self._exp = await self._fetch()
8891
return self._token
92+
93+
94+
class BasicTokenEndpointProvider:
95+
"""Token provider using HTTP Basic auth against a token endpoint."""
96+
97+
def __init__(
98+
self,
99+
token_url: str,
100+
basic: BasicPair,
101+
method: Literal["GET", "POST"] = "POST",
102+
early_refresh_s: int = 60,
103+
client: httpx.AsyncClient | None = None,
104+
token_timeout_s: float = 10.0,
105+
) -> None:
106+
"""Initialize the basic token endpoint provider.
107+
108+
Args:
109+
token_url: Token endpoint URL.
110+
basic: Basic auth credentials.
111+
method: HTTP method to use (GET or POST).
112+
early_refresh_s: Seconds before expiry to refresh token.
113+
client: Optional HTTP client to use.
114+
token_timeout_s: Timeout for token requests in seconds.
115+
116+
"""
117+
self._url = token_url
118+
self._basic = basic
119+
self._method = method
120+
self._early = early_refresh_s
121+
self._client = client or httpx.AsyncClient(timeout=token_timeout_s)
122+
self._lock = asyncio.Lock()
123+
# Security note: tokens stored in memory - consider using keyring for production
124+
self._token, self._exp = "", 0.0
125+
126+
async def _fetch(self) -> tuple[str, float]:
127+
"""Fetch a new token from the endpoint."""
128+
auth = (self._basic.client_id, self._basic.client_secret)
129+
130+
if self._method == "GET":
131+
resp = await self._client.get(self._url, auth=auth)
132+
else:
133+
resp = await self._client.post(self._url, auth=auth)
134+
135+
resp.raise_for_status()
136+
payload = resp.json()
137+
ttl = float(payload.get("expires_in", 3600))
138+
return payload["access_token"], time.time() + max(MIN_TOKEN_TTL_S, ttl - self._early)
139+
140+
async def get_token(self) -> str:
141+
"""Get the current access token, refreshing if necessary.
142+
143+
Returns:
144+
The access token.
145+
146+
"""
147+
async with self._lock:
148+
if self._token and time.time() < self._exp:
149+
return self._token
150+
self._token, self._exp = await self._fetch()
151+
return self._token
152+
153+
async def refresh(self) -> str:
154+
"""Force refresh the access token.
155+
156+
Returns:
157+
The new access token.
158+
159+
"""
160+
async with self._lock:
161+
self._token, self._exp = await self._fetch()
162+
return self._token

gavaconnect/facade_async.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
"""Async facade for GavaConnect SDK."""
2+
3+
from types import TracebackType
4+
5+
from gavaconnect.auth import BasicPair, BasicTokenEndpointProvider, BearerAuthPolicy
6+
from gavaconnect.config import SDKConfig
7+
from gavaconnect.http.transport import AsyncTransport
8+
from gavaconnect.resources.checkers import CheckersClient
9+
10+
__all__ = ["AsyncGavaConnect"]
11+
12+
13+
class AsyncGavaConnect:
14+
"""Async facade for GavaConnect SDK with per-family credentials."""
15+
16+
def __init__(
17+
self,
18+
config: SDKConfig,
19+
*,
20+
checkers_client_id: str,
21+
checkers_client_secret: str,
22+
token_url: str = "https://sbx.kra.go.ke/v1/token/generate",
23+
) -> None:
24+
"""Initialize the async GavaConnect client.
25+
26+
Args:
27+
config: SDK configuration.
28+
checkers_client_id: Client ID for checkers API.
29+
checkers_client_secret: Client secret for checkers API.
30+
token_url: Token endpoint URL.
31+
32+
"""
33+
self._config = config
34+
self._tr = AsyncTransport(config)
35+
36+
# Setup checkers client with Basic -> Bearer flow
37+
provider = BasicTokenEndpointProvider(
38+
token_url=token_url,
39+
basic=BasicPair(checkers_client_id, checkers_client_secret),
40+
method="GET",
41+
early_refresh_s=60,
42+
)
43+
self.checkers = CheckersClient(self._tr, BearerAuthPolicy(provider))
44+
45+
async def __aenter__(self) -> "AsyncGavaConnect":
46+
"""Async context manager entry."""
47+
return self
48+
49+
async def __aexit__(
50+
self,
51+
exc_type: type[BaseException] | None,
52+
exc_val: BaseException | None,
53+
exc_tb: TracebackType | None,
54+
) -> None:
55+
"""Async context manager exit."""
56+
await self.close()
57+
58+
async def close(self) -> None:
59+
"""Close the client and cleanup resources."""
60+
await self._tr.close()

gavaconnect/resources/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
"""Resources package for GavaConnect SDK."""
2+
3+
# Checkers resources require httpx and pydantic dependencies
4+
# Import explicitly: from gavaconnect.resources.checkers import CheckersClient
5+
6+
__all__ = [] # Explicit imports required due to dependencies
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
"""Checkers resource package."""
2+
3+
from ._pin import CheckersClient, PinCheckResult
4+
5+
__all__ = ["CheckersClient", "PinCheckResult"]
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
"""PIN validation client for KRA checkers."""
2+
3+
from typing import Any
4+
5+
from pydantic import BaseModel, ConfigDict, Field
6+
7+
from gavaconnect.auth.bearer import BearerAuthPolicy
8+
from gavaconnect.helpers.idempotency import idempotency_headers
9+
from gavaconnect.http.transport import AsyncTransport
10+
11+
12+
class PinCheckResult(BaseModel):
13+
"""Result of PIN validation check."""
14+
15+
pin: str | None = Field(default=None, alias="PIN")
16+
taxpayer_name: str | None = Field(default=None, alias="TaxPayerName")
17+
status: str | None = None
18+
valid: bool | None = None
19+
20+
model_config = ConfigDict(populate_by_name=True, extra="allow")
21+
22+
23+
class CheckersClient:
24+
"""Client for KRA PIN validation endpoints."""
25+
26+
def __init__(self, tr: AsyncTransport, auth: BearerAuthPolicy) -> None:
27+
"""Initialize the checkers client.
28+
29+
Args:
30+
tr: Transport instance for HTTP requests.
31+
auth: Bearer authentication policy.
32+
33+
"""
34+
self._tr = tr
35+
self._auth = auth
36+
37+
async def validate_pin(self, *, pin: str, pin_key: str = "PIN") -> PinCheckResult:
38+
"""Validate a KRA PIN using POST with JSON payload.
39+
40+
Args:
41+
pin: The PIN to validate.
42+
pin_key: The JSON key name for the PIN field.
43+
44+
Returns:
45+
PIN validation result.
46+
47+
"""
48+
payload = {pin_key: pin}
49+
return await self.validate_pin_raw(payload)
50+
51+
async def validate_pin_get(self, *, pin: str, query_key: str = "PIN") -> PinCheckResult:
52+
"""Validate a KRA PIN using GET with query parameters.
53+
54+
Args:
55+
pin: The PIN to validate.
56+
query_key: The query parameter name for the PIN field.
57+
58+
Returns:
59+
PIN validation result.
60+
61+
"""
62+
resp = await self._tr.request(
63+
"GET",
64+
"/checker/v1/pinbypin",
65+
auth=self._auth,
66+
params={query_key: pin},
67+
)
68+
self._tr.raise_for_api_error(resp)
69+
return PinCheckResult.model_validate(resp.json())
70+
71+
async def validate_pin_raw(self, payload: dict[str, Any]) -> PinCheckResult:
72+
"""Validate a PIN using raw payload.
73+
74+
Args:
75+
payload: Raw JSON payload to send.
76+
77+
Returns:
78+
PIN validation result.
79+
80+
"""
81+
headers = idempotency_headers() # Make POST requests retryable
82+
resp = await self._tr.request(
83+
"POST",
84+
"/checker/v1/pinbypin",
85+
auth=self._auth,
86+
json=payload,
87+
headers=headers,
88+
)
89+
self._tr.raise_for_api_error(resp)
90+
return PinCheckResult.model_validate(resp.json())

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ authors = [
1010
requires-python = ">=3.13"
1111
dependencies = [
1212
"httpx>=0.28.1",
13+
"pydantic>=2.0.0",
1314
]
1415
license = { text = "MIT" }
1516
keywords = ["gavaconnect", "sdk", "api"]

0 commit comments

Comments
 (0)