adding YAMLMerger

This commit is contained in:
Mihaela Mihaljevic 2025-10-26 00:45:59 +02:00
parent 78e3eaaf8f
commit 9561de6327
4 changed files with 994 additions and 0 deletions

View File

@ -0,0 +1,106 @@
# YAMLSplitter
A Swift package for splitting a single OpenAPI YAML file into organized schema files - the opposite of YAMLMerger.
## How It Works
YAMLSplitter takes a single OpenAPI YAML file and splits it into organized schema files following the same structure as YAMLMerger:
1. **01_Info/** - OpenAPI info section (version, title, description, etc.)
2. **02_Servers/** - API server URLs and environment configurations
3. **03_Tags/** - Tag definitions for organizing endpoints
4. **04_Paths/** - API endpoint definitions (one file per path)
5. **06_Components/** - Reusable components (one file per schema)
6. **07_Security/** - Security requirements
7. **08_ExternalDocs/** - External documentation references
## Usage
```swift
import YAMLSplitter
// Split a single OpenAPI YAML into organized schema files
let splitter = YAMLSplitter(
inputFile: URL(fileURLWithPath: "openapi.yaml"),
outputDirectory: URL(fileURLWithPath: "Schema")
)
try splitter.split()
```
## Directory Structure Created
```
Schema/
├── 01_Info/
│ ├── __info.yaml # Header file
│ └── api-info.yaml # Info content
├── 02_Servers/
│ ├── __servers.yaml # Header file
│ └── server-1.yaml # Individual server configs
├── 03_Tags/
│ ├── __tags.yaml # Header file
│ └── tags.yaml # Tag definitions
├── 04_Paths/
│ ├── __paths.yaml # Header file
│ ├── api_login.yaml
│ ├── api_buser.yaml
│ └── ... (one file per path)
├── 06_Components/
│ ├── __components.yaml # Header file
│ ├── Login.yaml
│ ├── User.yaml
│ └── ... (one file per schema)
├── 07_Security/
│ ├── __securitySchemes.yaml
│ └── security.yaml
└── 08_ExternalDocs/
├── __externalDocs.yaml
└── externalDocs.yaml
```
## Round Trip Testing
YAMLSplitter works perfectly with YAMLMerger for round-trip testing:
```swift
// 1. Split the original openapi.yaml
let splitter = YAMLSplitter(inputFile: originalFile, outputDirectory: schemaDir)
try splitter.split()
// 2. Merge back using YAMLMerger
let merger = YAMLMerger(rootDirectory: schemaDir, outputFileName: "CombinedSpec.yaml")
merger.merge()
// 3. The result should be identical to the original
```
## Key Features
- ✅ **Organized Structure** - Follows the same directory structure as YAMLMerger
- ✅ **One File Per Component** - Each path and schema gets its own file
- ✅ **Header Files** - Creates `__*.yaml` header files for proper merging
- ✅ **Round Trip Compatible** - Works perfectly with YAMLMerger
- ✅ **YAML Parsing** - Uses Yams library for robust YAML handling
- ✅ **Error Handling** - Comprehensive error handling and validation
## Dependencies
- **Yams** - Swift YAML library for parsing and generating YAML
## Testing
The package includes comprehensive tests that:
1. Split the ApiShared/openapi.yaml into organized schema files
2. Verify all expected directories and files are created
3. Test round-trip compatibility with YAMLMerger
4. Validate the structure matches YAMLMerger expectations
## Use Cases
- **API Documentation** - Split large OpenAPI specs into manageable files
- **Team Collaboration** - Allow different team members to work on different sections
- **Version Control** - Better diff tracking for specific components
- **Modular Development** - Work on individual endpoints and schemas separately
- **Round Trip Testing** - Validate that split + merge produces identical results

View File

@ -0,0 +1,197 @@
import Foundation
import Yams
/// YAMLSplitter - The opposite of YAMLMerger
/// Takes a single OpenAPI YAML file and splits it into organized schema files
public struct YAMLSplitter {
public let inputFile: URL
public let outputDirectory: URL
public init(inputFile: URL, outputDirectory: URL) {
self.inputFile = inputFile
self.outputDirectory = outputDirectory
}
/// Convenience initializer that creates Schema directory in Documents
public init(inputFile: URL) {
let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
let schemaDirectory = documentsURL.appendingPathComponent("Schema")
self.init(inputFile: inputFile, outputDirectory: schemaDirectory)
}
/// Splits the OpenAPI YAML file into organized schema files
public func split() throws {
// Ensure output directory exists
try FileManager.default.createDirectory(at: outputDirectory, withIntermediateDirectories: true)
// Load and parse the input YAML file
let yamlContent = try String(contentsOf: inputFile, encoding: .utf8)
guard let yaml = try Yams.load(yaml: yamlContent) as? [String: Any] else {
throw YAMLSplitterError.invalidYAMLStructure
}
// Split into organized sections
try splitInfoSection(from: yaml)
try splitServersSection(from: yaml)
try splitTagsSection(from: yaml)
try splitPathsSection(from: yaml)
try splitComponentsSection(from: yaml)
try splitSecuritySection(from: yaml)
try splitExternalDocsSection(from: yaml)
print("✅ YAML split successfully into \(outputDirectory.path)")
}
// MARK: - Section Splitters
private func splitInfoSection(from dict: [String: Any]) throws {
guard let info = dict["info"] else { return }
let infoDir = outputDirectory.appendingPathComponent("01_Info")
try FileManager.default.createDirectory(at: infoDir, withIntermediateDirectories: true)
// Create __info.yaml (header)
let infoHeader = """
openapi: 3.1.0
info:
"""
try infoHeader.write(to: infoDir.appendingPathComponent("__info.yaml"), atomically: true, encoding: .utf8)
// Create api-info.yaml
let apiInfo = try Yams.dump(object: info, width: -1, sortKeys: true)
try apiInfo.write(to: infoDir.appendingPathComponent("api-info.yaml"), atomically: true, encoding: .utf8)
}
private func splitServersSection(from dict: [String: Any]) throws {
guard let servers = dict["servers"] else { return }
let serversDir = outputDirectory.appendingPathComponent("02_Servers")
try FileManager.default.createDirectory(at: serversDir, withIntermediateDirectories: true)
// Create __servers.yaml (header)
let serversHeader = """
servers:
"""
try serversHeader.write(to: serversDir.appendingPathComponent("__servers.yaml"), atomically: true, encoding: .utf8)
// Create individual server files
if let serversArray = servers as? [[String: Any]] {
for (index, server) in serversArray.enumerated() {
let serverYaml = try Yams.dump(object: server, width: -1, sortKeys: true)
let fileName = "server-\(index + 1).yaml"
try serverYaml.write(to: serversDir.appendingPathComponent(fileName), atomically: true, encoding: .utf8)
}
}
}
private func splitTagsSection(from dict: [String: Any]) throws {
guard let tags = dict["tags"] else { return }
let tagsDir = outputDirectory.appendingPathComponent("03_Tags")
try FileManager.default.createDirectory(at: tagsDir, withIntermediateDirectories: true)
// Create __tags.yaml (header)
let tagsHeader = """
tags:
"""
try tagsHeader.write(to: tagsDir.appendingPathComponent("__tags.yaml"), atomically: true, encoding: .utf8)
// Create tags.yaml
let tagsYaml = try Yams.dump(object: tags, width: -1, sortKeys: true)
try tagsYaml.write(to: tagsDir.appendingPathComponent("tags.yaml"), atomically: true, encoding: .utf8)
}
private func splitPathsSection(from dict: [String: Any]) throws {
guard let paths = dict["paths"] as? [String: Any] else { return }
let pathsDir = outputDirectory.appendingPathComponent("04_Paths")
try FileManager.default.createDirectory(at: pathsDir, withIntermediateDirectories: true)
// Create __paths.yaml (header)
let pathsHeader = """
paths:
"""
try pathsHeader.write(to: pathsDir.appendingPathComponent("__paths.yaml"), atomically: true, encoding: .utf8)
// Split each path into its own file
for (path, pathData) in paths {
let fileName = sanitizeFileName(path) + ".yaml"
let pathYaml = try Yams.dump(object: [path: pathData], width: -1, sortKeys: true)
try pathYaml.write(to: pathsDir.appendingPathComponent(fileName), atomically: true, encoding: .utf8)
}
}
private func splitComponentsSection(from dict: [String: Any]) throws {
guard let components = dict["components"] as? [String: Any] else { return }
let componentsDir = outputDirectory.appendingPathComponent("06_Components")
try FileManager.default.createDirectory(at: componentsDir, withIntermediateDirectories: true)
// Create __components.yaml (header)
let componentsHeader = """
components:
"""
try componentsHeader.write(to: componentsDir.appendingPathComponent("__components.yaml"), atomically: true, encoding: .utf8)
// Split schemas into individual files
if let schemas = components["schemas"] as? [String: Any] {
for (schemaName, schemaData) in schemas {
let fileName = "\(schemaName).yaml"
let schemaYaml = try Yams.dump(object: [schemaName: schemaData], width: -1, sortKeys: true)
try schemaYaml.write(to: componentsDir.appendingPathComponent(fileName), atomically: true, encoding: .utf8)
}
}
}
private func splitSecuritySection(from dict: [String: Any]) throws {
guard let security = dict["security"] else { return }
let securityDir = outputDirectory.appendingPathComponent("07_Security")
try FileManager.default.createDirectory(at: securityDir, withIntermediateDirectories: true)
// Create __securitySchemes.yaml (header)
let securityHeader = """
security:
"""
try securityHeader.write(to: securityDir.appendingPathComponent("__securitySchemes.yaml"), atomically: true, encoding: .utf8)
// Create security.yaml
let securityYaml = try Yams.dump(object: security, width: -1, sortKeys: true)
try securityYaml.write(to: securityDir.appendingPathComponent("security.yaml"), atomically: true, encoding: .utf8)
}
private func splitExternalDocsSection(from dict: [String: Any]) throws {
guard let externalDocs = dict["externalDocs"] else { return }
let externalDocsDir = outputDirectory.appendingPathComponent("08_ExternalDocs")
try FileManager.default.createDirectory(at: externalDocsDir, withIntermediateDirectories: true)
// Create __externalDocs.yaml (header)
let externalDocsHeader = """
externalDocs:
"""
try externalDocsHeader.write(to: externalDocsDir.appendingPathComponent("__externalDocs.yaml"), atomically: true, encoding: .utf8)
// Create externalDocs.yaml
let externalDocsYaml = try Yams.dump(object: externalDocs, width: -1, sortKeys: true)
try externalDocsYaml.write(to: externalDocsDir.appendingPathComponent("externalDocs.yaml"), atomically: true, encoding: .utf8)
}
// MARK: - Helper Methods
private func sanitizeFileName(_ path: String) -> String {
return path
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "{", with: "")
.replacingOccurrences(of: "}", with: "")
.replacingOccurrences(of: " ", with: "_")
}
}
// MARK: - Errors
public enum YAMLSplitterError: Error {
case invalidYAMLStructure
case fileNotFound
case directoryCreationFailed
}

View File

@ -0,0 +1,583 @@
openapi: 3.1.0
info:
title: Demo API
version: 1.0.0
description: A demo OpenAPI specification for testing YAMLSplitter
contact:
name: API Support
email: support@demo.com
license:
name: MIT
url: https://opensource.org/licenses/MIT
servers:
- url: https://api.demo.com/v1
description: Production server
- url: https://staging-api.demo.com/v1
description: Staging server
- url: http://localhost:8080
description: Local development server
tags:
- name: Users
description: User management operations
- name: Products
description: Product catalog operations
- name: Orders
description: Order management operations
paths:
/users:
get:
tags:
- Users
summary: List all users
description: Retrieve a list of all users
operationId: listUsers
parameters:
- name: limit
in: query
description: Maximum number of users to return
required: false
schema:
type: integer
minimum: 1
maximum: 100
default: 10
- name: offset
in: query
description: Number of users to skip
required: false
schema:
type: integer
minimum: 0
default: 0
responses:
'200':
description: List of users
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/User'
'400':
description: Bad request
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
post:
tags:
- Users
summary: Create a new user
description: Create a new user account
operationId: createUser
requestBody:
description: User information
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreateUserRequest'
responses:
'201':
description: User created successfully
content:
application/json:
schema:
$ref: '#/components/schemas/User'
'400':
description: Bad request
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
/users/{userId}:
get:
tags:
- Users
summary: Get user by ID
description: Retrieve a specific user by their ID
operationId: getUserById
parameters:
- name: userId
in: path
required: true
description: User ID
schema:
type: string
format: uuid
responses:
'200':
description: User details
content:
application/json:
schema:
$ref: '#/components/schemas/User'
'404':
description: User not found
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
put:
tags:
- Users
summary: Update user
description: Update an existing user
operationId: updateUser
parameters:
- name: userId
in: path
required: true
description: User ID
schema:
type: string
format: uuid
requestBody:
description: Updated user information
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/UpdateUserRequest'
responses:
'200':
description: User updated successfully
content:
application/json:
schema:
$ref: '#/components/schemas/User'
'404':
description: User not found
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
delete:
tags:
- Users
summary: Delete user
description: Delete a user account
operationId: deleteUser
parameters:
- name: userId
in: path
required: true
description: User ID
schema:
type: string
format: uuid
responses:
'204':
description: User deleted successfully
'404':
description: User not found
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
/products:
get:
tags:
- Products
summary: List all products
description: Retrieve a list of all products
operationId: listProducts
parameters:
- name: category
in: query
description: Filter by product category
required: false
schema:
type: string
- name: minPrice
in: query
description: Minimum price filter
required: false
schema:
type: number
format: decimal
- name: maxPrice
in: query
description: Maximum price filter
required: false
schema:
type: number
format: decimal
responses:
'200':
description: List of products
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Product'
'400':
description: Bad request
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
/products/{productId}:
get:
tags:
- Products
summary: Get product by ID
description: Retrieve a specific product by its ID
operationId: getProductById
parameters:
- name: productId
in: path
required: true
description: Product ID
schema:
type: string
format: uuid
responses:
'200':
description: Product details
content:
application/json:
schema:
$ref: '#/components/schemas/Product'
'404':
description: Product not found
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
/orders:
get:
tags:
- Orders
summary: List user orders
description: Retrieve orders for the authenticated user
operationId: listOrders
security:
- bearerAuth: []
responses:
'200':
description: List of orders
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Order'
'401':
description: Unauthorized
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
post:
tags:
- Orders
summary: Create a new order
description: Create a new order
operationId: createOrder
security:
- bearerAuth: []
requestBody:
description: Order information
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreateOrderRequest'
responses:
'201':
description: Order created successfully
content:
application/json:
schema:
$ref: '#/components/schemas/Order'
'400':
description: Bad request
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'401':
description: Unauthorized
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
/orders/{orderId}:
get:
tags:
- Orders
summary: Get order by ID
description: Retrieve a specific order by its ID
operationId: getOrderById
security:
- bearerAuth: []
parameters:
- name: orderId
in: path
required: true
description: Order ID
schema:
type: string
format: uuid
responses:
'200':
description: Order details
content:
application/json:
schema:
$ref: '#/components/schemas/Order'
'401':
description: Unauthorized
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'404':
description: Order not found
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
components:
schemas:
User:
type: object
required:
- id
- email
- firstName
- lastName
properties:
id:
type: string
format: uuid
description: Unique user identifier
email:
type: string
format: email
description: User email address
firstName:
type: string
description: User's first name
lastName:
type: string
description: User's last name
phone:
type: string
description: User's phone number
dateOfBirth:
type: string
format: date
description: User's date of birth
createdAt:
type: string
format: date-time
description: Account creation timestamp
updatedAt:
type: string
format: date-time
description: Last update timestamp
CreateUserRequest:
type: object
required:
- email
- firstName
- lastName
properties:
email:
type: string
format: email
description: User email address
firstName:
type: string
description: User's first name
lastName:
type: string
description: User's last name
phone:
type: string
description: User's phone number
dateOfBirth:
type: string
format: date
description: User's date of birth
UpdateUserRequest:
type: object
properties:
firstName:
type: string
description: User's first name
lastName:
type: string
description: User's last name
phone:
type: string
description: User's phone number
dateOfBirth:
type: string
format: date
description: User's date of birth
Product:
type: object
required:
- id
- name
- price
- category
properties:
id:
type: string
format: uuid
description: Unique product identifier
name:
type: string
description: Product name
description:
type: string
description: Product description
price:
type: number
format: decimal
description: Product price
category:
type: string
description: Product category
inStock:
type: boolean
description: Whether the product is in stock
stockQuantity:
type: integer
description: Available stock quantity
createdAt:
type: string
format: date-time
description: Product creation timestamp
Order:
type: object
required:
- id
- userId
- items
- total
- status
properties:
id:
type: string
format: uuid
description: Unique order identifier
userId:
type: string
format: uuid
description: User who placed the order
items:
type: array
items:
$ref: '#/components/schemas/OrderItem'
description: Order items
total:
type: number
format: decimal
description: Total order amount
status:
type: string
enum:
- pending
- confirmed
- shipped
- delivered
- cancelled
description: Order status
shippingAddress:
$ref: '#/components/schemas/Address'
createdAt:
type: string
format: date-time
description: Order creation timestamp
updatedAt:
type: string
format: date-time
description: Last update timestamp
OrderItem:
type: object
required:
- productId
- quantity
- price
properties:
productId:
type: string
format: uuid
description: Product identifier
quantity:
type: integer
minimum: 1
description: Quantity ordered
price:
type: number
format: decimal
description: Price per unit
CreateOrderRequest:
type: object
required:
- items
- shippingAddress
properties:
items:
type: array
items:
$ref: '#/components/schemas/OrderItem'
description: Order items
shippingAddress:
$ref: '#/components/schemas/Address'
Address:
type: object
required:
- street
- city
- state
- zipCode
- country
properties:
street:
type: string
description: Street address
city:
type: string
description: City
state:
type: string
description: State or province
zipCode:
type: string
description: ZIP or postal code
country:
type: string
description: Country
Error:
type: object
required:
- code
- message
properties:
code:
type: string
description: Error code
message:
type: string
description: Error message
details:
type: string
description: Additional error details
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
description: JWT token authentication
security:
- bearerAuth: []
externalDocs:
description: Find more info about the Demo API
url: https://docs.demo.com

View File

@ -0,0 +1,108 @@
@testable import YAMLSplitter
@testable import YAMLMerger
import XCTest
final class YAMLSplitterTests: XCTestCase {
func testSplitOpenAPIYAML() throws {
print("📂 CWD:", FileManager.default.currentDirectoryPath)
// 1) INPUT: openapi.yaml from Resources
let bundle = Bundle.module
guard let openapiFile = bundle.url(forResource: "openapi", withExtension: "yaml", subdirectory: "Resources") else {
XCTFail("Failed to locate openapi.yaml in YAMLSplitterTests/Resources.")
return
}
// 2) OUTPUT: Schema directory will be created in Documents automatically
let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
let schemaOutputDir = documentsURL.appendingPathComponent("Schema")
// Clean up existing output directory
let fm = FileManager.default
if fm.fileExists(atPath: schemaOutputDir.path) {
try fm.removeItem(at: schemaOutputDir)
}
// 3) Split the OpenAPI YAML (convenience initializer creates Schema in Documents)
let splitter = YAMLSplitter(inputFile: openapiFile)
try splitter.split()
// 4) Verify the output structure
XCTAssertTrue(fm.fileExists(atPath: schemaOutputDir.path), "Schema directory not created")
// Verify all expected directories exist
let expectedDirs = [
"01_Info",
"02_Servers",
"03_Tags",
"04_Paths",
"06_Components",
"07_Security",
"08_ExternalDocs"
]
for dirName in expectedDirs {
let dirPath = schemaOutputDir.appendingPathComponent(dirName)
XCTAssertTrue(fm.fileExists(atPath: dirPath.path), "Directory \(dirName) not created")
}
// 5) Verify the split created files and folders
let infoDir = schemaOutputDir.appendingPathComponent("01_Info")
XCTAssertTrue(fm.fileExists(atPath: infoDir.path), "01_Info directory not created")
let pathsDir = schemaOutputDir.appendingPathComponent("04_Paths")
XCTAssertTrue(fm.fileExists(atPath: pathsDir.path), "04_Paths directory not created")
let componentsDir = schemaOutputDir.appendingPathComponent("06_Components")
XCTAssertTrue(fm.fileExists(atPath: componentsDir.path), "06_Components directory not created")
// Check that files were created in each directory
let pathFiles = try fm.contentsOfDirectory(at: pathsDir, includingPropertiesForKeys: nil)
XCTAssertTrue(pathFiles.count > 0, "No files created in 04_Paths directory")
let componentFiles = try fm.contentsOfDirectory(at: componentsDir, includingPropertiesForKeys: nil)
XCTAssertTrue(componentFiles.count > 0, "No files created in 06_Components directory")
print("✅ YAML split successfully into:", schemaOutputDir.path)
print("📁 Created directories:", expectedDirs.joined(separator: ", "))
print("📄 Path files created:", pathFiles.count)
print("📄 Component files created:", componentFiles.count)
}
func testSplitAndMergeRoundTrip() throws {
print("🔄 Testing split and merge round trip")
// 1) Split the openapi.yaml
let bundle = Bundle.module
guard let openapiFile = bundle.url(forResource: "openapi", withExtension: "yaml", subdirectory: "Resources") else {
XCTFail("Failed to locate openapi.yaml in YAMLSplitterTests/Resources")
return
}
let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
let schemaDir = documentsURL.appendingPathComponent("Schema")
// Clean up
let fm = FileManager.default
if fm.fileExists(atPath: schemaDir.path) {
try fm.removeItem(at: schemaDir)
}
// Split (convenience initializer creates Schema in Documents)
let splitter = YAMLSplitter(inputFile: openapiFile)
try splitter.split()
// 2) Merge back using YAMLMerger
let merger = YAMLMerger(rootDirectory: schemaDir, outputFileName: "CombinedSpec.yaml")
merger.merge()
// 3) Verify the merged file exists
let mergedFile = schemaDir.appendingPathComponent("CombinedSpec.yaml")
XCTAssertTrue(fm.fileExists(atPath: mergedFile.path), "Merged file not created")
print("✅ Round trip completed successfully")
print("📄 Original file:", openapiFile.path)
print("📄 Merged file:", mergedFile.path)
}
}