Skip to content

Commit ccc1623

Browse files
authored
Merge pull request #33 from sij411/feat/ap-discovery
Implement ActivityPub discovery endpoints
2 parents b8fe0ab + 0cc162c commit ccc1623

10 files changed

Lines changed: 420 additions & 3 deletions

File tree

Cargo.lock

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ license = "AGPL-3.0-only"
1515
iri-string = { version = "0.7.12", default-features = false, features = ["alloc", "serde"] }
1616
serde = { version = "1.0.219", default-features = false, features = ["alloc", "derive"] }
1717
serde_json = "1.0.140"
18+
tower = { version = "0.5", features = ["util"]}
1819

1920
[workspace.lints.rust]
2021
warnings = "deny"

crates/feder-runtime-server/Cargo.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@ thiserror = "2"
1212
tokio = { version = "1", features = ["macros", "net", "rt-multi-thread"] }
1313
tracing = "0.1"
1414
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
15+
serde.workspace = true
16+
17+
[dev-dependencies]
18+
serde_json.workspace = true
19+
tower.workspace = true
1520

1621
[lints]
1722
workspace = true
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
// Feder: A portable ActivityPub core for many runtimes.
2+
// Copyright (C) 2026 Feder contributors
3+
//
4+
// This program is free software: you can redistribute it and/or modify
5+
// it under the terms of the GNU Affero General Public License as published by
6+
// the Free Software Foundation, version 3.
7+
//
8+
// This program is distributed in the hope that it will be useful,
9+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
10+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11+
// GNU Affero General Public License for more details.
12+
//
13+
// You should have received a copy of the GNU Affero General Public License
14+
// along with this program. If not, see <https://www.gnu.org/licenses/>.
15+
16+
use axum::{
17+
Json,
18+
extract::{Path, State},
19+
http::{StatusCode, header},
20+
response::{IntoResponse, Response},
21+
};
22+
23+
use crate::app::AppState;
24+
25+
pub async fn actor(
26+
State(app_state): State<AppState>,
27+
Path(username): Path<String>,
28+
) -> Result<Response, StatusCode> {
29+
if username != app_state.username {
30+
return Err(StatusCode::NOT_FOUND);
31+
}
32+
let local_actor = app_state.local_actor.clone();
33+
34+
Ok((
35+
[(header::CONTENT_TYPE, "application/activity+json")],
36+
Json(local_actor),
37+
)
38+
.into_response())
39+
}
40+
41+
#[cfg(test)]
42+
mod tests {
43+
use axum::{
44+
body::{Body, to_bytes},
45+
http::{Request, StatusCode, header},
46+
};
47+
use serde_json::Value;
48+
use tower::ServiceExt;
49+
50+
use crate::{app::build_router, config::RuntimeConfig};
51+
52+
#[tokio::test]
53+
async fn returns_local_actor() {
54+
let app = build_router(&RuntimeConfig::default_local());
55+
56+
let response = app
57+
.oneshot(
58+
Request::builder()
59+
.uri("/users/alice")
60+
.body(Body::empty())
61+
.expect("valid request"),
62+
)
63+
.await
64+
.expect("response");
65+
66+
assert_eq!(response.status(), StatusCode::OK);
67+
assert_eq!(
68+
response.headers().get(header::CONTENT_TYPE).unwrap(),
69+
"application/activity+json"
70+
);
71+
72+
let body = to_bytes(response.into_body(), 2048)
73+
.await
74+
.expect("read response body");
75+
let json: Value = serde_json::from_slice(&body).expect("valid json");
76+
77+
assert_eq!(json["@context"], "https://www.w3.org/ns/activitystreams");
78+
assert_eq!(json["type"], "Person");
79+
assert_eq!(json["id"], "http://127.0.0.1:3000/users/alice");
80+
assert_eq!(json["inbox"], "http://127.0.0.1:3000/users/alice/inbox");
81+
assert_eq!(json["outbox"], "http://127.0.0.1:3000/users/alice/outbox");
82+
assert_eq!(json["preferredUsername"], "alice");
83+
assert_eq!(json["name"], "alice");
84+
}
85+
86+
#[tokio::test]
87+
async fn rejects_unknown_actor() {
88+
let app = build_router(&RuntimeConfig::default_local());
89+
90+
let response = app
91+
.oneshot(
92+
Request::builder()
93+
.uri("/users/bob")
94+
.body(Body::empty())
95+
.expect("valid request"),
96+
)
97+
.await
98+
.expect("response");
99+
100+
assert_eq!(response.status(), StatusCode::NOT_FOUND);
101+
}
102+
}

