diff --git a/activate/activate_manager.py b/activate/activate_manager.py index 622fee6..f8567aa 100644 --- a/activate/activate_manager.py +++ b/activate/activate_manager.py @@ -1,29 +1,42 @@ +import functools from typing import Callable +from fastapi import HTTPException + from activate.types.activity_streams.core_types import Activity +activate_manager: "ActivateManager" + def get_activate_manager(): - return ActivateManager() + global activate_manager + if not activate_manager: + activate_manager = ActivateManager() + return activate_manager class ActivateManager: - schema: dict[Activity, Callable] + schema: dict[str, Callable] = {} - def resolve(self, activity: Activity): + async def resolve(self, activity: str): """ Called by the web adapter to resolve activities """ - # TODO: check the schema for a registered activity that matches the requested one - # TODO: if so, call it + if self.schema[Activity]: + return await self.schema[Activity](activity) + else: + raise HTTPException(status_code=404, detail="Activity can NOT be handled") - def resolver(self): + def resolver(self, resolver_function): """ A decorator used by the package user to register custom resolvers for activities """ - def wrapper(resolver_function): - # TODO: check that the first argument to the function is an activity - # TODO: if so, register the resolver function in schema - return resolver_function + @functools.wraps(resolver_function) + async def wrapper(activity: Activity): + self.schema[Activity] = resolver_function + return resolver_function(activity) return wrapper + + +activate_manager = ActivateManager() diff --git a/activate/types/activity_streams/activity_types.py b/activate/types/activity_streams/activity_types.py index 47423a5..f995484 100644 --- a/activate/types/activity_streams/activity_types.py +++ b/activate/types/activity_streams/activity_types.py @@ -2,11 +2,12 @@ from pydantic import AnyUrl + from activate.types.activity_streams.core_types import ( Activity, IntransitiveActivity, Object, - Link + Link, ) @@ -26,7 +27,11 @@ class Arrive(Activity): type: AnyUrl = "Arrive" -class Block("Ignore"): +class Ignore(Activity): + type: AnyUrl = "Ignore" + + +class Block(Ignore): type: AnyUrl = "Block" @@ -50,11 +55,11 @@ class Follow(Activity): type: AnyUrl = "Follow" -class Ignore(Activity): - type: AnyUrl = "Ignore" +class Offer(Activity): + type: AnyUrl = "Offer" -class Invite("Offer"): +class Invite(Offer): type: AnyUrl = "Invite" @@ -68,63 +73,59 @@ class Leave(Activity): class Like(Activity): type: AnyUrl = "Like" - - + + class Listen(Activity): type: AnyUrl = "Listen" - - + + class Move(Activity): type: AnyUrl = "Move" - - -class Offer(Activity): - type: AnyUrl = "Offer" - - + + class Question(IntransitiveActivity): type: AnyUrl = "Question" # oneOf and anyOf must NOT exist together - #FIXME: how to validate this? - oneOf: list[Object | Link] | Object | Link - anyOf: list[Object | Link] | Object | Link + # FIXME: how to validate this? + # oneOf: list[Object | Link] | Object | Link + # anyOf: list[Object | Link] | Object | Link # closed can also be a generic object as per the vocabulary. HOW? - closed: datetime | bool - - + # closed: datetime | bool + + class Reject(Activity): type: AnyUrl = "Reject" - - + + class Read(Activity): type: AnyUrl = "Read" - - + + class Remove(Activity): type: AnyUrl = "Remove" - - + + class TentativeReject(Reject): type: AnyUrl = "TentativeReject" - - + + class TentativeAccept(Accept): type: AnyUrl = "TentativeAccept" - - + + class Travel(IntransitiveActivity): - type: AnyUrl = "Travel" - - + type: AnyUrl = "Travel" + + class Undo(Activity): type: AnyUrl = "Undo" - - + + class Update(Activity): type: AnyUrl = "Update" - - + + class View(Activity): type: AnyUrl = "View" diff --git a/activate/types/activity_streams/core_types.py b/activate/types/activity_streams/core_types.py index df69ad1..0deb316 100644 --- a/activate/types/activity_streams/core_types.py +++ b/activate/types/activity_streams/core_types.py @@ -1,8 +1,11 @@ from datetime import datetime, timedelta +from typing import Union + from pydantic import BaseModel, AnyUrl from pydantic import Field + class Link(BaseModel): # Type is NOT explicitly defined in the standard vocabulary as functional. # It is assumed to be functional (a single value) here @@ -17,7 +20,7 @@ class Link(BaseModel): # In the [HTML5], any string NOT containing the # "space" U+0020, "tab" (U+0009), "LF" (U+000A), "FF" (U+000C), "CR" (U+000D) or "," (U+002C) characters # can be used as a valid link relation. - rel: list[str] | str + # rel: list[str] | str # should be a MIME media type (how to validate this? Is there a specific set of MIME types?) mediaType: str @@ -37,7 +40,7 @@ class Link(BaseModel): height: int = Field(ge=0) width: int = Field(ge=0) - preview: list["Object" | "Link"] | "Object" | "Link" + preview: Union[list["Object"], list["Link"], "Object", "Link"] class Object(BaseModel): @@ -47,9 +50,9 @@ class Object(BaseModel): id: AnyUrl | None - attachment: list["Object" | Link] | "Object" | Link - attributedTo: list["Object" | Link] | "Object" | Link - audience: list["Object" | Link] | "Object" | Link + # attachment: Union[list["Object"], Link, "Object", Link] + # attributedTo: Union[list["Object"], Link, "Object", Link] + # audience: Union[list["Object" | Link], "Object", Link] # By default, content is an HTML string. # Otherwise, use the mediatype property to declare the type of string. How to enforce this? @@ -60,7 +63,7 @@ class Object(BaseModel): # content is NOT explicitly defined in the standard vocabulary as functional. # It is assumed to be functional (a single value) here - context: "Object" | Link + # context: Union["Object", Link] # name must NOT include HTML markup (how to enforce this?). # It can be a map of the name in different languages (maybe a map would be supported later) @@ -72,16 +75,16 @@ class Object(BaseModel): # content is NOT explicitly defined in the standard vocabulary as functional. # It is assumed to be functional (a single value) here - generator: "Object" | Link + # generator: Union["Object", Link] # Icons should be of 1:1 aspect ratio and should be suitable for presentation at a small size - icon: list["Image" | Link] | "Image" | Link + # icon: Union[list["Image", Link], "Image", Link] # Images don't have the limitations assumed in icons - image: list["Image" | Link] | "Image" | Link + # image: list["Image" | Link] | "Image" | Link - inReplyTo: list["Object" | Link] | "Object" | Link - location: list["Object" | Link] | "Object" | Link - preview: list["Object" | Link] | "Object" | Link + # inReplyTo: list["Object" | Link] | "Object" | Link + # location: list["Object" | Link] | "Object" | Link + # preview: list["Object" | Link] | "Object" | Link published: datetime # replies is a collection, but it is a functional property (a single collection) @@ -90,17 +93,17 @@ class Object(BaseModel): startTime: datetime # Like the content property - summary: list[str] | str + # summary: list[str] | str # Attachments imply association by inclusion. Tags imply association by reference. - tag: list["Object" | Link] | "Object" | Link + # tag: list["Object" | Link] | "Object" | Link updated: datetime - url: list["AnyUrl" | Link] | "AnyUrl" | Link - to: list["Object" | Link] | "Object" | Link - bto: list["Object" | Link] | "Object" | Link - cc: list["Object" | Link] | "Object" | Link - bcc: list["Object" | Link] | "Object" | Link + # url: list["AnyUrl" | Link] | "AnyUrl" | Link + # to: list["Object" | Link] | "Object" | Link + # bto: list["Object" | Link] | "Object" | Link + # cc: list["Object" | Link] | "Object" | Link + # bcc: list["Object" | Link] | "Object" | Link # should be a MIME media type (how to validate this? Is there a specific set of MIME types?) mediaType: str @@ -112,16 +115,17 @@ class Object(BaseModel): class BaseActivity(Object): + ... # Abstract class for the activity and IntransitiveActivity Types # Subproperty of "attributedTO" - #TODO: what are the complications of this? - actor: list["Object" | Link] | "Object" | Link + # TODO: what are the complications of this? + # actor: list["Object" | Link] | "Object" | Link - target: list["Object" | Link] | "Object" | Link - result: list["Object" | Link] | "Object" | Link - origin: list["Object" | Link] | "Object" | Link - instrument: list["Object" | Link] | "Object" | Link + # target: list["Object" | Link] | "Object" | Link + # result: list["Object" | Link] | "Object" | Link + # origin: list["Object" | Link] | "Object" | Link + # instrument: list["Object" | Link] | "Object" | Link class IntransitiveActivity(BaseActivity): @@ -131,7 +135,7 @@ class IntransitiveActivity(BaseActivity): class Activity(BaseActivity): type: AnyUrl = "Activity" - object: list["Object" | Link] | "Object" | Link + # object: list["Object" | Link] | "Object" | Link class Collection(Object): @@ -141,10 +145,10 @@ class Collection(Object): # serialized within the Collection object instance totalItems: int = Field(ge=0) - current: "CollectionPage" | Link - first: "CollectionPage" | Link - last: "CollectionPage" | Link - items: list[Object | Link] | Object | Link + # current: "CollectionPage" | Link + # first: "CollectionPage" | Link + # last: "CollectionPage" | Link + # items: list[Object | Link] | Object | Link class OrderedCollection(Collection): @@ -154,17 +158,12 @@ class OrderedCollection(Collection): class CollectionPage(Collection): type: AnyUrl = "CollectionPage" - partOf: Collection | Link - next: "CollectionPage" | Link - prev: "CollectionPage" | Link + # partOf: Collection | Link + # next: "CollectionPage" | Link + # prev: "CollectionPage" | Link class OrderedCollectionPage(OrderedCollection, CollectionPage): type: AnyUrl = "OrderedCollectionPage" startIndex: int = Field(ge=0) - - - - - diff --git a/activate/types/activity_streams/extended_object_types.py b/activate/types/activity_streams/extended_object_types.py index be1be4b..cc8e1f3 100644 --- a/activate/types/activity_streams/extended_object_types.py +++ b/activate/types/activity_streams/extended_object_types.py @@ -15,24 +15,27 @@ class Relationship(Object): object: Object | Link relationship: Object - + class Article(Object): type: AnyUrl = "Article" - - + + class Document(Object): type: AnyUrl = "Document" - - + + class Audio(Document): type: AnyUrl = "Audio" - - + + class Video(Document): type: AnyUrl = "Video" - - + + +class Image(Document): + type: AnyUrl = "Video" + class Note(Object): type: AnyUrl = "Note" @@ -41,9 +44,10 @@ class Page(Document): """ Represents a web page """ + type: AnyUrl = "Page" - - + + class Event(Object): type: AnyUrl = "Event" @@ -61,12 +65,13 @@ class Place(Object): # By default it is meters. # How to validate it is a unit? units: str = "m" - + class Mention(Link): """ A specialized Link that represents an @mention. """ + type: AnyUrl = "Mention" @@ -82,6 +87,7 @@ class Tombstone(Object): It can be used in Collections to signify that there used to be an object at this position, but it has been deleted. """ + type: AnyUrl = "Tombstone" formerType: Object diff --git a/activate/web/fastapi/app.py b/activate/web/fastapi/app.py index c937011..29e59f0 100644 --- a/activate/web/fastapi/app.py +++ b/activate/web/fastapi/app.py @@ -9,13 +9,18 @@ # TODO: check the initialization of the activate manager # TODO: check the middleware to ensure the right content-type and accept header are correct + @activate_router.get("/") -async def activitypub_get_endpoint(activity: Activity = Body(), - activate_manager: ActivateManager = Depends(get_activate_manager)): +async def activitypub_get_endpoint( + activity: Activity = Body(), + activate_manager: ActivateManager = Depends(get_activate_manager), +): return await activate_manager.resolve(activity) @activate_router.post("/") -async def activitypub_post_endpoint(activity: Activity, - activate_manager: ActivateManager = Depends(get_activate_manager)): +async def activitypub_post_endpoint( + activity: Activity, + activate_manager: ActivateManager = Depends(get_activate_manager), +): return await activate_manager.resolve(activity) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..d68c6e3 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +fastapi==0.91.0 +pytest==7.2.1 +black==23.1.0 +isort==5.12.0 +mypy==1.0.0 \ No newline at end of file diff --git a/tests/test_reciever.py b/tests/test_reciever.py new file mode 100644 index 0000000..b4ababf --- /dev/null +++ b/tests/test_reciever.py @@ -0,0 +1,16 @@ +from activate.activate_manager import get_activate_manager +import pytest + + +@pytest.fixture +def manager(): + yield get_activate_manager() + + +def test_manager_resolver(manager): + @manager.resolver + def to_be_decorated(): + return "BUG!" + + to_be_decorated("test") + assert manager.schema.get("test") is not None