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
- Custom Collision Handlers
- Limitations
- Configuration Reference
- JoinResult Structure
- Source Map Integration
- Package Chaining
- Best Practices
- Common Patterns
- CLI Usage
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.
Collision Handlers
For advanced collision handling beyond the built-in strategies, you can register a collision handler callback. The handler is invoked when a collision is detected, receiving full context about both values and their sources. Handlers can:
- Observe and log - Return
ContinueWithStrategy()to log collisions while deferring to the configured strategy - Make decisions - Return
AcceptLeft(),AcceptRight(),Rename(),Deduplicate(), orFail()to override the strategy - Provide custom values - Return
UseCustomValue(mergedSchema)to supply a custom merged result
If a handler returns an error, the joiner logs a warning and falls back to the configured strategy, ensuring handlers cannot break join operations.
See Custom Collision Handlers for complete documentation and examples.
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:
- Retrieves the operation lineage for the schema
- Selects a primary operation based on the configured policy
- Builds a
RenameContextwith all available operation metadata - 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,TagsUsageType,StatusCode,ParamName,MediaTypeAllPaths,AllMethods,AllOperationIDs,AllTagsRefCount,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:
- Structurally identical schemas (e.g.,
Addressin users and orders) are consolidated - Structurally different schemas with the same name (e.g., different
Responseschemas) 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
Custom Collision Handlers
While the built-in collision strategies (StrategyAcceptLeft, StrategyRenameRight, etc.) handle most use cases, some scenarios require custom logic. Collision handlers provide a callback mechanism for fine-grained collision control.
When to Use Collision Handlers
Use collision handlers when you need to:
- Log all collisions for audit trails or debugging
- Apply conditional logic - different decisions based on schema names, sources, or content
- Implement custom merging - combine properties from both schemas
- Integrate with external systems - validate decisions against a schema registry
- Fail selectively - reject specific collisions while allowing others
Basic Usage
Register a collision handler using WithCollisionHandler:
package main
import (
"fmt"
"log"
"github.com/erraggy/oastools/joiner"
)
func main() {
result, err := joiner.JoinWithOptions(
joiner.WithFilePaths([]string{"base.yaml", "overlay.yaml"}),
joiner.WithSchemaStrategy(joiner.StrategyAcceptLeft), // Default strategy
joiner.WithCollisionHandler(func(collision joiner.CollisionContext) (joiner.CollisionResolution, error) {
// Log all collisions
log.Printf("Collision: %s %s at %s", collision.Type, collision.Name, collision.JSONPath)
// Custom logic: always accept right for "Response" schemas
if collision.Name == "Response" {
return joiner.AcceptRightWithMessage("Response schemas always use overlay version"), nil
}
// Defer to configured strategy for everything else
return joiner.ContinueWithStrategy(), nil
}),
)
if err != nil {
log.Fatal(err)
}
fmt.Printf("Collisions resolved: %d\n", result.CollisionCount)
}
CollisionContext Reference
The handler receives a CollisionContext with complete information about the collision:
| Field | Type | Description |
|---|---|---|
Type |
CollisionType |
What collided: CollisionTypeSchema, CollisionTypePath, etc. |
Name |
string |
The colliding name (e.g., "User", "/pets") |
JSONPath |
string |
Full JSON path (e.g., "$.components.schemas.User") |
LeftSource |
string |
Source file/identifier for the left (base) document |
LeftLocation |
*SourceLocation |
Line/column in left document (nil if unknown) |
LeftValue |
any |
The left component (*parser.Schema, *parser.PathItem, etc.) |
RightSource |
string |
Source file/identifier for the right (incoming) document |
RightLocation |
*SourceLocation |
Line/column in right document (nil if unknown) |
RightValue |
any |
The right component |
RenameInfo |
*RenameContext |
Operation context for rename templates (nil for paths) |
ConfiguredStrategy |
CollisionStrategy |
The strategy that would apply without handler |
Resolution Actions
Return one of these resolution helpers from your handler:
| Helper | Description |
|---|---|
ContinueWithStrategy() |
Defer to the configured strategy (observe-only mode) |
AcceptLeft() |
Keep the left (base) value |
AcceptRight() |
Keep the right (incoming) value |
Rename() |
Rename the right value using the rename template |
Deduplicate() |
Treat colliding values as equivalent (skip the right) |
Fail() |
Abort the join with an error |
UseCustomValue(value) |
Use a custom merged value (schemas only) |
All helpers have WithMessage(string) variants for logging:
Handler Patterns
Observe-Only (Logging)
Log all collisions without affecting behavior:
joiner.WithCollisionHandler(func(collision joiner.CollisionContext) (joiner.CollisionResolution, error) {
log.Printf("[COLLISION] %s: %s (%s vs %s)",
collision.Type,
collision.Name,
collision.LeftSource,
collision.RightSource,
)
return joiner.ContinueWithStrategy(), nil
})
Conditional Decisions
Apply different resolutions based on collision attributes:
joiner.WithCollisionHandler(func(collision joiner.CollisionContext) (joiner.CollisionResolution, error) {
// Fail on path collisions (strict)
if collision.Type == joiner.CollisionTypePath {
return joiner.FailWithMessage("Path collisions not allowed"), nil
}
// Accept right for "Error" schemas (overlay overrides)
if collision.Name == "Error" || collision.Name == "ErrorResponse" {
return joiner.AcceptRight(), nil
}
// Use deduplication for common schemas
if collision.Name == "Pagination" || collision.Name == "Links" {
return joiner.Deduplicate(), nil
}
// Default to configured strategy
return joiner.ContinueWithStrategy(), nil
})
Custom Schema Merging
Provide a merged schema that combines properties from both:
joiner.WithCollisionHandler(func(collision joiner.CollisionContext) (joiner.CollisionResolution, error) {
if collision.Type != joiner.CollisionTypeSchema {
return joiner.ContinueWithStrategy(), nil
}
leftSchema := collision.LeftValue.(*parser.Schema)
rightSchema := collision.RightValue.(*parser.Schema)
// Create merged schema with properties from both
merged := &parser.Schema{
Type: leftSchema.Type,
Description: rightSchema.Description, // Prefer right description
Properties: make(map[string]*parser.Schema),
}
// Copy all properties from left
for name, prop := range leftSchema.Properties {
merged.Properties[name] = prop
}
// Add/override with properties from right
for name, prop := range rightSchema.Properties {
merged.Properties[name] = prop
}
return joiner.UseCustomValueWithMessage(merged, "Merged properties from both schemas"), nil
})
Type-Filtered Handlers
Use WithCollisionHandlerFor to handle only specific collision types:
// Only handle schema collisions - paths use configured strategy
joiner.WithCollisionHandlerFor(
func(collision joiner.CollisionContext) (joiner.CollisionResolution, error) {
// This handler only receives schema collisions
log.Printf("Schema collision: %s", collision.Name)
return joiner.ContinueWithStrategy(), nil
},
joiner.CollisionTypeSchema,
)
Filter for multiple types:
Supported Collision Types
| Type | Description | Custom Value Support | Rename Support |
|---|---|---|---|
CollisionTypeSchema |
Schema in components.schemas (OAS3) or definitions (OAS2) | โ Yes | โ Yes |
CollisionTypePath |
Path in paths section | โ Yes | โ No |
Note: Rename() is not supported for path collisions because paths are URL endpoints that cannot be renamed without breaking API contracts. However, UseCustomValue() is supported for paths, allowing you to provide a merged *parser.PathItem that combines operations from both colliding paths.
Error Handling
If your handler returns an error, the joiner:
- Logs a warning with the error message
- Falls back to the configured strategy
- Continues the join operation
This ensures handlers cannot break the join:
joiner.WithCollisionHandler(func(collision joiner.CollisionContext) (joiner.CollisionResolution, error) {
// If validation fails, error triggers fallback to strategy
if err := validateCollision(collision); err != nil {
return joiner.CollisionResolution{}, err
}
return joiner.AcceptLeft(), nil
})
Warnings
Handler-related events appear in JoinResult.StructuredWarnings:
| Category | When |
|---|---|
WarnHandlerError |
Handler returned an error (fell back to strategy) |
WarnHandlerResolution |
Handler provided a resolution with a message |
for _, warning := range result.StructuredWarnings {
if warning.Category == joiner.WarnHandlerError {
log.Printf("Handler error at %s: %s", warning.Path, warning.Message)
}
}
Complete Example
A production-ready handler that implements a schema governance policy:
package main
import (
"fmt"
"log"
"strings"
"github.com/erraggy/oastools/joiner"
"github.com/erraggy/oastools/parser"
)
// governanceHandler implements organization schema policies
func governanceHandler(collision joiner.CollisionContext) (joiner.CollisionResolution, error) {
// Only handle schemas
if collision.Type != joiner.CollisionTypeSchema {
return joiner.ContinueWithStrategy(), nil
}
name := collision.Name
// Policy 1: Common schemas must be deduplicated
commonSchemas := []string{"Error", "Pagination", "Links", "Meta"}
for _, common := range commonSchemas {
if name == common {
return joiner.DeduplicateWithMessage(
fmt.Sprintf("%s is a common schema - deduplicating", name),
), nil
}
}
// Policy 2: Response schemas always come from the service (right)
if strings.HasSuffix(name, "Response") {
return joiner.AcceptRightWithMessage("Service-specific response schema"), nil
}
// Policy 3: Request schemas always come from the base (left)
if strings.HasSuffix(name, "Request") {
return joiner.AcceptLeftWithMessage("Base request schema takes precedence"), nil
}
// Policy 4: Fail on unexpected collisions
return joiner.FailWithMessage(
fmt.Sprintf("Unexpected schema collision: %s - review governance policy", name),
), nil
}
func main() {
result, err := joiner.JoinWithOptions(
joiner.WithFilePaths([]string{
"base-api.yaml",
"users-service.yaml",
"orders-service.yaml",
}),
joiner.WithSchemaStrategy(joiner.StrategyFailOnCollision),
joiner.WithCollisionHandler(governanceHandler),
)
if err != nil {
log.Fatal(err)
}
fmt.Printf("Join completed with %d collisions resolved\n", result.CollisionCount)
// Review any governance decisions
for _, warning := range result.StructuredWarnings {
if warning.Category == joiner.WarnHandlerResolution {
fmt.Printf(" Policy applied at %s: %s\n", warning.Path, warning.Message)
}
}
}
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:
Empty Schemas Are Preserved
Empty schemas (those with no structural constraints) are automatically excluded from deduplication, even when they appear structurally identical. This is because empty schemas serve different semantic purposes depending on context:
- Placeholders for schemas to be defined later
- "Any type" markers that accept any value
- Context-specific wildcards with meaning derived from their name or position
A schema is considered "empty" if it has no type, format, properties, validation rules, or composition keywords. Metadata fields (title, description, example, deprecated) are NOT considered constraints.
# users-api.yaml
components:
schemas:
AnyPayload: {} # "Accept any request body"
User:
type: object
properties:
name:
type: string
# events-api.yaml
components:
schemas:
DynamicData: {} # "Event data can be anything"
User: # Identical to users-api User
type: object
properties:
name:
type: string
After joining with semantic deduplication enabled:
AnyPayloadandDynamicDataare both preserved (empty schemas are never consolidated)- The two
Userschemas are consolidated into one (structurally identical, non-empty)
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 |
WithCollisionHandler(handler) |
Register collision handler callback |
WithCollisionHandlerFor(handler, types...) |
Register handler for specific collision types |
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
}
JoinResult Methods
| Method | Returns | Description |
|---|---|---|
ToParseResult() |
*parser.ParseResult |
Converts result for package chaining |
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.
Package Chaining
The ToParseResult() method enables seamless chaining with other oastools packages by converting JoinResult to a parser.ParseResult:
// Join then validate
joinResult, err := joiner.JoinWithOptions(
joiner.WithFilePaths([]string{"users-api.yaml", "orders-api.yaml"}),
)
if err != nil {
log.Fatal(err)
}
// Chain to validator
v := validator.New()
valResult, _ := v.ValidateParsed(*joinResult.ToParseResult())
fmt.Printf("Valid: %v\n", valResult.Valid)
// Or chain to converter
c := converter.New()
convResult, _ := c.ConvertParsed(*joinResult.ToParseResult(), "3.1.0")
// Or chain to fixer
fixResult, _ := fixer.FixWithOptions(
fixer.WithParsed(*joinResult.ToParseResult()),
)
This enables workflows like: parse โ join โ validate โ convert โ diff
Note: Join warnings are converted to string warnings in the resulting ParseResult.
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.
Use collision handlers when you need conditional logic, audit logging, or custom schema merging beyond what built-in strategies provide. Start with observe-only handlers (ContinueWithStrategy()) to understand collision patterns before implementing custom resolution logic.
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
})
CLI Usage
The joiner's operation-aware schema renaming and overlay integration features are fully accessible from the command line.
Basic Schema Renaming
# Rename colliding schemas with source file suffix
oastools join --schema-strategy rename-right \
--rename-template "{{.Name}}_{{.Source}}" \
-o merged.yaml users-api.yaml orders-api.yaml
Operation-Aware Renaming
Enable --operation-context to access path, method, operation ID, and tag information in templates:
# Use operation ID as prefix (e.g., "ListUsersResponse")
oastools join --schema-strategy rename-right --operation-context \
--rename-template "{{.OperationID | pascalCase}}{{.Name}}" \
-o merged.yaml api1.yaml api2.yaml
# Use path resource as prefix (e.g., "OrdersResponse")
oastools join --schema-strategy rename-right --operation-context \
--rename-template "{{pathResource .Path | pascalCase}}{{.Name}}" \
-o merged.yaml api1.yaml api2.yaml
# Full method + resource naming (e.g., "GetOrdersResponse")
oastools join --schema-strategy rename-right --operation-context \
--rename-template "{{.Method | pascalCase}}{{pathResource .Path | pascalCase}}{{.Name}}" \
-o merged.yaml api1.yaml api2.yaml
Primary Operation Policy
Control which operation provides context when a schema is used by multiple operations:
# Prefer operations with operationId defined
oastools join --schema-strategy rename-right --operation-context \
--primary-operation-policy most-specific \
--rename-template "{{.OperationID | default .Name}}" \
-o merged.yaml api1.yaml api2.yaml
# Alphabetical for reproducible builds
oastools join --schema-strategy rename-right --operation-context \
--primary-operation-policy alphabetical \
--rename-template "{{.OperationID | pascalCase}}{{.Name}}" \
-o merged.yaml api1.yaml api2.yaml
Template Patterns
Common template patterns for different use cases:
# With fallback for schemas without operationId
oastools join --schema-strategy rename-right --operation-context \
--rename-template "{{coalesce .OperationID (pathResource .Path) .Source | pascalCase}}{{.Name}}" \
-o merged.yaml api1.yaml api2.yaml
# Different handling for shared vs single-use schemas
oastools join --schema-strategy rename-right --operation-context \
--rename-template "{{if .IsShared}}Shared{{else}}{{.OperationID | pascalCase}}{{end}}{{.Name}}" \
-o merged.yaml api1.yaml api2.yaml
# Include response status code
oastools join --schema-strategy rename-right --operation-context \
--rename-template "{{.OperationID | pascalCase}}{{.StatusCode}}{{.Name}}" \
-o merged.yaml api1.yaml api2.yaml
# Tag-based naming
oastools join --schema-strategy rename-right --operation-context \
--rename-template "{{firstTag .Tags | pascalCase}}{{.Name}}" \
-o merged.yaml api1.yaml api2.yaml
Overlay Integration
Apply overlays during the join process:
# Pre-overlay: applied to each input before merging
# Post-overlay: applied to the final merged result
oastools join \
--pre-overlay normalize.yaml \
--post-overlay enhance.yaml \
-o merged.yaml api1.yaml api2.yaml
# Multiple overlays (applied in order)
oastools join \
--pre-overlay strip-internal.yaml \
--pre-overlay standardize-responses.yaml \
--post-overlay add-security.yaml \
--post-overlay add-metadata.yaml \
-o merged.yaml api1.yaml api2.yaml
Example pre-overlay (normalize.yaml):
overlay: 1.0.0
info:
title: Normalization Overlay
version: 1.0.0
actions:
- target: $..description
update:
description: "" # Clear all descriptions before merge
Example post-overlay (enhance.yaml):
overlay: 1.0.0
info:
title: Enhancement Overlay
version: 1.0.0
actions:
- target: $.info
update:
title: "Unified API"
version: "1.0.0"
- target: $.servers
update:
- url: https://api.example.com/v1
description: Production
Combined Features
Use multiple features together for comprehensive join operations:
# Full-featured join with all options
oastools join \
--schema-strategy rename-right \
--operation-context \
--primary-operation-policy most-specific \
--rename-template "{{coalesce .OperationID (pathResource .Path) | pascalCase}}{{.Name}}" \
--semantic-dedup \
--pre-overlay normalize.yaml \
--post-overlay finalize.yaml \
-o merged.yaml \
users-service.yaml orders-service.yaml billing-service.yaml
This command:
- Applies
normalize.yamlto each input document - Joins the documents with semantic deduplication
- Renames colliding schemas using operation context
- Uses the most-specific operation policy for context selection
- Applies
finalize.yamlto the merged result
For complete API documentation and programmatic usage, see the sections above or the Go package documentation.
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