Skip to content

Getting Started with apkit

This tutorial will guide you through creating a basic ActivityPub server using apkit. You'll learn how to create an actor, make it discoverable, and handle incoming Follow requests.

Prerequisites

apkit's server module is built on FastAPI. To get started, you'll need to install apkit with the server extras.

pip install "apkit[server]"

You will also need an ASGI server to run your application. We'll use uvicorn in this tutorial.

pip install uvicorn

1. Basic Server and Actor Setup

First, let's import the necessary components and set up a basic server and a Person actor.

# main.py
import uuid
from fastapi import Request, Response
from fastapi.responses import JSONResponse
from cryptography.hazmat.primitives import rsa
from cryptography.hazmat.primitives import serialization as crypto_serialization

from apkit.server import ActivityPubServer, SubRouter
from apkit.server.types import Context, ActorKey
from apkit.server.responses import ActivityResponse
from apkit.models import (
    Person, CryptographicKey, Follow, Actor as APKitActor,
    Nodeinfo, NodeinfoSoftware, NodeinfoProtocol, NodeinfoServices, NodeinfoUsage, NodeinfoUsageUsers
)
from apkit.client.models import Resource, WebfingerResult, WebfingerLink
from apkit.client.asyncio.client import ActivityPubClient

# --- Configuration ---
HOST = "example.com"
USER_ID = str(uuid.uuid4())

# --- Key Generation (for demonstration) ---
# In a real application, you would load a persistent key from a secure storage.
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
public_key_pem = private_key.public_key().public_bytes(
    encoding=crypto_serialization.Encoding.PEM,
    format=crypto_serialization.PublicFormat.SubjectPublicKeyInfo
).decode('utf-8')

# --- Actor Definition ---
actor = Person(
    id=f"https://{HOST}/users/{USER_ID}",
    name="apkit Demo",
    preferredUsername="demo",
    summary="This is a demo actor powered by apkit!",
    inbox=f"https://{HOST}/users/{USER_ID}/inbox",
    outbox=f"https://{HOST}/users/{USER_ID}/outbox",
    publicKey=CryptographicKey(
        id=f"https://{HOST}/users/{USER_ID}#main-key",
        owner=f"https://{HOST}/users/{USER_ID}",
        publicKeyPem=public_key_pem
    )
)

# --- Server Initialization ---
app = ActivityPubServer()

2. Serving the Actor

To make your actor accessible to others, you need an endpoint that serves the actor object.

# main.py (continued)
@app.get("/users/{identifier}")
async def get_actor_endpoint(identifier: str):
    if identifier == USER_ID:
        return ActivityResponse(actor)
    return JSONResponse({"error": "Not Found"}, status_code=404)

3. Making the Actor Discoverable with Webfinger

Webfinger allows users on other servers to find your actor using an address like demo@example.com.

# main.py (continued)
@app.webfinger()
async def webfinger_endpoint(request: Request, acct: Resource) -> Response:
    if acct.username == "demo" and acct.host == HOST:
        link = WebfingerLink(
            rel="self",
            type="application/activity+json",
            href=f"https://{HOST}/users/{USER_ID}"
        )
        wf_result = WebfingerResult(subject=acct, links=[link])
        return JSONResponse(wf_result.to_json(), media_type="application/jrd+json")
    return JSONResponse({"message": "Not Found"}, status_code=404)

4. Setting up the Inbox

Before your server can receive activities, you need to define an inbox endpoint. The app.inbox() method registers a URL pattern as an inbox. Any valid POST request to this URL will be processed by your activity handlers.

# main.py (continued)
app.inbox("/users/{identifier}/inbox")

5. Handling Incoming Activities

The @app.on() decorator registers a handler for specific incoming activities. Let's create a handler for Follow requests that automatically sends back a signed Accept activity.

# main.py (continued)

# This function provides the private key for signing outgoing activities.
async def get_keys_for_actor(identifier: str) -> list[ActorKey]:
    if identifier == USER_ID:
        return [ActorKey(key_id=actor.publicKey.id, private_key=private_key)]
    return []

@app.on(Follow)
async def on_follow_activity(ctx: Context):
    activity = ctx.activity
    if not isinstance(activity, Follow):
        return JSONResponse({"error": "Invalid activity type"}, status_code=400)

    # Resolve the actor who sent the Follow request
    follower_actor = None
    if isinstance(activity.actor, str):
        async with ActivityPubClient() as client:
            follower_actor = await client.actor.fetch(activity.actor)
    elif isinstance(activity.actor, APKitActor):
        follower_actor = activity.actor

    if not follower_actor:
        return JSONResponse({"error": "Could not resolve follower actor"}, status_code=400)

    # Automatically accept the follow request
    accept_activity = activity.accept()

    # Send the signed Accept activity back to the follower's inbox
    await ctx.send(
        get_keys_for_actor,
        follower_actor,
        accept_activity
    )
    return Response(status_code=202)

6. Adding Server Metadata with NodeInfo

NodeInfo is a protocol to publish standardized metadata about your server. apkit provides a simple decorator to expose this information.

# main.py (continued)
@app.nodeinfo("/nodeinfo/2.1", "2.1")
async def nodeinfo_endpoint():
    return ActivityResponse(
        Nodeinfo(
            version="2.1",
            software=NodeinfoSoftware(name="apkit-demo", version="0.1.0"),
            protocols=[NodeinfoProtocol.ACTIVITYPUB],
            services=NodeinfoServices(inbound=[], outbound=[]),
            openRegistrations=False,
            usage=NodeinfoUsage(users=NodeinfoUsageUsers(total=1)),
            metadata={},
        )
    )

