Customization and namespacing for swift-openapi-generator generated code

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-dependencies so 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.) and Invalid redeclaration of errors.

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)

  1. 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.swift symbols don’t collide?
  2. Renaming default symbols: Can we configure defaults like APIProtocol, Client, and other types emitted in Types.swift and Client.swift? If not today, would a templated name option (e.g. {{Vendor}}Client, {{Vendor}}APIProtocol) be acceptable?

  3. 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.

  4. 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?

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,

1 Like

That looks like a fairly complicated way to deal with a simple problem. My current way to do this is to just use separate target for separate OpenAPI clients.

4 Likes

Effectively, your approach handles name collisions if everything is internal, but it does not handle customization such as adding prefixes, suffixes, or macros to the generated code.

Hi @mackoj,

thanks for the details of what you're trying to achieve. For the naming collisions, I'd echo @lsb’s suggestion of separate modules, and you can set the accessModifier: package in the config file, allowing you to consume both clients in a single file. You'd import both modules, one for each client, and where needed, use the fully qualified name, such as GitHubClient.Client.

Now, for attaching macros, that's not easily doable today, but we're interested in offering this feature. There truly are very useful things that it'd unlock, if the generator can simply include specific macros on specific generated types. We have an issue tracking it here, feel free to chime in there, or we can even provide guidance if you'd be interested in contributing this feature!