crates/feder-runtime-server/src/app.rs

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,27 +16,47 @@
1616
use std::sync::{Arc, Mutex};
1717

1818
use crate::config::RuntimeConfig;
19+
use crate::webfinger::webfinger;
20+
use crate::{actor::actor, note::note};
1921
use axum::{Router, http::StatusCode, routing::get};
2022
use feder_core::{FederConfig, FederCore};
21-
use feder_vocab::Actor;
23+
use feder_vocab::{Actor, Note, Reference};
2224

2325
#[derive(Clone)]
2426
pub struct AppState {
2527
pub core: Arc<Mutex<FederCore>>,
28+
pub local_actor: Actor,
29+
pub username: String,
30+
pub handle_host: String,
31+
// TODO(#25): Replace this seeded preview note with durable runtime storage.
32+
pub notes: Vec<Note>,
2633
}
2734

2835
impl AppState {
2936
pub fn from_config(config: &RuntimeConfig) -> Self {
30-
let actor = Actor::person(
37+
let mut actor = Actor::person(
3138
config.actor_id.clone(),
3239
config.inbox.clone(),
3340
config.outbox.clone(),
3441
);
42+
actor.preferred_username = Some(config.preferred_username.clone());
43+
actor.name = Some(config.username.clone());
3544

36-
let core = FederCore::new(FederConfig::new(actor));
45+
// TODO(#25): Replace this seeded preview note with durable runtime storage.
46+
let mut note = Note::new(config.note_id.clone());
47+
note.attributed_to = Some(Reference::id(config.actor_id.clone()));
48+
note.content =
49+
Some("Hello, World! This is Feder, a portable AP core for many runtimes.".to_string());
50+
let notes = vec![note];
51+
52+
let core = FederCore::new(FederConfig::new(actor.clone()));
3753

3854
Self {
3955
core: Arc::new(Mutex::new(core)),
56+
local_actor: actor,
57+
username: config.username.clone(),
58+
handle_host: config.handle_host.clone(),
59+
notes,
4060
}
4161
}
4262
}
@@ -46,6 +66,9 @@ pub fn build_router(config: &RuntimeConfig) -> Router {
4666

4767
Router::new()
4868
.route("/healthz", get(healthz))
69+
.route("/.well-known/webfinger", get(webfinger))
70+
.route("/users/{username}", get(actor))
71+
.route("/notes/{id}", get(note))
4972
.with_state(state)
5073
}
5174

crates/feder-runtime-server/src/config.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@ pub struct RuntimeConfig {
2222
pub actor_id: Iri,
2323
pub inbox: Iri,
2424
pub outbox: Iri,
25+
pub username: String,
26+
pub preferred_username: String,
27+
pub handle_host: String,
28+
// TODO(#25): Replace this seeded preview note with durable runtime storage.
29+
pub note_id: Iri,
2530
}
2631

2732
impl RuntimeConfig {
@@ -39,6 +44,13 @@ impl RuntimeConfig {
3944
bind: "127.0.0.1:3000"
4045
.parse()
4146
.expect("valid default bind address"),
47+
username: "alice".to_string(),
48+
preferred_username: "alice".to_string(),
49+
handle_host: "127.0.0.1:3000".to_string(),
50+
// TODO(#25): Replace this seeded preview note with durable runtime storage.
51+
note_id: "http://127.0.0.1:3000/notes/1"
52+
.parse()
53+
.expect("valid default note IRI"),
4254
}
4355
}
4456
}

