Hello,
Thanks to the maintainers(Honza, Si) and contributors of swift-openapi-generator.
I have a couple of questions around customizing the generated code so it plays nicely when we generate multiple implementations in the same projects and extend it’s behaviour.
What I’m trying to do
-
I want to consume the generated client via
swift-dependenciesso that:- I can provide a live implementation for real network traffic
- easily mock endpoints in tests
- and provide preview values for SwiftUI
-
Multiple generated client in one app(yes it’s not really server side)
- Example: my app talks to both GitHub and Figma, each with its own OpenAPI spec. Generating both clients into the same module currently leads to duplicate type and symbol names (e.g.,
APIProtocol,Client, etc.) andInvalid redeclaration oferrors.
- Example: my app talks to both GitHub and Figma, each with its own OpenAPI spec. Generating both clients into the same module currently leads to duplicate type and symbol names (e.g.,
If there’s already a supported way to do any of the above and I missed it, I’d love to be pointed in the right direction!
Questions about customization (namespacing, symbols, files)
-
Per-vendor namespacing: Is there a supported way to disambiguate names when generating multiple clients into the same target?
- e.g. a type name prefix/suffix (
GithubClient,FigmaClient), - or a module/namespace option so each spec’s
Types.swift/Client.swiftsymbols don’t collide?
- e.g. a type name prefix/suffix (
-
Renaming default symbols: Can we configure defaults like
APIProtocol,Client, and other types emitted inTypes.swiftandClient.swift? If not today, would a templated name option (e.g.{{Vendor}}Client,{{Vendor}}APIProtocol) be acceptable? -
File names/paths: Is there a way to change the output filenames (e.g.
Github.Types.swift,Github.Client.swift) or the output directory structure per spec? This would also help avoid collisions and improve discoverability. -
Codegen hooks / plugin point: Is there a supported hook to:
- attach attributes to generated types (e.g. add a macro attribute to
struct Client), or - run a post-processing step that can rewrite or augment the emitted code?
- attach attributes to generated types (e.g. add a macro attribute to
A light-weight hook (template variable, naming option, or attribute injection) would solve both the collision problem and the swift-dependencies integration described below.
So… to make it work with swift-dependencies I did do a macro that turns the generated Client into a façade struct of function closures that mirrors the client’s methods. This façade is then trivial to inject via Dependencies.
If you are interested the macro it’s here it’s not great but it can give you an idea of what I’m trying to do.
When attached to the generated struct Client, @OpenAPIFacade synthesizes a façade struct whose properties are closures one per client instance method with the same signatures (parameters, async/throws, and return types). It also generates .live(using:) and .live(baseURL:session:) factories that adapt the real Client to the façade. The façade’s “all-closure” shape makes it ideal for integration with swift-dependencies: you register a façade value in the dependency graph, and in tests/previews you replace any endpoint by supplying a custom closure.
Why it’s useful:
- Works seamlessly with
Dependencies(live, mock, preview) - No hand-written boilerplate per endpoint methods are mirrored automatically
- Keeps signatures in sync with codegen over time
Usage
@OpenAPIFacade
struct Client: APIProtocol { /* generated by swift-openapi-generator */ }
Code
import SwiftCompilerPlugin
import SwiftSyntax
import SwiftSyntaxBuilder
import SwiftSyntaxMacros
import Foundation
@main
struct Plugin: CompilerPlugin {
let providingMacros: [Macro.Type] = [OpenAPIFacadeMacro.self]
}
public struct OpenAPIFacadeMacro: PeerMacro {
public static func expansion(
of node: AttributeSyntax,
providingPeersOf decl: some DeclSyntaxProtocol,
in context: some MacroExpansionContext
) throws -> [DeclSyntax] {
guard let structDecl = decl.as(StructDeclSyntax.self) else {
return [DeclSyntax(stringLiteral: """
#error("`@OpenAPIFacade` must be attached to the `struct` declaration (e.g. the generated `Client`).")
""")]
}
let args = node.arguments?.as(LabeledExprListSyntax.self)
let facadeName = stringArg(named: "facade", in: args) ?? "APIClient"
let baseURL = stringArg(named: "baseURL", in: args) ?? "http://localhost:8080"
let clientType = structDecl.name.text
let methods: [FunctionDeclSyntax] = structDecl.memberBlock.members
.compactMap { $0.decl.as(FunctionDeclSyntax.self) }
.filter { fn in
guard fn.name.text != "init" else { return false }
let isStatic = fn.modifiers.contains(
where: { $0.name.tokenKind == .keyword(.static)
})
if isStatic { return false }
let isPrivate = fn.modifiers.contains(
where: { ["private","fileprivate"].contains($0.name.text)
})
return !isPrivate
}
var propDecls: [String] = []
var initParams: [String] = []
var initBody: [String] = []
var liveUsingArms: [String] = []
for (idx, fn) in methods.enumerated() {
let fnName = fn.name.text
let closureParamSig = makeClosureParamSignature(from: fn.signature.parameterClause.parameters, indexSeed: idx)
let callArgList = makeCallArgumentList(from: fn.signature.parameterClause.parameters, indexSeed: idx)
let asyncTok = fn.signature.effectSpecifiers?.asyncSpecifier?.text ?? ""
let throwsTok = fn.signature.effectSpecifiers?.throwsClause?.throwsSpecifier.text ?? ""
let returnTy = fn.signature.returnClause?.type.description ?? "Void"
propDecls.append("public var \(fnName): @Sendable (\(closureParamSig)) \(asyncTok) \(throwsTok) -> \(returnTy)")
initParams.append("\(fnName): @escaping @Sendable (\(closureParamSig)) \(asyncTok) \(throwsTok) -> \(returnTy)")
initBody.append("self.\(fnName) = \(fnName)")
liveUsingArms.append("""
\(fnName): { \(closureParamSig) in
return try \(asyncTok.isEmpty ? "" : "await ")client.\(fnName)(\(callArgList))
}
""")
}
var pieces: [String] = []
pieces.append("""
import Foundation
import OpenAPIURLSession
import OpenAPIRuntime
import Dependencies
""")
pieces.append("""
public struct \(facadeName) {
\(propDecls.map { " " + $0 }.joined(separator: "\n"))
public init(
\(initParams.joined(separator: ",\n "))
) {
\(initBody.map { " " + $0 }.joined(separator: "\n"))
}
public static func live(using client: \(clientType)) -> Self {
return Self(
\(liveUsingArms.joined(separator: ",\n "))
)
}
public static func live(baseURL: URL, session: URLSession = .shared) -> Self {
let transport = URLSessionTransport(configuration: .init(session: session))
let client = \(clientType)(
serverURL: baseURL,
configuration: .init(),
transport: transport
)
return .live(using: client)
}
public static func live(session: URLSession = .shared) -> Self {
.live(baseURL: URL(string: "\(baseURL)")!, session: session)
}
}
""")
return [DeclSyntax(stringLiteral: pieces.joined(separator: "\n\n"))]
}
}
fileprivate func stringArg(named: String, in args: LabeledExprListSyntax?) -> String? {
guard let expr = args?.first(where: { $0.label?.text == named })?.expression else { return nil }
if let str = expr.as(StringLiteralExprSyntax.self)?.segments.first?.description {
return str.trimmingCharacters(in: CharacterSet(charactersIn: "\""))
}
if let seg = expr.as(StringLiteralExprSyntax.self)?.segments.first?.as(StringSegmentSyntax.self) {
return seg.content.text
}
return expr.description
}
fileprivate func boolArg(named: String, in args: LabeledExprListSyntax?) -> Bool? {
guard let expr = args?.first(where: { $0.label?.text == named })?.expression else { return nil }
return expr.description.trimmingCharacters(in: .whitespacesAndNewlines) == "true"
}
fileprivate func makeClosureParamSignature(from params: FunctionParameterListSyntax, indexSeed: Int) -> String {
var parts: [String] = []
var counter = indexSeed
for p in params {
let name = (p.secondName ?? p.firstName)?.text ?? "arg\(counter)"; counter += 1
let type = p.type.description
let inoutPrefix = (p.type.as(AttributedTypeSyntax.self)?.specifier?.text == "inout") ? "inout " : ""
parts.append("_ \(name): \(inoutPrefix)\(type)")
}
return parts.joined(separator: ", ")
}
fileprivate func makeCallArgumentList(from params: FunctionParameterListSyntax, indexSeed: Int) -> String {
var parts: [String] = []
var counter = indexSeed
for p in params {
let label = p.firstName.text
let varName = (p.secondName ?? p.firstName)?.text ?? "arg\(counter)"; counter += 1
if label == "_" {
parts.append(varName)
} else {
parts.append("\(label): \(varName)")
}
}
return parts.joined(separator: ", ")
}
fileprivate func makeCrashingDefaultInit(for methods: [FunctionDeclSyntax], facadeName: String) -> String {
let arms = methods.map { fn -> String in
let fnName = fn.name.text
let params = makeClosureParamSignature(from: fn.signature.parameterClause.parameters, indexSeed: 0)
let asyncTok = fn.signature.effectSpecifiers?.asyncSpecifier?.text ?? ""
let throwsTok = fn.signature.effectSpecifiers?.throwsClause?.throwsSpecifier.text ?? ""
let ret = fn.signature.returnClause?.type.description.trimmingCharacters(in: .whitespacesAndNewlines) ?? "Void"
let body = throwsTok.isEmpty ? "fatalError(\"\\(String(describing: \(facadeName).self)).\(fnName) is unimplemented\")"
: "throw NSError(domain: \"Unimplemented\", code: -1)"
return "\(fnName): { (\(params)) \(asyncTok) \(throwsTok) -> \(ret) in \(body) }"
}
return "\(facadeName)(\n \(arms.joined(separator: ",\n "))\n )"
}
fileprivate func makePreviewDefaultInit(for methods: [FunctionDeclSyntax], facadeName: String) -> String {
let arms = methods.map { fn -> String in
let fnName = fn.name.text
let params = makeClosureParamSignature(from: fn.signature.parameterClause.parameters, indexSeed: 0)
let asyncTok = fn.signature.effectSpecifiers?.asyncSpecifier?.text ?? ""
let throwsTok = fn.signature.effectSpecifiers?.throwsClause?.throwsSpecifier.text ?? ""
let ret = fn.signature.returnClause?.type.description.trimmingCharacters(in: .whitespacesAndNewlines) ?? "Void"
let placeholderReturn: String = {
if ret == "Void" { return "" }
return "return (\(ret)).self is ExpressibleByNilLiteral.Type ? nil : { fatalError(\"Provide preview for \(fnName)\") }()"
}()
return "\(fnName): { (\(params)) \(asyncTok) \(throwsTok) -> \(ret) in \(placeholderReturn) }"
}
return "\(facadeName)(\n \(arms.joined(separator: ",\n "))\n )"
}
Thanks,