Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/build_and_test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ jobs:
-w /work \
swaggerswift:test \
--swagger-file-path SwaggerFile.yml \
--git-hub-token unused
--github-token unused

- name: Verify generated output
run: |
Expand Down
75 changes: 75 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## What This Project Does

SwaggerSwift is a Swift code generator that converts Swagger (OpenAPI 2.0) specifications into a fully-typed, async/await-ready Swift networking layer. It downloads specs from GitHub and generates Swift packages with API clients, data models, and shared utilities.

## Commands

```bash
# Build
swift build -c release
# Binary: .build/release/swaggerswift

# Run tests
swift test --parallel

# Run a single test
swift test --filter SwaggerSwiftMLTests.ParserTests/testSomething

# Run the generator locally
export GITHUB_TOKEN="ghp_…"
.build/release/swaggerswift --swagger-file-path ./SwaggerFile.yml --verbose
```

Code style is enforced by `swift-format` (`.swift-format` config): 120-char line limit, 4-space indentation.

## Architecture

Three targets in `Package.swift`:

**`SwaggerSwiftML`** — Parsing layer. Defines all Swagger data model types (`Operation`, `Parameter`, `Schema`, `Response`, etc.) and parses JSON/YAML specs via Yams. Produces an in-memory Swagger AST.

**`SwaggerSwiftCore`** — Code generation engine. Takes the parsed AST and produces Swift source files via Stencil templates. Key components:
- `Generator` — Main orchestrator; reads `SwaggerFile.yml`, downloads specs from GitHub, and drives the pipeline
- `ModelTypeResolver` — Maps Swagger types (primitives, enums, arrays, objects, `$ref`) to Swift types
- `APIRequestFactory` / `APIResponseTypeFactory` — Build async API client methods from `Operation` nodes
- `TemplateRenderer` — Renders Stencil templates from `Sources/SwaggerSwiftCore/Templates/`

**`SwaggerSwift`** (executable) — Thin CLI wrapper using swift-argument-parser. Entry point: `MyMain.swift`.

### Data Flow

```
SwaggerFile.yml → Generator → GitHub API (downloads specs)
→ SwaggerParser (SwaggerSwiftML)
→ ModelTypeResolver → ObjectModelFactory
→ APIRequestFactory → TemplateRenderer (Stencil)
→ FileWriter → Generated Swift Package
```

### Generated Output Structure

```
Generated/
└── MyAPI/
├── Package.swift
└── Sources/
├── MyAPIShared/ # Shared networking utilities (templates)
└── UserService/ # One target per service
├── UserServiceClient.swift
└── Models/
```

### Template System

Stencil templates live in `Sources/SwaggerSwiftCore/Templates/`. Each template maps to a generated file type (API client, model struct, enum, typealias, shared library files). Templates are loaded from the bundle at runtime — the `Dockerfile` copies them explicitly.

### Testing

- `SwaggerSwiftMLTests` — Unit tests for parsing; uses YAML/JSON fixtures in `Tests/SwaggerSwiftMLTests/TestResources/`
- `SwaggerSwiftCoreTests` — Unit tests for code generation; includes golden file (snapshot) tests

