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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Projekt odwzorowuje oficjalne przepływy KSeF i zapewnia spójny model pracy w d

## 🔄 Kompatybilność

Aktualna kompatybilność: **KSeF API `v2.1.2`** ([api-changelog.md](https://github.com/CIRFMF/ksef-docs/blob/2.1.2/api-changelog.md)).
Aktualna kompatybilność: **KSeF API `v2.2.0`** ([api-changelog.md](https://github.com/CIRFMF/ksef-docs/blob/2.2.0/api-changelog.md)).

## 🧭 Spis treści

Expand Down
2 changes: 1 addition & 1 deletion docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Dokumentacja opisuje **publiczne API** biblioteki `ksef-client-python` (import:

Opis kontraktu API (OpenAPI) oraz dokumenty procesowe i ograniczenia systemu znajdują się w `ksef-docs/`.

Kompatybilność SDK: **KSeF API `v2.1.2`**.
Kompatybilność SDK: **KSeF API `v2.2.0`**.

## Wymagania

Expand Down
1 change: 1 addition & 0 deletions docs/api/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ Endpoint: `POST /auth/challenge` (bez `accessToken`).

Zwraca JSON z polami m.in.:
- `challenge`
- `clientIp`
- `timestamp`
- `timestampMs`

Expand Down
3 changes: 3 additions & 0 deletions docs/api/permissions.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ Endpoint: `POST /permissions/query/authorizations/grants`
### `query_entities_roles(..., access_token)`
Endpoint: `GET /permissions/query/entities/roles`

### `query_entities_grants(request_payload, ..., access_token)`
Endpoint: `POST /permissions/query/entities/grants`

### `query_eu_entities_grants(request_payload, ..., access_token)`
Endpoint: `POST /permissions/query/eu-entities/grants`

Expand Down
34 changes: 34 additions & 0 deletions src/ksef_client/clients/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,23 @@ def query_entities_roles(
access_token=access_token,
)

def query_entities_grants(
self,
request_payload: dict[str, Any],
*,
page_offset: int | None = None,
page_size: int | None = None,
access_token: str,
) -> Any:
params = _page_params(page_offset, page_size)
return self._request_json(
"POST",
"/permissions/query/entities/grants",
params=params or None,
json=request_payload,
access_token=access_token,
)

def query_eu_entities_grants(
self,
request_payload: dict[str, Any],
Expand Down Expand Up @@ -356,6 +373,23 @@ async def query_entities_roles(
access_token=access_token,
)

async def query_entities_grants(
self,
request_payload: dict[str, Any],
*,
page_offset: int | None = None,
page_size: int | None = None,
access_token: str,
) -> Any:
params = _page_params(page_offset, page_size)
return await self._request_json(
"POST",
"/permissions/query/entities/grants",
params=params or None,
json=request_payload,
access_token=access_token,
)

async def query_eu_entities_grants(
self,
request_payload: dict[str, Any],
Expand Down
9 changes: 7 additions & 2 deletions src/ksef_client/http.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ def _is_absolute_http_url(url: str) -> bool:
return url.startswith("http://") or url.startswith("https://")


def _is_json_content_type(content_type: str) -> bool:
media_type = content_type.split(";", 1)[0].strip().lower()
return media_type == "application/json" or media_type.endswith("+json")


def _host_allowed(host: str, allowed_hosts: list[str]) -> bool:
normalized_host = host.lower().rstrip(".")
for allowed in allowed_hosts:
Expand Down Expand Up @@ -177,7 +182,7 @@ def _raise_for_status(self, response: httpx.Response) -> None:
retry_after = response.headers.get("Retry-After")
content_type = response.headers.get("Content-Type", "")
body: Any = None
if "application/json" in content_type:
if _is_json_content_type(content_type):
try:
body = response.json()
except ValueError:
Expand Down Expand Up @@ -285,7 +290,7 @@ def _raise_for_status(self, response: httpx.Response) -> None:
retry_after = response.headers.get("Retry-After")
content_type = response.headers.get("Content-Type", "")
body: Any = None
if "application/json" in content_type:
if _is_json_content_type(content_type):
try:
body = response.json()
except ValueError:
Expand Down
3 changes: 3 additions & 0 deletions src/ksef_client/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,13 +62,16 @@ class AuthenticationChallengeResponse:
challenge: str
timestamp: str
timestamp_ms: int
client_ip: str | None = None

@staticmethod
def from_dict(data: dict[str, Any]) -> AuthenticationChallengeResponse:
raw_client_ip = data.get("clientIp")
return AuthenticationChallengeResponse(
challenge=str(data.get("challenge", "")),
timestamp=str(data.get("timestamp", "")),
timestamp_ms=int(data.get("timestampMs", 0)),
client_ip=str(raw_client_ip) if raw_client_ip is not None else None,
)


Expand Down
54 changes: 52 additions & 2 deletions src/ksef_client/openapi_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -348,10 +348,18 @@ class EntityAuthorizationsAuthorizedEntityIdentifierType(Enum):
class EntityAuthorizationsAuthorizingEntityIdentifierType(Enum):
NIP = "Nip"

class EntityPermissionItemScope(Enum):
INVOICEWRITE = "InvoiceWrite"
INVOICEREAD = "InvoiceRead"

class EntityPermissionType(Enum):
INVOICEWRITE = "InvoiceWrite"
INVOICEREAD = "InvoiceRead"

class EntityPermissionsContextIdentifierType(Enum):
NIP = "Nip"
INTERNALID = "InternalId"

class EntityPermissionsSubjectIdentifierType(Enum):
NIP = "Nip"

Expand Down Expand Up @@ -734,6 +742,7 @@ class AttachmentPermissionRevokeRequest(OpenApiModel):
@dataclass(frozen=True)
class AuthenticationChallengeResponse(OpenApiModel):
challenge: Challenge
clientIp: str
timestamp: str
timestampMs: int

Expand Down Expand Up @@ -995,13 +1004,31 @@ class EntityPermission(OpenApiModel):
type: EntityPermissionType
canDelegate: Optional[bool] = None

@dataclass(frozen=True)
class EntityPermissionItem(OpenApiModel):
canDelegate: bool
contextIdentifier: EntityPermissionsContextIdentifier
description: str
id: PermissionId
permissionScope: EntityPermissionItemScope
startDate: str

@dataclass(frozen=True)
class EntityPermissionsContextIdentifier(OpenApiModel):
type: EntityPermissionsContextIdentifierType
value: str

@dataclass(frozen=True)
class EntityPermissionsGrantRequest(OpenApiModel):
description: str
permissions: list[EntityPermission]
subjectDetails: EntityDetails
subjectIdentifier: EntityPermissionsSubjectIdentifier

@dataclass(frozen=True)
class EntityPermissionsQueryRequest(OpenApiModel):
contextIdentifier: Optional[EntityPermissionsContextIdentifier] = None

@dataclass(frozen=True)
class EntityPermissionsSubjectIdentifier(OpenApiModel):
type: EntityPermissionsSubjectIdentifierType
Expand Down Expand Up @@ -1115,6 +1142,16 @@ class ExceptionResponse(OpenApiModel):
class ExportInvoicesResponse(OpenApiModel):
referenceNumber: ReferenceNumber

@dataclass(frozen=True)
class ForbiddenProblemDetails(OpenApiModel):
detail: str
reasonCode: str
status: int
title: str
instance: Optional[str] = None
security: Optional[dict[str, Optional[Any]]] = None
traceId: Optional[str] = None

@dataclass(frozen=True)
class FormCode(OpenApiModel):
schemaVersion: str
Expand Down Expand Up @@ -1292,7 +1329,7 @@ class InvoiceStatusInfo(OpenApiModel):
code: int
description: str
details: Optional[list[str]] = None
extensions: Optional[dict[str, Any]] = None
extensions: Optional[dict[str, Optional[str]]] = None

@dataclass(frozen=True)
class OnlineSessionContextLimitsOverride(OpenApiModel):
Expand Down Expand Up @@ -1330,7 +1367,7 @@ class OpenOnlineSessionResponse(OpenApiModel):

@dataclass(frozen=True)
class PartUploadRequest(OpenApiModel):
headers: dict[str, Any]
headers: dict[str, Optional[str]]
method: str
ordinalNumber: int
url: str
Expand Down Expand Up @@ -1550,6 +1587,11 @@ class QueryEntityAuthorizationPermissionsResponse(OpenApiModel):
authorizationGrants: list[EntityAuthorizationGrant]
hasMore: bool

@dataclass(frozen=True)
class QueryEntityPermissionsResponse(OpenApiModel):
hasMore: bool
permissions: list[EntityPermissionItem]

@dataclass(frozen=True)
class QueryEntityRolesResponse(OpenApiModel):
hasMore: bool
Expand Down Expand Up @@ -1855,6 +1897,14 @@ class TokenStatusResponse(OpenApiModel):
class TooManyRequestsResponse(OpenApiModel):
status: dict[str, Any]

@dataclass(frozen=True)
class UnauthorizedProblemDetails(OpenApiModel):
detail: str
status: int
title: str
instance: Optional[str] = None
traceId: Optional[str] = None

@dataclass(frozen=True)
class UnblockContextAuthenticationRequest(OpenApiModel):
contextIdentifier: Optional[TestDataAuthenticationContextIdentifier] = None
Expand Down
9 changes: 9 additions & 0 deletions tests/test_clients.py
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,9 @@ def test_permissions_client(self):
payload, page_offset=0, page_size=10, access_token="token"
)
client.query_entities_roles(page_offset=0, page_size=10, access_token="token")
client.query_entities_grants(
payload, page_offset=0, page_size=10, access_token="token"
)
client.query_eu_entities_grants(
payload, page_offset=0, page_size=10, access_token="token"
)
Expand Down Expand Up @@ -602,6 +605,12 @@ async def test_async_clients(self):
page_size=10,
access_token="token",
)
await permissions.query_entities_grants(
payload,
page_offset=0,
page_size=10,
access_token="token",
)
await permissions.query_eu_entities_grants(
payload,
page_offset=0,
Expand Down
37 changes: 37 additions & 0 deletions tests/test_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,19 @@
BaseHttpClient,
HttpResponse,
_host_allowed,
_is_json_content_type,
_merge_headers,
_validate_presigned_url_security,
)


class HttpTests(unittest.TestCase):
def test_is_json_content_type(self):
self.assertTrue(_is_json_content_type("application/json"))
self.assertTrue(_is_json_content_type("application/problem+json"))
self.assertTrue(_is_json_content_type("application/vnd.api+json; charset=utf-8"))
self.assertFalse(_is_json_content_type("text/plain"))

def test_merge_headers(self):
base = {"a": "1"}
merged = _merge_headers(base, {"b": "2"})
Expand Down Expand Up @@ -90,6 +97,24 @@ def test_raise_for_status_api_error(self):
with self.assertRaises(KsefApiError):
client._raise_for_status(response)

def test_raise_for_status_problem_json_error(self):
options = KsefClientOptions(base_url="https://api-test.ksef.mf.gov.pl")
client = BaseHttpClient(options)
response = httpx.Response(
403,
headers={"Content-Type": "application/problem+json"},
json={
"title": "Forbidden",
"status": 403,
"detail": "Missing permissions",
"reasonCode": "missing-permissions",
},
)
with self.assertRaises(KsefApiError) as ctx:
client._raise_for_status(response)
assert isinstance(ctx.exception.response_body, dict)
self.assertEqual(ctx.exception.response_body["reasonCode"], "missing-permissions")

def test_raise_for_status_http_error(self):
options = KsefClientOptions(base_url="https://api-test.ksef.mf.gov.pl")
client = BaseHttpClient(options)
Expand Down Expand Up @@ -287,6 +312,18 @@ async def test_async_raise_for_status_paths(self):
with self.assertRaises(KsefHttpError):
client._raise_for_status(response_http)

response_problem = httpx.Response(
401,
headers={"Content-Type": "application/problem+json"},
json={
"title": "Unauthorized",
"status": 401,
"detail": "Missing bearer token",
},
)
with self.assertRaises(KsefApiError):
client._raise_for_status(response_problem)

async def test_async_skip_auth_presigned_validation_rejects_localhost(self):
options = KsefClientOptions(base_url="https://api-test.ksef.mf.gov.pl")
client = AsyncBaseHttpClient(options)
Expand Down
8 changes: 7 additions & 1 deletion tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,16 @@ def test_token_info_from_dict(self):
self.assertEqual(token.valid_until, "2024-01-01")

def test_auth_challenge_from_dict(self):
data = {"challenge": "c", "timestamp": "t", "timestampMs": 123}
data = {
"challenge": "c",
"timestamp": "t",
"timestampMs": 123,
"clientIp": "203.0.113.10",
}
parsed = models.AuthenticationChallengeResponse.from_dict(data)
self.assertEqual(parsed.challenge, "c")
self.assertEqual(parsed.timestamp_ms, 123)
self.assertEqual(parsed.client_ip, "203.0.113.10")

def test_auth_init_from_dict(self):
data = {"referenceNumber": "ref", "authenticationToken": {"token": "tok"}}
Expand Down
Loading
Loading