use crate::error::{PricingError, Result};
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FaasPricing {
pub memory_gb_second: f64,
pub request_cost: f64,
pub compute_cost: f64,
pub region: String,
pub provider: String,
}
#[derive(Debug, Clone, Deserialize)]
struct AwsLambdaPriceList {
#[serde(rename = "products")]
products: HashMap<String, AwsProduct>,
#[serde(rename = "terms")]
terms: AwsTerms,
}
#[derive(Debug, Clone, Deserialize)]
struct AwsProduct {
#[serde(rename = "productFamily")]
product_family: String,
attributes: AwsAttributes,
}
#[derive(Debug, Clone, Deserialize)]
#[allow(dead_code)] struct AwsAttributes {
#[serde(rename = "group")]
group: Option<String>,
#[serde(rename = "groupDescription")]
group_description: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
struct AwsTerms {
#[serde(rename = "OnDemand")]
on_demand: HashMap<String, HashMap<String, AwsPriceDimension>>,
}
#[derive(Debug, Clone, Deserialize)]
struct AwsPriceDimension {
#[serde(rename = "priceDimensions")]
price_dimensions: HashMap<String, AwsPriceDetail>,
}
#[derive(Debug, Clone, Deserialize)]
struct AwsPriceDetail {
#[serde(rename = "pricePerUnit")]
price_per_unit: HashMap<String, String>,
}
#[derive(Debug, Clone, Deserialize)]
struct GcpBillingCatalog {
skus: Vec<GcpSku>,
}
#[derive(Debug, Clone, Deserialize)]
#[allow(dead_code)] struct GcpSku {
name: String,
description: String,
category: GcpCategory,
#[serde(rename = "pricingInfo")]
pricing_info: Vec<GcpPricingInfo>,
}
#[derive(Debug, Clone, Deserialize)]
#[allow(dead_code)] struct GcpCategory {
#[serde(rename = "serviceDisplayName")]
service_display_name: String,
#[serde(rename = "resourceFamily")]
resource_family: String,
}
#[derive(Debug, Clone, Deserialize)]
struct GcpPricingInfo {
#[serde(rename = "pricingExpression")]
pricing_expression: GcpPricingExpression,
}
#[derive(Debug, Clone, Deserialize)]
struct GcpPricingExpression {
#[serde(rename = "tieredRates")]
tiered_rates: Vec<GcpTieredRate>,
}
#[derive(Debug, Clone, Deserialize)]
struct GcpTieredRate {
#[serde(rename = "unitPrice")]
unit_price: GcpMoney,
}
#[derive(Debug, Clone, Deserialize)]
#[allow(dead_code)] struct GcpMoney {
#[serde(rename = "currencyCode")]
currency_code: String,
units: String,
nanos: i64,
}
#[derive(Debug, Clone, Deserialize)]
struct AzureRetailPrices {
#[serde(rename = "Items")]
items: Vec<AzurePriceItem>,
}
#[derive(Debug, Clone, Deserialize)]
#[allow(dead_code)] struct AzurePriceItem {
#[serde(rename = "currencyCode")]
currency_code: String,
#[serde(rename = "tierMinimumUnits")]
tier_minimum_units: f64,
#[serde(rename = "retailPrice")]
retail_price: f64,
#[serde(rename = "unitPrice")]
unit_price: f64,
#[serde(rename = "armRegionName")]
arm_region_name: String,
#[serde(rename = "location")]
location: String,
#[serde(rename = "productName")]
product_name: String,
#[serde(rename = "skuName")]
sku_name: String,
#[serde(rename = "serviceName")]
service_name: String,
#[serde(rename = "meterName")]
meter_name: String,
}
pub struct FaasPricingFetcher {
client: Client,
cache: Arc<RwLock<PricingCache>>,
}
struct PricingCache {
aws_lambda: Option<(std::time::Instant, HashMap<String, FaasPricing>)>,
gcp_functions: Option<(std::time::Instant, HashMap<String, FaasPricing>)>,
azure_functions: Option<(std::time::Instant, HashMap<String, FaasPricing>)>,
}
impl FaasPricingFetcher {
pub fn new() -> Self {
Self {
client: Client::new(),
cache: Arc::new(RwLock::new(PricingCache {
aws_lambda: None,
gcp_functions: None,
azure_functions: None,
})),
}
}
pub async fn fetch_aws_lambda_pricing(&self, region: &str) -> Result<FaasPricing> {
{
let cache = self.cache.read().await;
if let Some((timestamp, prices)) = &cache.aws_lambda {
if timestamp.elapsed().as_secs() < 3600 {
if let Some(pricing) = prices.get(region) {
return Ok(pricing.clone());
}
}
}
}
let url =
"https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AWSLambda/current/index.json";
let response = self.client.get(url).send().await.map_err(|e| {
PricingError::HttpError(format!("Failed to fetch AWS Lambda pricing: {e}"))
})?;
let price_list: AwsLambdaPriceList = response.json().await.map_err(|e| {
PricingError::HttpError(format!("Failed to parse AWS Lambda pricing: {e}"))
})?;
let mut region_prices = HashMap::new();
for (product_id, product) in &price_list.products {
if product.product_family != "Serverless" {
continue;
}
if let Some(on_demand_terms) = price_list.terms.on_demand.get(product_id) {
for price_dim in on_demand_terms.values() {
for price_detail in price_dim.price_dimensions.values() {
if let Some(usd_price) = price_detail.price_per_unit.get("USD") {
let price: f64 = usd_price.parse().unwrap_or(0.0);
let group = product.attributes.group.as_deref().unwrap_or("");
let pricing = FaasPricing {
memory_gb_second: if group.contains("Duration") {
price
} else {
0.00001667
}, request_cost: if group.contains("Request") {
price
} else {
0.0000002
}, compute_cost: if group.contains("Compute") {
price
} else {
0.0000166667
},
region: region.to_string(),
provider: "AWS Lambda".to_string(),
};
region_prices.insert(region.to_string(), pricing);
break;
}
}
}
}
}
let pricing = region_prices
.entry(region.to_string())
.or_insert_with(|| {
FaasPricing {
memory_gb_second: 0.0000166667, request_cost: 0.0000002, compute_cost: 0.0000166667,
region: region.to_string(),
provider: "AWS Lambda".to_string(),
}
})
.clone();
{
let mut cache = self.cache.write().await;
cache.aws_lambda = Some((std::time::Instant::now(), region_prices));
}
Ok(pricing)
}
pub async fn fetch_gcp_functions_pricing(&self, region: &str) -> Result<FaasPricing> {
{
let cache = self.cache.read().await;
if let Some((timestamp, prices)) = &cache.gcp_functions {
if timestamp.elapsed().as_secs() < 3600 {
if let Some(pricing) = prices.get(region) {
return Ok(pricing.clone());
}
}
}
}
let api_key = std::env::var("GCP_API_KEY").unwrap_or_else(|_| {
String::new()
});
if api_key.is_empty() {
return Ok(FaasPricing {
memory_gb_second: 0.0000025, request_cost: 0.0000004, compute_cost: 0.0000100, region: region.to_string(),
provider: "GCP Cloud Functions".to_string(),
});
}
let service_id = "services/cloud-run";
let url = format!("https://cloudbilling.googleapis.com/v1/{service_id}/skus?key={api_key}");
let response =
self.client.get(&url).send().await.map_err(|e| {
PricingError::HttpError(format!("Failed to fetch GCP pricing: {e}"))
})?;
let catalog: GcpBillingCatalog = response
.json()
.await
.map_err(|e| PricingError::HttpError(format!("Failed to parse GCP pricing: {e}")))?;
let mut region_prices = HashMap::new();
let mut memory_cost = 0.0000025;
let mut request_cost = 0.0000004;
let mut cpu_cost = 0.0000100;
for sku in catalog.skus {
if sku.category.service_display_name != "Cloud Run" {
continue;
}
for pricing_info in &sku.pricing_info {
for tiered_rate in &pricing_info.pricing_expression.tiered_rates {
let units: f64 = tiered_rate.unit_price.units.parse().unwrap_or(0.0);
let nanos = tiered_rate.unit_price.nanos as f64 / 1_000_000_000.0;
let price = units + nanos;
if sku.description.contains("Memory") {
memory_cost = price;
} else if sku.description.contains("Request") {
request_cost = price;
} else if sku.description.contains("CPU") || sku.description.contains("vCPU") {
cpu_cost = price;
}
}
}
}
let pricing = FaasPricing {
memory_gb_second: memory_cost,
request_cost,
compute_cost: cpu_cost,
region: region.to_string(),
provider: "GCP Cloud Functions".to_string(),
};
region_prices.insert(region.to_string(), pricing.clone());
{
let mut cache = self.cache.write().await;
cache.gcp_functions = Some((std::time::Instant::now(), region_prices));
}
Ok(pricing)
}
pub async fn fetch_azure_functions_pricing(&self, region: &str) -> Result<FaasPricing> {
{
let cache = self.cache.read().await;
if let Some((timestamp, prices)) = &cache.azure_functions {
if timestamp.elapsed().as_secs() < 3600 {
if let Some(pricing) = prices.get(region) {
return Ok(pricing.clone());
}
}
}
}
let filter = format!("serviceName eq 'Functions' and armRegionName eq '{region}'");
let encoded_filter = urlencoding::encode(&filter);
let url = format!("https://prices.azure.com/api/retail/prices?$filter={encoded_filter}");
let response =
self.client.get(&url).send().await.map_err(|e| {
PricingError::HttpError(format!("Failed to fetch Azure pricing: {e}"))
})?;
let prices: AzureRetailPrices = response
.json()
.await
.map_err(|e| PricingError::HttpError(format!("Failed to parse Azure pricing: {e}")))?;
let mut memory_cost = 0.000016; let mut execution_cost = 0.0000002;
for item in &prices.items {
if item.service_name != "Functions" {
continue;
}
if item.meter_name.contains("Execution") {
execution_cost = item.retail_price / 1_000_000.0; } else if item.meter_name.contains("Memory") || item.meter_name.contains("GB-s") {
memory_cost = item.retail_price;
}
}
let pricing = FaasPricing {
memory_gb_second: memory_cost,
request_cost: execution_cost,
compute_cost: memory_cost, region: region.to_string(),
provider: "Azure Functions".to_string(),
};
{
let mut cache = self.cache.write().await;
let mut region_prices = cache
.azure_functions
.as_ref()
.map(|(_, prices)| prices.clone())
.unwrap_or_default();
region_prices.insert(region.to_string(), pricing.clone());
cache.azure_functions = Some((std::time::Instant::now(), region_prices));
}
Ok(pricing)
}
pub fn estimate_execution_cost(
&self,
pricing: &FaasPricing,
memory_gb: f64,
duration_seconds: f64,
requests: u64,
) -> f64 {
let memory_cost = pricing.memory_gb_second * memory_gb * duration_seconds;
let request_cost = pricing.request_cost * requests as f64;
let compute_cost = pricing.compute_cost * duration_seconds;
memory_cost + request_cost + compute_cost
}
}
impl Default for FaasPricingFetcher {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_aws_lambda_pricing_structure() {
let fetcher = FaasPricingFetcher::new();
let pricing = FaasPricing {
memory_gb_second: 0.0000166667,
request_cost: 0.0000002,
compute_cost: 0.0000166667,
region: "us-east-1".to_string(),
provider: "AWS Lambda".to_string(),
};
let cost = fetcher.estimate_execution_cost(&pricing, 1.0, 1.0, 1000);
assert!(cost > 0.0, "Cost should be positive");
assert!(
cost < 1.0,
"Cost for single execution should be less than $1"
);
}
#[tokio::test]
async fn test_gcp_functions_pricing_structure() {
let fetcher = FaasPricingFetcher::new();
let pricing = FaasPricing {
memory_gb_second: 0.0000025,
request_cost: 0.0000004,
compute_cost: 0.0000100,
region: "us-central1".to_string(),
provider: "GCP Cloud Functions".to_string(),
};
let cost = fetcher.estimate_execution_cost(&pricing, 2.0, 0.5, 500);
assert!(cost > 0.0, "Cost should be positive");
}
#[tokio::test]
async fn test_azure_functions_pricing_structure() {
let fetcher = FaasPricingFetcher::new();
let pricing = FaasPricing {
memory_gb_second: 0.000016,
request_cost: 0.0000002,
compute_cost: 0.000016,
region: "eastus".to_string(),
provider: "Azure Functions".to_string(),
};
let cost = fetcher.estimate_execution_cost(&pricing, 1.5, 2.0, 2000);
assert!(cost > 0.0, "Cost should be positive");
}
#[tokio::test]
#[ignore = "requires_network_and_api_keys"]
async fn test_fetch_aws_lambda_pricing_integration() {
let fetcher = FaasPricingFetcher::new();
let result = fetcher.fetch_aws_lambda_pricing("us-east-1").await;
assert!(result.is_ok(), "Should fetch AWS Lambda pricing");
let pricing = result.unwrap();
assert!(
pricing.memory_gb_second > 0.0,
"Memory cost should be positive"
);
assert!(
pricing.request_cost > 0.0,
"Request cost should be positive"
);
}
#[tokio::test]
#[ignore = "requires_network_and_gcp_api_key"]
async fn test_fetch_gcp_functions_pricing_integration() {
let fetcher = FaasPricingFetcher::new();
let result = fetcher.fetch_gcp_functions_pricing("us-central1").await;
assert!(result.is_ok(), "Should fetch GCP pricing");
let pricing = result.unwrap();
assert!(
pricing.memory_gb_second > 0.0,
"Memory cost should be positive"
);
}
#[tokio::test]
#[ignore = "requires_network"]
async fn test_fetch_azure_functions_pricing_integration() {
let fetcher = FaasPricingFetcher::new();
let result = fetcher.fetch_azure_functions_pricing("eastus").await;
assert!(result.is_ok(), "Should fetch Azure pricing");
let pricing = result.unwrap();
assert!(
pricing.memory_gb_second > 0.0,
"Memory cost should be positive"
);
}
}