Skip to content

Commit 45f08ec

Browse files
authored
Merge pull request #24 from fedi-libs/sync-client
feat: add synchronous support for apkit client
2 parents 83de3af + 592bf70 commit 45f08ec

11 files changed

Lines changed: 524 additions & 2 deletions

File tree

pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,11 @@ dependencies = [
1111
"aiohttp>=3.12.15",
1212
"apmodel>=0.4.1",
1313
"apsig>=0.5.4",
14+
"charset-normalizer>=3.4.3",
15+
"httpcore[http2,socks]>=1.0.9",
1416
"httpx>=0.28.1",
17+
"requests>=2.32.5",
18+
"types-requests>=2.32.4.20250913",
1519
]
1620

1721
[project.urls]

src/apkit/abc/server.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
from abc import ABC, abstractmethod
2+
from typing import Any, Optional, Callable, Union, Literal
3+
4+
from ..types import Outbox
5+
from ..models import Activity
6+
7+
class AbstractApkitIntegration(ABC):
8+
@abstractmethod
9+
def outbox(self, *args) -> None: ...
10+
11+
@abstractmethod
12+
def inbox(self, *args) -> None: ...
13+
14+
@abstractmethod
15+
def on(self, type: Union[type[Activity], type[Outbox]], func: Optional[Callable] = None) -> Any:
16+
def decorator(func: Callable) -> Callable: ...
17+
18+
@abstractmethod
19+
def webfinger(self, func: Optional[Callable] = None) -> Any:
20+
def decorator(func: Callable) -> Callable: ...
21+
22+
@abstractmethod
23+
def nodeinfo(
24+
self,
25+
route: str,
26+
version: Literal["2.0", "2.1"],
27+
func: Optional[Callable] = None,
28+
) -> Any:
29+
def decorator(fn: Callable) -> Callable: ...
30+

src/apkit/abc/types.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
from dataclasses import dataclass
2+
from typing import Any, List, Optional
3+
from abc import ABC, abstractmethod
4+
5+
from apmodel import Activity
6+
from apmodel.types import ActivityPubModel
7+
from apmodel.vocab.actor import Actor
8+
9+
from ..types import ActorKey
10+
11+
@dataclass
12+
class AbstractContext(ABC):
13+
activity: Activity
14+
request: Any
15+
16+
@abstractmethod
17+
def send(self, keys: List[ActorKey], target: Actor, activity: ActivityPubModel): ...
18+
19+
@abstractmethod
20+
def get_actor_keys(self, identifier: Optional[str]) -> List[ActorKey]: ...

