[WIP] Start an in-mem backend

This commit is contained in:
Thomas Sileo 2018-06-10 13:51:43 +02:00
parent f0880f0119
commit b75da521e4
4 changed files with 119 additions and 68 deletions

View file

@ -1,6 +1,6 @@
# Little Boxes
Tiny ActivityPub framework.
Tiny ActivityPub framework written in Python, both database and server agnostic.
## Getting Started

View file

@ -9,8 +9,9 @@ from enum import Enum
from .errors import BadActivityError
from .errors import UnexpectedActivityTypeError
from .errors import NotFromOutboxError
from .errors import ActivityNotFoundError
from .urlutils import check_url
from .utils import parse_collection
from .remote_object import OBJECT_FETCHER
from typing import List
from typing import Optional
@ -19,6 +20,9 @@ from typing import Any
from typing import Union
from typing import Type
import requests
logger = logging.getLogger(__name__)
# Helper/shortcut for typing
@ -48,6 +52,30 @@ def use_backend(backend_instance):
BACKEND = backend_instance
class DefaultRemoteObjectFetcher(object):
"""Not meant to be used on production, a caching layer, and DB shortcut fox inbox/outbox should be hooked."""
def __init__(self):
self._user_agent = 'Little Boxes (+https://github.com/tsileo/little_boxes)'
def fetch(self, iri):
print('OLD FETCHER')
check_url(iri)
resp = requests.get(iri, headers={
'Accept': 'application/activity+json',
'User-Agent': self._user_agent,
})
if resp.status_code == 404:
raise ActivityNotFoundError(f'{iri} cannot be fetched, 404 not found error')
resp.raise_for_status()
return resp.json()
class ActivityType(Enum):
"""Supported activity `type`."""
ANNOUNCE = 'Announce'
@ -116,38 +144,95 @@ def _get_actor_id(actor: ObjectOrIDType) -> str:
class BaseBackend(object):
"""In-memory backend meant to be used for the test suite."""
DB = {}
USERS = {}
FETCH_MOCK = {}
INBOX_IDX = {}
OUTBOX_IDX = {}
FOLLOWERS = {}
FOLLOWING = {}
def __init__(self):
self._setup_user('Thomas2', 'tom2')
self._setup_user('Thomas', 'tom')
def _setup_user(self, name, pusername):
p = Person(
name=name,
preferredUsername=pusername,
summary='Hello',
id=f'https://lol.com/{pusername}',
inbox=f'https://lol.com/{pusername}/inbox',
)
self.USERS[p.preferredUsername] = p
self.DB[p.id] = {
'inbox': [],
'outbox': [],
}
self.INBOX_IDX[p.id] = {}
self.OUTBOX_IDX[p.id] = {}
self.FOLLOWERS[p.id] = []
self.FOLLOWING[p.id] = []
self.FETCH_MOCK[p.id] = p.to_dict()
def fetch_iri(self, iri: str):
return self.FETCH_MOCK[iri]
def get_user(self, username: str) -> 'Person':
if username in self.USERS:
return self.USERS[username]
else:
raise ValueError(f'bad username {username}')
def outbox_is_blocked(self, as_actor: 'Person', actor_id: str) -> bool:
"""Returns True if `as_actor` has blocked `actor_id`."""
pass
for activity in self.DB[as_actor.id]['outbox']:
if activity.ACTIVITY_TYPE == ActivityType.BLOCK:
return True
return False
def inbox_get_by_iri(self, as_actor: 'Person', iri: str) -> 'BaseActivity':
pass
for activity in self.DB[as_actor.id]['inbox']:
if activity.id == iri:
return activity
raise ActivityNotFoundError()
def inbox_new(self, as_actor: 'Person', activity: 'BaseActivity') -> None:
pass
if activity.id in self.INBOX_IDX[as_actor.id]:
return
self.DB[as_actor.id]['inbox'].append(activity)
self.INBOX_IDX[as_actor.id][activity.id] = activity
def activity_url(self, obj_id: str) -> str:
# from the random hex ID
return 'TODO'
def outbox_new(self, activity: 'BaseActivity') -> None:
pass
print(f'saving {activity!r} to DB')
actor_id = activity.get_actor().id
if activity.id in self.OUTBOX_IDX[actor_id]:
return
self.DB[actor_id]['outbox'].append(activity)
self.OUTBOX_IDX[actor_id][activity.id] = activity
def new_follower(self, actor: 'Person') -> None:
def new_follower(self, as_actor: 'Person', actor: 'Person') -> None:
pass
def undo_new_follower(self, actor: 'Person') -> None:
pass
def new_following(self, actor: 'Person') -> None:
pass
def new_following(self, as_actor: 'Person', actor: 'Person') -> None:
print(f'new following {actor!r}')
self.FOLLOWING[as_actor.id].append(actor)
def undo_new_following(self, actor: 'Person') -> None:
pass
def post_to_remote_inbox(self, payload: ObjectType, recp: str) -> None:
pass
print(f'post_to_remote_inbox {payload} {recp}')
def is_from_outbox(self, activity: 'BaseActivity') -> None:
pass
@ -201,7 +286,7 @@ class _ActivityMeta(type):
cls = type.__new__(meta, name, bases, class_dict)
# Ensure the class has an activity type defined
if not cls.ACTIVITY_TYPE:
if name != 'BaseActivity' and not cls.ACTIVITY_TYPE:
raise ValueError(f'class {name} has no ACTIVITY_TYPE')
# Register it
@ -321,7 +406,7 @@ class BaseActivity(object, metaclass=_ActivityMeta):
logger.debug(f'setting ID {uri} / {obj_id}')
self._data['id'] = uri
try:
self._set_id(uri, obj_id)
self._outbox_set_id(uri, obj_id)
except NotImplementedError:
pass
@ -339,7 +424,7 @@ class BaseActivity(object, metaclass=_ActivityMeta):
def _validate_person(self, obj: ObjectOrIDType) -> str:
obj_id = self._actor_id(obj)
try:
actor = OBJECT_FETCHER.fetch(obj_id)
actor = BACKEND.fetch_iri(obj_id)
except Exception:
raise BadActivityError(f'failed to validate actor {obj!r}')
@ -355,7 +440,7 @@ class BaseActivity(object, metaclass=_ActivityMeta):
if isinstance(self._data['object'], dict):
p = parse_activity(self._data['object'])
else:
obj = OBJECT_FETCHER.fetch(self._data['object'])
obj = BACKEND.fetch_iri(self._data['object'])
if ActivityType(obj.get('type')) not in self.ALLOWED_OBJECT_TYPES:
raise UnexpectedActivityTypeError(f'invalid object type {obj.get("type")!r}')
p = parse_activity(obj)
@ -392,7 +477,7 @@ class BaseActivity(object, metaclass=_ActivityMeta):
raise BadActivityError(f'failed to fetch actor: {self._data!r}')
actor_id = self._actor_id(actor)
return Person(**OBJECT_FETCHER.fetch(actor_id))
return Person(**BACKEND.fetch_iri(actor_id))
def _pre_post_to_outbox(self) -> None:
raise NotImplementedError
@ -449,7 +534,7 @@ class BaseActivity(object, metaclass=_ActivityMeta):
# Assign create a random ID
obj_id = random_object_id()
# ABC
self.set_id(self.activity_url(obj_id), obj_id)
self.outbox_set_id(BACKEND.activity_url(obj_id), obj_id)
try:
self._pre_post_to_outbox()
@ -457,8 +542,7 @@ class BaseActivity(object, metaclass=_ActivityMeta):
except NotImplementedError:
logger.debug('pre post to outbox hook not implemented')
# ABC
self.outbox_new(self)
BACKEND.outbox_new(self)
recipients = self.recipients()
logger.info(f'recipients={recipients}')
@ -475,7 +559,7 @@ class BaseActivity(object, metaclass=_ActivityMeta):
logger.debug(f'posting to {recp}')
# ABC
self.post_to_remote_inbox(payload, recp)
BACKEND.post_to_remote_inbox(payload, recp)
def _recipients(self) -> List[str]:
return []
@ -497,8 +581,8 @@ class BaseActivity(object, metaclass=_ActivityMeta):
continue
actor = recipient
else:
raw_actor = OBJECT_FETCHER.fetch(recipient)
if raw_actor['type'] == ActivityType.PERSON.name:
raw_actor = BACKEND.fetch_iri(recipient)
if raw_actor['type'] == ActivityType.PERSON.value:
actor = Person(**raw_actor)
if actor.endpoints:
@ -517,7 +601,7 @@ class BaseActivity(object, metaclass=_ActivityMeta):
if item in [actor_id, AS_PUBLIC]:
continue
try:
col_actor = Person(**OBJECT_FETCHER.fetch(item))
col_actor = Person(**BACKEND.fetch_iri(item))
except UnexpectedActivityTypeError:
logger.exception(f'failed to fetch actor {item!r}')
@ -599,6 +683,9 @@ class Follow(BaseActivity):
# ABC
self.new_follower(remote_actor)
def _post_to_outbox(self, obj_id: str, activity: ObjectType, recipients: List[str]) -> None:
BACKEND.new_following(self.get_actor(), self.get_object())
def _undo_inbox(self) -> None:
# ABC
self.undo_new_follower(self.get_actor())
@ -770,7 +857,7 @@ class Delete(BaseActivity):
# FIXME(tsileo): overrides get_object instead?
obj = self.get_object()
if obj.type_enum == ActivityType.TOMBSTONE:
obj = parse_activity(OBJECT_FETCHER.fetch(obj.id))
obj = parse_activity(BACKEND.fetch_iri(obj.id))
return obj
def _recipients(self) -> List[str]:
@ -961,10 +1048,10 @@ class Outbox(Box):
def collection(self):
# TODO(tsileo): figure out an API
pass
class Inbox(Box):
def post(self, activity: BaseActivity) -> None:
activity.process_from_inbox(self.actor)

