pub mod asyncapi;
pub mod grpc;
pub mod openapi;
pub mod orpc;
pub mod types;
pub use asyncapi::*;
pub use grpc::*;
pub use openapi::*;
pub use orpc::*;
pub use types::*;
use crate::errors::Result;
use crate::types::{ConflictStrategy, SchemaManifest, SchemaType};
use std::collections::HashMap;
pub struct Merger {
config: MergerConfig,
}
#[derive(Debug, Clone)]
pub struct MergerConfig {
pub default_conflict_strategy: ConflictStrategy,
pub merged_title: String,
pub merged_description: String,
pub merged_version: String,
pub include_service_tags: bool,
pub collapse_service_tags: bool,
pub sort_output: bool,
pub servers: Vec<Server>,
}
impl Default for MergerConfig {
fn default() -> Self {
Self {
default_conflict_strategy: ConflictStrategy::Prefix,
merged_title: "Federated API".to_string(),
merged_description: "Merged API specification from multiple services".to_string(),
merged_version: "1.0.0".to_string(),
include_service_tags: true,
collapse_service_tags: false,
sort_output: true,
servers: Vec::new(),
}
}
}
#[derive(Debug, Clone)]
pub struct ServiceSchema {
pub manifest: SchemaManifest,
pub schema: serde_json::Value,
pub parsed: Option<OpenAPISpec>,
}
#[derive(Debug, Clone)]
pub struct MergeResult {
pub spec: OpenAPISpec,
pub included_services: Vec<String>,
pub excluded_services: Vec<String>,
pub conflicts: Vec<Conflict>,
pub warnings: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Conflict {
pub conflict_type: ConflictType,
pub item: String,
pub services: Vec<String>,
pub resolution: String,
pub strategy: ConflictStrategy,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ConflictType {
Path,
Component,
Tag,
OperationID,
SecurityScheme,
}
impl Merger {
pub fn new(config: MergerConfig) -> Self {
Self { config }
}
#[allow(clippy::should_implement_trait)]
pub fn default() -> Self {
Self::new(MergerConfig::default())
}
pub fn merge(&self, schemas: Vec<ServiceSchema>) -> Result<MergeResult> {
let mut result = MergeResult {
spec: OpenAPISpec {
openapi: "3.1.0".to_string(),
info: Info {
title: self.config.merged_title.clone(),
description: Some(self.config.merged_description.clone()),
version: self.config.merged_version.clone(),
terms_of_service: None,
contact: None,
license: None,
extensions: HashMap::new(),
},
servers: self.config.servers.clone(),
paths: HashMap::new(),
components: Some(Components {
schemas: HashMap::new(),
responses: HashMap::new(),
parameters: HashMap::new(),
request_bodies: HashMap::new(),
headers: HashMap::new(),
security_schemes: HashMap::new(),
}),
security: Vec::new(),
tags: Vec::new(),
extensions: HashMap::new(),
},
included_services: Vec::new(),
excluded_services: Vec::new(),
conflicts: Vec::new(),
warnings: Vec::new(),
};
let mut seen_paths: HashMap<String, String> = HashMap::new();
let mut seen_components: HashMap<String, String> = HashMap::new();
let mut seen_operation_ids: HashMap<String, String> = HashMap::new();
let mut seen_tags: HashMap<String, Tag> = HashMap::new();
let mut seen_security_schemes: HashMap<String, String> = HashMap::new();
for mut schema in schemas {
let service_name = schema.manifest.service_name.clone();
if !should_include_in_merge(&schema) {
result.excluded_services.push(service_name);
continue;
}
result.included_services.push(service_name.clone());
if schema.parsed.is_none() {
match parse_openapi_schema(&schema.schema) {
Ok(parsed) => schema.parsed = Some(parsed),
Err(e) => {
result
.warnings
.push(format!("Failed to parse schema for {service_name}: {e}"));
continue;
}
}
}
let parsed = schema.parsed.as_ref().unwrap();
let comp_config = get_composition_config(&schema.manifest);
let strategy = self.get_conflict_strategy(comp_config.as_ref());
let component_prefix = get_component_prefix(&schema.manifest, comp_config.as_ref());
let tag_prefix = get_tag_prefix(&schema.manifest, comp_config.as_ref());
let operation_id_prefix =
get_operation_id_prefix(&schema.manifest, comp_config.as_ref());
let paths = apply_routing(&parsed.paths, &schema.manifest);
for (mut path, mut path_item) in paths {
if let Some(existing_service) = seen_paths.get(&path) {
let conflict = Conflict {
conflict_type: ConflictType::Path,
item: path.clone(),
services: vec![existing_service.clone(), service_name.clone()],
resolution: String::new(),
strategy,
};
match strategy {
ConflictStrategy::Error => {
return Err(crate::errors::Error::Custom(format!(
"path conflict: {path} exists in both {existing_service} and {service_name}"
)));
}
ConflictStrategy::Skip => {
let mut c = conflict;
c.resolution = format!("Skipped path from {service_name}");
result.conflicts.push(c);
continue;
}
ConflictStrategy::Overwrite => {
let mut c = conflict;
c.resolution = format!("Overwritten with {service_name} version");
result.conflicts.push(c);
}
ConflictStrategy::Prefix => {
let new_path = format!("/{service_name}{path}");
let mut c = conflict;
c.resolution = format!("Prefixed to {new_path}");
result.conflicts.push(c);
path = new_path;
}
ConflictStrategy::Merge => {
let existing = result.spec.paths.get(&path).cloned();
if let Some(existing) = existing {
path_item = merge_path_items(existing, path_item);
}
let mut c = conflict;
c.resolution = "Merged operations".to_string();
result.conflicts.push(c);
}
}
}
path_item = apply_operation_prefixes(
path_item,
&operation_id_prefix,
&tag_prefix,
&service_name,
self.config.collapse_service_tags,
&mut seen_operation_ids,
&mut result,
);
rewrite_path_item_refs(&mut path_item, &component_prefix);
result.spec.paths.insert(path.clone(), path_item);
seen_paths.insert(path, service_name.clone());
}
if let Some(components) = &parsed.components {
let prefixed = prefix_component_names(components, &component_prefix);
for (name, schema_obj) in &prefixed.schemas {
if let Some(existing_service) = seen_components.get(name) {
let conflict = Conflict {
conflict_type: ConflictType::Component,
item: name.clone(),
services: vec![existing_service.clone(), service_name.clone()],
resolution: if strategy == ConflictStrategy::Skip {
format!("Skipped component from {service_name}")
} else {
format!("Overwritten with {service_name} version")
},
strategy,
};
result.conflicts.push(conflict);
if strategy == ConflictStrategy::Skip {
continue;
}
}
if let Some(spec_components) = result.spec.components.as_mut() {
spec_components
.schemas
.insert(name.clone(), schema_obj.clone());
}
seen_components.insert(name.clone(), service_name.clone());
}
if let Some(spec_components) = result.spec.components.as_mut() {
for (name, response) in &prefixed.responses {
spec_components
.responses
.insert(name.clone(), response.clone());
}
for (name, param) in &prefixed.parameters {
spec_components
.parameters
.insert(name.clone(), param.clone());
}
for (name, body) in &prefixed.request_bodies {
spec_components
.request_bodies
.insert(name.clone(), body.clone());
}
for (name, scheme) in &prefixed.security_schemes {
if let Some(existing_service) = seen_security_schemes.get(name) {
let conflict = Conflict {
conflict_type: ConflictType::SecurityScheme,
item: name.clone(),
services: vec![existing_service.clone(), service_name.clone()],
resolution: String::new(),
strategy,
};
match strategy {
ConflictStrategy::Error => {
return Err(crate::errors::Error::Custom(format!(
"security scheme conflict: {name} exists in both {existing_service} and {service_name}"
)));
}
ConflictStrategy::Skip => {
let mut c = conflict;
c.resolution =
format!("Skipped security scheme from {service_name}");
result.conflicts.push(c);
continue;
}
ConflictStrategy::Overwrite => {
let mut c = conflict;
c.resolution =
format!("Overwritten with {service_name} version");
result.conflicts.push(c);
}
ConflictStrategy::Prefix => {
let prefixed_name = format!("{service_name}_{name}");
let mut c = conflict;
c.resolution = format!("Prefixed to {prefixed_name}");
result.conflicts.push(c);
spec_components
.security_schemes
.insert(prefixed_name.clone(), scheme.clone());
seen_security_schemes
.insert(prefixed_name, service_name.clone());
continue;
}
ConflictStrategy::Merge => {
let mut c = conflict;
c.resolution =
format!("Merged (overwritten) with {service_name} version");
result.conflicts.push(c);
}
}
}
spec_components
.security_schemes
.insert(name.clone(), scheme.clone());
seen_security_schemes.insert(name.clone(), service_name.clone());
}
}
}
if self.config.collapse_service_tags {
let service_tag = Tag {
name: service_name.clone(),
description: Some(format!("Routes from {service_name}")),
extensions: HashMap::new(),
};
if !seen_tags.contains_key(&service_tag.name) {
seen_tags.insert(service_tag.name.clone(), service_tag.clone());
result.spec.tags.push(service_tag);
}
} else {
for mut tag in parsed.tags.clone() {
if !tag_prefix.is_empty() && self.config.include_service_tags {
tag.name = format!("{}_{}", tag_prefix, tag.name);
}
if let Some(existing) = seen_tags.get(&tag.name) {
if tag.description.is_some() && existing.description.is_none() {
let mut updated = existing.clone();
updated.description = tag.description;
seen_tags.insert(tag.name.clone(), updated.clone());
if let Some(pos) =
result.spec.tags.iter().position(|t| t.name == tag.name)
{
result.spec.tags[pos] = updated;
}
}
} else {
seen_tags.insert(tag.name.clone(), tag.clone());
result.spec.tags.push(tag);
}
}
}
}
if self.config.sort_output {
result.spec.tags.sort_by(|a, b| a.name.cmp(&b.name));
}
Ok(result)
}
fn get_conflict_strategy(
&self,
config: Option<&crate::types::CompositionConfig>,
) -> ConflictStrategy {
config
.map(|c| c.conflict_strategy)
.unwrap_or(self.config.default_conflict_strategy)
}
}
fn should_include_in_merge(schema: &ServiceSchema) -> bool {
for schema_desc in &schema.manifest.schemas {
if schema_desc.schema_type == SchemaType::OpenAPI {
if let Some(metadata) = &schema_desc.metadata {
if let Some(openapi_metadata) = &metadata.openapi {
if let Some(composition) = &openapi_metadata.composition {
return composition.include_in_merged;
}
}
}
return true;
}
}
false
}
fn get_composition_config(manifest: &SchemaManifest) -> Option<crate::types::CompositionConfig> {
for schema_desc in &manifest.schemas {
if schema_desc.schema_type == SchemaType::OpenAPI {
if let Some(metadata) = &schema_desc.metadata {
if let Some(openapi_metadata) = &metadata.openapi {
return openapi_metadata.composition.clone();
}
}
}
}
None
}
fn get_component_prefix(
manifest: &SchemaManifest,
config: Option<&crate::types::CompositionConfig>,
) -> String {
config
.and_then(|c| c.component_prefix.clone())
.unwrap_or_else(|| manifest.service_name.clone())
}
fn get_tag_prefix(
manifest: &SchemaManifest,
config: Option<&crate::types::CompositionConfig>,
) -> String {
config
.and_then(|c| c.tag_prefix.clone())
.unwrap_or_else(|| manifest.service_name.clone())
}
fn get_operation_id_prefix(
manifest: &SchemaManifest,
config: Option<&crate::types::CompositionConfig>,
) -> String {
config
.and_then(|c| c.operation_id_prefix.clone())
.unwrap_or_else(|| manifest.service_name.clone())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_merger_default_config() {
let config = MergerConfig::default();
assert_eq!(config.merged_title, "Federated API");
assert_eq!(config.default_conflict_strategy, ConflictStrategy::Prefix);
assert!(config.include_service_tags);
}
#[test]
fn test_conflict_type() {
let conflict = Conflict {
conflict_type: ConflictType::Path,
item: "/users".to_string(),
services: vec!["service-a".to_string(), "service-b".to_string()],
resolution: "Prefixed".to_string(),
strategy: ConflictStrategy::Prefix,
};
assert_eq!(conflict.conflict_type, ConflictType::Path);
assert_eq!(conflict.services.len(), 2);
}
}