Split admin service feature modules
Deploy / deploy (push) Successful in 1m33s
CI / Rust Checks (push) Failing after 6m2s
CI / UI Checks (push) Has been skipped
CI / Frontend E2E (push) Has been skipped
CI / Deployment Manifests (push) Has been skipped

This commit is contained in:
github-ops
2026-06-21 09:07:27 +00:00
parent 7df9b48513
commit 0f509b1317
4 changed files with 402 additions and 360 deletions
+13 -360
View File
@@ -4,37 +4,36 @@ use std::sync::Arc;
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use crank_core::{
AgentId, AuditSink, AuthProfile, AuthProfileId, CapabilityProfile, CommunityCapabilityProfile,
EditionCapabilities, ExecutionMode, GeneratedDraft, GeneratedDraftStatus, IdentityError,
IdentityProvider, InvocationLevel, InvocationLog, InvocationLogId, InvocationSource,
InvocationStatus, MembershipRole, NoopAuditSink, OperationId, OperationSecurityLevel,
OperationStatus, OwnerOnlyPolicyEngine, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyScope,
PlatformApiKeyStatus, PolicyEngine, ProductEdition, Protocol, ResponseCachePolicy, SampleId,
Target, ToolQualityMappingRule, ToolQualityMappingSet, ToolQualitySchemaKind,
ToolQualitySchemaNode, UsagePeriod, WizardState, WorkspaceId,
AgentId, AuditSink, AuthProfile, CapabilityProfile, CommunityCapabilityProfile,
EditionCapabilities, ExecutionMode, IdentityError, IdentityProvider, InvocationLevel,
InvocationLog, InvocationLogId, InvocationSource, InvocationStatus, MembershipRole,
NoopAuditSink, OperationId, OperationSecurityLevel, OperationStatus, OwnerOnlyPolicyEngine,
PlatformApiKeyScope, PlatformApiKeyStatus, PolicyEngine, ProductEdition, Protocol,
ResponseCachePolicy, Target, ToolQualityMappingRule, ToolQualityMappingSet,
ToolQualitySchemaKind, ToolQualitySchemaNode, UsagePeriod, WizardState, WorkspaceId,
};
use crank_mapping::{JsonPathRoot, MappingRule, MappingSet, infer_mapping_from_samples};
use crank_registry::{
AgentSummary, CreateInvocationLogRequest, CreatePlatformApiKeyRequest, ListInvocationLogsQuery,
OperationAgentRef, OperationSampleMetadata, OperationSummary, OperationUsageSummary,
PlatformApiKeyRecord, PostgresRegistry, RegistryOperation, SampleKind,
SaveSampleMetadataRequest, SaveWorkspaceUpstreamRequest, UsageBucket, WorkspaceUpstream,
WorkspaceUpstreamId,
AgentSummary, CreateInvocationLogRequest, ListInvocationLogsQuery, OperationAgentRef,
OperationSummary, OperationUsageSummary, PostgresRegistry, RegistryOperation, SampleKind,
UsageBucket,
};
use crank_runtime::{PreparedRequest, ResolvedAuth, RuntimeError, RuntimeExecutor, SecretCrypto};
use crank_schema::{Schema, SchemaKind};
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
use tracing::{info, instrument};
use uuid::Uuid;
mod agents;
mod api_keys;
mod auth;
mod import_export;
mod observability;
mod operations;
mod samples;
mod secrets;
mod upstreams;
mod workspaces;
use crate::{auth::AuthSettings, error::ApiError, storage::LocalArtifactStorage};
@@ -203,102 +202,6 @@ impl AdminService {
})
}
#[instrument(skip(self))]
pub async fn list_agent_platform_api_keys(
&self,
workspace_id: &WorkspaceId,
agent_id: &AgentId,
) -> Result<Vec<PlatformApiKeyRecord>, ApiError> {
self.ensure_workspace_exists(workspace_id).await?;
self.registry
.get_agent_summary(workspace_id, agent_id)
.await?
.ok_or_else(|| {
ApiError::not_found_with_context(
format!("agent {} was not found", agent_id.as_str()),
json!({ "agent_id": agent_id.as_str() }),
)
})?;
Ok(self
.registry
.list_platform_api_keys_for_agent(workspace_id, agent_id)
.await?)
}
#[instrument(skip(self, payload), fields(workspace_id = %workspace_id.as_str(), agent_id = %agent_id.as_str(), key_name = %payload.name))]
pub async fn create_agent_platform_api_key(
&self,
workspace_id: &WorkspaceId,
agent_id: &AgentId,
payload: PlatformApiKeyPayload,
) -> Result<CreatedPlatformApiKeyResponse, ApiError> {
self.ensure_workspace_exists(workspace_id).await?;
self.registry
.get_agent_summary(workspace_id, agent_id)
.await?
.ok_or_else(|| {
ApiError::not_found_with_context(
format!("agent {} was not found", agent_id.as_str()),
json!({ "agent_id": agent_id.as_str() }),
)
})?;
let secret = generate_access_secret("crk");
let api_key = PlatformApiKeyRecord {
api_key: PlatformApiKey {
id: PlatformApiKeyId::new(new_prefixed_id("pk")),
workspace_id: workspace_id.clone(),
agent_id: Some(agent_id.clone()),
name: payload.name,
prefix: secret.chars().take(16).collect(),
scopes: payload.scopes,
status: PlatformApiKeyStatus::Active,
created_at: OffsetDateTime::now_utc(),
last_used_at: None,
},
};
self.registry
.create_platform_api_key(CreatePlatformApiKeyRequest {
api_key: &api_key.api_key,
secret_hash: &hash_access_secret(&secret),
})
.await?;
Ok(CreatedPlatformApiKeyResponse { api_key, secret })
}
#[instrument(skip(self), fields(workspace_id = %workspace_id.as_str(), agent_id = %agent_id.as_str(), key_id = %key_id.as_str()))]
pub async fn revoke_agent_platform_api_key(
&self,
workspace_id: &WorkspaceId,
agent_id: &AgentId,
key_id: &PlatformApiKeyId,
) -> Result<(), ApiError> {
self.registry
.revoke_platform_api_key_for_agent(
workspace_id,
agent_id,
key_id,
&OffsetDateTime::now_utc(),
)
.await?;
Ok(())
}
#[instrument(skip(self), fields(workspace_id = %workspace_id.as_str(), agent_id = %agent_id.as_str(), key_id = %key_id.as_str()))]
pub async fn delete_agent_platform_api_key(
&self,
workspace_id: &WorkspaceId,
agent_id: &AgentId,
key_id: &PlatformApiKeyId,
) -> Result<(), ApiError> {
self.registry
.delete_platform_api_key_for_agent(workspace_id, agent_id, key_id)
.await?;
Ok(())
}
pub async fn list_protocol_capabilities(&self) -> Vec<ProtocolCapabilityView> {
let capabilities = self.get_capabilities().await;
capabilities
@@ -387,214 +290,6 @@ impl AdminService {
ResolvedAuth::from_profile(auth_profile, &secrets)
}
#[instrument(skip(self))]
pub async fn list_workspace_upstreams(
&self,
workspace_id: &WorkspaceId,
) -> Result<Vec<WorkspaceUpstream>, ApiError> {
self.ensure_workspace_exists(workspace_id).await?;
self.ensure_default_workspace_upstreams(workspace_id)
.await?;
Ok(self.registry.list_workspace_upstreams(workspace_id).await?)
}
#[instrument(skip(self, payload), fields(workspace_id = %workspace_id.as_str(), upstream_name = %payload.name))]
pub async fn save_workspace_upstream(
&self,
workspace_id: &WorkspaceId,
upstream_id: Option<&WorkspaceUpstreamId>,
payload: UpstreamPayload,
) -> Result<WorkspaceUpstream, ApiError> {
self.ensure_workspace_exists(workspace_id).await?;
validate_upstream_payload(&payload)?;
if let Some(auth_profile_id) = payload.auth_profile_id.as_deref() {
self.get_auth_profile(
workspace_id,
&AuthProfileId::new(auth_profile_id.to_owned()),
)
.await?;
}
let now = OffsetDateTime::now_utc();
let existing = match upstream_id {
Some(id) => {
self.registry
.get_workspace_upstream(workspace_id, id)
.await?
}
None => None,
};
if upstream_id.is_some() && existing.is_none() {
return Err(ApiError::not_found_with_context(
"upstream was not found",
json!({ "upstream_id": upstream_id.map(|id| id.as_str()).unwrap_or_default() }),
));
}
let upstream = WorkspaceUpstream {
id: existing
.as_ref()
.map(|item| item.id.clone())
.unwrap_or_else(|| WorkspaceUpstreamId::new(new_prefixed_id("upstream"))),
workspace_id: workspace_id.clone(),
name: payload.name.trim().to_owned(),
base_url: normalize_base_url(&payload.base_url),
static_headers: payload.static_headers,
auth_profile_id: payload
.auth_profile_id
.filter(|value| !value.trim().is_empty()),
created_at: existing.as_ref().map(|item| item.created_at).unwrap_or(now),
updated_at: now,
};
self.registry
.save_workspace_upstream(SaveWorkspaceUpstreamRequest {
upstream: &upstream,
})
.await?;
info!(upstream_id = %upstream.id.as_str(), "workspace upstream saved");
Ok(upstream)
}
async fn ensure_default_workspace_upstreams(
&self,
workspace_id: &WorkspaceId,
) -> Result<(), ApiError> {
let existing = self.registry.list_workspace_upstreams(workspace_id).await?;
if existing.iter().any(|item| item.name == "Open Meteo") {
return Ok(());
}
let now = OffsetDateTime::now_utc();
let upstream = WorkspaceUpstream {
id: WorkspaceUpstreamId::new(new_prefixed_id("upstream")),
workspace_id: workspace_id.clone(),
name: "Open Meteo".to_owned(),
base_url: "https://api.open-meteo.com".to_owned(),
static_headers: json!({}),
auth_profile_id: None,
created_at: now,
updated_at: now,
};
self.registry
.save_workspace_upstream(SaveWorkspaceUpstreamRequest {
upstream: &upstream,
})
.await?;
Ok(())
}
#[instrument(skip(self, payload), fields(operation_id = %operation_id.as_str(), sample_kind = ?sample_kind))]
pub async fn save_json_sample(
&self,
workspace_id: &WorkspaceId,
operation_id: &OperationId,
sample_kind: SampleKind,
payload: &Value,
) -> Result<OperationSampleMetadata, ApiError> {
let summary = self.get_operation(workspace_id, operation_id).await?;
let version = summary.current_draft_version;
let sample_id = SampleId::new(new_prefixed_id("sample"));
let now = OffsetDateTime::now_utc();
let file_name = match sample_kind {
SampleKind::InputJson => "input.json",
SampleKind::OutputJson => "output.json",
SampleKind::YamlImportSource => "source.yaml",
};
let storage_ref = self
.storage
.write_json_sample(operation_id, version, sample_kind, &sample_id, payload)
.await?;
let metadata = OperationSampleMetadata {
id: sample_id,
operation_id: operation_id.clone(),
version,
sample_kind,
storage_ref,
content_type: "application/json".to_owned(),
file_name: Some(file_name.to_owned()),
created_at: now,
};
self.registry
.save_sample_metadata(SaveSampleMetadataRequest { sample: &metadata })
.await?;
info!(
operation_id = %operation_id.as_str(),
sample_id = %metadata.id.as_str(),
version,
"json sample saved"
);
Ok(metadata)
}
#[instrument(skip(self, payload), fields(operation_id = %operation_id.as_str()))]
pub async fn generate_draft(
&self,
workspace_id: &WorkspaceId,
operation_id: &OperationId,
payload: GenerateDraftPayload,
) -> Result<DraftGenerationResult, ApiError> {
let summary = self.get_operation(workspace_id, operation_id).await?;
let samples = self
.registry
.list_sample_metadata(operation_id, summary.current_draft_version)
.await?;
let input_sample = latest_sample_ref(&samples, SampleKind::InputJson)
.ok_or_else(|| ApiError::validation("input_json sample was not found"))?;
let output_sample = latest_sample_ref(&samples, SampleKind::OutputJson)
.ok_or_else(|| ApiError::validation("output_json sample was not found"))?;
let input_value = self.storage.read_json(&input_sample.storage_ref).await?;
let output_value = self.storage.read_json(&output_sample.storage_ref).await?;
let input_schema = Schema::from_json_sample(&input_value);
let output_schema = Schema::from_json_sample(&output_value);
let input_mapping = infer_mapping_from_samples(
&input_value,
JsonPathRoot::Mcp,
&input_value,
JsonPathRoot::RequestBody,
);
let output_mapping = infer_mapping_from_samples(
&output_value,
JsonPathRoot::ResponseBody,
&output_value,
JsonPathRoot::Output,
);
let source_types = if payload.sources.is_empty() {
vec![
"input_json_sample".to_owned(),
"output_json_sample".to_owned(),
]
} else {
payload.sources
};
let generated_draft = GeneratedDraft {
status: GeneratedDraftStatus::Available,
source_types,
generated_at: Some(now_string()?),
input_schema_generated: true,
output_schema_generated: true,
input_mapping_generated: true,
output_mapping_generated: true,
warnings: Vec::new(),
};
let result = DraftGenerationResult {
generated_draft,
input_schema,
output_schema,
input_mapping,
output_mapping,
};
info!(operation_id = %operation_id.as_str(), "draft generated from samples");
Ok(result)
}
fn validate_operation_payload(&self, payload: &OperationPayload) -> Result<(), ApiError> {
self.validate_operation_capabilities(payload.protocol, payload.security_level)?;
validate_protocol_target(payload.protocol, &payload.target)?;
@@ -1024,37 +719,6 @@ fn validate_protocol_target(protocol: Protocol, target: &Target) -> Result<(), A
Err(ApiError::validation("protocol and target kind must match"))
}
fn validate_upstream_payload(payload: &UpstreamPayload) -> Result<(), ApiError> {
if payload.name.trim().is_empty() {
return Err(ApiError::validation("upstream name is required"));
}
let base_url = normalize_base_url(&payload.base_url);
if !(base_url.starts_with("https://") || base_url.starts_with("http://")) {
return Err(ApiError::validation(
"upstream base_url must start with http:// or https://",
));
}
if !payload.static_headers.is_object() {
return Err(ApiError::validation(
"upstream static_headers must be a JSON object",
));
}
if let Some(headers) = payload.static_headers.as_object() {
for (key, value) in headers {
if key.trim().is_empty() || !value.is_string() {
return Err(ApiError::validation(
"upstream static_headers must contain string values",
));
}
}
}
Ok(())
}
fn normalize_base_url(value: &str) -> String {
value.trim().trim_end_matches('/').to_owned()
}
fn validate_profile_display_name(value: &str) -> Result<String, ApiError> {
let display_name = value.trim();
if display_name.is_empty() {
@@ -1090,17 +754,6 @@ fn validate_profile_email(value: &str) -> Result<String, ApiError> {
Ok(email)
}
fn latest_sample_ref(
samples: &[OperationSampleMetadata],
sample_kind: SampleKind,
) -> Option<OperationSampleMetadata> {
samples
.iter()
.rev()
.find(|sample| sample.sample_kind == sample_kind)
.cloned()
}
fn demo_revops_agent_payload() -> AgentPayload {
AgentPayload {
slug: "revops-copilot".to_owned(),
+111
View File
@@ -0,0 +1,111 @@
use crank_core::{AgentId, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyStatus, WorkspaceId};
use crank_registry::{CreatePlatformApiKeyRequest, PlatformApiKeyRecord};
use serde_json::json;
use time::OffsetDateTime;
use tracing::instrument;
use crate::{
error::ApiError,
service::{
AdminService, CreatedPlatformApiKeyResponse, PlatformApiKeyPayload, generate_access_secret,
hash_access_secret, new_prefixed_id,
},
};
impl AdminService {
#[instrument(skip(self))]
pub async fn list_agent_platform_api_keys(
&self,
workspace_id: &WorkspaceId,
agent_id: &AgentId,
) -> Result<Vec<PlatformApiKeyRecord>, ApiError> {
self.ensure_workspace_exists(workspace_id).await?;
self.registry
.get_agent_summary(workspace_id, agent_id)
.await?
.ok_or_else(|| {
ApiError::not_found_with_context(
format!("agent {} was not found", agent_id.as_str()),
json!({ "agent_id": agent_id.as_str() }),
)
})?;
Ok(self
.registry
.list_platform_api_keys_for_agent(workspace_id, agent_id)
.await?)
}
#[instrument(skip(self, payload), fields(workspace_id = %workspace_id.as_str(), agent_id = %agent_id.as_str(), key_name = %payload.name))]
pub async fn create_agent_platform_api_key(
&self,
workspace_id: &WorkspaceId,
agent_id: &AgentId,
payload: PlatformApiKeyPayload,
) -> Result<CreatedPlatformApiKeyResponse, ApiError> {
self.ensure_workspace_exists(workspace_id).await?;
self.registry
.get_agent_summary(workspace_id, agent_id)
.await?
.ok_or_else(|| {
ApiError::not_found_with_context(
format!("agent {} was not found", agent_id.as_str()),
json!({ "agent_id": agent_id.as_str() }),
)
})?;
let secret = generate_access_secret("crk");
let api_key = PlatformApiKeyRecord {
api_key: PlatformApiKey {
id: PlatformApiKeyId::new(new_prefixed_id("pk")),
workspace_id: workspace_id.clone(),
agent_id: Some(agent_id.clone()),
name: payload.name,
prefix: secret.chars().take(16).collect(),
scopes: payload.scopes,
status: PlatformApiKeyStatus::Active,
created_at: OffsetDateTime::now_utc(),
last_used_at: None,
},
};
self.registry
.create_platform_api_key(CreatePlatformApiKeyRequest {
api_key: &api_key.api_key,
secret_hash: &hash_access_secret(&secret),
})
.await?;
Ok(CreatedPlatformApiKeyResponse { api_key, secret })
}
#[instrument(skip(self), fields(workspace_id = %workspace_id.as_str(), agent_id = %agent_id.as_str(), key_id = %key_id.as_str()))]
pub async fn revoke_agent_platform_api_key(
&self,
workspace_id: &WorkspaceId,
agent_id: &AgentId,
key_id: &PlatformApiKeyId,
) -> Result<(), ApiError> {
self.registry
.revoke_platform_api_key_for_agent(
workspace_id,
agent_id,
key_id,
&OffsetDateTime::now_utc(),
)
.await?;
Ok(())
}
#[instrument(skip(self), fields(workspace_id = %workspace_id.as_str(), agent_id = %agent_id.as_str(), key_id = %key_id.as_str()))]
pub async fn delete_agent_platform_api_key(
&self,
workspace_id: &WorkspaceId,
agent_id: &AgentId,
key_id: &PlatformApiKeyId,
) -> Result<(), ApiError> {
self.registry
.delete_platform_api_key_for_agent(workspace_id, agent_id, key_id)
.await?;
Ok(())
}
}
+137
View File
@@ -0,0 +1,137 @@
use crank_core::{GeneratedDraft, GeneratedDraftStatus, OperationId, SampleId, WorkspaceId};
use crank_mapping::{JsonPathRoot, infer_mapping_from_samples};
use crank_registry::{OperationSampleMetadata, SampleKind, SaveSampleMetadataRequest};
use crank_schema::Schema;
use serde_json::Value;
use time::OffsetDateTime;
use tracing::{info, instrument};
use crate::{
error::ApiError,
service::{
AdminService, DraftGenerationResult, GenerateDraftPayload, new_prefixed_id, now_string,
},
};
impl AdminService {
#[instrument(skip(self, payload), fields(operation_id = %operation_id.as_str(), sample_kind = ?sample_kind))]
pub async fn save_json_sample(
&self,
workspace_id: &WorkspaceId,
operation_id: &OperationId,
sample_kind: SampleKind,
payload: &Value,
) -> Result<OperationSampleMetadata, ApiError> {
let summary = self.get_operation(workspace_id, operation_id).await?;
let version = summary.current_draft_version;
let sample_id = SampleId::new(new_prefixed_id("sample"));
let now = OffsetDateTime::now_utc();
let file_name = match sample_kind {
SampleKind::InputJson => "input.json",
SampleKind::OutputJson => "output.json",
SampleKind::YamlImportSource => "source.yaml",
};
let storage_ref = self
.storage
.write_json_sample(operation_id, version, sample_kind, &sample_id, payload)
.await?;
let metadata = OperationSampleMetadata {
id: sample_id,
operation_id: operation_id.clone(),
version,
sample_kind,
storage_ref,
content_type: "application/json".to_owned(),
file_name: Some(file_name.to_owned()),
created_at: now,
};
self.registry
.save_sample_metadata(SaveSampleMetadataRequest { sample: &metadata })
.await?;
info!(
operation_id = %operation_id.as_str(),
sample_id = %metadata.id.as_str(),
version,
"json sample saved"
);
Ok(metadata)
}
#[instrument(skip(self, payload), fields(operation_id = %operation_id.as_str()))]
pub async fn generate_draft(
&self,
workspace_id: &WorkspaceId,
operation_id: &OperationId,
payload: GenerateDraftPayload,
) -> Result<DraftGenerationResult, ApiError> {
let summary = self.get_operation(workspace_id, operation_id).await?;
let samples = self
.registry
.list_sample_metadata(operation_id, summary.current_draft_version)
.await?;
let input_sample = latest_sample_ref(&samples, SampleKind::InputJson)
.ok_or_else(|| ApiError::validation("input_json sample was not found"))?;
let output_sample = latest_sample_ref(&samples, SampleKind::OutputJson)
.ok_or_else(|| ApiError::validation("output_json sample was not found"))?;
let input_value = self.storage.read_json(&input_sample.storage_ref).await?;
let output_value = self.storage.read_json(&output_sample.storage_ref).await?;
let input_schema = Schema::from_json_sample(&input_value);
let output_schema = Schema::from_json_sample(&output_value);
let input_mapping = infer_mapping_from_samples(
&input_value,
JsonPathRoot::Mcp,
&input_value,
JsonPathRoot::RequestBody,
);
let output_mapping = infer_mapping_from_samples(
&output_value,
JsonPathRoot::ResponseBody,
&output_value,
JsonPathRoot::Output,
);
let source_types = if payload.sources.is_empty() {
vec![
"input_json_sample".to_owned(),
"output_json_sample".to_owned(),
]
} else {
payload.sources
};
let generated_draft = GeneratedDraft {
status: GeneratedDraftStatus::Available,
source_types,
generated_at: Some(now_string()?),
input_schema_generated: true,
output_schema_generated: true,
input_mapping_generated: true,
output_mapping_generated: true,
warnings: Vec::new(),
};
let result = DraftGenerationResult {
generated_draft,
input_schema,
output_schema,
input_mapping,
output_mapping,
};
info!(operation_id = %operation_id.as_str(), "draft generated from samples");
Ok(result)
}
}
fn latest_sample_ref(
samples: &[OperationSampleMetadata],
sample_kind: SampleKind,
) -> Option<OperationSampleMetadata> {
samples
.iter()
.rev()
.find(|sample| sample.sample_kind == sample_kind)
.cloned()
}
+141
View File
@@ -0,0 +1,141 @@
use crank_core::{AuthProfileId, WorkspaceId};
use crank_registry::{SaveWorkspaceUpstreamRequest, WorkspaceUpstream, WorkspaceUpstreamId};
use serde_json::json;
use time::OffsetDateTime;
use tracing::{info, instrument};
use crate::{
error::ApiError,
service::{AdminService, UpstreamPayload, new_prefixed_id},
};
impl AdminService {
#[instrument(skip(self))]
pub async fn list_workspace_upstreams(
&self,
workspace_id: &WorkspaceId,
) -> Result<Vec<WorkspaceUpstream>, ApiError> {
self.ensure_workspace_exists(workspace_id).await?;
self.ensure_default_workspace_upstreams(workspace_id)
.await?;
Ok(self.registry.list_workspace_upstreams(workspace_id).await?)
}
#[instrument(skip(self, payload), fields(workspace_id = %workspace_id.as_str(), upstream_name = %payload.name))]
pub async fn save_workspace_upstream(
&self,
workspace_id: &WorkspaceId,
upstream_id: Option<&WorkspaceUpstreamId>,
payload: UpstreamPayload,
) -> Result<WorkspaceUpstream, ApiError> {
self.ensure_workspace_exists(workspace_id).await?;
validate_upstream_payload(&payload)?;
if let Some(auth_profile_id) = payload.auth_profile_id.as_deref() {
self.get_auth_profile(
workspace_id,
&AuthProfileId::new(auth_profile_id.to_owned()),
)
.await?;
}
let now = OffsetDateTime::now_utc();
let existing = match upstream_id {
Some(id) => {
self.registry
.get_workspace_upstream(workspace_id, id)
.await?
}
None => None,
};
if upstream_id.is_some() && existing.is_none() {
return Err(ApiError::not_found_with_context(
"upstream was not found",
json!({ "upstream_id": upstream_id.map(|id| id.as_str()).unwrap_or_default() }),
));
}
let upstream = WorkspaceUpstream {
id: existing
.as_ref()
.map(|item| item.id.clone())
.unwrap_or_else(|| WorkspaceUpstreamId::new(new_prefixed_id("upstream"))),
workspace_id: workspace_id.clone(),
name: payload.name.trim().to_owned(),
base_url: normalize_base_url(&payload.base_url),
static_headers: payload.static_headers,
auth_profile_id: payload
.auth_profile_id
.filter(|value| !value.trim().is_empty()),
created_at: existing.as_ref().map(|item| item.created_at).unwrap_or(now),
updated_at: now,
};
self.registry
.save_workspace_upstream(SaveWorkspaceUpstreamRequest {
upstream: &upstream,
})
.await?;
info!(upstream_id = %upstream.id.as_str(), "workspace upstream saved");
Ok(upstream)
}
pub(super) async fn ensure_default_workspace_upstreams(
&self,
workspace_id: &WorkspaceId,
) -> Result<(), ApiError> {
let existing = self.registry.list_workspace_upstreams(workspace_id).await?;
if existing.iter().any(|item| item.name == "Open Meteo") {
return Ok(());
}
let now = OffsetDateTime::now_utc();
let upstream = WorkspaceUpstream {
id: WorkspaceUpstreamId::new(new_prefixed_id("upstream")),
workspace_id: workspace_id.clone(),
name: "Open Meteo".to_owned(),
base_url: "https://api.open-meteo.com".to_owned(),
static_headers: json!({}),
auth_profile_id: None,
created_at: now,
updated_at: now,
};
self.registry
.save_workspace_upstream(SaveWorkspaceUpstreamRequest {
upstream: &upstream,
})
.await?;
Ok(())
}
}
fn validate_upstream_payload(payload: &UpstreamPayload) -> Result<(), ApiError> {
if payload.name.trim().is_empty() {
return Err(ApiError::validation("upstream name is required"));
}
let base_url = normalize_base_url(&payload.base_url);
if !(base_url.starts_with("https://") || base_url.starts_with("http://")) {
return Err(ApiError::validation(
"upstream base_url must start with http:// or https://",
));
}
if !payload.static_headers.is_object() {
return Err(ApiError::validation(
"upstream static_headers must be a JSON object",
));
}
if let Some(headers) = payload.static_headers.as_object() {
for (key, value) in headers {
if key.trim().is_empty() || !value.is_string() {
return Err(ApiError::validation(
"upstream static_headers must contain string values",
));
}
}
}
Ok(())
}
fn normalize_base_url(value: &str) -> String {
value.trim().trim_end_matches('/').to_owned()
}