View file

@ -1,39 +0,0 @@
import logging
from typing import Any
import requests
from .urlutils import check_url
from .errors import ActivityNotFoundError
logger = logging.getLogger(__name__)
class DefaultRemoteObjectFetcher(object):
"""Not meant to be used on production, a caching layer, and DB shortcut fox inbox/outbox should be hooked."""
def __init__(self):
self._user_agent = 'Little Boxes (+https://github.com/tsileo/little_boxes)'
def fetch(self, iri):
check_url(iri)
resp = requests.get(iri, headers={
'Accept': 'application/activity+json',
'User-Agent': self._user_agent,
})
if resp.status_code == 404:
raise ActivityNotFoundError(f'{iri} cannot be fetched, 404 not found error')
resp.raise_for_status()
return resp.json()
OBJECT_FETCHER = DefaultRemoteObjectFetcher()
def set_object_fetcher(object_fetcher: Any):
global OBJECT_FETCHER
OBJECT_FETCHER = object_fetcher

View file

@ -1,5 +1,6 @@
"""Contains some ActivityPub related utils."""
from typing import Optional
from typing import Callable
from typing import Dict
from typing import List
from typing import Any
@ -7,22 +8,24 @@ from typing import Any
from .errors import RecursionLimitExceededError
from .errors import UnexpectedActivityTypeError
from .remote_object import OBJECT_FETCHER
def parse_collection(
payload: Optional[Dict[str, Any]] = None,
url: Optional[str] = None,
level: int = 0,
fetcher: Optional[Callable[[str], Dict[str, Any]]] = None,
) -> List[Any]:
"""Resolve/fetch a `Collection`/`OrderedCollection`."""
if not fetcher:
raise Exception('must provide a fetcher')
if level > 3:
raise RecursionLimitExceededError('recursion limit exceeded')
# Go through all the pages
out: List[Any] = []
if url:
payload = OBJECT_FETCHER.fetch(url)
payload = fetcher(url)
if not payload:
raise ValueError('must at least prove a payload or an URL')
@ -38,7 +41,7 @@ def parse_collection(
out.extend(payload['first']['items'])
n = payload['first'].get('next')
if n:
out.extend(parse_collection(url=n, level=level+1))
out.extend(parse_collection(url=n, level=level+1, fetcher=fetcher))
return out
while payload:
@ -50,7 +53,7 @@ def parse_collection(
n = payload.get('next')
if n is None:
break
payload = OBJECT_FETCHER.fetch(n)
payload = fetcher(n)
else:
raise UnexpectedActivityTypeError('unexpected activity type {}'.format(payload['type']))