7. Organizing your code with SubRouter

For larger applications, you can use SubRouter, which works just like FastAPI's APIRouter. It allows you to organize your endpoints into different files. SubRouter also supports apkit-specific decorators like @sub.nodeinfo().

# main.py (continued)
# You could move this to a separate file, e.g., `nodeinfo.py`
sub = SubRouter()

@sub.nodeinfo("/ni/2.0", "2.0")
async def nodeinfo_20_endpoint():
    # ... (implementation similar to the above)
    pass

app.include_router(sub)

8. Running the Server

Save the complete code to a file named main.py. You can then run it with uvicorn.

uvicorn main:app --host 0.0.0.0 --port 8000

Your simple ActivityPub server is now running! You can test it by searching for @demo@example.com from another Fediverse instance.

9. Full Code Example

Here is the complete code for main.py:

import uuid
from fastapi import Request, Response
from fastapi.responses import JSONResponse
from cryptography.hazmat.primitives import rsa
from cryptography.hazmat.primitives import serialization as crypto_serialization

from apkit.server import ActivityPubServer, SubRouter
from apkit.server.types import Context, ActorKey
from apkit.server.responses import ActivityResponse
from apkit.models import (
    Person, CryptographicKey, Follow, Actor as APKitActor,
    Nodeinfo, NodeinfoSoftware, NodeinfoProtocol, NodeinfoServices, NodeinfoUsage, NodeinfoUsageUsers
)
from apkit.client.models import Resource, WebfingerResult, WebfingerLink
from apkit.client.asyncio.client import ActivityPubClient

# --- Configuration ---
HOST = "example.com"
USER_ID = str(uuid.uuid4())

# --- Key Generation (for demonstration) ---
# In a real application, you would load a persistent key from a secure storage.
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
public_key_pem = private_key.public_key().public_bytes(
    encoding=crypto_serialization.Encoding.PEM,
    format=crypto_serialization.PublicFormat.SubjectPublicKeyInfo
).decode('utf-8')

# --- Actor Definition ---
actor = Person(
    id=f"https://{HOST}/users/{USER_ID}",
    name="apkit Demo",
    preferredUsername="demo",
    summary="This is a demo actor powered by apkit!",
    inbox=f"https://{HOST}/users/{USER_ID}/inbox",
    outbox=f"https://{HOST}/users/{USER_ID}/outbox",
    publicKey=CryptographicKey(
        id=f"https://{HOST}/users/{USER_ID}#main-key",
        owner=f"https://{HOST}/users/{USER_ID}",
        publicKeyPem=public_key_pem
    )
)

# --- Server Initialization ---
app = ActivityPubServer()

# --- Key Retrieval Function ---
# This function provides the private key for signing outgoing activities.
async def get_keys_for_actor(identifier: str) -> list[ActorKey]:
    if identifier == USER_ID:
        return [ActorKey(key_id=actor.publicKey.id, private_key=private_key)]
    return []

# --- Endpoints ---
app.inbox("/users/{identifier}/inbox")

@app.get("/users/{identifier}")
async def get_actor_endpoint(identifier: str):
    if identifier == USER_ID:
        return ActivityResponse(actor)
    return JSONResponse({"error": "Not Found"}, status_code=404)

@app.webfinger()
async def webfinger_endpoint(request: Request, acct: Resource) -> Response:
    if acct.username == "demo" and acct.host == HOST:
        link = WebfingerLink(
            rel="self",
            type="application/activity+json",
            href=f"https://{HOST}/users/{USER_ID}"
        )
        wf_result = WebfingerResult(subject=acct, links=[link])
        return JSONResponse(wf_result.to_json(), media_type="application/jrd+json")
    return JSONResponse({"message": "Not Found"}, status_code=404)

@app.nodeinfo("/nodeinfo/2.1", "2.1")
async def nodeinfo_endpoint():
    return ActivityResponse(
        Nodeinfo(
            version="2.1",
            software=NodeinfoSoftware(name="apkit-demo", version="0.1.0"),
            protocols=[NodeinfoProtocol.ACTIVITYPUB],
            services=NodeinfoServices(inbound=[], outbound=[]),
            openRegistrations=False,
            usage=NodeinfoUsage(users=NodeinfoUsageUsers(total=1)),
            metadata={},
        )
    )

# --- Activity Handlers ---
@app.on(Follow)
async def on_follow_activity(ctx: Context):
    activity = ctx.activity
    if not isinstance(activity, Follow):
        return JSONResponse({"error": "Invalid activity type"}, status_code=400)

    # Resolve the actor who sent the Follow request
    follower_actor = None
    if isinstance(activity.actor, str):
        async with ActivityPubClient() as client:
            follower_actor = await client.actor.fetch(activity.actor)
    elif isinstance(activity.actor, APKitActor):
        follower_actor = activity.actor

    if not follower_actor:
        return JSONResponse({"error": "Could not resolve follower actor"}, status_code=400)

    # Automatically accept the follow request
    accept_activity = activity.accept()

    # Send the signed Accept activity back to the follower's inbox
    await ctx.send(
        get_keys_for_actor,
        follower_actor,
        accept_activity
    )
    return Response(status_code=202)