Skip to content

Commit 285b998

Browse files
authored
Merge pull request #2 from Qwizi/v2
V2 version, like django-orm
2 parents 62e9012 + ef2d198 commit 285b998

File tree

19 files changed

+1925
-1
lines changed

19 files changed

+1925
-1
lines changed

src/swapi_client/v2/__init__.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
"""High-level exports for the ``swapi_client.v2`` package.
2+
3+
This module re-exports the primary public API of the v2 package so users
4+
can do:
5+
6+
from swapi_client.v2 import SWAPIClient, Q, SWAPIQuerySet
7+
8+
and also access the `models` subpackage as `swapi_client.v2.models`.
9+
"""
10+
11+
from .client import SWAPIClient
12+
from .q import Q
13+
from .queryset import SWAPIQuerySet, SWAPIListResponse
14+
from .queryset_core import CoreQuerySet
15+
from .dynamic import DynamicObject, DynamicList
16+
from .core_init import Core
17+
from . import models
18+
19+
from .exceptions import (
20+
SWAPIError,
21+
SWAPIAuthError,
22+
SWAPINotFoundError,
23+
SWAPISchemaError,
24+
SWAPIValidationError,
25+
SWAPIPermissionDenied,
26+
SWAPIConnectionError,
27+
)
28+
29+
from .utils import parse_filter_key, is_iterable_but_not_string, list_to_csv
30+
31+
__all__ = [
32+
"SWAPIClient",
33+
"Q",
34+
"SWAPIQuerySet",
35+
"SWAPIListResponse",
36+
"CoreQuerySet",
37+
"Core",
38+
"DynamicObject",
39+
"DynamicList",
40+
"models",
41+
# exceptions
42+
"SWAPIError",
43+
"SWAPIAuthError",
44+
"SWAPINotFoundError",
45+
"SWAPISchemaError",
46+
"SWAPIValidationError",
47+
"SWAPIPermissionDenied",
48+
"SWAPIConnectionError",
49+
# utils
50+
"parse_filter_key",
51+
"is_iterable_but_not_string",
52+
"list_to_csv",
53+
]
54+

