Joiner Package Deep Dive
Try it Online
No installation required! Try the joiner in your browser →
Table of Contents
- Overview
- Key Concepts
- API Styles
- Practical Examples
- Operation-Aware Schema Renaming
- Limitations
- Configuration Reference
- JoinResult Structure
- Source Map Integration
- Best Practices
- Common Patterns
The joiner package merges multiple OpenAPI Specification documents into a single unified document. It provides sophisticated collision handling strategies, automatic reference rewriting, and semantic deduplication for large-scale API consolidation scenarios.
Overview
When organizations maintain multiple API specifications—whether from different teams, microservices, or API modules—the joiner enables consolidation into a single document. This is particularly valuable for generating unified documentation, client SDKs, or gateway configurations from distributed API definitions.
The joiner supports OAS 2.0 documents merging with other 2.0 documents, and all OAS 3.x versions together (3.0.x, 3.1.x, 3.2.x). It uses the version and format (JSON or YAML) from the first document as the result format, ensuring consistency in the output.
Key Concepts
Collision Handling
When merging multiple documents, name collisions are inevitable—two documents might define different schemas with the same name, or contain overlapping paths. The joiner provides seven collision strategies to handle these situations:
| Strategy | Behavior |
|---|---|
StrategyFailOnCollision |
Return error on any collision (default, safest) |
StrategyAcceptLeft |
Keep value from first/left document |
StrategyAcceptRight |
Keep value from last/right document (overwrite) |
StrategyFailOnPaths |
Fail only on path collisions, allow schema merging |
StrategyRenameLeft |
Rename left schema, keep right under original name |
StrategyRenameRight |
Rename right schema, keep left under original name |
StrategyDeduplicateEquivalent |
Merge structurally identical schemas |
Strategies can be set globally or per-component type (paths, schemas, other components), giving fine-grained control over merge behavior.
Semantic Deduplication
Beyond handling same-named collisions, the joiner can identify and consolidate schemas that are structurally identical but have different names. When your Users API and Orders API both define equivalent Address and Location schemas, semantic deduplication recognizes they're identical and consolidates them.
Reference Rewriting
When schemas are renamed or deduplicated, all $ref references throughout the merged document are automatically updated. This ensures the resulting document maintains valid internal references without manual intervention.
API Styles
See also: Basic example, Custom strategies example, Semantic deduplication example on pkg.go.dev
Functional Options API
Best for single merge operations with inline configuration:
result, err := joiner.JoinWithOptions(
joiner.WithFilePaths([]string{"base.yaml", "ext.yaml"}),
joiner.WithPathStrategy(joiner.StrategyFailOnCollision),
joiner.WithSchemaStrategy(joiner.StrategyAcceptLeft),
)
Struct-Based API
Best for multiple merge operations or complex configuration:
config := joiner.DefaultConfig()
config.PathStrategy = joiner.StrategyFailOnPaths
config.SchemaStrategy = joiner.StrategyDeduplicateEquivalent
config.EquivalenceMode = "deep"
j := joiner.New(config)
result1, _ := j.Join([]string{"api1-base.yaml", "api1-ext.yaml"})
result2, _ := j.Join([]string{"api2-base.yaml", "api2-ext.yaml"})
Practical Examples
Basic Document Joining
The simplest use case merges two or more documents with default settings:
package main
import (
"fmt"
"log"
"os"
"path/filepath"
"github.com/erraggy/oastools/joiner"
)
func main() {
outputPath := filepath.Join(os.TempDir(), "merged.yaml")
config := joiner.DefaultConfig()
j := joiner.New(config)
result, err := j.Join([]string{
"users-api.yaml",
"orders-api.yaml",
"products-api.yaml",
})
if err != nil {
log.Fatal(err)
}
if err := j.WriteResult(result, outputPath); err != nil {
log.Fatal(err)
}
fmt.Printf("Successfully merged %d documents\n", 3)
fmt.Printf("Output version: %s\n", result.Version)
fmt.Printf("Total paths: %d\n", result.Stats.PathCount)
fmt.Printf("Total schemas: %d\n", result.Stats.SchemaCount)
fmt.Printf("Collisions resolved: %d\n", result.CollisionCount)
}
Example Input (users-api.yaml):
openapi: 3.0.3
info:
title: Users API
version: 1.0.0
paths:
/users:
get:
operationId: listUsers
responses:
'200':
description: Success
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/User'
components:
schemas:
User:
type: object
properties:
id:
type: integer
name:
type: string
Example Input (orders-api.yaml):
openapi: 3.0.3
info:
title: Orders API
version: 1.0.0
paths:
/orders:
get:
operationId: listOrders
responses:
'200':
description: Success
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Order'
components:
schemas:
Order:
type: object
properties:
id:
type: integer
userId:
type: integer
Example Output (merged.yaml):
openapi: 3.0.3
info:
title: Users API # Info from first document
version: 1.0.0
paths:
/users:
get:
operationId: listUsers
responses:
'200':
description: Success
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/User'
/orders:
get:
operationId: listOrders
responses:
'200':
description: Success
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Order'
components:
schemas:
User:
type: object
properties:
id:
type: integer
name:
type: string
Order:
type: object
properties:
id:
type: integer
userId:
type: integer
Handling Schema Collisions with Rename Strategies
When different APIs define schemas with the same name but different structures, use rename strategies to preserve both:
package main
import (
"fmt"
"log"
"github.com/erraggy/oastools/joiner"
)
func main() {
config := joiner.DefaultConfig()
// Keep left schema, rename right schema
config.SchemaStrategy = joiner.StrategyRenameRight
// Template for renamed schemas: "User_orders-api" format
config.RenameTemplate = "{{.Name}}_{{.Source}}"
j := joiner.New(config)
result, err := j.Join([]string{
"users-api.yaml", // Has User schema (id, name, email)
"orders-api.yaml", // Has User schema (id, customerId) - different structure!
})
if err != nil {
log.Fatal(err)
}
// Result will have:
// - User (from users-api.yaml, original name)
// - User_orders-api (from orders-api.yaml, renamed)
// All $refs in orders-api paths are rewritten to User_orders-api
fmt.Printf("Collisions resolved: %d\n", result.CollisionCount)
for _, warning := range result.Warnings {
fmt.Printf(" %s\n", warning)
}
}
Example Output:
Operation-Aware Schema Renaming
The Problem
When joining OpenAPI specifications from different services, you often encounter generic schema names that collide. Consider two microservices:
users-service.yaml:
openapi: 3.0.3
info:
title: Users Service
version: 1.0.0
paths:
/users:
get:
operationId: listUsers
tags: [users]
responses:
'200':
description: Success
content:
application/json:
schema:
$ref: '#/components/schemas/Response'
components:
schemas:
Response:
type: object
properties:
data:
type: array
items:
$ref: '#/components/schemas/User'
total:
type: integer
User:
type: object
properties:
id:
type: integer
name:
type: string
orders-service.yaml:
openapi: 3.0.3
info:
title: Orders Service
version: 1.0.0
paths:
/orders:
get:
operationId: listOrders
tags: [orders]
responses:
'200':
description: Success
content:
application/json:
schema:
$ref: '#/components/schemas/Response'
components:
schemas:
Response:
type: object
properties:
data:
type: array
items:
$ref: '#/components/schemas/Order'
count:
type: integer
Order:
type: object
properties:
id:
type: integer
userId:
type: integer
Both services define a Response schema with different structures. A basic rename template like {{.Name}}_{{.Source}} would produce Response_orders_service—functional but not descriptive. For programmatically generated specs or code generation, you want names like ListUsersResponse and ListOrdersResponse.
The Solution
Operation-aware renaming traces schemas back to their originating operations, enabling semantic names based on paths, methods, operation IDs, and tags:
package main
import (
"fmt"
"log"
"github.com/erraggy/oastools/joiner"
)
func main() {
config := joiner.DefaultConfig()
config.SchemaStrategy = joiner.StrategyRenameRight
// Enable operation context for rich rename templates
config.OperationContext = true
// Use operation-derived naming
config.RenameTemplate = "{{pascalCase .OperationID}}{{.Name}}"
// Select how to pick the primary operation when a schema
// is referenced by multiple operations
config.PrimaryOperationPolicy = joiner.PolicyMostSpecific
j := joiner.New(config)
result, err := j.Join([]string{
"users-service.yaml",
"orders-service.yaml",
})
if err != nil {
log.Fatal(err)
}
// Result will have:
// - Response (from users-service, kept original)
// - ListOrdersResponse (from orders-service, renamed with context)
fmt.Printf("Schemas renamed with operation context\n")
fmt.Printf("Collisions: %d\n", result.CollisionCount)
}
How It Works
The joiner builds a reference graph that maps each schema to the operations that use it. This graph captures both direct references (operation → schema) and indirect references (operation → schema → nested schema).
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ GET /users │────▶│ Response │────▶│ User │
│ listUsers │ │ (schema) │ │ (schema) │
└─────────────────┘ └─────────────────┘ └─────────────────┘
│ ▲ ▲
│ │ │
└───────────────────────┴───────────────────────┘
Reference Graph
The reference graph construction:
- Traverses all paths and operations - Records which schemas are referenced in request bodies, responses, parameters, and headers
- Tracks schema-to-schema references - Records
$refchains through properties, items, allOf/anyOf/oneOf, and other composition keywords - Resolves lineage - For any schema, walks up the reference chain to find all operations that ultimately use it
- Caches results - Lineage is computed once and cached for efficient template evaluation
When a collision occurs and renaming is needed, the joiner:
1. Retrieves the operation lineage for the schema
2. Selects a primary operation based on the configured policy
3. Builds a RenameContext with all available operation metadata
4. Executes the rename template with this rich context
RenameContext Reference
The RenameContext provides comprehensive metadata for rename template evaluation:
Core Fields (Always Available)
| Field | Type | Description | Example |
|---|---|---|---|
Name |
string | Original schema name | "Response" |
Source |
string | Source file name (sanitized, no extension) | "orders_service" |
Index |
int | Document index (0-based) | 1 |
Operation Context Fields (When OperationContext is true)
| Field | Type | Description | Example |
|---|---|---|---|
Path |
string | API path from primary operation | "/orders" |
Method |
string | HTTP method (lowercase) | "get" |
OperationID |
string | Operation ID if defined | "listOrders" |
Tags |
[]string | Tags from primary operation | ["orders"] |
UsageType |
string | Where schema is used | "response" |
StatusCode |
string | Response status code | "200" |
ParamName |
string | Parameter name (for parameter usage) | "filter" |
MediaType |
string | Content media type | "application/json" |
PrimaryResource |
string | First path segment (resource name) | "orders" |
Aggregate Context Fields (Multi-Operation Schemas)
| Field | Type | Description | Example |
|---|---|---|---|
AllPaths |
[]string | All paths referencing this schema | ["/orders", "/orders/{id}"] |
AllMethods |
[]string | All HTTP methods (deduplicated) | ["get", "post"] |
AllOperationIDs |
[]string | All operation IDs (non-empty only) | ["listOrders", "getOrder"] |
AllTags |
[]string | All tags (deduplicated, sorted) | ["admin", "orders"] |
RefCount |
int | Total operation references | 3 |
IsShared |
bool | True if used by multiple operations | true |
UsageType Values
| Value | Description |
|---|---|
"request" |
Schema used in request body |
"response" |
Schema used in response body |
"parameter" |
Schema used in parameter definition |
"header" |
Schema used in header definition |
"callback" |
Schema used in callback definition |
Template Functions Reference
The joiner provides built-in template functions for transforming context values:
Path Functions
| Function | Description | Example Input | Example Output |
|---|---|---|---|
pathSegment |
Extract nth segment (0-indexed, negative from end) | pathSegment "/users/{id}/orders" 0 |
"users" |
pathSegment |
Negative index | pathSegment "/users/{id}/orders" -1 |
"orders" |
pathResource |
First non-parameter segment | pathResource "/users/{id}/orders" |
"users" |
pathLast |
Last non-parameter segment | pathLast "/users/{id}/orders" |
"orders" |
pathClean |
Sanitize path for naming | pathClean "/users/{id}" |
"users_id" |
Path functions automatically skip path parameters (segments like {id} or {userId}).
Tag Functions
| Function | Description | Example Input | Example Output |
|---|---|---|---|
firstTag |
First tag or empty string | firstTag .Tags |
"orders" |
joinTags |
Join tags with separator | joinTags .Tags "_" |
"admin_orders" |
hasTag |
Check if tag exists | hasTag .Tags "admin" |
true |
Case Functions
| Function | Description | Example Input | Example Output |
|---|---|---|---|
pascalCase |
PascalCase conversion | pascalCase "list_orders" |
"ListOrders" |
camelCase |
camelCase conversion | camelCase "list_orders" |
"listOrders" |
snakeCase |
snake_case conversion | snakeCase "ListOrders" |
"list_orders" |
kebabCase |
kebab-case conversion | kebabCase "ListOrders" |
"list-orders" |
Case functions handle various input formats: snake_case, kebab-case, camelCase, PascalCase, and space-separated words.
Conditional Helpers
| Function | Description | Example |
|---|---|---|
default |
Return fallback if value empty | default .OperationID "Unknown" |
coalesce |
First non-empty value | coalesce .OperationID .Path .Name |
Primary Operation Policy
When a schema is referenced by multiple operations, the joiner must select one as the "primary" operation for template context. Three policies are available:
| Policy | Behavior | Best For |
|---|---|---|
PolicyFirstEncountered |
Uses the first operation found during graph traversal | Deterministic results based on document order |
PolicyMostSpecific |
Prefers operations with operationId, then those with tags | Well-documented APIs with operation IDs |
PolicyAlphabetical |
Sorts by path+method, uses alphabetically first | Reproducible builds regardless of traversal order |
Example: Policy behavior with a shared schema
Consider an Address schema used by three operations:
paths:
/users/{id}:
get:
operationId: getUser
tags: [users]
/orders:
post:
operationId: createOrder
tags: [orders]
/shipping:
get:
# No operationId
tags: [shipping]
| Policy | Selected Operation | Reason |
|---|---|---|
PolicyFirstEncountered |
GET /users/{id} | First in document order |
PolicyMostSpecific |
GET /users/{id} | Has operationId (both GET /users and POST /orders do, but GET comes first) |
PolicyAlphabetical |
POST /orders | "/orders" + "post" comes before "/shipping" + "get" and "/users/{id}" + "get" |
Configure the policy in your joiner config:
config := joiner.DefaultConfig()
config.OperationContext = true
config.PrimaryOperationPolicy = joiner.PolicyMostSpecific
Example Template Patterns
Common rename template patterns for different scenarios:
| Scenario | Template | Example Output |
|---|---|---|
| Operation ID prefix | {{pascalCase .OperationID}}{{.Name}} |
ListOrdersResponse |
| Resource-based | {{pascalCase (pathResource .Path)}}{{.Name}} |
OrdersResponse |
| Tag-based | {{pascalCase (firstTag .Tags)}}{{.Name}} |
OrdersResponse |
| Method + resource | {{pascalCase .Method}}{{pascalCase (pathResource .Path)}}{{.Name}} |
GetOrdersResponse |
| Full path | {{pascalCase (pathClean .Path)}}{{.Name}} |
OrdersIdResponse |
| With fallback | {{pascalCase (coalesce .OperationID (pathResource .Path) .Source)}}{{.Name}} |
ListOrdersResponse |
| Versioned API | {{.Name}}_{{pathSegment .Path 0}}_{{.Source}} |
Response_v2_orders |
| Response codes | {{pascalCase .OperationID}}{{.StatusCode}}{{.Name}} |
ListOrders200Response |
| Shared indicator | {{if .IsShared}}Shared{{end}}{{.Name}}_{{.Source}} |
SharedResponse_orders |
Handling Shared Schemas
Schemas referenced by multiple operations require special consideration. Use the IsShared field to detect and handle these cases:
// Template that indicates shared schemas
config.RenameTemplate = `{{if .IsShared}}Common{{else}}{{pascalCase .OperationID}}{{end}}{{.Name}}`
// Results:
// - Schema used by one operation: "ListOrdersResponse"
// - Schema used by multiple operations: "CommonResponse"
For more granular control, use aggregate fields:
// Use all operation IDs for shared schemas
config.RenameTemplate = `{{if .IsShared}}{{range $i, $id := .AllOperationIDs}}{{if $i}}_{{end}}{{$id}}{{end}}_{{.Name}}{{else}}{{.OperationID}}_{{.Name}}{{end}}`
// Shared schema used by listOrders and getOrder: "listOrders_getOrder_Response"
// Single-use schema: "listOrders_Response"
Limitations
Operation Context for Base Document Schemas
When using WithOperationContext(true), only schemas from the RIGHT (incoming) documents receive operation-derived context. The LEFT (base) document's schemas do not have their operation references traced.
This means for base document schemas, the following RenameContext fields will be empty:
- Path, Method, OperationID, Tags
- UsageType, StatusCode, ParamName, MediaType
- AllPaths, AllMethods, AllOperationIDs, AllTags
- RefCount, PrimaryResource, IsShared
Only the core fields (Name, Source, Index) are populated for base document schemas.
Workaround: If you need operation context for all schemas, consider restructuring your join order so the document with schemas requiring operation context is joined as the RIGHT document.
OAS 2.0 Support
Operation-aware renaming works with both OAS 2.0 and OAS 3.x documents. The reference graph construction adapts to each version's structure:
| OAS Version | Request Body Detection | Schema Reference Path |
|---|---|---|
| OAS 2.0 | Body parameter with in: body |
#/definitions/SchemaName |
| OAS 3.x | requestBody.content.*.schema |
#/components/schemas/SchemaName |
For OAS 2.0 documents:
config := joiner.DefaultConfig()
config.SchemaStrategy = joiner.StrategyRenameRight
config.OperationContext = true
config.RenameTemplate = "{{pascalCase .OperationID}}{{.Name}}"
j := joiner.New(config)
// Works with OAS 2.0 (Swagger) documents
result, err := j.Join([]string{
"swagger-users.yaml", // OAS 2.0
"swagger-orders.yaml", // OAS 2.0
})
Webhook Support (OAS 3.1+)
For OAS 3.1+ documents with webhooks, the reference graph includes webhook operations:
webhooks:
orderCreated:
post:
operationId: handleOrderCreated
tags: [webhooks]
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/OrderEvent'
The Path field for webhook operations uses the format webhook:<name>:
| Template | Webhook Path | Result |
|---|---|---|
{{.Path}} |
orderCreated webhook | webhook:orderCreated |
{{pathResource .Path}} |
orderCreated webhook | webhook |
Callback Support
Callbacks in OAS 3.0+ are also tracked in the reference graph. The path includes the parent operation and callback name:
paths:
/orders:
post:
operationId: createOrder
callbacks:
orderStatus:
'{$request.body#/callbackUrl}':
post:
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/StatusUpdate'
For callback operations, the Path field uses the format <parent_path>-><callback_name>:<callback_path>:
The UsageType will be "callback" for schemas referenced within callbacks.
Debugging Rename Templates
To debug rename templates, use a template that outputs all available fields:
// Debug template that shows all context
config.RenameTemplate = `DEBUG_{{.Name}}_path={{.Path}}_method={{.Method}}_op={{.OperationID}}_usage={{.UsageType}}_shared={{.IsShared}}`
This produces names like:
Once you've identified the available fields, simplify to your production template.
Performance Considerations
Building the reference graph adds a traversal pass over the document. For most specifications, this overhead is negligible:
| Document Size | Typical Overhead |
|---|---|
| Small (< 50 paths) | < 1ms |
| Medium (50-500 paths) | 1-5ms |
| Large (500+ paths) | 5-20ms |
The reference graph is built once per document and cached. Lineage resolution is also cached, so multiple schema renames reuse the same graph traversal results.
To minimize overhead when operation context is not needed:
config := joiner.DefaultConfig()
config.OperationContext = false // Default - skip graph building
config.RenameTemplate = "{{.Name}}_{{.Source}}" // Core fields only
Integration with Semantic Deduplication
Operation-aware renaming and semantic deduplication are complementary features:
| Feature | Purpose | When to Use |
|---|---|---|
| Semantic Deduplication | Consolidates structurally identical schemas | When schemas are duplicated across services |
| Operation-Aware Renaming | Creates meaningful names for colliding schemas | When schemas have the same name but different structures |
Use both together for comprehensive schema management:
config := joiner.DefaultConfig()
// Consolidate identical schemas first
config.SemanticDeduplication = true
// For remaining collisions (same name, different structure),
// use operation-aware renaming
config.SchemaStrategy = joiner.StrategyRenameRight
config.OperationContext = true
config.RenameTemplate = "{{pascalCase .OperationID}}{{.Name}}"
config.PrimaryOperationPolicy = joiner.PolicyMostSpecific
j := joiner.New(config)
result, err := j.Join(files)
With this configuration:
1. Structurally identical schemas (e.g., Address in users and orders) are consolidated
2. Structurally different schemas with the same name (e.g., different Response schemas) are renamed with operation context
Common Pitfalls
Empty OperationID: Not all APIs define operation IDs. Use fallbacks:
// Bad - empty OperationID produces "Response"
config.RenameTemplate = "{{.OperationID}}{{.Name}}"
// Good - falls back to path resource
config.RenameTemplate = "{{pascalCase (coalesce .OperationID (pathResource .Path) .Source)}}{{.Name}}"
Orphaned Schemas: Schemas not referenced by any operation will have empty operation context. These typically include:
- Base schemas used only via
allOf/anyOf/oneOf - Schemas defined but never referenced
- Nested
$defsschemas
For orphaned schemas, the template receives only core fields (Name, Source, Index). Design templates with fallbacks:
// Handles orphaned schemas gracefully
config.RenameTemplate = `{{if .Path}}{{pascalCase .OperationID}}{{.Name}}{{else}}{{.Name}}_{{.Source}}{{end}}`
Path Parameters in Templates: Path functions skip parameters, but pathClean converts them:
pathClean("/users/{id}") // "users_id" - includes parameter name
pathResource("/users/{id}") // "users" - excludes parameter
pathLast("/users/{id}/orders") // "orders" - excludes parameter
Namespace Prefixes for Team-Based APIs
When consolidating APIs from different teams, namespace prefixes prevent collisions while maintaining clarity about schema origins:
package main
import (
"fmt"
"log"
"github.com/erraggy/oastools/joiner"
)
func main() {
config := joiner.DefaultConfig()
config.SchemaStrategy = joiner.StrategyAcceptLeft
// Map source files to namespace prefixes
config.NamespacePrefix = map[string]string{
"users-api.yaml": "Users",
"billing-api.yaml": "Billing",
"orders-api.yaml": "Orders",
}
// Apply prefix to ALL schemas, not just collisions
config.AlwaysApplyPrefix = true
j := joiner.New(config)
result, err := j.Join([]string{
"users-api.yaml",
"billing-api.yaml",
"orders-api.yaml",
})
if err != nil {
log.Fatal(err)
}
// Schemas will be named:
// Users_User, Users_Profile
// Billing_Invoice, Billing_Payment
// Orders_Order, Orders_LineItem
fmt.Printf("Merged with namespace prefixes\n")
fmt.Printf("Schema count: %d\n", result.Stats.SchemaCount)
}
Semantic Deduplication Across Documents
When multiple APIs define structurally identical schemas with different names, semantic deduplication consolidates them:
package main
import (
"fmt"
"log"
"github.com/erraggy/oastools/joiner"
)
func main() {
result, err := joiner.JoinWithOptions(
joiner.WithFilePaths([]string{
"users-api.yaml", // Has Address schema
"orders-api.yaml", // Has ShippingAddress schema (identical structure)
"billing-api.yaml", // Has BillingAddress schema (identical structure)
}),
joiner.WithSemanticDeduplication(true),
)
if err != nil {
log.Fatal(err)
}
// If Address, ShippingAddress, and BillingAddress are structurally identical,
// they'll be consolidated to "Address" (alphabetically first)
// All references are rewritten automatically
fmt.Printf("Schema count after deduplication: %d\n", result.Stats.SchemaCount)
for _, warning := range result.Warnings {
fmt.Printf(" %s\n", warning)
}
}
Example Input Documents:
users-api.yaml:
components:
schemas:
Address:
type: object
properties:
street:
type: string
city:
type: string
zip:
type: string
orders-api.yaml:
components:
schemas:
ShippingAddress:
type: object
properties:
street:
type: string
city:
type: string
zip:
type: string
Example Output:
Schema Equivalence Detection
For collision handling with StrategyDeduplicateEquivalent, configure the depth of structural comparison:
package main
import (
"log"
"github.com/erraggy/oastools/joiner"
)
func main() {
config := joiner.DefaultConfig()
// Use deduplication for same-named schemas
config.SchemaStrategy = joiner.StrategyDeduplicateEquivalent
// Configure comparison depth:
// "none" - No comparison, always treat as collision
// "shallow" - Compare top-level properties only
// "deep" - Full recursive structural comparison
config.EquivalenceMode = "deep"
j := joiner.New(config)
// If both files have User schema with identical structure,
// they'll be merged without error
// If structures differ, join fails with collision error
result, err := j.Join([]string{"api1.yaml", "api2.yaml"})
if err != nil {
log.Fatal(err)
}
log.Printf("Merged successfully, %d collisions resolved", result.CollisionCount)
}
High-Performance Joining with Pre-Parsed Documents
For integration with other oastools packages, use pre-parsed documents for 154x faster performance:
package main
import (
"fmt"
"log"
"time"
"github.com/erraggy/oastools/joiner"
"github.com/erraggy/oastools/parser"
"github.com/erraggy/oastools/validator"
)
func main() {
// Parse and validate documents
files := []string{"api1.yaml", "api2.yaml", "api3.yaml"}
var parsed []parser.ParseResult
for _, file := range files {
p, err := parser.ParseWithOptions(
parser.WithFilePath(file),
parser.WithValidateStructure(true),
)
if err != nil {
log.Fatalf("Failed to parse %s: %v", file, err)
}
// Validate before joining (required)
v, err := validator.ValidateWithOptions(
validator.WithParsed(*p),
)
if err != nil {
log.Fatalf("Failed to validate %s: %v", file, err)
}
if !v.Valid {
log.Fatalf("%s has validation errors", file)
}
parsed = append(parsed, *p)
}
// Join using pre-parsed documents (154x faster)
start := time.Now()
result, err := joiner.JoinWithOptions(
joiner.WithParsed(parsed...),
joiner.WithSchemaStrategy(joiner.StrategyAcceptLeft),
)
elapsed := time.Since(start)
if err != nil {
log.Fatal(err)
}
fmt.Printf("Join completed in %v\n", elapsed)
fmt.Printf("Paths: %d, Schemas: %d\n",
result.Stats.PathCount, result.Stats.SchemaCount)
}
Collision Report Generation
For debugging complex merges, enable collision reporting:
package main
import (
"fmt"
"log"
"github.com/erraggy/oastools/joiner"
)
func main() {
config := joiner.DefaultConfig()
config.SchemaStrategy = joiner.StrategyAcceptLeft
config.CollisionReport = true // Enable detailed reporting
j := joiner.New(config)
result, err := j.Join([]string{
"api1.yaml",
"api2.yaml",
"api3.yaml",
})
if err != nil {
log.Fatal(err)
}
if result.CollisionDetails != nil {
fmt.Printf("Collision Report:\n")
fmt.Printf(" Total collisions: %d\n", result.CollisionDetails.TotalCount)
fmt.Printf(" Schema collisions: %d\n", result.CollisionDetails.SchemaCount)
fmt.Printf(" Path collisions: %d\n", result.CollisionDetails.PathCount)
for _, collision := range result.CollisionDetails.Collisions {
fmt.Printf("\n %s collision: %s\n", collision.Type, collision.Name)
fmt.Printf(" Sources: %v\n", collision.Sources)
fmt.Printf(" Resolution: %s\n", collision.Resolution)
}
}
}
Overlay Integration During Join
Apply transformations during the join process:
package main
import (
"log"
"github.com/erraggy/oastools/joiner"
)
func main() {
result, err := joiner.JoinWithOptions(
joiner.WithFilePaths([]string{"api1.yaml", "api2.yaml"}),
// Apply overlay to each input before merging
joiner.WithPreJoinOverlayFile("normalize.yaml"),
// Apply overlay to final result
joiner.WithPostJoinOverlayFile("enhance.yaml"),
)
if err != nil {
log.Fatal(err)
}
log.Printf("Join with overlays completed: %d paths", result.Stats.PathCount)
}
Example Pre-Join Overlay (normalize.yaml):
overlay: 1.0.0
info:
title: Normalization Overlay
actions:
- target: $.paths.*.*.responses.*.description
update:
description: Standardized response
Different Strategies per Component Type
Fine-grained control over collision handling for different specification elements:
package main
import (
"fmt"
"log"
"github.com/erraggy/oastools/joiner"
)
func main() {
config := joiner.JoinerConfig{
// Fail on path collisions - paths must be unique
PathStrategy: joiner.StrategyFailOnCollision,
// For schemas, rename collisions from the right document
SchemaStrategy: joiner.StrategyRenameRight,
RenameTemplate: "{{.Name}}_v{{.Index}}",
// For other components (parameters, responses), keep left
ComponentStrategy: joiner.StrategyAcceptLeft,
// Merge arrays (servers, security requirements)
MergeArrays: true,
// Remove duplicate tags by name
DeduplicateTags: true,
}
j := joiner.New(config)
result, err := j.Join([]string{"base.yaml", "extension.yaml"})
if err != nil {
log.Fatal(err)
}
fmt.Printf("Merged with custom strategies\n")
fmt.Printf("Warnings: %d\n", len(result.Warnings))
}
Configuration Reference
JoinerConfig Fields
type JoinerConfig struct {
// Global default strategy for all collisions
DefaultStrategy CollisionStrategy
// Per-component type strategies (override DefaultStrategy)
PathStrategy CollisionStrategy
SchemaStrategy CollisionStrategy
ComponentStrategy CollisionStrategy
// Tag and array handling
DeduplicateTags bool // Remove duplicate tags by name
MergeArrays bool // Merge servers, security, tags arrays
// Rename strategy configuration
RenameTemplate string // Go template: "{{.Name}}_{{.Source}}"
NamespacePrefix map[string]string // Source file → prefix mapping
AlwaysApplyPrefix bool // Apply prefix to all schemas, not just collisions
// Equivalence detection configuration
EquivalenceMode string // "none", "shallow", or "deep"
// Reporting
CollisionReport bool // Generate detailed collision analysis
// Post-processing
SemanticDeduplication bool // Consolidate identical schemas across documents
}
Available Options
| Option | Description |
|---|---|
WithFilePaths([]string) |
Input file paths or URLs |
WithParsed(docs ...ParseResult) |
Pre-parsed documents (154x faster) |
WithConfig(JoinerConfig) |
Full configuration object |
WithPathStrategy(CollisionStrategy) |
Strategy for path collisions |
WithSchemaStrategy(CollisionStrategy) |
Strategy for schema collisions |
WithComponentStrategy(CollisionStrategy) |
Strategy for other components |
WithSemanticDeduplication(bool) |
Enable cross-document deduplication |
WithPreJoinOverlayFile(string) |
Overlay applied to each input |
WithPostJoinOverlayFile(string) |
Overlay applied to merged result |
JoinResult Structure
type JoinResult struct {
// Document contains the merged document
// (*parser.OAS2Document or *parser.OAS3Document)
Document any
// Version is the OpenAPI version string (e.g., "3.0.3")
Version string
// OASVersion is the enumerated version
OASVersion parser.OASVersion
// SourceFormat is the format of the first source file
SourceFormat parser.SourceFormat
// Warnings contains non-fatal issues encountered
Warnings []string
// CollisionCount tracks resolved collisions
CollisionCount int
// Stats contains document statistics
Stats parser.DocumentStats
// CollisionDetails contains detailed analysis
// (when CollisionReport is enabled)
CollisionDetails *CollisionReport
}
Source Map Integration
Source maps enable precise collision and warning locations by tracking line and column numbers from your YAML/JSON source files. Without source maps, collision errors only show JSON paths. With source maps, collision errors include file:line:column positions that IDEs can click to jump directly to the conflict.
Without source maps:
schema collision: 'User' defined in users-api.yaml (components.schemas.User) and orders-api.yaml (components.schemas.User)
With source maps:
When joining multiple files, use WithSourceMaps (plural) to pass source maps for all input documents:
sourceMaps := make(map[string]*parser.SourceMap)
var docs []parser.ParseResult
for _, path := range []string{"users-api.yaml", "orders-api.yaml"} {
p, _ := parser.ParseWithOptions(
parser.WithFilePath(path),
parser.WithSourceMap(true), // Enable line tracking during parse
)
sourceMaps[path] = p.SourceMap
docs = append(docs, *p)
}
result, _ := joiner.JoinWithOptions(
joiner.WithParsed(docs...),
joiner.WithSourceMaps(sourceMaps), // Pass all source maps (keyed by file path)
)
// Warnings and collision details now include line/column/file info
for _, warning := range result.Warnings {
fmt.Println(warning) // Includes file:line:column when available
}
The joiner uses WithSourceMaps (plural, with a map) because it needs source maps from multiple input files to track collision locations across documents.
Best Practices
Always validate input documents before joining. The joiner requires documents with no validation errors. Use the validator package first.
Use StrategyFailOnCollision initially to understand what collisions exist in your documents before choosing a resolution strategy.
Choose collision strategies based on your use case: - Fail strategies for strict merging where collisions indicate problems - Accept strategies when you have a clear "primary" document - Rename strategies when you need to preserve both versions - Deduplicate strategies when schemas should be consolidated
Use namespace prefixes for team-based consolidation to maintain clarity about schema origins in large merged documents.
Enable semantic deduplication when consolidating APIs that likely share common schemas (addresses, pagination, error responses).
Use the parse-once pattern when integrating with validation or other processing for 154x performance improvement.
Common Patterns
Microservices Consolidation
// Collect all service specs
services := []string{
"auth-service/openapi.yaml",
"user-service/openapi.yaml",
"order-service/openapi.yaml",
"payment-service/openapi.yaml",
}
config := joiner.DefaultConfig()
config.PathStrategy = joiner.StrategyFailOnCollision
config.SchemaStrategy = joiner.StrategyRenameRight
config.RenameTemplate = "{{.Name}}_{{.Source}}"
config.SemanticDeduplication = true
j := joiner.New(config)
result, err := j.Join(services)
API Gateway Aggregation
// Prefix schemas by team for gateway configuration
config.NamespacePrefix = map[string]string{
"team-a/api.yaml": "TeamA",
"team-b/api.yaml": "TeamB",
"team-c/api.yaml": "TeamC",
}
config.AlwaysApplyPrefix = true
Extension Document Pattern
// Base API with extensions
config.PathStrategy = joiner.StrategyAcceptRight // Extensions override base
config.SchemaStrategy = joiner.StrategyAcceptLeft // Base schemas take priority
result, _ := j.Join([]string{
"base-api.yaml", // Core API
"custom-extension.yaml", // Customer-specific additions
})
Learn More
For additional examples and complete API documentation:
- 📦 API Reference on pkg.go.dev - Complete API documentation with all examples
- 🔗 Basic example - Merge two OpenAPI specifications
- ⚙️ Custom strategies example - Configure collision handling per component type
- 🧹 Semantic deduplication example - Consolidate identical schemas