pub mod fees;
pub mod limitation;
pub mod retention;
use std::future::Future;
use std::pin::Pin;
use serde::{Deserialize, Serialize};
use thiserror::Error;
pub use self::fees::{RelayFee, RelayFees};
pub use self::limitation::RelayLimitation;
pub use self::retention::{KindRange, RelayRetention};
use crate::key::PublicKey;
use crate::types::Url;
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct RelayInformation {
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub pubkey: Option<PublicKey>,
#[serde(rename = "self", skip_serializing_if = "Option::is_none")]
pub self_pubkey: Option<PublicKey>,
#[serde(skip_serializing_if = "Option::is_none")]
pub contact: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub supported_nips: Vec<u16>,
#[serde(skip_serializing_if = "Option::is_none")]
pub software: Option<Url>,
#[serde(skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub terms_of_service: Option<Url>,
#[serde(skip_serializing_if = "Option::is_none")]
pub banner: Option<Url>,
#[serde(skip_serializing_if = "Option::is_none")]
pub icon: Option<Url>,
#[serde(skip_serializing_if = "Option::is_none")]
pub limitation: Option<RelayLimitation>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub relay_countries: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub language_tags: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub posting_policy: Option<Url>,
#[serde(skip_serializing_if = "Option::is_none")]
pub payments_url: Option<Url>,
#[serde(skip_serializing_if = "Option::is_none")]
pub fees: Option<RelayFees>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub retention: Vec<RelayRetention>,
}
impl RelayInformation {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn supports_nip(&self, nip: u16) -> bool {
self.supported_nips.contains(&nip)
}
}
pub const NIP11_MEDIA_TYPE: &str = "application/nostr+json";
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum Nip11FetchError {
#[error("NIP-11 relay-info fetch failed: {0}")]
Transport(String),
#[error("NIP-11 relay-info fetch returned HTTP {0}")]
Status(u16),
#[error("NIP-11 relay-info JSON decode failed: {0}")]
Decode(#[from] serde_json::Error),
}
pub type FetchFuture<'a, T, E> = Pin<Box<dyn Future<Output = Result<T, E>> + Send + 'a>>;
pub trait Nip11Fetcher: Send + Sync {
fn fetch<'a>(&'a self, url: &'a Url) -> FetchFuture<'a, RelayInformation, Nip11FetchError>;
}
#[cfg(feature = "nip11-fetch")]
#[cfg_attr(docsrs, doc(cfg(feature = "nip11-fetch")))]
pub use reqwest_impl::ReqwestNip11Fetcher;
#[cfg(feature = "nip11-fetch")]
mod reqwest_impl {
use super::{
FetchFuture, NIP11_MEDIA_TYPE, Nip11FetchError, Nip11Fetcher, RelayInformation, Url,
};
#[derive(Debug, Clone)]
pub struct ReqwestNip11Fetcher {
client: reqwest::Client,
}
impl ReqwestNip11Fetcher {
pub fn new() -> Result<Self, reqwest::Error> {
let client = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.build()?;
Ok(Self { client })
}
#[must_use]
pub const fn from_client(client: reqwest::Client) -> Self {
Self { client }
}
}
async fn do_fetch(
client: &reqwest::Client,
url: &Url,
) -> Result<RelayInformation, Nip11FetchError> {
let response = client
.get(url.as_str())
.header(reqwest::header::ACCEPT, NIP11_MEDIA_TYPE)
.send()
.await
.map_err(|e| Nip11FetchError::Transport(e.to_string()))?;
let status = response.status();
if !status.is_success() {
return Err(Nip11FetchError::Status(status.as_u16()));
}
let body = response
.text()
.await
.map_err(|e| Nip11FetchError::Transport(e.to_string()))?;
serde_json::from_str::<RelayInformation>(&body).map_err(Nip11FetchError::Decode)
}
impl Nip11Fetcher for ReqwestNip11Fetcher {
fn fetch<'a>(&'a self, url: &'a Url) -> FetchFuture<'a, RelayInformation, Nip11FetchError> {
Box::pin(do_fetch(&self.client, url))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Keys;
fn fixture_pubkey() -> PublicKey {
let keys = Keys::parse("0000000000000000000000000000000000000000000000000000000000000003")
.unwrap();
*keys.public_key()
}
#[test]
fn empty_serializes_to_empty_object() {
let json = serde_json::to_string(&RelayInformation::default()).unwrap();
assert_eq!(json, "{}");
}
#[test]
fn round_trip_full_document() {
let info = RelayInformation {
name: Some("Nula Relay".to_owned()),
description: Some("A reliable Nostr relay.".to_owned()),
pubkey: Some(fixture_pubkey()),
self_pubkey: Some(fixture_pubkey()),
contact: Some("ops@nula.example".to_owned()),
supported_nips: vec![1, 9, 11, 19, 42],
software: Some(Url::parse("https://github.com/qntx/nula").unwrap()),
version: Some("0.1.0".to_owned()),
terms_of_service: Some(Url::parse("https://nula.example/tos").unwrap()),
banner: Some(Url::parse("https://nula.example/banner.png").unwrap()),
icon: Some(Url::parse("https://nula.example/icon.png").unwrap()),
limitation: Some(RelayLimitation {
max_message_length: Some(16_384),
max_subscriptions: Some(20),
auth_required: Some(true),
..RelayLimitation::default()
}),
relay_countries: vec!["US".into(), "JP".into()],
language_tags: vec!["en".into(), "ja".into()],
tags: vec!["general".into()],
posting_policy: Some(Url::parse("https://nula.example/policy").unwrap()),
payments_url: Some(Url::parse("https://nula.example/billing").unwrap()),
fees: Some(RelayFees {
admission: vec![RelayFee {
amount: 1000,
unit: "msats".into(),
period: None,
kinds: None,
}],
..RelayFees::default()
}),
retention: vec![RelayRetention {
kinds: vec![KindRange::Single(crate::Kind::from(0_u16))],
time: Some(3600),
count: None,
}],
};
let json = serde_json::to_string(&info).unwrap();
assert!(json.contains(r#""banner":"https://nula.example/banner.png""#));
assert!(json.contains(r#""self":""#));
let parsed: RelayInformation = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, info);
}
#[test]
fn self_field_round_trips_independently_from_pubkey() {
let info = RelayInformation {
self_pubkey: Some(fixture_pubkey()),
..RelayInformation::default()
};
let json = serde_json::to_string(&info).unwrap();
assert!(json.contains(r#""self":""#));
assert!(!json.contains(r#""pubkey""#));
let parsed: RelayInformation = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, info);
}
#[test]
fn unknown_fields_are_ignored() {
let json = r#"{
"name": "Future",
"future_field": 42,
"supported_nips": [1, 99]
}"#;
let info: RelayInformation = serde_json::from_str(json).unwrap();
assert_eq!(info.name.as_deref(), Some("Future"));
assert!(info.supports_nip(99));
assert!(!info.supports_nip(7));
}
#[derive(Debug, Clone)]
struct MockNip11Fetcher {
body: String,
}
impl Nip11Fetcher for MockNip11Fetcher {
fn fetch<'a>(
&'a self,
_url: &'a Url,
) -> FetchFuture<'a, RelayInformation, Nip11FetchError> {
let body = self.body.clone();
Box::pin(async move {
serde_json::from_str::<RelayInformation>(&body).map_err(Nip11FetchError::Decode)
})
}
}
fn block_on<F: Future>(future: F) -> F::Output {
use std::task::{Context, Poll, Waker};
let waker = Waker::noop();
let mut cx = Context::from_waker(waker);
let mut future = Box::pin(future);
loop {
if let Poll::Ready(v) = future.as_mut().poll(&mut cx) {
return v;
}
}
}
#[test]
fn mock_fetcher_round_trips_relay_information() {
let fetcher = MockNip11Fetcher {
body: r#"{
"name": "Mock Relay",
"supported_nips": [1, 11, 42],
"limitation": {"max_message_length": 8192, "auth_required": false}
}"#
.to_owned(),
};
let url = Url::parse("https://relay.example/").unwrap();
let info = block_on(fetcher.fetch(&url)).unwrap();
assert_eq!(info.name.as_deref(), Some("Mock Relay"));
assert!(info.supports_nip(11));
assert_eq!(
info.limitation.as_ref().and_then(|l| l.max_message_length),
Some(8192),
);
}
#[test]
fn mock_fetcher_surfaces_decode_error_on_invalid_json() {
let fetcher = MockNip11Fetcher {
body: "not json".to_owned(),
};
let url = Url::parse("https://relay.example/").unwrap();
let err = block_on(fetcher.fetch(&url)).unwrap_err();
assert!(matches!(err, Nip11FetchError::Decode(_)));
}
}