src/swapi_client/v2/client.py

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import asyncio
2+
from typing import Optional, Dict, Any
3+
4+
import httpx
5+
6+
from .exceptions import SWAPIError, SWAPIAuthError
7+
8+
9+
class SWAPIClient:
10+
"""
11+
SWAPIClient — async, auto-login, auto-retry po 401.
12+
13+
Funkcje:
14+
- login() — pozyskanie tokena
15+
- _request() — jedno miejsce dla GET/POST/PATCH/DELETE
16+
- auto-refresh tokena przy 401 (bez refresh tokenów — ponowne logowanie)
17+
- concurrency-safe dzięki asyncio.Lock
18+
- obsługa JSON + błędów
19+
"""
20+
21+
def __init__(
22+
self,
23+
base_url: str,
24+
username: str,
25+
password: str,
26+
client_id: str,
27+
auth_token: str,
28+
timeout: float = 20.0,
29+
):
30+
self.base_url = base_url.rstrip("/")
31+
self.username = username
32+
self.password = password
33+
self.client_id = client_id
34+
self.auth_token = auth_token
35+
self.timeout = timeout
36+
37+
self.token: Optional[str] = None
38+
self._login_lock = asyncio.Lock() # tylko jeden login naraz
39+
40+
# --------------------------------------------------------
41+
# LOGIN
42+
# --------------------------------------------------------
43+
async def login(self) -> str:
44+
"""
45+
Logowanie do SWAPI. Zakładamy endpoint /_/security/login:
46+
47+
POST /_/security/login
48+
{ "username": "...", "password": "..." }
49+
-> { "token": "..." }
50+
51+
Dostosuj jeśli endpoint działa inaczej.
52+
"""
53+
url = f"{self.base_url}/_/security/login"
54+
55+
async with httpx.AsyncClient(timeout=self.timeout) as client:
56+
resp = await client.post(url, json={
57+
"clientId": self.client_id,
58+
"authToken": self.auth_token,
59+
"login": self.username,
60+
"password": self.password,
61+
})
62+
63+
if resp.status_code >= 400:
64+
raise SWAPIAuthError(
65+
f"Login failed ({resp.status_code}): {resp.text}"
66+
)
67+
68+
data = resp.json()
69+
token = data.get("token")
70+
if not token:
71+
raise SWAPIAuthError("Login response missing 'token' field")
72+
73+
self.token = token
74+
return token
75+
76+
# --------------------------------------------------------
77+
# HEADERS
78+
# --------------------------------------------------------
79+
async def _headers(self) -> Dict[str, str]:
80+
headers = {
81+
"Accept": "application/json",
82+
}
83+
if self.token:
84+
headers["Authorization"] = f"Bearer {self.token}"
85+
return headers
86+
87+
# --------------------------------------------------------
88+
# GŁÓWNY REQUEST
89+
# --------------------------------------------------------
90+
async def _request(self, method: str, endpoint: str, **kwargs) -> Dict[str, Any]:
91+
"""
92+
- Dodaje base_url
93+
- Dodaje Authorization
94+
- Jeśli token jest None → login()
95+
- Jeśli 401 → retry z loginem
96+
"""
97+
url = f"{self.base_url}{endpoint}"
98+
99+
async with httpx.AsyncClient(timeout=self.timeout) as client:
100+
101+
# 1) Jeśli pierwszy request → login()
102+
if not self.token:
103+
async with self._login_lock:
104+
if not self.token: # drugi request czekał na lock
105+
await self.login()
106+
107+
# 2) Pierwsze podejście
108+
resp = await client.request(
109+
method,
110+
url,
111+
headers=await self._headers(),
112+
**kwargs
113+
)
114+
115+
# 3) Jeśli token wygasł → ponawiamy login
116+
if resp.status_code == 401:
117+
async with self._login_lock:
118+
await self.login()
119+
120+
# 4) Retry z nowym tokenem
121+
resp = await client.request(
122+
method,
123+
url,
124+
headers=await self._headers(),
125+
**kwargs
126+
)
127+
128+
# 5) Obsługa błędów
129+
if resp.status_code >= 400:
130+
raise SWAPIError(
131+
f"SWAPI HTTP {resp.status_code} on {method} {url}: {resp.text}"
132+
)
133+
134+
# 6) OBSŁUGA JSON
135+
if not resp.text:
136+
return {}
137+
try:
138+
return resp.json()
139+
except Exception:
140+
raise SWAPIError(
141+
f"Invalid JSON response from SWAPI: {resp.text}"
142+
)
143+
144+
# --------------------------------------------------------
145+
# PUBLIC API METHODS
146+
# --------------------------------------------------------
147+
async def get(self, endpoint: str, params: dict = None) -> Dict[str, Any]:
148+
return await self._request("GET", endpoint, params=params)
149+
150+
async def post(self, endpoint: str, json: dict = None) -> Dict[str, Any]:
151+
return await self._request("POST", endpoint, json=json)
152+
153+
async def patch(self, endpoint: str, json: dict = None) -> Dict[str, Any]:
154+
return await self._request("PATCH", endpoint, json=json)
155+
156+
async def delete(self, endpoint: str, params: dict = None) -> Dict[str, Any]:
157+
return await self._request("DELETE", endpoint, params=params)

src/swapi_client/v2/core_init.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
"""Core API entry-point for simple endpoints like /api/me, /api/home, etc."""
2+
3+
from .queryset_core import CoreQuerySet
4+
from .models.core import CoreModel
5+
6+
7+
class Core:
8+
"""
9+
Główna entry-point dla prostych endpointów typu:
10+
/api/me
11+
/api/home
12+
/api/settings
13+
/api/modules
14+
/api/user/profile
15+
itd.
16+
"""
17+
18+
client = None # identycznie jak Commission.client
19+
20+
@classmethod
21+
def objects(cls) -> CoreQuerySet:
22+
if cls.client is None:
23+
raise RuntimeError("Core.client must be assigned with SWAPIClient.")
24+
return CoreQuerySet(cls.client, CoreModel)

