forecite 0.1.3

Official Rust SDK for the Forecite API — scored news feed (REST), Verdict scoring, and realtime WebSocket streaming.
Documentation
use std::collections::HashMap;

use reqwest::{Client, Method, RequestBuilder};
use serde::de::DeserializeOwned;
use serde::Deserialize;

use crate::error::ForeciteError;
use crate::stream::ForeciteStream;
use crate::types::*;

const DEFAULT_BASE: &str = "https://api.forecite.dev";
const DEFAULT_WS: &str = "wss://ws.forecite.dev";

#[derive(Deserialize)]
struct Envelope<T> {
    data: T,
}

/// Builder for [`Forecite`] — override `base_url` / `ws_url` for local dev.
pub struct ForeciteBuilder {
    api_key: String,
    base_url: String,
    ws_url: String,
}

impl ForeciteBuilder {
    /// API base. Default `https://api.forecite.dev`; local dev `http://localhost:3000/api`.
    pub fn base_url(mut self, url: impl Into<String>) -> Self {
        self.base_url = url.into().trim_end_matches('/').to_string();
        self
    }
    /// WebSocket base. Default `wss://ws.forecite.dev`; local dev `ws://localhost:8080`.
    pub fn ws_url(mut self, url: impl Into<String>) -> Self {
        self.ws_url = url.into().trim_end_matches('/').to_string();
        self
    }
    pub fn build(self) -> Forecite {
        Forecite {
            api_key: self.api_key,
            base_url: self.base_url,
            ws_url: self.ws_url,
            http: Client::new(),
        }
    }
}

/// Forecite API client — REST + scoring + realtime streaming.
#[derive(Clone)]
pub struct Forecite {
    api_key: String,
    base_url: String,
    ws_url: String,
    http: Client,
}

impl Forecite {
    pub fn new(api_key: impl Into<String>) -> Self {
        Self::builder(api_key).build()
    }

    pub fn builder(api_key: impl Into<String>) -> ForeciteBuilder {
        ForeciteBuilder {
            api_key: api_key.into(),
            base_url: DEFAULT_BASE.to_string(),
            ws_url: DEFAULT_WS.to_string(),
        }
    }

    fn request(&self, method: Method, path: &str) -> RequestBuilder {
        self.http
            .request(method, format!("{}{}", self.base_url, path))
            .bearer_auth(&self.api_key)
    }

    async fn send<T: DeserializeOwned>(&self, builder: RequestBuilder) -> Result<T, ForeciteError> {
        let resp = builder.send().await?;
        let status = resp.status();
        let bytes = resp.bytes().await?;
        if !status.is_success() {
            let (code, message) = parse_error(&bytes);
            return Err(ForeciteError::Api {
                status: status.as_u16(),
                code,
                message,
            });
        }
        Ok(serde_json::from_slice(&bytes)?)
    }

    async fn send_data<T: DeserializeOwned>(
        &self,
        builder: RequestBuilder,
    ) -> Result<T, ForeciteError> {
        let env: Envelope<T> = self.send(builder).await?;
        Ok(env.data)
    }

    // ── Account ──
    pub async fn me(&self) -> Result<KeyInfo, ForeciteError> {
        self.send_data(self.request(Method::GET, "/v1/me")).await
    }

    pub async fn usage(&self, days: u32) -> Result<UsageInfo, ForeciteError> {
        self.send_data(self.request(Method::GET, "/v1/usage").query(&[("days", days)]))
            .await
    }

    // ── Scoring (Verdict Engine) ──
    pub async fn score(
        &self,
        artifact: &Artifact,
        options: Option<&ScoreOptions>,
    ) -> Result<Verdict, ForeciteError> {
        let mut body = serde_json::json!({ "artifact": artifact });
        if let Some(opts) = options {
            body["options"] = serde_json::to_value(opts)?;
        }
        self.send_data(self.request(Method::POST, "/v1/score").json(&body))
            .await
    }

    // ── Feeds ──
    pub async fn feeds_list(&self, query: &FeedsQuery) -> Result<FeedList, ForeciteError> {
        self.send(self.request(Method::GET, "/v1/feeds").query(query))
            .await
    }

