ventureinkorea 0.1.2

Async Rust client for the VentureInKorea API -- Korean venture companies, startup glossary, and ecosystem guides.
Documentation
use std::time::Duration;

use crate::error::VentureError;
use crate::types::*;

const DEFAULT_BASE_URL: &str = "https://ventureinkorea.com";
const DEFAULT_TIMEOUT_SECS: u64 = 30;

/// Builder for configuring a [`VentureInKorea`] client.
pub struct VentureInKoreaBuilder {
    base_url: String,
    timeout: Duration,
}

impl VentureInKoreaBuilder {
    fn new() -> Self {
        Self {
            base_url: DEFAULT_BASE_URL.to_string(),
            timeout: Duration::from_secs(DEFAULT_TIMEOUT_SECS),
        }
    }

    /// Set the API base URL.
    pub fn base_url(mut self, url: &str) -> Self {
        self.base_url = url.trim_end_matches('/').to_string();
        self
    }

    /// Set the request timeout.
    pub fn timeout(mut self, timeout: Duration) -> Self {
        self.timeout = timeout;
        self
    }

    /// Build the client.
    pub fn build(self) -> Result<VentureInKorea, VentureError> {
        let http = reqwest::Client::builder().timeout(self.timeout).build()?;
        Ok(VentureInKorea {
            base_url: self.base_url,
            http,
        })
    }
}

/// Async client for the VentureInKorea REST API.
///
/// Provides access to Korean venture-certified companies, startup glossary,
/// blog posts, and unified search.
pub struct VentureInKorea {
    pub(crate) base_url: String,
    pub(crate) http: reqwest::Client,
}

impl VentureInKorea {
    /// Create a new client with default settings.
    pub fn new() -> Self {
        Self {
            base_url: DEFAULT_BASE_URL.to_string(),
            http: reqwest::Client::builder()
                .timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS))
                .build()
                .expect("failed to create HTTP client"),
        }
    }

    /// Create a builder for custom configuration.
    pub fn builder() -> VentureInKoreaBuilder {
        VentureInKoreaBuilder::new()
    }

    async fn get<T: serde::de::DeserializeOwned>(&self, path: &str) -> Result<T, VentureError> {
        let url = format!("{}{}", self.base_url, path);
        let resp = self.http.get(&url).send().await?;
        let status = resp.status();

        if status == reqwest::StatusCode::NOT_FOUND {
            return Err(VentureError::NotFound {
                resource: path.to_string(),
            });
        }
        if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
            let retry = resp
                .headers()
                .get("retry-after")
                .and_then(|v| v.to_str().ok())
                .and_then(|v| v.parse().ok())
                .unwrap_or(60);
            return Err(VentureError::RateLimit {
                retry_after: retry,
            });
        }
        if !status.is_success() {
            let body = resp.text().await.unwrap_or_default();
            return Err(VentureError::Api {
                status: status.as_u16(),
                message: body,
            });
        }

        Ok(resp.json().await?)
    }

    // -- Glossary Terms -----------------------------------------------------

    /// List all glossary terms.
    pub async fn list_terms(
        &self,
        params: Option<&[(&str, &str)]>,
    ) -> Result<PaginatedResponse<GlossaryTerm>, VentureError> {
        let qs = build_query(params);
        self.get(&format!("/api/v1/terms/{}", qs)).await
    }

    /// Get a glossary term by slug.
    pub async fn get_term(&self, slug: &str) -> Result<GlossaryTerm, VentureError> {
        self.get(&format!("/api/v1/terms/{}/", slug)).await
    }

    // -- Blog Posts ---------------------------------------------------------

    /// List all blog posts.
    pub async fn list_posts(
        &self,
        params: Option<&[(&str, &str)]>,
    ) -> Result<PaginatedResponse<BlogPost>, VentureError> {
        let qs = build_query(params);
        self.get(&format!("/api/v1/posts/{}", qs)).await
    }

    /// Get a blog post by slug.
    pub async fn get_post(&self, slug: &str) -> Result<BlogPost, VentureError> {
        self.get(&format!("/api/v1/posts/{}/", slug)).await
    }

    // -- Companies ----------------------------------------------------------

    /// List all venture-certified companies.
    pub async fn list_companies(
        &self,
        params: Option<&[(&str, &str)]>,
    ) -> Result<PaginatedResponse<Company>, VentureError> {
        let qs = build_query(params);
        self.get(&format!("/api/v1/companies/{}", qs)).await
    }

    /// Get a company by primary key.
    pub async fn get_company(&self, pk: u64) -> Result<Company, VentureError> {
        self.get(&format!("/api/v1/companies/{}/", pk)).await
    }

    // -- Categories ---------------------------------------------------------

    /// List all post categories.
    pub async fn list_categories(&self) -> Result<PaginatedResponse<PostCategory>, VentureError> {
        self.get("/api/v1/categories/").await
    }

    // -- FAQ ----------------------------------------------------------------

    /// List aggregated FAQs.
    pub async fn list_faqs(
        &self,
        params: Option<&[(&str, &str)]>,
    ) -> Result<PaginatedResponse<Faq>, VentureError> {
        let qs = build_query(params);
        self.get(&format!("/api/v1/faq/{}", qs)).await
    }

    // -- Statistics ---------------------------------------------------------

    /// Get platform-wide statistics.
    pub async fn get_stats(&self) -> Result<PlatformStats, VentureError> {
        self.get("/api/v1/stats/").await
    }

    // -- Search & Autocomplete ----------------------------------------------

    /// Unified search across all content.
    pub async fn search(&self, query: &str) -> Result<SearchResponse, VentureError> {
        self.get(&format!("/api/v1/search/?q={}", urlencoding(query)))
            .await
    }

    /// Autocomplete suggestions.
    pub async fn autocomplete(&self, query: &str) -> Result<AutocompleteResponse, VentureError> {
        self.get(&format!(
            "/api/v1/autocomplete/?q={}",
            urlencoding(query)
        ))
        .await
    }
}

impl Default for VentureInKorea {
    fn default() -> Self {
        Self::new()
    }
}

fn build_query(params: Option<&[(&str, &str)]>) -> String {
    match params {
        None => String::new(),
        Some(pairs) => {
            let qs: Vec<String> = pairs
                .iter()
                .filter(|(_, v)| !v.is_empty())
                .map(|(k, v)| format!("{}={}", k, urlencoding(v)))
                .collect();
            if qs.is_empty() {
                String::new()
            } else {
                format!("?{}", qs.join("&"))
            }
        }
    }
}

fn urlencoding(s: &str) -> String {
    s.replace(' ', "%20")
        .replace('&', "%26")
        .replace('=', "%3D")
}