crates/feder-runtime-server/src/lib.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@
1313
// You should have received a copy of the GNU Affero General Public License
1414
// along with this program. If not, see <https://www.gnu.org/licenses/>.
1515

16+
pub mod actor;
1617
pub mod app;
1718
pub mod config;
1819
pub mod error;
20+
pub mod note;
21+
pub mod webfinger;
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
// Feder: A portable ActivityPub core for many runtimes.
2+
// Copyright (C) 2026 Feder contributors
3+
//
4+
// This program is free software: you can redistribute it and/or modify
5+
// it under the terms of the GNU Affero General Public License as published by
6+
// the Free Software Foundation, version 3.
7+
//
8+
// This program is distributed in the hope that it will be useful,
9+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
10+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11+
// GNU Affero General Public License for more details.
12+
//
13+
// You should have received a copy of the GNU Affero General Public License
14+
// along with this program. If not, see <https://www.gnu.org/licenses/>.
15+
16+
use axum::{
17+
Json,
18+
extract::{Path, State},
19+
http::{StatusCode, header},
20+
response::{IntoResponse, Response},
21+
};
22+
23+
use crate::app::AppState;
24+
25+
pub async fn note(
26+
State(app_state): State<AppState>,
27+
Path(id): Path<String>,
28+
) -> Result<Response, StatusCode> {
29+
// TODO(#25): Replace this seeded preview note with durable runtime storage.
30+
let expected_suffix = format!("/notes/{id}");
31+
let note = app_state
32+
.notes
33+
.iter()
34+
.find(|note| note.id.as_str().ends_with(&expected_suffix))
35+
.cloned()
36+
.ok_or(StatusCode::NOT_FOUND)?;
37+
38+
Ok((
39+
[(header::CONTENT_TYPE, "application/activity+json")],
40+
Json(note),
41+
)
42+
.into_response())
43+
}
44+
45+
#[cfg(test)]
46+
mod tests {
47+
use axum::{
48+
body::{Body, to_bytes},
49+
http::{Request, StatusCode, header},
50+
};
51+
use serde_json::Value;
52+
use tower::ServiceExt;
53+
54+
use crate::{app::build_router, config::RuntimeConfig};
55+
56+
#[tokio::test]
57+
async fn returns_public_note() {
58+
let app = build_router(&RuntimeConfig::default_local());
59+
60+
let response = app
61+
.oneshot(
62+
Request::builder()
63+
.uri("/notes/1")
64+
.body(Body::empty())
65+
.expect("valid request"),
66+
)
67+
.await
68+
.expect("response");
69+
70+
assert_eq!(response.status(), StatusCode::OK);
71+
assert_eq!(
72+
response.headers().get(header::CONTENT_TYPE).unwrap(),
73+
"application/activity+json"
74+
);
75+
76+
let body = to_bytes(response.into_body(), 2048)
77+
.await
78+
.expect("read response body");
79+
let json: Value = serde_json::from_slice(&body).expect("valid json");
80+
81+
assert_eq!(json["@context"], "https://www.w3.org/ns/activitystreams");
82+
assert_eq!(json["type"], "Note");
83+
assert_eq!(json["id"], "http://127.0.0.1:3000/notes/1");
84+
assert_eq!(json["attributedTo"], "http://127.0.0.1:3000/users/alice");
85+
assert_eq!(
86+
json["content"],
87+
"Hello, World! This is Feder, a portable AP core for many runtimes."
88+
);
89+
}
90+
91+
#[tokio::test]
92+
async fn rejects_unknown_note() {
93+
let app = build_router(&RuntimeConfig::default_local());
94+
95+
let response = app
96+
.oneshot(
97+
Request::builder()
98+
.uri("/notes/missing")
99+
.body(Body::empty())
100+
.expect("valid request"),
101+
)
102+
.await
103+
.expect("response");
104+
105+
assert_eq!(response.status(), StatusCode::NOT_FOUND);
106+
}
107+
}

0 commit comments

Comments
 (0)