src/apkit/client/_common.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,16 @@ def validate_webfinger_result(result: WebfingerResult, expected_subject: Resourc
1212
raise ValueError(
1313
f"Mismatched subject in response. Expected {expected_subject}, got {result.subject}"
1414
)
15+
16+
def _is_expected_content_type(actual_ctype: str, expected_ctype_prefix: str) -> bool:
17+
mime_type = actual_ctype.split(';')[0].strip().lower()
18+
19+
if mime_type == 'application/json':
20+
return True
21+
if mime_type.endswith('+json'):
22+
return True
23+
24+
if expected_ctype_prefix and mime_type.startswith(expected_ctype_prefix.split(';')[0].lower()):
25+
return True
26+
27+
return False

src/apkit/client/models.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
import re
2-
from dataclasses import dataclass
32
from typing import Any, Dict, List, Union, Optional
43

4+
from dataclasses import dataclass
5+
56
@dataclass(frozen=True)
67
class Resource:
78
"""Represents a WebFinger resource."""
89

910
username: str
1011
host: str
11-
url: Optional[str]
12+
url: Optional[str] = None
1213

1314
def __str__(self) -> str:
1415
return f"acct:{self.username}@{self.host}"

src/apkit/client/sync/actor.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
from typing import TYPE_CHECKING, Union
2+
3+
from apmodel.types import ActivityPubModel
4+
from .. import _common, models
5+
6+
if TYPE_CHECKING:
7+
from .client import ActivityPubClient
8+
9+
10+
class ActorFetcher:
11+
def __init__(self, client: "ActivityPubClient"):
12+
self.__client: "ActivityPubClient" = client
13+
14+
def resolve(self, username: str, host: str) -> models.WebfingerResult:
15+
"""Resolves an actor's profile from a remote server."""
16+
resource = models.Resource(username=username, host=host)
17+
url = _common.build_webfinger_url(host=host, resource=resource)
18+
19+
resp = self.__client.get(url)
20+
if resp.ok:
21+
data = resp.json()
22+
result = models.WebfingerResult.from_dict(data)
23+
_common.validate_webfinger_result(result, resource)
24+
return result
25+
else:
26+
raise ValueError(f"Failed to resolve Actor: {url}")
27+
28+
def fetch(self, url: str) -> Union[ActivityPubModel, dict]:
29+
resp = self.__client.get(url, headers={"User-Agent": "apkit/0.3.0", "Accept": "application/activity+json"})
30+
if resp.ok:
31+
data = resp.parse()
32+
return data
33+
else:
34+
raise ValueError(f"Failed to resolve Actor: {url}")

src/apkit/client/sync/client.py

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
import datetime
2+
import typing
3+
from typing_extensions import Optional
4+
import json
5+
import warnings
6+
7+
import apsig
8+
from apsig import draft
9+
from apmodel.types import ActivityPubModel
10+
from cryptography.hazmat.primitives.asymmetric import ed25519, rsa
11+
import httpcore
12+
13+
from .actor import ActorFetcher
14+
from .exceptions import TooManyRedirects, NotImplementedWarning
15+
from .types import Response
16+
from ...types import ActorKey
17+
from ..._version import __version__
18+
19+
20+
class ActivityPubClient:
21+
def __init__(self, user_agent: str = f"apkit/{__version__}") -> None:
22+
self.user_agent = user_agent
23+
self.actor: ActorFetcher = ActorFetcher(self)
24+
25+
self.__http: Optional[httpcore.ConnectionPool] = None
26+
27+
def __enter__(self) -> "ActivityPubClient":
28+
self.__http = httpcore.ConnectionPool()
29+
return self
30+
31+
def __exit__(self, *args) -> None:
32+
if self.__http:
33+
self.__http.close()
34+
35+
def __sign_request(
36+
self,
37+
url: str,
38+
headers: dict,
39+
signatures: typing.List[ActorKey],
40+
body: typing.Optional[typing.Union[dict, ActivityPubModel, bytes]] = None,
41+
sign_with: typing.List[
42+
typing.Literal["draft-cavage", "rsa2017", "fep8b32", "rfc9421"]
43+
] = ["draft-cavage", "rsa2017", "fep8b32"],
44+
) -> typing.Tuple[Optional[bytes], dict]:
45+
if isinstance(body, ActivityPubModel):
46+
body = body.to_json()
47+
48+
signed_cavage = False
49+
signed_rsa2017 = False
50+
signed_fep8b32 = False
51+
signed_rfc9421 = False
52+
53+
for signature in signatures:
54+
if isinstance(signature.private_key, rsa.RSAPrivateKey):
55+
if "rfc9421" in sign_with and not signed_rfc9421:
56+
warnings.warn(
57+
'This signature spec "rfc9421" is not implemented yet.',
58+
category=NotImplementedWarning,
59+
stacklevel=2,
60+
)
61+
signed_rfc9421 = True
62+
63+
if "draft-cavage" in sign_with and not signed_cavage:
64+
signer = draft.Signer(
65+
headers=dict(headers) if headers else {},
66+
method="POST",
67+
url=str(url),
68+
key_id=signature.key_id,
69+
private_key=signature.private_key,
70+
body=body if body else b"",
71+
)
72+
headers = signer.sign()
73+
signed_cavage = True
74+
75+
if "rsa2017" in sign_with and body and not signed_rsa2017:
76+
ld_signer = apsig.LDSignature()
77+
body = ld_signer.sign(
78+
doc=(body if not isinstance(body, bytes) else json.loads(body)),
79+
creator=signature.key_id,
80+
private_key=signature.private_key,
81+
)
82+
signed_rsa2017 = True
83+
elif isinstance(signature.private_key, ed25519.Ed25519PrivateKey):
84+
if "fep8b32" in sign_with and body and not signed_fep8b32:
85+
now = (
86+
datetime.datetime.now().isoformat(sep="T", timespec="seconds")
87+
+ "Z"
88+
)
89+
fep_8b32_signer = apsig.ProofSigner(
90+
private_key=signature.private_key
91+
)
92+
body = fep_8b32_signer.sign(
93+
unsecured_document=(
94+
body if not isinstance(body, bytes) else json.loads(body)
95+
),
96+
options={
97+
"type": "DataIntegrityProof",
98+
"cryptosuite": "eddsa-jcs-2022",
99+
"proofPurpose": "assertionMethod",
100+
"verificationMethod": signature.key_id,
101+
"created": now,
102+
},
103+
)
104+
signed_fep8b32 = True
105+
if isinstance(body, bytes):
106+
return body, headers
107+
return json.dumps(body, ensure_ascii=False).encode("utf-8"), headers
108+
109+
def __transform_to_bytes(
110+
self, content: typing.Union[bytes, str, dict, ActivityPubModel]
111+
) -> bytes:
112+
if isinstance(content, bytes):
113+
return content
114+
elif isinstance(content, str):
115+
return content.encode("utf-8")
116+
elif isinstance(content, dict):
117+
return json.dumps(content, ensure_ascii=False).encode("utf-8")
118+
elif isinstance(content, ActivityPubModel):
119+
return json.dumps(content.to_json(), ensure_ascii=False).encode("utf-8")
120+
121+
def request(
122+
self,
123+
method: str,
124+
url: httpcore.URL | str,
125+
headers: dict = {},
126+
content: str | dict | ActivityPubModel | bytes | None = None,
127+
allow_redirect: bool = True,
128+
max_redirects: int = 5,
129+
signatures: typing.List[ActorKey] = [],
130+
sign_with: typing.List[
131+
typing.Literal["draft-cavage", "rsa2017", "fep8b32", "rfc9421"]
132+
] = ["draft-cavage", "rsa2017", "fep8b32"],
133+
) -> Response:
134+
if not self.__http:
135+
raise NotImplementedError
136+
if headers.get("user_agent") is None:
137+
headers["user_agent"] = self.user_agent
138+
if content is not None:
139+
content = self.__transform_to_bytes(content)
140+
if signatures != []:
141+
content, headers = self.__sign_request(
142+
url=bytes(url).decode("ascii")
143+
if isinstance(url, httpcore.URL)
144+
else url,
145+
headers=headers,
146+
signatures=signatures,
147+
body=content,
148+
sign_with=sign_with,
149+
)
150+
response = self.__http.request(
151+
method=method.upper(), url=url, headers=headers, content=content
152+
)
153+
if allow_redirect:
154+
if response.status in [301, 307, 308]:
155+
for i in range(max_redirects):
156+
location = (
157+
{
158+
key.decode("utf-8"): value.decode("utf-8")
159+
for key, value in response.headers
160+
}
161+
).get("Location")
162+
response = self.__http.request(
163+
method=method.upper(),
164+
url=location,
165+
headers=headers,
166+
content=content,
167+
)
168+
if response.status not in [301, 307, 308]:
169+
return Response(response)
170+
raise TooManyRedirects
171+
return Response(response)
172+
173+
def post(
174+
self,
175+
url: httpcore.URL | str,
176+
headers: dict = {},
177+
body: dict | str | bytes | None = None,
178+
allow_redirect: bool = True,
179+
max_redirects: int = 5,
180+
signatures: typing.List[ActorKey] = [],
181+
sign_with: typing.List[
182+
typing.Literal["draft-cavage", "rsa2017", "fep8b32", "rfc9421"]
183+
] = ["draft-cavage", "rsa2017", "fep8b32"],
184+
) -> Response:
185+
if body is not None:
186+
body = self.__transform_to_bytes(body)
187+
resp = self.request(
188+
"POST",
189+
url=url,
190+
headers=headers,
191+
content=body,
192+
allow_redirect=allow_redirect,
193+
max_redirects=max_redirects,
194+
signatures=signatures,
195+
sign_with=sign_with,
196+
)
197+
return resp
198+
199+
def get(
200+
self,
201+
url: httpcore.URL | str,
202+
headers: dict = {},
203+
allow_redirect: bool = True,
204+
max_redirects: int = 5,
205+
signatures: typing.List[ActorKey] = [],
206+
sign_with: typing.List[
207+
typing.Literal["draft-cavage", "rsa2017", "fep8b32", "rfc9421"]
208+
] = ["draft-cavage", "rsa2017", "fep8b32"],
209+
) -> Response:
210+
resp = self.request(
211+
"GET",
212+
url=url,
213+
headers=headers,
214+
allow_redirect=allow_redirect,
215+
max_redirects=max_redirects,
216+
signatures=signatures,
217+
sign_with=sign_with,
218+
)
219+
return resp
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
class ContentTypeError(Exception):
2+
def __init__(self, message: str, status: int, headers: dict):
3+
super().__init__(message)
4+
self.status = status
5+
self.headers = headers
6+
7+
class TooManyRedirects(Exception):
8+
pass
9+
10+
class NotImplementedWarning(Warning):
11+
pass

0 commit comments

Comments
 (0)