    pub async fn feeds_get(&self, id: &str) -> Result<FeedItem, ForeciteError> {
        self.send_data(self.request(Method::GET, &format!("/v1/feeds/{}", encode(id))))
            .await
    }

    // ── Reference ──
    pub async fn providers_list(&self) -> Result<Vec<Provider>, ForeciteError> {
        self.send_data(self.request(Method::GET, "/v1/providers"))
            .await
    }
    pub async fn providers_get(&self, id: &str) -> Result<Provider, ForeciteError> {
        self.send_data(self.request(Method::GET, &format!("/v1/providers/{}", encode(id))))
            .await
    }

    pub async fn sources_list(&self) -> Result<Vec<String>, ForeciteError> {
        self.send_data(self.request(Method::GET, "/v1/sources")).await
    }
    pub async fn sources_get(&self, name: &str) -> Result<SourceInfo, ForeciteError> {
        self.send_data(self.request(Method::GET, &format!("/v1/sources/{}", encode(name))))
            .await
    }

    pub async fn symbols_list(&self, limit: Option<u32>) -> Result<Vec<SymbolInfo>, ForeciteError> {
        let mut req = self.request(Method::GET, "/v1/symbols");
        if let Some(l) = limit {
            req = req.query(&[("limit", l)]);
        }
        self.send_data(req).await
    }
    pub async fn symbols_get(&self, ticker: &str) -> Result<SymbolInfo, ForeciteError> {
        self.send_data(self.request(Method::GET, &format!("/v1/symbols/{}", encode(ticker))))
            .await
    }

    pub async fn tags_list(&self) -> Result<HashMap<String, Vec<TagValue>>, ForeciteError> {
        self.send_data(self.request(Method::GET, "/v1/tags")).await
    }
    pub async fn tags_get(&self, dimension: &str) -> Result<TagDimensionValues, ForeciteError> {
        self.send_data(self.request(Method::GET, &format!("/v1/tags/{}", encode(dimension))))
            .await
    }

    // ── Webhooks ──
    pub async fn webhooks_list(&self) -> Result<Vec<Webhook>, ForeciteError> {
        self.send_data(self.request(Method::GET, "/v1/webhooks"))
            .await
    }
    pub async fn webhooks_create(
        &self,
        url: &str,
        description: Option<&str>,
    ) -> Result<Webhook, ForeciteError> {
        let body = serde_json::json!({ "url": url, "description": description });
        self.send_data(self.request(Method::POST, "/v1/webhooks").json(&body))
            .await
    }
    pub async fn webhooks_get(&self, id: &str) -> Result<Webhook, ForeciteError> {
        self.send_data(self.request(Method::GET, &format!("/v1/webhooks/{}", encode(id))))
            .await
    }
    pub async fn webhooks_update(
        &self,
        id: &str,
        update: &UpdateWebhook,
    ) -> Result<Webhook, ForeciteError> {
        self.send_data(
            self.request(Method::PATCH, &format!("/v1/webhooks/{}", encode(id)))
                .json(update),
        )
        .await
    }
    pub async fn webhooks_delete(&self, id: &str) -> Result<(), ForeciteError> {
        let _: Envelope<serde_json::Value> = self
            .send(self.request(Method::DELETE, &format!("/v1/webhooks/{}", encode(id))))
            .await?;
        Ok(())
    }

    // ── Realtime ──
    /// Open a realtime feed subscription over the WebSocket gateway.
    pub async fn stream(
        &self,
        filters: &StreamFilters,
        snapshot: u32,
    ) -> Result<ForeciteStream, ForeciteError> {
        ForeciteStream::connect(&self.ws_url, &self.api_key, filters, snapshot).await
    }
}

fn encode(segment: &str) -> String {
    urlencoding::encode(segment).into_owned()
}

fn parse_error(bytes: &[u8]) -> (Option<String>, Option<String>) {
    #[derive(Deserialize)]
    struct ErrEnvelope {
        error: Option<ErrBody>,
    }
    #[derive(Deserialize)]
    struct ErrBody {
        code: Option<String>,
        message: Option<String>,
    }
    serde_json::from_slice::<ErrEnvelope>(bytes)
        .ok()
        .and_then(|e| e.error)
        .map(|b| (b.code, b.message))
        .unwrap_or((None, None))
}