src/swapi_client/v2/dynamic.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
class DynamicObject:
2+
"""
3+
Wrapper na dict, który pozwala na dostęp przez dot notation:
4+
obj.field
5+
oraz:
6+
obj.deep.field.value
7+
"""
8+
9+
def __init__(self, data: dict):
10+
for key, value in data.items():
11+
setattr(self, key, self._wrap(value))
12+
13+
def _wrap(self, value):
14+
if isinstance(value, dict):
15+
return DynamicObject(value)
16+
if isinstance(value, list):
17+
return DynamicList(value)
18+
return value
19+
20+
def to_dict(self):
21+
"""
22+
Rekurencyjnie konwertuje obiekt do dict.
23+
"""
24+
result = {}
25+
for key, value in self.__dict__.items():
26+
if isinstance(value, DynamicObject):
27+
result[key] = value.to_dict()
28+
elif isinstance(value, DynamicList):
29+
result[key] = value.to_list()
30+
else:
31+
result[key] = value
32+
return result
33+
34+
def __repr__(self):
35+
return f"DynamicObject({self.__dict__})"
36+
37+
38+
class DynamicList(list):
39+
"""
40+
Wrapper na listę, automatycznie konwertujący elementy dict → DynamicObject.
41+
"""
42+
43+
def __init__(self, data: list):
44+
super().__init__(self._wrap(item) for item in data)
45+
46+
def _wrap(self, value):
47+
if isinstance(value, dict):
48+
return DynamicObject(value)
49+
if isinstance(value, list):
50+
return DynamicList(value)
51+
return value
52+
53+
def to_list(self):
54+
return [
55+
item.to_dict() if isinstance(item, DynamicObject)
56+
else item.to_list() if isinstance(item, DynamicList)
57+
else item
58+
for item in self
59+
]

src/swapi_client/v2/exceptions.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
class SWAPIError(RuntimeError):
2+
"""
3+
Ogólny błąd SWAPI — wszystko co nie jest konkretną kategorią,
4+
np. złe dane, błędna struktura JSON lub nieobsłużona sytuacja.
5+
"""
6+
pass
7+
8+
9+
class SWAPIAuthError(SWAPIError):
10+
"""
11+
Błąd autoryzacji:
12+
- login zwrócił 400/401/403
13+
- response z loginu nie ma pola token
14+
"""
15+
pass
16+
17+
class SWAPINotFoundError(SWAPIError):
18+
"""
19+
Błąd 404 — obiekt nie istnieje po stronie SW API.
20+
"""
21+
pass
22+
23+
24+
class SWAPISchemaError(SWAPIError):
25+
"""
26+
Nieprawidłowa struktura odpowiedzi JSON, np.
27+
API zwraca pola inne niż oczekiwane.
28+
29+
Ten wyjątek jest przydatny przy walidacji dynamicznych modeli.
30+
"""
31+
pass
32+
33+
34+
class SWAPIValidationError(SWAPIError):
35+
"""
36+
Zwrotka API informująca o błędach walidacji,
37+
np. POST/PUT z nieprawidłowym payloadem.
38+
39+
Gdy API zwraca:
40+
{
41+
\"errors\": [
42+
{\"field\": \"name\", \"message\": \"required\"}
43+
]
44+
}
45+
"""
46+
pass
47+
48+
49+
class SWAPIPermissionDenied(SWAPIError):
50+
"""
51+
Błąd autoryzacji — brak uprawnień do wykonania danej akcji.
52+
Najczęściej API odpowiada statusem 403.
53+
"""
54+
pass
55+
56+
57+
class SWAPIConnectionError(SWAPIError):
58+
"""
59+
Błąd połączenia (timeout, brak internetu, problemy sieciowe).
60+
"""
61+
pass

0 commit comments

Comments
 (0)