Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 137 additions & 7 deletions src/functions/api.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use serde_json::json;
use serde_json::Value;
use urlencoding::encode;

Expand Down Expand Up @@ -61,6 +62,30 @@ pub struct CodeUploadSlot {
#[derive(Debug, Clone)]
pub struct InsertFunctionsResult {
pub ignored_entries: Option<usize>,
pub xact_id: Option<String>,
pub functions: Vec<InsertedFunction>,
}

#[derive(Debug, Clone)]
pub struct InsertedFunction {
pub id: String,
pub slug: String,
pub project_id: String,
pub found_existing: bool,
}

#[derive(Debug, Clone, Deserialize)]
pub struct EnvironmentObject {
#[serde(default)]
pub environment_slug: Option<String>,
#[serde(default)]
pub object_version: Option<String>,
}

#[derive(Debug, Deserialize)]
struct EnvironmentObjectsResponse {
#[serde(default)]
objects: Vec<EnvironmentObject>,
}

pub async fn list_functions(
Expand Down Expand Up @@ -98,12 +123,7 @@ pub async fn invoke_function(
client: &ApiClient,
body: &serde_json::Value,
) -> Result<serde_json::Value> {
let org_name = client.org_name();
let headers = if !org_name.is_empty() {
vec![("x-bt-org-name", org_name)]
} else {
Vec::new()
};
let headers = org_headers(client);
let timeout = std::time::Duration::from_secs(300);
client
.post_with_headers_timeout("/function/invoke", body, &headers, Some(timeout))
Expand Down Expand Up @@ -223,23 +243,110 @@ pub async fn insert_functions(
client: &ApiClient,
functions: &[Value],
) -> Result<InsertFunctionsResult> {
let headers = org_headers(client);
let body = serde_json::json!({ "functions": functions });
let raw: Value = client
.post("/insert-functions", &body)
.post_with_headers("/insert-functions", &body, &headers)
.await
.context("failed to insert functions")?;

Ok(InsertFunctionsResult {
ignored_entries: ignored_count(&raw),
xact_id: xact_id(&raw),
functions: inserted_functions(&raw),
})
}

pub async fn list_environment_objects_for_prompt(
client: &ApiClient,
prompt_id: &str,
) -> Result<Vec<EnvironmentObject>> {
let headers = org_headers(client);
let path = format!("/environment-object/prompt/{}", encode(prompt_id));
let response: EnvironmentObjectsResponse = client
.get_with_headers(&path, &headers)
.await
.with_context(|| format!("failed to list environments via {path}"))?;
Ok(response.objects)
}

pub async fn upsert_environment_object_for_prompt(
client: &ApiClient,
prompt_id: &str,
environment_slug: &str,
object_version: &str,
) -> Result<()> {
let headers = org_headers(client);
let path = format!(
"/environment-object/prompt/{}/{}",
encode(prompt_id),
encode(environment_slug)
);
let mut body = json!({
"object_version": object_version,
});
let org_name = client.org_name().trim();
if !org_name.is_empty() {
body["org_name"] = Value::String(org_name.to_string());
}
let _: Value = client
.put_with_headers(&path, &body, &headers)
.await
.with_context(|| format!("failed to upsert environment association via {path}"))?;
Ok(())
}

fn org_headers(client: &ApiClient) -> Vec<(&str, &str)> {
let org_name = client.org_name().trim();
if org_name.is_empty() {
Vec::new()
} else {
vec![("x-bt-org-name", org_name)]
}
}

fn ignored_count(raw: &Value) -> Option<usize> {
raw.get("ignored_count")
.and_then(Value::as_u64)
.and_then(|count| usize::try_from(count).ok())
}

fn xact_id(raw: &Value) -> Option<String> {
raw.get("xact_id")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}

fn inserted_functions(raw: &Value) -> Vec<InsertedFunction> {
let Some(items) = raw.get("functions").and_then(Value::as_array) else {
return Vec::new();
};

items
.iter()
.filter_map(|item| {
let id = item.get("id").and_then(Value::as_str)?.trim();
let slug = item.get("slug").and_then(Value::as_str)?.trim();
let project_id = item.get("project_id").and_then(Value::as_str)?.trim();
if id.is_empty() || slug.is_empty() || project_id.is_empty() {
return None;
}
let found_existing = item
.get("found_existing")
.and_then(Value::as_bool)
.unwrap_or(false);
Some(InsertedFunction {
id: id.to_string(),
slug: slug.to_string(),
project_id: project_id.to_string(),
found_existing,
})
})
.collect()
}

#[cfg(test)]
mod tests {
use super::*;
Expand All @@ -258,6 +365,29 @@ mod tests {
assert_eq!(ignored_count(&serde_json::json!({})), None);
}

#[test]
fn insert_functions_response_extracts_metadata() {
let raw = serde_json::json!({
"xact_id": "123",
"functions": [
{
"id": "fn_1",
"slug": "hello",
"project_id": "proj_1",
"found_existing": true
}
]
});

assert_eq!(xact_id(&raw).as_deref(), Some("123"));
let functions = inserted_functions(&raw);
assert_eq!(functions.len(), 1);
assert_eq!(functions[0].id, "fn_1");
assert_eq!(functions[0].slug, "hello");
assert_eq!(functions[0].project_id, "proj_1");
assert!(functions[0].found_existing);
}

#[test]
fn parse_function_list_page_allows_non_paginated_shape() {
let raw = serde_json::json!({
Expand Down
Loading
Loading