907 lines
31 KiB
Rust
907 lines
31 KiB
Rust
use std::path::PathBuf;
|
|
|
|
use mcpaas_core::{
|
|
AuthConfig, AuthKind, AuthProfile, AuthProfileId, ConfigExport, ExportMode, GeneratedDraft,
|
|
GeneratedDraftStatus, OperationId, OperationStatus, Protocol, SampleId, Samples, Target,
|
|
};
|
|
use mcpaas_mapping::{JsonPathRoot, MappingSet, infer_mapping_from_samples};
|
|
use mcpaas_proto::{ProtoService, services_from_descriptor_set_bytes};
|
|
use mcpaas_registry::{
|
|
CreateVersionRequest, OperationSampleMetadata, OperationSummary, OperationVersionRecord,
|
|
PostgresRegistry, PublishRequest, RegistryOperation, SampleKind, SaveAuthProfileRequest,
|
|
SaveDescriptorMetadataRequest, SaveSampleMetadataRequest,
|
|
};
|
|
use mcpaas_runtime::{PreparedRequest, RuntimeError, RuntimeExecutor, RuntimeOperation};
|
|
use mcpaas_schema::Schema;
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::{Value, json};
|
|
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
|
|
use tracing::{info, instrument};
|
|
use uuid::Uuid;
|
|
|
|
use crate::{error::ApiError, storage::LocalArtifactStorage};
|
|
|
|
#[derive(Clone)]
|
|
pub struct AdminService {
|
|
registry: PostgresRegistry,
|
|
runtime: RuntimeExecutor,
|
|
storage: LocalArtifactStorage,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
|
pub struct OperationPayload {
|
|
pub name: String,
|
|
pub display_name: String,
|
|
pub protocol: Protocol,
|
|
pub target: Target,
|
|
pub input_schema: Schema,
|
|
pub output_schema: Schema,
|
|
pub input_mapping: MappingSet,
|
|
pub output_mapping: MappingSet,
|
|
pub execution_config: mcpaas_core::ExecutionConfig,
|
|
pub tool_description: mcpaas_core::ToolDescription,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Deserialize)]
|
|
pub struct NewVersionPayload {
|
|
#[serde(flatten)]
|
|
pub operation: OperationPayload,
|
|
#[serde(default)]
|
|
pub change_note: Option<String>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Deserialize)]
|
|
pub struct PublishPayload {
|
|
pub version: u32,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Deserialize)]
|
|
pub struct TestRunPayload {
|
|
pub version: u32,
|
|
pub input: Value,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Serialize)]
|
|
pub struct TestRunResult {
|
|
pub ok: bool,
|
|
pub request_preview: Value,
|
|
pub response_preview: Value,
|
|
pub errors: Vec<Value>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Deserialize)]
|
|
pub struct AuthProfilePayload {
|
|
pub name: String,
|
|
pub kind: AuthKind,
|
|
pub config: AuthConfig,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Deserialize)]
|
|
pub struct GenerateDraftPayload {
|
|
#[serde(default)]
|
|
pub sources: Vec<String>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Serialize)]
|
|
pub struct DraftGenerationResult {
|
|
pub generated_draft: GeneratedDraft,
|
|
pub input_schema: Schema,
|
|
pub output_schema: Schema,
|
|
pub input_mapping: MappingSet,
|
|
pub output_mapping: MappingSet,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Deserialize)]
|
|
pub struct ImportQuery {
|
|
#[serde(default)]
|
|
pub mode: ImportMode,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum ImportMode {
|
|
#[default]
|
|
Create,
|
|
Upsert,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Deserialize)]
|
|
pub struct ExportQuery {
|
|
pub version: Option<u32>,
|
|
#[serde(default = "default_export_mode")]
|
|
pub mode: ExportMode,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
|
pub struct YamlOperationDocument {
|
|
pub format_version: String,
|
|
pub kind: String,
|
|
pub operation: RegistryOperation,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Serialize)]
|
|
pub struct CreatedOperationResponse {
|
|
pub operation_id: String,
|
|
pub version: u32,
|
|
pub status: OperationStatus,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Serialize)]
|
|
pub struct PublishResponse {
|
|
pub operation_id: String,
|
|
pub published_version: u32,
|
|
pub published_at: String,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Serialize)]
|
|
pub struct ImportResponse {
|
|
pub operation_id: String,
|
|
pub version: u32,
|
|
pub import_mode: ImportMode,
|
|
pub warnings: Vec<String>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Serialize)]
|
|
pub struct DescriptorUploadResponse {
|
|
pub descriptor_id: String,
|
|
pub version: u32,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Serialize)]
|
|
pub struct GrpcServiceSummary {
|
|
pub package: String,
|
|
pub service: String,
|
|
pub methods: Vec<GrpcMethodSummary>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Serialize)]
|
|
pub struct GrpcMethodSummary {
|
|
pub name: String,
|
|
pub kind: String,
|
|
pub input_schema: Schema,
|
|
pub output_schema: Schema,
|
|
}
|
|
|
|
impl AdminService {
|
|
pub fn new(registry: PostgresRegistry, storage_root: PathBuf) -> Self {
|
|
Self {
|
|
registry,
|
|
runtime: RuntimeExecutor::new(),
|
|
storage: LocalArtifactStorage::new(storage_root),
|
|
}
|
|
}
|
|
|
|
#[instrument(skip(self))]
|
|
pub async fn list_operations(&self) -> Result<Vec<OperationSummary>, ApiError> {
|
|
Ok(self.registry.list_operations().await?)
|
|
}
|
|
|
|
#[instrument(skip(self))]
|
|
pub async fn get_operation(
|
|
&self,
|
|
operation_id: &OperationId,
|
|
) -> Result<OperationSummary, ApiError> {
|
|
self.registry
|
|
.get_operation_summary(operation_id)
|
|
.await?
|
|
.ok_or_else(|| {
|
|
ApiError::not_found(format!("operation {} was not found", operation_id.as_str()))
|
|
})
|
|
}
|
|
|
|
#[instrument(skip(self))]
|
|
pub async fn get_operation_version(
|
|
&self,
|
|
operation_id: &OperationId,
|
|
version: u32,
|
|
) -> Result<OperationVersionRecord, ApiError> {
|
|
self.registry
|
|
.get_operation_version(operation_id, version)
|
|
.await?
|
|
.ok_or_else(|| {
|
|
ApiError::not_found(format!(
|
|
"operation version {version} for {} was not found",
|
|
operation_id.as_str()
|
|
))
|
|
})
|
|
}
|
|
|
|
#[instrument(skip(self, payload), fields(protocol = ?payload.protocol, operation_name = %payload.name))]
|
|
pub async fn create_operation(
|
|
&self,
|
|
payload: OperationPayload,
|
|
) -> Result<CreatedOperationResponse, ApiError> {
|
|
self.validate_operation_payload(&payload)?;
|
|
|
|
if self.find_operation_by_name(&payload.name).await?.is_some() {
|
|
return Err(ApiError::conflict(format!(
|
|
"operation with name {} already exists",
|
|
payload.name
|
|
)));
|
|
}
|
|
|
|
let now = now_string()?;
|
|
let operation_id = OperationId::new(new_prefixed_id("op"));
|
|
let snapshot = RegistryOperation {
|
|
id: operation_id.clone(),
|
|
name: payload.name,
|
|
display_name: payload.display_name,
|
|
protocol: payload.protocol,
|
|
status: OperationStatus::Draft,
|
|
version: 1,
|
|
target: payload.target,
|
|
input_schema: payload.input_schema,
|
|
output_schema: payload.output_schema,
|
|
input_mapping: payload.input_mapping,
|
|
output_mapping: payload.output_mapping,
|
|
execution_config: payload.execution_config,
|
|
tool_description: payload.tool_description,
|
|
samples: Some(Samples::default()),
|
|
generated_draft: None,
|
|
config_export: Some(ConfigExport {
|
|
format_version: "1".to_owned(),
|
|
export_mode: ExportMode::Portable,
|
|
}),
|
|
created_at: now.clone(),
|
|
updated_at: now,
|
|
published_at: None,
|
|
};
|
|
|
|
self.registry.create_operation(&snapshot, None).await?;
|
|
info!(operation_id = %operation_id.as_str(), version = 1, "operation created");
|
|
|
|
Ok(CreatedOperationResponse {
|
|
operation_id: operation_id.as_str().to_owned(),
|
|
version: 1,
|
|
status: OperationStatus::Draft,
|
|
})
|
|
}
|
|
|
|
#[instrument(skip(self, payload), fields(operation_id = %operation_id.as_str(), protocol = ?payload.operation.protocol))]
|
|
pub async fn create_version(
|
|
&self,
|
|
operation_id: &OperationId,
|
|
payload: NewVersionPayload,
|
|
) -> Result<CreatedOperationResponse, ApiError> {
|
|
self.validate_operation_payload(&payload.operation)?;
|
|
|
|
let summary = self.get_operation(operation_id).await?;
|
|
let now = now_string()?;
|
|
let version = summary.current_draft_version + 1;
|
|
let snapshot = RegistryOperation {
|
|
id: operation_id.clone(),
|
|
name: payload.operation.name,
|
|
display_name: payload.operation.display_name,
|
|
protocol: payload.operation.protocol,
|
|
status: OperationStatus::Draft,
|
|
version,
|
|
target: payload.operation.target,
|
|
input_schema: payload.operation.input_schema,
|
|
output_schema: payload.operation.output_schema,
|
|
input_mapping: payload.operation.input_mapping,
|
|
output_mapping: payload.operation.output_mapping,
|
|
execution_config: payload.operation.execution_config,
|
|
tool_description: payload.operation.tool_description,
|
|
samples: Some(Samples::default()),
|
|
generated_draft: None,
|
|
config_export: Some(ConfigExport {
|
|
format_version: "1".to_owned(),
|
|
export_mode: ExportMode::Portable,
|
|
}),
|
|
created_at: summary.created_at,
|
|
updated_at: now,
|
|
published_at: None,
|
|
};
|
|
|
|
self.registry
|
|
.create_version(CreateVersionRequest {
|
|
snapshot: &snapshot,
|
|
change_note: payload.change_note.as_deref(),
|
|
created_by: None,
|
|
})
|
|
.await?;
|
|
info!(operation_id = %operation_id.as_str(), version, "operation version created");
|
|
|
|
Ok(CreatedOperationResponse {
|
|
operation_id: operation_id.as_str().to_owned(),
|
|
version,
|
|
status: OperationStatus::Draft,
|
|
})
|
|
}
|
|
|
|
#[instrument(skip(self), fields(operation_id = %operation_id.as_str(), version))]
|
|
pub async fn publish_operation(
|
|
&self,
|
|
operation_id: &OperationId,
|
|
version: u32,
|
|
) -> Result<PublishResponse, ApiError> {
|
|
let published_at = now_string()?;
|
|
self.registry
|
|
.publish_operation(PublishRequest {
|
|
operation_id,
|
|
version,
|
|
published_at: &published_at,
|
|
published_by: None,
|
|
})
|
|
.await?;
|
|
info!(operation_id = %operation_id.as_str(), version, "operation published");
|
|
|
|
Ok(PublishResponse {
|
|
operation_id: operation_id.as_str().to_owned(),
|
|
published_version: version,
|
|
published_at,
|
|
})
|
|
}
|
|
|
|
#[instrument(skip(self, payload), fields(operation_id = %operation_id.as_str(), version = payload.version))]
|
|
pub async fn run_test(
|
|
&self,
|
|
operation_id: &OperationId,
|
|
payload: TestRunPayload,
|
|
) -> Result<TestRunResult, ApiError> {
|
|
let record = self
|
|
.get_operation_version(operation_id, payload.version)
|
|
.await?;
|
|
let runtime = RuntimeOperation::from(record.snapshot.clone());
|
|
let request_preview =
|
|
match build_request_preview(&record.snapshot.input_mapping, &payload.input) {
|
|
Ok(preview) => preview,
|
|
Err(error) => {
|
|
return Ok(TestRunResult {
|
|
ok: false,
|
|
request_preview: Value::Null,
|
|
response_preview: Value::Null,
|
|
errors: vec![crate::error::runtime_test_failure(&RuntimeError::Mapping(
|
|
error,
|
|
))],
|
|
});
|
|
}
|
|
};
|
|
|
|
match self.runtime.execute(&runtime, &payload.input).await {
|
|
Ok(output) => Ok(TestRunResult {
|
|
ok: true,
|
|
request_preview,
|
|
response_preview: output,
|
|
errors: Vec::new(),
|
|
}),
|
|
Err(error) => Ok(TestRunResult {
|
|
ok: false,
|
|
request_preview,
|
|
response_preview: Value::Null,
|
|
errors: vec![crate::error::runtime_test_failure(&error)],
|
|
}),
|
|
}
|
|
}
|
|
|
|
#[instrument(skip(self))]
|
|
pub async fn list_auth_profiles(&self) -> Result<Vec<AuthProfile>, ApiError> {
|
|
Ok(self.registry.list_auth_profiles().await?)
|
|
}
|
|
|
|
#[instrument(skip(self, payload), fields(operation_id = %operation_id.as_str(), source_name = source_name.unwrap_or("descriptor-set.bin")))]
|
|
pub async fn upload_descriptor_set(
|
|
&self,
|
|
operation_id: &OperationId,
|
|
source_name: Option<&str>,
|
|
payload: &[u8],
|
|
) -> Result<DescriptorUploadResponse, ApiError> {
|
|
let summary = self.get_operation(operation_id).await?;
|
|
if summary.protocol != Protocol::Grpc {
|
|
return Err(ApiError::validation(
|
|
"descriptor upload is only allowed for grpc operations",
|
|
));
|
|
}
|
|
|
|
let services = services_from_descriptor_set_bytes(payload)
|
|
.map_err(|error| ApiError::validation(error.to_string()))?;
|
|
let descriptor_id = mcpaas_core::DescriptorId::new(new_prefixed_id("desc"));
|
|
let storage_ref = self
|
|
.storage
|
|
.write_descriptor(
|
|
operation_id,
|
|
summary.current_draft_version,
|
|
mcpaas_registry::DescriptorKind::DescriptorSet,
|
|
&descriptor_id,
|
|
source_name,
|
|
payload,
|
|
)
|
|
.await?;
|
|
|
|
self.registry
|
|
.save_descriptor_metadata(SaveDescriptorMetadataRequest {
|
|
descriptor: &mcpaas_registry::DescriptorMetadata {
|
|
id: descriptor_id.clone(),
|
|
operation_id: Some(operation_id.clone()),
|
|
version: Some(summary.current_draft_version),
|
|
descriptor_kind: mcpaas_registry::DescriptorKind::DescriptorSet,
|
|
storage_ref,
|
|
source_name: source_name.map(ToOwned::to_owned),
|
|
package_index: Some(
|
|
serde_json::to_value(&services)
|
|
.map_err(|error| ApiError::internal(error.to_string()))?,
|
|
),
|
|
created_at: now_string()?,
|
|
},
|
|
})
|
|
.await?;
|
|
info!(
|
|
operation_id = %operation_id.as_str(),
|
|
descriptor_id = %descriptor_id.as_str(),
|
|
version = summary.current_draft_version,
|
|
"descriptor set uploaded"
|
|
);
|
|
|
|
Ok(DescriptorUploadResponse {
|
|
descriptor_id: descriptor_id.as_str().to_owned(),
|
|
version: summary.current_draft_version,
|
|
})
|
|
}
|
|
|
|
#[instrument(skip(self, payload), fields(operation_id = %operation_id.as_str(), source_name = source_name.unwrap_or("schema.proto")))]
|
|
pub async fn upload_proto_file(
|
|
&self,
|
|
operation_id: &OperationId,
|
|
source_name: Option<&str>,
|
|
payload: &[u8],
|
|
) -> Result<DescriptorUploadResponse, ApiError> {
|
|
let summary = self.get_operation(operation_id).await?;
|
|
if summary.protocol != Protocol::Grpc {
|
|
return Err(ApiError::validation(
|
|
"proto upload is only allowed for grpc operations",
|
|
));
|
|
}
|
|
|
|
let descriptor_id = mcpaas_core::DescriptorId::new(new_prefixed_id("desc"));
|
|
let storage_ref = self
|
|
.storage
|
|
.write_descriptor(
|
|
operation_id,
|
|
summary.current_draft_version,
|
|
mcpaas_registry::DescriptorKind::ProtoUpload,
|
|
&descriptor_id,
|
|
source_name,
|
|
payload,
|
|
)
|
|
.await?;
|
|
|
|
self.registry
|
|
.save_descriptor_metadata(SaveDescriptorMetadataRequest {
|
|
descriptor: &mcpaas_registry::DescriptorMetadata {
|
|
id: descriptor_id.clone(),
|
|
operation_id: Some(operation_id.clone()),
|
|
version: Some(summary.current_draft_version),
|
|
descriptor_kind: mcpaas_registry::DescriptorKind::ProtoUpload,
|
|
storage_ref,
|
|
source_name: source_name.map(ToOwned::to_owned),
|
|
package_index: None,
|
|
created_at: now_string()?,
|
|
},
|
|
})
|
|
.await?;
|
|
info!(
|
|
operation_id = %operation_id.as_str(),
|
|
descriptor_id = %descriptor_id.as_str(),
|
|
version = summary.current_draft_version,
|
|
"proto file uploaded"
|
|
);
|
|
|
|
Ok(DescriptorUploadResponse {
|
|
descriptor_id: descriptor_id.as_str().to_owned(),
|
|
version: summary.current_draft_version,
|
|
})
|
|
}
|
|
|
|
#[instrument(skip(self), fields(operation_id = %operation_id.as_str(), version))]
|
|
pub async fn list_grpc_services(
|
|
&self,
|
|
operation_id: &OperationId,
|
|
version: Option<u32>,
|
|
) -> Result<Vec<GrpcServiceSummary>, ApiError> {
|
|
let summary = self.get_operation(operation_id).await?;
|
|
if summary.protocol != Protocol::Grpc {
|
|
return Err(ApiError::validation(
|
|
"grpc services are only available for grpc operations",
|
|
));
|
|
}
|
|
|
|
let version = version.unwrap_or(summary.current_draft_version);
|
|
let descriptor = self
|
|
.registry
|
|
.list_descriptor_metadata(operation_id, version)
|
|
.await?
|
|
.into_iter()
|
|
.rev()
|
|
.find(|descriptor| {
|
|
descriptor.descriptor_kind == mcpaas_registry::DescriptorKind::DescriptorSet
|
|
})
|
|
.ok_or_else(|| ApiError::not_found("descriptor set was not uploaded"))?;
|
|
let bytes = self.storage.read_bytes(&descriptor.storage_ref).await?;
|
|
let services = services_from_descriptor_set_bytes(&bytes)
|
|
.map_err(|error| ApiError::validation(error.to_string()))?;
|
|
|
|
services
|
|
.into_iter()
|
|
.map(proto_service_summary)
|
|
.collect::<Result<Vec<_>, _>>()
|
|
}
|
|
|
|
#[instrument(skip(self))]
|
|
pub async fn get_auth_profile(
|
|
&self,
|
|
auth_profile_id: &AuthProfileId,
|
|
) -> Result<AuthProfile, ApiError> {
|
|
self.registry
|
|
.get_auth_profile(auth_profile_id)
|
|
.await?
|
|
.ok_or_else(|| {
|
|
ApiError::not_found(format!(
|
|
"auth profile {} was not found",
|
|
auth_profile_id.as_str()
|
|
))
|
|
})
|
|
}
|
|
|
|
#[instrument(skip(self, payload), fields(auth_profile_name = %payload.name, auth_kind = ?payload.kind))]
|
|
pub async fn create_auth_profile(
|
|
&self,
|
|
payload: AuthProfilePayload,
|
|
) -> Result<AuthProfile, ApiError> {
|
|
validate_auth_profile_kind(payload.kind, &payload.config)?;
|
|
|
|
let now = now_string()?;
|
|
let profile = AuthProfile {
|
|
id: AuthProfileId::new(new_prefixed_id("auth")),
|
|
name: payload.name,
|
|
kind: payload.kind,
|
|
config: payload.config,
|
|
created_at: now.clone(),
|
|
updated_at: now,
|
|
};
|
|
|
|
self.registry
|
|
.save_auth_profile(SaveAuthProfileRequest { profile: &profile })
|
|
.await?;
|
|
info!(auth_profile_id = %profile.id.as_str(), "auth profile created");
|
|
|
|
Ok(profile)
|
|
}
|
|
|
|
#[instrument(skip(self), fields(operation_id = %operation_id.as_str(), version = query.version.unwrap_or_default(), mode = ?query.mode))]
|
|
pub async fn export_operation(
|
|
&self,
|
|
operation_id: &OperationId,
|
|
query: ExportQuery,
|
|
) -> Result<String, ApiError> {
|
|
let version = match query.version {
|
|
Some(version) => version,
|
|
None => {
|
|
self.get_operation(operation_id)
|
|
.await?
|
|
.current_draft_version
|
|
}
|
|
};
|
|
let record = self.get_operation_version(operation_id, version).await?;
|
|
let document = YamlOperationDocument {
|
|
format_version: "1".to_owned(),
|
|
kind: "operation".to_owned(),
|
|
operation: RegistryOperation {
|
|
config_export: Some(ConfigExport {
|
|
format_version: "1".to_owned(),
|
|
export_mode: query.mode,
|
|
}),
|
|
..record.snapshot
|
|
},
|
|
};
|
|
|
|
serde_yaml::to_string(&document).map_err(|error| ApiError::internal(error.to_string()))
|
|
}
|
|
|
|
#[instrument(skip(self, yaml_document), fields(mode = ?query.mode))]
|
|
pub async fn import_operation(
|
|
&self,
|
|
query: ImportQuery,
|
|
yaml_document: &str,
|
|
) -> Result<ImportResponse, ApiError> {
|
|
let document: YamlOperationDocument = serde_yaml::from_str(yaml_document)
|
|
.map_err(|error| ApiError::validation(error.to_string()))?;
|
|
if document.kind != "operation" {
|
|
return Err(ApiError::validation("yaml kind must be operation"));
|
|
}
|
|
|
|
let payload = OperationPayload {
|
|
name: document.operation.name.clone(),
|
|
display_name: document.operation.display_name.clone(),
|
|
protocol: document.operation.protocol,
|
|
target: document.operation.target.clone(),
|
|
input_schema: document.operation.input_schema.clone(),
|
|
output_schema: document.operation.output_schema.clone(),
|
|
input_mapping: document.operation.input_mapping.clone(),
|
|
output_mapping: document.operation.output_mapping.clone(),
|
|
execution_config: document.operation.execution_config.clone(),
|
|
tool_description: document.operation.tool_description.clone(),
|
|
};
|
|
|
|
match query.mode {
|
|
ImportMode::Create => {
|
|
let created = self.create_operation(payload).await?;
|
|
Ok(ImportResponse {
|
|
operation_id: created.operation_id,
|
|
version: created.version,
|
|
import_mode: ImportMode::Create,
|
|
warnings: Vec::new(),
|
|
})
|
|
}
|
|
ImportMode::Upsert => {
|
|
if let Some(existing) = self
|
|
.find_operation_by_name(&document.operation.name)
|
|
.await?
|
|
{
|
|
let created = self
|
|
.create_version(
|
|
&existing.id,
|
|
NewVersionPayload {
|
|
operation: payload,
|
|
change_note: Some("yaml upsert".to_owned()),
|
|
},
|
|
)
|
|
.await?;
|
|
|
|
let response = ImportResponse {
|
|
operation_id: created.operation_id,
|
|
version: created.version,
|
|
import_mode: ImportMode::Upsert,
|
|
warnings: Vec::new(),
|
|
};
|
|
info!(
|
|
operation_id = %response.operation_id,
|
|
version = response.version,
|
|
"operation imported by upsert"
|
|
);
|
|
Ok(response)
|
|
} else {
|
|
let created = self.create_operation(payload).await?;
|
|
let response = ImportResponse {
|
|
operation_id: created.operation_id,
|
|
version: created.version,
|
|
import_mode: ImportMode::Upsert,
|
|
warnings: Vec::new(),
|
|
};
|
|
info!(
|
|
operation_id = %response.operation_id,
|
|
version = response.version,
|
|
"operation imported by upsert"
|
|
);
|
|
Ok(response)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[instrument(skip(self, payload), fields(operation_id = %operation_id.as_str(), sample_kind = ?sample_kind))]
|
|
pub async fn save_json_sample(
|
|
&self,
|
|
operation_id: &OperationId,
|
|
sample_kind: SampleKind,
|
|
payload: &Value,
|
|
) -> Result<OperationSampleMetadata, ApiError> {
|
|
let summary = self.get_operation(operation_id).await?;
|
|
let version = summary.current_draft_version;
|
|
let sample_id = SampleId::new(new_prefixed_id("sample"));
|
|
let now = now_string()?;
|
|
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,
|
|
operation_id: &OperationId,
|
|
payload: GenerateDraftPayload,
|
|
) -> Result<DraftGenerationResult, ApiError> {
|
|
let summary = self.get_operation(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> {
|
|
validate_protocol_target(payload.protocol, &payload.target)?;
|
|
payload.input_mapping.validate_paths()?;
|
|
payload.output_mapping.validate_paths()?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn find_operation_by_name(
|
|
&self,
|
|
name: &str,
|
|
) -> Result<Option<OperationSummary>, ApiError> {
|
|
Ok(self
|
|
.registry
|
|
.list_operations()
|
|
.await?
|
|
.into_iter()
|
|
.find(|operation| operation.name == name))
|
|
}
|
|
}
|
|
|
|
fn build_request_preview(
|
|
mapping: &MappingSet,
|
|
input: &Value,
|
|
) -> Result<Value, mcpaas_mapping::MappingError> {
|
|
let mapped = mapping.apply(&json!({ "mcp": input }))?;
|
|
let prepared = PreparedRequest::from_mapping_output(&mapped).map_err(|error| {
|
|
mcpaas_mapping::MappingError::InvalidJsonPath {
|
|
path: error.to_string(),
|
|
}
|
|
})?;
|
|
|
|
Ok(json!({
|
|
"path": prepared.path_params,
|
|
"query": prepared.query_params,
|
|
"headers": prepared.headers,
|
|
"grpc": prepared.grpc.unwrap_or(Value::Null),
|
|
"variables": prepared.variables.unwrap_or(Value::Null),
|
|
"body": prepared.body.unwrap_or(Value::Null)
|
|
}))
|
|
}
|
|
|
|
fn validate_protocol_target(protocol: Protocol, target: &Target) -> Result<(), ApiError> {
|
|
let is_match = matches!(
|
|
(protocol, target),
|
|
(Protocol::Rest, Target::Rest(_))
|
|
| (Protocol::Graphql, Target::Graphql(_))
|
|
| (Protocol::Grpc, Target::Grpc(_))
|
|
);
|
|
|
|
if is_match {
|
|
return Ok(());
|
|
}
|
|
|
|
Err(ApiError::validation("protocol and target kind must match"))
|
|
}
|
|
|
|
fn validate_auth_profile_kind(kind: AuthKind, config: &AuthConfig) -> Result<(), ApiError> {
|
|
let is_match = matches!(
|
|
(kind, config),
|
|
(AuthKind::Bearer, AuthConfig::Bearer(_))
|
|
| (AuthKind::Basic, AuthConfig::Basic(_))
|
|
| (AuthKind::ApiKeyHeader, AuthConfig::ApiKeyHeader(_))
|
|
| (AuthKind::ApiKeyQuery, AuthConfig::ApiKeyQuery(_))
|
|
);
|
|
|
|
if is_match {
|
|
return Ok(());
|
|
}
|
|
|
|
Err(ApiError::validation("auth kind and config must match"))
|
|
}
|
|
|
|
fn latest_sample_ref(
|
|
samples: &[OperationSampleMetadata],
|
|
sample_kind: SampleKind,
|
|
) -> Option<OperationSampleMetadata> {
|
|
samples
|
|
.iter()
|
|
.rev()
|
|
.find(|sample| sample.sample_kind == sample_kind)
|
|
.cloned()
|
|
}
|
|
|
|
fn default_export_mode() -> ExportMode {
|
|
ExportMode::Portable
|
|
}
|
|
|
|
fn new_prefixed_id(prefix: &str) -> String {
|
|
format!("{prefix}_{}", Uuid::now_v7().simple())
|
|
}
|
|
|
|
fn now_string() -> Result<String, ApiError> {
|
|
OffsetDateTime::now_utc()
|
|
.format(&Rfc3339)
|
|
.map_err(|error| ApiError::internal(error.to_string()))
|
|
}
|
|
|
|
fn proto_service_summary(service: ProtoService) -> Result<GrpcServiceSummary, ApiError> {
|
|
let methods = service
|
|
.unary_methods()
|
|
.map(|method| {
|
|
Ok(GrpcMethodSummary {
|
|
name: method.name.clone(),
|
|
kind: "unary".to_owned(),
|
|
input_schema: mcpaas_proto::message_to_schema(&method.input)
|
|
.map_err(|error| ApiError::validation(error.to_string()))?,
|
|
output_schema: mcpaas_proto::message_to_schema(&method.output)
|
|
.map_err(|error| ApiError::validation(error.to_string()))?,
|
|
})
|
|
})
|
|
.collect::<Result<Vec<_>, ApiError>>()?;
|
|
|
|
Ok(GrpcServiceSummary {
|
|
package: service.package,
|
|
service: service.name,
|
|
methods,
|
|
})
|
|
}
|