reka 0.1.0

Async Rust SDK for the Reka API.
Documentation
use std::collections::BTreeMap;
use std::fmt;

use reqwest::Method;
use serde::ser::{SerializeSeq, Serializer};
use serde::{Deserialize, Serialize};
use serde_json::Value;

use crate::config::ServiceBase;
use crate::{Client, Result};

/// Handle for vision video operations.
#[derive(Clone)]
pub struct VisionVideosClient {
    client: Client,
}

impl VisionVideosClient {
    pub(crate) fn new(client: Client) -> Self {
        Self { client }
    }

    /// Fetches a single video by ID.
    pub async fn get(&self, args: &GetVideoArgs) -> Result<Video> {
        self.client
            .request(
                ServiceBase::Vision,
                Method::GET,
                format!("/v1/videos/{}", args.video_id.as_str()),
            )
            .send_json()
            .await
    }

    /// Lists videos visible to the current API key.
    pub async fn list(&self, args: &ListVideosArgs) -> Result<Vec<Video>> {
        let response: ListVideosResponse = self
            .client
            .request(ServiceBase::Vision, Method::GET, "/v1/videos")
            .query(args)
            .send_json()
            .await?;
        Ok(response.results)
    }

    /// Deletes a video by ID.
    pub async fn delete(&self, args: &DeleteVideoArgs) -> Result<DeleteVideoResponse> {
        self.client
            .request(
                ServiceBase::Vision,
                Method::DELETE,
                format!("/v1/videos/{}", args.video_id.as_str()),
            )
            .send_json()
            .await
    }
}

/// Arguments for `client.vision().videos().get(...)`.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct GetVideoArgs {
    pub video_id: VideoId,
}

impl GetVideoArgs {
    /// Creates video lookup arguments from a video ID.
    pub fn new(video_id: impl Into<VideoId>) -> Self {
        Self {
            video_id: video_id.into(),
        }
    }
}

/// Arguments for `client.vision().videos().list(...)`.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct ListVideosArgs {
    pub ids: Option<Vec<VideoId>>,
}

impl ListVideosArgs {
    /// Creates empty list arguments.
    pub fn new() -> Self {
        Self::default()
    }

    /// Filters the list request to one or more video IDs.
    ///
    /// ```rust
    /// use reka::ListVideosArgs;
    ///
    /// let args = ListVideosArgs::new().with_ids(["video-1", "video-2"]);
    /// assert_eq!(args.ids.as_ref().map(Vec::len), Some(2));
    /// ```
    pub fn with_ids<I, V>(mut self, ids: I) -> Self
    where
        I: IntoIterator<Item = V>,
        V: Into<VideoId>,
    {
        let ids = ids.into_iter().map(Into::into).collect::<Vec<_>>();
        self.ids = (!ids.is_empty()).then_some(ids);
        self
    }
}

/// Arguments for `client.vision().videos().delete(...)`.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct DeleteVideoArgs {
    pub video_id: VideoId,
}

impl DeleteVideoArgs {
    /// Creates video deletion arguments from a video ID.
    pub fn new(video_id: impl Into<VideoId>) -> Self {
        Self {
            video_id: video_id.into(),
        }
    }
}

impl Serialize for ListVideosArgs {
    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
    where
        S: Serializer,
    {
        let ids = self.ids.as_deref().unwrap_or(&[]);
        let mut seq = serializer.serialize_seq(Some(ids.len()))?;
        for video_id in ids {
            seq.serialize_element(&("ids", video_id.as_str()))?;
        }
        seq.end()
    }
}

/// Vision video object returned by the API.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Video {
    pub video_id: VideoId,
    #[serde(default)]
    pub url: Option<String>,
    #[serde(default)]
    pub metadata: Option<VideoMetadata>,
    #[serde(default)]
    pub indexing_status: Option<String>,
    #[serde(default)]
    pub indexing_type: Option<String>,
    #[serde(default)]
    pub group_id: Option<String>,
    #[serde(flatten)]
    pub extra: BTreeMap<String, Value>,
}

/// Metadata returned for a vision video.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct VideoMetadata {
    #[serde(default)]
    pub width: Option<u32>,
    #[serde(default)]
    pub height: Option<u32>,
    #[serde(default)]
    pub avg_fps: Option<f64>,
    #[serde(default)]
    pub video_name: Option<String>,
    #[serde(default)]
    pub title: Option<String>,
    #[serde(default)]
    pub video_start_timestamp_utc_ms: Option<i64>,
    #[serde(default)]
    pub duration: Option<f64>,
    #[serde(default)]
    pub thumbnail: Option<String>,
    #[serde(default)]
    pub description: Option<String>,
    #[serde(default)]
    pub source: Option<String>,
    #[serde(flatten)]
    pub extra: BTreeMap<String, Value>,
}

