feat: implement merge readiness plan to close gaps between v2 and v1
Some checks failed
lint / lint (push) Has been cancelled
test / unit (push) Has been cancelled
test / integration (push) Has been cancelled
lint / lint (pull_request) Failing after 5m8s
test / unit (pull_request) Successful in 16m18s
test / integration (pull_request) Failing after 16m59s

- Task 1: Fix feed response hydration by adding `to_thought_response` helper and updating feed handlers to return full `ThoughtResponse`.
- Task 2: Wire follower/following REST routes for user feeds.
- Task 3: Add user listing and count endpoints, including `GET /users` and `GET /users/count`.
- Task 4: Implement popular tags feature with `GET /tags/popular`.
- Task 5: Enhance configuration with HOST, CORS_ORIGINS, and optional rate limiting using tower-governor.
This commit is contained in:
2026-05-14 16:28:18 +02:00
parent e6f4a6256f
commit 004bfb427b
30 changed files with 8716 additions and 808 deletions

View File

@@ -1,5 +1,9 @@
use async_trait::async_trait;
use domain::{errors::DomainError, events::{DomainEvent, EventEnvelope}, ports::{EventConsumer, EventPublisher}};
use domain::{
errors::DomainError,
events::{DomainEvent, EventEnvelope},
ports::{EventConsumer, EventPublisher},
};
use event_payload::EventPayload;
use futures::stream::BoxStream;
@@ -31,8 +35,8 @@ impl<T: Transport> EventPublisher for EventPublisherAdapter<T> {
async fn publish(&self, event: &DomainEvent) -> Result<(), DomainError> {
let payload = EventPayload::from(event);
let subject = payload.subject();
let bytes = serde_json::to_vec(&payload)
.map_err(|e| DomainError::Internal(e.to_string()))?;
let bytes =
serde_json::to_vec(&payload).map_err(|e| DomainError::Internal(e.to_string()))?;
tracing::debug!(subject, "publishing event");
self.transport.publish_bytes(subject, &bytes).await
}
@@ -44,7 +48,7 @@ impl<T: Transport> EventPublisher for EventPublisherAdapter<T> {
pub struct RawMessage {
pub subject: String,
pub payload: Vec<u8>,
pub ack: Box<dyn Fn() + Send + Sync>,
pub ack: Box<dyn Fn() + Send + Sync>,
pub nack: Box<dyn Fn() + Send + Sync>,
}
@@ -60,7 +64,9 @@ pub struct EventConsumerAdapter<S: MessageSource> {
}
impl<S: MessageSource> EventConsumerAdapter<S> {
pub fn new(source: S) -> Self { Self { source } }
pub fn new(source: S) -> Self {
Self { source }
}
}
impl<S: MessageSource> EventConsumer for EventConsumerAdapter<S> {
@@ -90,7 +96,7 @@ impl<S: MessageSource> EventConsumer for EventConsumerAdapter<S> {
};
Some(Ok(EventEnvelope {
event,
ack: msg.ack,
ack: msg.ack,
nack: msg.nack,
}))
}
@@ -103,8 +109,8 @@ impl<S: MessageSource> EventConsumer for EventConsumerAdapter<S> {
mod tests {
use super::*;
use async_trait::async_trait;
use std::sync::{Arc, Mutex};
use domain::value_objects::{ThoughtId, UserId};
use std::sync::{Arc, Mutex};
struct SpyTransport {
calls: Arc<Mutex<Vec<(String, Vec<u8>)>>>,
@@ -112,13 +118,21 @@ mod tests {
impl SpyTransport {
fn new() -> (Self, Arc<Mutex<Vec<(String, Vec<u8>)>>>) {
let calls = Arc::new(Mutex::new(vec![]));
(Self { calls: calls.clone() }, calls)
(
Self {
calls: calls.clone(),
},
calls,
)
}
}
#[async_trait]
impl Transport for SpyTransport {
async fn publish_bytes(&self, subject: &str, bytes: &[u8]) -> Result<(), DomainError> {
self.calls.lock().unwrap().push((subject.to_string(), bytes.to_vec()));
self.calls
.lock()
.unwrap()
.push((subject.to_string(), bytes.to_vec()));
Ok(())
}
}
@@ -127,11 +141,14 @@ mod tests {
async fn thought_created_routes_to_correct_subject() {
let (spy, calls) = SpyTransport::new();
let publisher = EventPublisherAdapter::new(spy);
publisher.publish(&DomainEvent::ThoughtCreated {
thought_id: ThoughtId::new(),
user_id: UserId::new(),
in_reply_to_id: None,
}).await.unwrap();
publisher
.publish(&DomainEvent::ThoughtCreated {
thought_id: ThoughtId::new(),
user_id: UserId::new(),
in_reply_to_id: None,
})
.await
.unwrap();
let calls = calls.lock().unwrap();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].0, "thoughts.created");
@@ -141,10 +158,13 @@ mod tests {
async fn serialized_payload_is_valid_json() {
let (spy, calls) = SpyTransport::new();
let publisher = EventPublisherAdapter::new(spy);
publisher.publish(&DomainEvent::UserBlocked {
blocker_id: UserId::new(),
blocked_id: UserId::new(),
}).await.unwrap();
publisher
.publish(&DomainEvent::UserBlocked {
blocker_id: UserId::new(),
blocked_id: UserId::new(),
})
.await
.unwrap();
let bytes = calls.lock().unwrap()[0].1.clone();
let json: serde_json::Value = serde_json::from_slice(&bytes).expect("valid JSON");
assert_eq!(json["type"], "UserBlocked");
@@ -163,14 +183,16 @@ mod tests {
let payload = EventPayload::from(&event);
let bytes = serde_json::to_vec(&payload).unwrap();
struct OneMessageSource { bytes: Vec<u8> }
struct OneMessageSource {
bytes: Vec<u8>,
}
#[async_trait::async_trait]
impl MessageSource for OneMessageSource {
fn messages(&self) -> futures::stream::BoxStream<'_, Result<RawMessage, DomainError>> {
let msg = RawMessage {
subject: "thoughts.created".to_string(),
payload: self.bytes.clone(),
ack: Box::new(|| {}),
ack: Box::new(|| {}),
nack: Box::new(|| {}),
};
Box::pin(futures::stream::once(async { Ok(msg) }))
@@ -194,7 +216,7 @@ mod tests {
let msg = RawMessage {
subject: "bad".to_string(),
payload: b"not valid json".to_vec(),
ack: Box::new(|| {}),
ack: Box::new(|| {}),
nack: Box::new(|| {}),
};
Box::pin(futures::stream::once(async { Ok(msg) }))