CI runs on both macOS (Xcode 26.2) and Linux (Swift 6.0) via `.github/workflows/build_and_test.yml`.
11 changes: 9 additions & 2 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,22 @@ let package = Package(
"SwaggerSwiftCore",
]
),
.target(
name: "SwaggerSwiftIR",
dependencies: []
),
.target(
name: "SwaggerSwiftML",
dependencies: [
.product(name: "Yams", package: "Yams")
"SwaggerSwiftIR",
.product(name: "Yams", package: "Yams"),
]
),
.target(
name: "SwaggerSwiftCore",
dependencies: [
"SwaggerSwiftML",
"SwaggerSwiftIR",
.product(name: "Yams", package: "Yams"),
.product(name: "Stencil", package: "Stencil"),
],
Expand All @@ -39,7 +45,7 @@ let package = Package(
),
.testTarget(
name: "SwaggerSwiftMLTests",
dependencies: ["SwaggerSwiftML"],
dependencies: ["SwaggerSwiftML", "SwaggerSwiftIR"],
resources: [
.copy("BasicSwagger.yaml"),
.copy("Parameter"),
Expand All @@ -48,6 +54,7 @@ let package = Package(
.copy("Items"),
.copy("Operation"),
.copy("Response"),
.copy("OpenAPI3"),
]
),
.testTarget(
Expand Down
4 changes: 2 additions & 2 deletions Sources/SwaggerSwift/MyMain.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ struct SwaggerSwiftParser: AsyncParsableCommand {
var verbose: Bool = false

@Option(name: .shortAndLong, help: "GitHub token (or set GITHUB_TOKEN env var)")
var gitHubToken: String?
var githubToken: String?

@Option(
name: .shortAndLong,
Expand All @@ -26,7 +26,7 @@ struct SwaggerSwiftParser: AsyncParsableCommand {
var apiList: [String]?

mutating func run() async throws {
guard let token = gitHubToken ?? ProcessInfo.processInfo.environment["GITHUB_TOKEN"] else {
guard let token = githubToken ?? ProcessInfo.processInfo.environment["GITHUB_TOKEN"] else {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Error message references old CLI flag name

Medium Severity

The property was renamed from gitHubToken to githubToken, which changes the swift-argument-parser derived CLI flag from --git-hub-token to --github-token. The ValidationError message still references the old --git-hub-token flag, so users following the error guidance will use a flag that doesn't exist.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 8ad1c98. Configure here.

throw ValidationError(
"GitHub token must be provided via --git-hub-token or GITHUB_TOKEN environment variable"
)
Expand Down
46 changes: 15 additions & 31 deletions Sources/SwaggerSwiftCore/API Factory/APIFactory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,7 @@ struct APIFactory {
return []
}

let serviceName = swagger.serviceName
var allDefinitions = [ModelDefinition]()
for (typeName, schemaNode) in definitions {
if case .reference(let reference) = schemaNode {
Expand All @@ -355,10 +356,10 @@ struct APIFactory {
guard case .node(let schema) = schemaNode else { continue }

let resolved = try modelTypeResolver.resolve(
forSchema: schema,
forSchema: schema.toIR(),
typeNamePrefix: typeName,
namespace: swagger.serviceName,
swagger: swagger
namespace: serviceName,
serviceName: serviceName
)

allDefinitions.append(contentsOf: resolved.inlineModelDefinitions)
Expand Down Expand Up @@ -421,6 +422,7 @@ struct APIFactory {
/// - Parameter swagger: the swagger
/// - Returns: the set of global response model definitions
private func getResponseModelDefinitions(fromSwagger swagger: Swagger) throws -> [ModelDefinition] {
let serviceName = swagger.serviceName
var modelDefinitions = [ModelDefinition]()
for (typeName, response) in swagger.responses ?? [:] {
guard let schema = response.schema else {
Expand All @@ -440,34 +442,16 @@ struct APIFactory {
continue
}

switch schema {
case .reference(let reference):
if let typeSchema = try? swagger.findSchema(reference: reference) {
// we dont need the type part as it just represents the primary model definition returned from this function
let resolvedModel = try modelTypeResolver.resolve(
forSchema: typeSchema,
typeNamePrefix: typeName,
namespace: swagger.serviceName,
swagger: swagger
)
modelDefinitions.append(contentsOf: resolvedModel.inlineModelDefinitions)
} else {
log(
"[\(swagger.serviceName)] Failed to find definition for reference: \(reference)",
error: true
)
continue
}
case .node(let schema):
// we dont need the type part as it just represents the primary model definition returned from this function
let resolvedModel = try modelTypeResolver.resolve(
forSchema: schema,
typeNamePrefix: typeName,
namespace: swagger.serviceName,
swagger: swagger
)
modelDefinitions.append(contentsOf: resolvedModel.inlineModelDefinitions)
}
// Convert schema node to IR and resolve; we only need inline model definitions here —
// the top-level response type itself is not added as a model definition.
let irSchema = schema.toIR()
let resolvedModel = try modelTypeResolver.resolve(
forSchema: irSchema,
typeNamePrefix: typeName,
namespace: serviceName,
serviceName: serviceName
)
modelDefinitions.append(contentsOf: resolvedModel.inlineModelDefinitions)
}

return modelDefinitions
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -206,28 +206,23 @@ public struct APIRequestFactory {
}

if let schemaNode = request.schema {
switch schemaNode {
case .node(let schema):
let resolvedType = try modelTypeResolver.resolve(
forSchema: schema,
typeNamePrefix: prefix,
namespace: swagger.serviceName,
swagger: swagger
)

return (resolvedType.propertyType, resolvedType.inlineModelDefinitions)
case .reference(let ref):
let schema = try swagger.findSchema(reference: ref)
let modelReference = try ModelReference(rawValue: ref)

let resolvedType = schema.type(named: modelReference.typeName)

if case .array = resolvedType {
return (TypeType.object(typeName: modelReference.typeName), [])
} else {
return (resolvedType, [])
}
let irSchema = schemaNode.toIR()
let serviceName = swagger.serviceName

// A bare $ref in a response schema maps directly to the named type without namespace
// qualification — matching the historical codegen behaviour where top-level response
// type references are not prefixed with the service name.
if case .reference(let name) = irSchema.type {
return (.object(typeName: name), [])
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Response $ref ignores actual type of referenced schema

Medium Severity

When a response or body schema contains a bare $ref, the new code unconditionally returns .object(typeName: name). The old code resolved the referenced schema and returned its actual type — e.g., .string(defaultValue: nil) for string typedefs, or .enumeration for enum types. This changes generated function signatures for any spec where a response or body $ref points to a non-object definition.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 6b6662e. Configure here.

}

let resolvedType = try modelTypeResolver.resolve(
forSchema: irSchema,
typeNamePrefix: prefix,
namespace: serviceName,
serviceName: serviceName
)
return (resolvedType.propertyType, resolvedType.inlineModelDefinitions)
} else {
return (.void, [])
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -354,54 +354,41 @@ public struct RequestParameterFactory {
return nil
}

switch schemaNode {
case .node(let schema):
let resolvedType = try modelTypeResolver.resolve(
forSchema: schema,
typeNamePrefix: typePrefix,
namespace: namespace,
swagger: swagger
)
let irSchema = schemaNode.toIR()
let serviceName = swagger.serviceName

// A bare $ref in a body parameter maps directly to the named type without namespace
// qualification — matching the historical codegen behaviour where top-level body
// parameter references are not prefixed with the service name.
if case .reference(let name) = irSchema.type {
let param = FunctionParameter(
description: parameter.description,
name: "body",
typeName: resolvedType.propertyType,
typeName: .object(typeName: name),
required: parameter.required,
in: .body,
isEnum: false
)
return (param, [])
}

return (param, resolvedType.inlineModelDefinitions)
case .reference(let reference):
let schema = try swagger.findSchema(reference: reference)
let modelDefinition = try ModelReference(rawValue: reference)

let type = schema.type(named: modelDefinition.typeName)

if case TypeType.array(let typeName) = type {
let param = FunctionParameter(
description: parameter.description,
name: "body",
typeName: typeName,
required: parameter.required,
in: .body,
isEnum: false
)
return (param, [])
}
let resolvedType = try modelTypeResolver.resolve(
forSchema: irSchema,
typeNamePrefix: typePrefix,
namespace: namespace,
serviceName: serviceName
)

let param = FunctionParameter(
description: parameter.description,
name: "body",
typeName: type,
required: parameter.required,
in: .body,
isEnum: false
)
let param = FunctionParameter(
description: parameter.description,
name: "body",
typeName: resolvedType.propertyType,
required: parameter.required,
in: .body,
isEnum: false
)

return (param, [])
}
return (param, resolvedType.inlineModelDefinitions)
}

private func resolveFormDataParameters(
Expand Down
Loading
Loading