/// Delete response returned by the video delete endpoint.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct DeleteVideoResponse {
    pub status: String,
    #[serde(default)]
    pub message: Option<String>,
    #[serde(flatten)]
    pub extra: BTreeMap<String, Value>,
}

/// Identifier for a vision video.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct VideoId(String);

impl VideoId {
    /// Creates a video ID from a string-like value.
    pub fn new(value: impl Into<String>) -> Self {
        Self(value.into())
    }

    /// Returns the underlying video ID.
    pub fn as_str(&self) -> &str {
        &self.0
    }
}

impl From<&str> for VideoId {
    fn from(value: &str) -> Self {
        Self(value.to_owned())
    }
}

impl From<String> for VideoId {
    fn from(value: String) -> Self {
        Self(value)
    }
}

impl AsRef<str> for VideoId {
    fn as_ref(&self) -> &str {
        self.as_str()
    }
}

impl fmt::Display for VideoId {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(self.as_str())
    }
}

#[derive(Debug, Clone, Deserialize, PartialEq)]
struct ListVideosResponse {
    results: Vec<Video>,
    #[serde(flatten)]
    extra: BTreeMap<String, Value>,
}

#[cfg(test)]
mod tests {
    use serde_json::json;

    use super::{
        DeleteVideoArgs, DeleteVideoResponse, GetVideoArgs, ListVideosArgs, ListVideosResponse,
        Video, VideoId,
    };

    #[test]
    fn video_id_round_trips_as_string() {
        let video_id = VideoId::from("550e8400-e29b-41d4-a716-446655440000");

        assert_eq!(video_id.as_str(), "550e8400-e29b-41d4-a716-446655440000");
        assert_eq!(video_id.to_string(), "550e8400-e29b-41d4-a716-446655440000");
    }

    #[test]
    fn typed_video_requests_wrap_video_ids() {
        let get = GetVideoArgs::new("video-1");
        let delete = DeleteVideoArgs::new("video-2");

        assert_eq!(get.video_id, VideoId::from("video-1"));
        assert_eq!(delete.video_id, VideoId::from("video-2"));
    }

    #[test]
    fn list_videos_params_serializes_repeated_ids_query_parameters() {
        let args = ListVideosArgs::new().with_ids(["video-1", "video-2"]);
        let request = reqwest::Client::new()
            .get("https://vision-agent.api.reka.ai/v1/videos")
            .query(&args)
            .build()
            .expect("request should build");

        assert_eq!(request.url().query(), Some("ids=video-1&ids=video-2"));
    }

    #[test]
    fn video_deserializes_metadata_and_unknown_fields() {
        let video: Video = serde_json::from_value(json!({
            "video_id": "550e8400-e29b-41d4-a716-446655440000",
            "url": "https://example.com/video.mp4",
            "metadata": {
                "width": 1920,
                "height": 1080,
                "avg_fps": 23.976,
                "video_name": "demo.mp4",
                "title": "Demo",
                "video_start_timestamp_utc_ms": 1,
                "duration": 641.939,
                "thumbnail": "https://example.com/thumb.jpg",
                "description": "Uploaded video file",
                "source": "/tmp/demo.mp4",
                "codec": "h264"
            },
            "indexing_status": "indexed",
            "indexing_type": "fast_search",
            "group_id": "default",
            "owner": "test-user"
        }))
        .expect("video should deserialize");

        assert_eq!(
            video.video_id,
            VideoId::from("550e8400-e29b-41d4-a716-446655440000")
        );
        assert_eq!(
            video.metadata.as_ref().and_then(|metadata| metadata.width),
            Some(1920)
        );
        assert_eq!(
            video
                .metadata
                .as_ref()
                .and_then(|metadata| metadata.extra.get("codec")),
            Some(&json!("h264"))
        );
        assert_eq!(video.extra["owner"], "test-user");
    }

    #[test]
    fn list_videos_response_deserializes_results_envelope() {
        let response: ListVideosResponse = serde_json::from_value(json!({
            "results": [
                {
                    "video_id": "550e8400-e29b-41d4-a716-446655440000",
                    "url": "https://example.com/video.mp4"
                }
            ],
            "next_cursor": null
        }))
        .expect("list response should deserialize");

        assert_eq!(response.results.len(), 1);
        assert_eq!(
            response.results[0].video_id,
            VideoId::from("550e8400-e29b-41d4-a716-446655440000")
        );
        assert_eq!(response.extra["next_cursor"], json!(null));
    }

    #[test]
    fn delete_video_response_deserializes_status_message() {
        let response: DeleteVideoResponse = serde_json::from_value(json!({
            "status": "success",
            "message": "deleted"
        }))
        .expect("delete response should deserialize");

        assert_eq!(response.status, "success");
        assert_eq!(response.message.as_deref(), Some("deleted"));
    }
}