Skip to main content

One post tagged with "OpenAPI"

View All Tags

Automating the Networking Layer in iOS: Implementing Swift OpenAPI Generator at Scale

Published: · 14 min read
Robin Alex Panicker
Cofounder and CPO, Appxiom

Modern iOS teams waste countless hours hand-writing networking code: request builders, response decoding, error mapping, auth headers, pagination helpers, retries - the list never ends. It’s brittle, hard to keep in sync with backend changes, and risky to refactor at scale.

This post is a practical, production-focused swift openapi generator tutorial. We’ll walk through how to use Swift OpenAPI Generator to generate a type-safe networking layer in Swift, integrate it with your app’s architecture, and roll it out across large iOS codebases. We’ll cover the end-to-end flow - OpenAPI spec to generated client, dependency injection, MVVM integration, testing, error handling, retries, and CI - so you can confidently adopt it at scale.

What you’ll build

  • A dedicated SPM module that owns your generated API client
  • A URLSession-based transport with auth, logging, and retry middleware
  • A repository layer that maps OpenAPI DTOs into your domain models
  • A SwiftUI view model calling the generated client with async/await
  • A testable setup using a mock transport and fixtures
  • A scalable rollout plan with CI verification and spec drift detection

Prerequisites

  • Xcode 15.4+ (or Xcode 16+) with Swift 5.9+
  • iOS 16+ (targets earlier than iOS 16 are possible but require more conditional availability)
  • An OpenAPI 3.0/3.1 spec provided by your backend team
  • Packages: https://github.com/apple/swift-openapi-generator (provides the generator plugin, OpenAPIRuntime, and OpenAPIURLSession)

Why Swift OpenAPI Generator for type-safe networking in Swift?

  • Strongly typed requests and responses: Compile-time types for parameters, bodies, and success/error responses.
  • Drift resistance: If the backend changes the API spec, your build breaks where your client code is inconsistent. You fix it once.
  • Less boilerplate: You no longer write URLRequest setup, encoders/decoders, or status code branching by hand.
  • Extensible runtime: URLSession transport, middleware for auth/logging/retries, and test transports for mocks.
  • Scales with teams: A single source of truth (the spec) generates consistent, predictable client code across features and modules.

Architecture overview (modularized)

  • App (iOS target): SwiftUI/UIKit views + feature view models
  • NetworkingAPI (SPM target): Generated client + lightweight custom glue
    • Generated code: By Swift OpenAPI Generator (not checked into source)
    • Runtime: OpenAPIRuntime + OpenAPIURLSession for URLSession transport
    • Middlewares: Auth, logging, retry
  • Domain/Repositories (SPM or local modules): Protocols and mapping from API DTOs to domain models
  • Tests: Unit tests use a mock transport to simulate responses

This separation keeps your generated code isolated, easily swappable, and testable.

Step 1: Add the package and plugin to your project

Add the package to your workspace (File > Add Packages… in Xcode) or edit Package.swift:

// Package.swift (excerpt)
let package = Package(
name: "YourWorkspace",
platforms: [.iOS(.v16)],
products: [
.library(name: "NetworkingAPI", targets: ["NetworkingAPI"]),
],
dependencies: [
.package(url: "https://github.com/apple/swift-openapi-generator", from: "1.3.0"),
],
targets: [
.target(
name: "NetworkingAPI",
dependencies: [
.product(name: "OpenAPIRuntime", package: "swift-openapi-generator"),
.product(name: "OpenAPIURLSession", package: "swift-openapi-generator"),
],
plugins: [
// This is the SwiftPM build plugin Xcode runs at build time.
.plugin(name: "OpenAPIGenerator", package: "swift-openapi-generator")
]
),
.testTarget(
name: "NetworkingAPITests",
dependencies: ["NetworkingAPI"]
),
]
)

The plugin will run on each build to generate sources for NetworkingAPI.

Step 2: Provide your OpenAPI document

By default, the plugin looks for an openapi.yaml or openapi.json file inside the target directory. Create this file at Sources/NetworkingAPI/openapi.yaml.

Example (trimmed) OpenAPI snippet:

openapi: 3.0.3
info:
title: Example API
version: "1.0.0"
servers:
- url: https://api.example.com
paths:
/users:
get:
operationId: listUsers
parameters:
- in: query
name: limit
schema: { type: integer, minimum: 1, maximum: 100 }
responses:
"200":
description: OK
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/User"
/users/{id}:
get:
operationId: getUser
parameters:
- in: path
name: id
required: true
schema: { type: string, format: uuid }
responses:
"200":
description: OK
content:
application/json:
schema:
$ref: "#/components/schemas/User"
components:
schemas:
User:
type: object
required: [id, name]
properties:
id: { type: string, format: uuid }
name: { type: string }
email: { type: string, format: email }

Tip:

  • Set operationId for stable, readable Swift method names.
  • Document error responses (e.g., 400/401/403/404/429/5xx) so they become typed enum cases.

If you need custom generation options (e.g., public access), add a config file (e.g., openapi-generator-config.yaml) beside your spec. Xcode will detect it automatically. Refer to the project’s README for exact keys and defaults.

Step 3: Build and explore the generated client

Build once. The plugin generates a Client type and request/response models in the build directory. In your NetworkingAPI module, wire up a URLSession transport:

// Sources/NetworkingAPI/APIClient.swift

import Foundation
import OpenAPIRuntime
import OpenAPIURLSession

// Holds baseline dependencies for the generated Client
public struct APIEnvironment {
public var serverURL: URL
public var session: URLSession
public var middlewares: [any ClientMiddleware]

public init(
serverURL: URL,
session: URLSession = .shared,
middlewares: [any ClientMiddleware] = []
) {
self.serverURL = serverURL
self.session = session
self.middlewares = middlewares
}
}

// Factory to build a configured Client from the generated code.
public enum APIClientFactory {
public static func makeClient(env: APIEnvironment) -> Client {
let transport = URLSessionTransport(configuration: env.session.configuration, session: env.session)
return Client(
serverURL: env.serverURL,
transport: transport,
middlewares: env.middlewares
)
}
}

Notes:

  • Client is generated. Its exact module and symbol names depend on your target and spec. Use Xcode autocomplete to confirm available operations (e.g., listUsers, getUser).
  • OpenAPIURLSession.URLSessionTransport integrates URLSession with the generator’s runtime.

Step 4: Middlewares: auth, logging, and retries

Use runtime middleware to attach headers, log requests, and retry transient failures. This keeps concerns out of feature code.

// Sources/NetworkingAPI/Middlewares.swift

import Foundation
import OpenAPIRuntime

// Injects Authorization: Bearer <token> header if available.
public struct BearerAuthMiddleware: ClientMiddleware {
private let tokenProvider: () -> String?

public init(tokenProvider: @escaping () -> String?) {
self.tokenProvider = tokenProvider
}

public func intercept(
_ request: inout Request,
baseURL: URL,
operationID: String,
next: (inout Request, URL) async throws -> Response
) async throws -> Response {
if let token = tokenProvider() {
request.headerFields[.authorization] = "Bearer \(token)"
}
return try await next(&request, baseURL)
}
}

// Simple request/response logger (sanitize PII in production).
public struct LoggingMiddleware: ClientMiddleware {
public init() {}
public func intercept(
_ request: inout Request,
baseURL: URL,
operationID: String,
next: (inout Request, URL) async throws -> Response
) async throws -> Response {
#if DEBUG
print("➡️ [\(operationID)] \(request.method.rawValue) \(baseURL)\(request.path)")
#endif
let response = try await next(&request, baseURL)
#if DEBUG
print("⬅️ [\(operationID)] \(response.statusCode)")
#endif
return response
}
}

// Exponential backoff with jitter for 429/5xx.
public struct RetryMiddleware: ClientMiddleware {
public init() {}

public func intercept(
_ request: inout Request,
baseURL: URL,
operationID: String,
next: (inout Request, URL) async throws -> Response
) async throws -> Response {
var attempt = 0
let maxAttempts = 3

while true {
do {
let response = try await next(&request, baseURL)
if shouldRetry(status: response.statusCode), attempt < maxAttempts - 1 {
attempt += 1
try await Task.sleep(nanoseconds: backoff(attempt))
continue
}
return response
} catch {
// Retry for network-layer transient errors if desired.
if attempt < maxAttempts - 1, isTransient(error) {
attempt += 1
try await Task.sleep(nanoseconds: backoff(attempt))
continue
}
throw error
}
}
}

private func shouldRetry(status: Int) -> Bool {
status == 429 || (500...599).contains(status)
}

private func isTransient(_ error: Error) -> Bool {
// Expand with URL error codes as needed.
(error as? URLError)?.code == .timedOut
}

private func backoff(_ attempt: Int) -> UInt64 {
let base: Double = 0.3
let max: Double = 2.0
let delay = min(max, pow(2.0, Double(attempt)) * base)
let jitter = Double.random(in: 0...(delay * 0.2))
return UInt64((delay + jitter) * 1_000_000_000)
}
}

Compose them when building the client:

// Example composition (e.g., in AppDelegate/DI container)
let env = APIEnvironment(
serverURL: URL(string: "https://api.example.com")!,
session: {
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 30
config.timeoutIntervalForResource = 60
return URLSession(configuration: config, delegate: nil, delegateQueue: nil)
}(),
middlewares: [
BearerAuthMiddleware { AuthStore.shared.token },
LoggingMiddleware(),
RetryMiddleware()
]
)
let apiClient = APIClientFactory.makeClient(env: env)

Security tip: If you need certificate pinning, provide a URLSession with a custom delegate and store minimal PII in logs.

Step 5: A repository that shields your app from generated types

In production, keep generated types out of your domain. Introduce a repository protocol to abstract the generated client and map DTOs to domain models.

// Sources/NetworkingAPI/UsersRepository.swift

import Foundation

public struct User: Equatable, Identifiable {
public let id: UUID
public let name: String
public let email: String?
}

public enum UsersRepositoryError: Error {
case notFound
case unauthorized
case server
case decoding
case transport(Error)
}

public protocol UsersRepository {
func list(limit: Int?) async throws -> [User]
func get(id: UUID) async throws -> User
}

Implementation using the generated client:

// Sources/NetworkingAPI/UsersRepositoryImpl.swift

import Foundation

public final class UsersRepositoryImpl: UsersRepository {
private let client: Client

public init(client: Client) {
self.client = client
}

public func list(limit: Int?) async throws -> [User] {
// The generator creates functions based on operationId.
// Adjust names to your generated API.
let response = try await client.listUsers(.init(query: .init(limit: limit)))
switch response {
case .ok(let ok):
// Access the typed body based on content type; typically .json.
return try ok.body.json.map(mapUser)
case .undocumented(let status, _):
throw mapStatus(status)
default:
throw UsersRepositoryError.server
}
}

public func get(id: UUID) async throws -> User {
let response = try await client.getUser(.init(path: .init(id: id.uuidString)))
switch response {
case .ok(let ok):
return try mapUser(ok.body.json)
case .undocumented(let status, _):
throw mapStatus(status)
default:
throw UsersRepositoryError.server
}
}

private func mapUser(_ dto: Components.Schemas.User) throws -> User {
guard let uuid = UUID(uuidString: dto.id) else { throw UsersRepositoryError.decoding }
return User(id: uuid, name: dto.name, email: dto.email)
}

private func mapStatus(_ status: Int) -> UsersRepositoryError {
switch status {
case 401: return .unauthorized
case 404: return .notFound
case 500...599: return .server
default: return .server
}
}
}

Key points:

  • The generator returns typed “response enums” for each operation (cases per status code, plus .undocumented for unexpected ones).
  • Map DTOs to domain early and keep generated types inside the networking module.

Step 6: Use in SwiftUI with async/await

// App/Features/Users/UsersViewModel.swift

import Foundation
import Observation // or @MainActor with ObservableObject if preferred

@Observable
final class UsersViewModel {
private let repo: UsersRepository
private var loadTask: Task<Void, Never>?

// UI state
var users: [User] = []
var isLoading = false
var errorMessage: String?

init(repo: UsersRepository) {
self.repo = repo
}

@MainActor
func load(limit: Int? = 20) {
loadTask?.cancel()
isLoading = true
errorMessage = nil

loadTask = Task { [weak self] in
guard let self else { return }
do {
let result = try await repo.list(limit: limit)
await MainActor.run {
self.users = result
self.isLoading = false
}
} catch {
await MainActor.run {
self.errorMessage = Self.map(error)
self.isLoading = false
}
}
}
}

func cancel() {
loadTask?.cancel()
}

private static func map(_ error: Error) -> String {
switch error {
case UsersRepositoryError.unauthorized:
return "Please sign in again."
case UsersRepositoryError.notFound:
return "Not found."
default:
return "Something went wrong."
}
}
}

Wire up in your SwiftUI view:

// App/Features/Users/UsersView.swift

import SwiftUI

struct UsersView: View {
@State private var model: UsersViewModel

init(repo: UsersRepository) {
_model = State(initialValue: UsersViewModel(repo: repo))
}

var body: some View {
List(model.users) { user in
VStack(alignment: .leading) {
Text(user.name).font(.headline)
if let email = user.email {
Text(email).font(.subheadline).foregroundStyle(.secondary)
}
}
}
.overlay {
if model.isLoading { ProgressView() }
}
.task { model.load() }
.refreshable { model.load() }
.alert("Error", isPresented: .constant(model.errorMessage != nil)) {
Button("OK") { model.errorMessage = nil }
} message: {
Text(model.errorMessage ?? "")
}
}
}

Testing with a mock transport (no network required)

You can fully test repositories by swapping out the transport, without touching URLSession. Implement a ClientTransport that returns canned responses.

// Tests/NetworkingAPITests/MockTransport.swift

import Foundation
import OpenAPIRuntime

struct MockTransport: ClientTransport {
let handler: (Request, URL) throws -> Response

func send(_ request: Request, baseURL: URL) async throws -> Response {
// In real tests, support async and fixtures from disk.
try handler(request, baseURL)
}
}

Build a Client that uses MockTransport, then test the repository:

// Tests/NetworkingAPITests/UsersRepositoryTests.swift

import XCTest
@testable import NetworkingAPI
import OpenAPIRuntime

final class UsersRepositoryTests: XCTestCase {
func testListUsers_ok() async throws {
let mock = MockTransport { request, _ in
// Validate request method/path if you want.
var response = Response(statusCode: 200)
// Encode a JSON array that matches the generated schema.
let body = try JSONEncoder().encode([
["id": UUID().uuidString, "name": "Alice", "email": "a@example.com"],
])
response.body = .init(body, contentType: "application/json")
return response
}

let client = Client(
serverURL: URL(string: "https://example.test")!,
transport: mock,
middlewares: []
)
let repo = UsersRepositoryImpl(client: client)

let users = try await repo.list(limit: 10)
XCTAssertEqual(users.count, 1)
XCTAssertEqual(users.first?.name, "Alice")
}
}

This approach gives you deterministic, fast tests without spinning URLSession.

Scaling the approach in a large codebase

  • Modularize by API domain:
    • Create one SPM target per backend service (e.g., PaymentsAPI, CatalogAPI, AuthAPI).
    • Each has its own openapi.yaml and Client, keeping compile and generation scopes small.
  • Keep generated code internal:
    • Do not re-export generated types across modules; keep a repository facade and domain models per feature.
  • Build performance:
    • The plugin runs at build time; incremental builds are generally fast if the spec is stable.
    • If the spec changes infrequently, consider building a small “API generation” scheme on CI to validate before developers update locally.
  • Continuous Integration:
    • Run regular builds to catch spec drift.
    • Add a job that lints the OpenAPI (e.g., with Spectral) before merging.
    • If you serve the spec from a URL, gate merges on fetching and validating the latest version.
  • Backward compatibility:
    • If your backend produces breaking changes, version the spec (e.g., v1, v2).
    • Run two modules concurrently during migration; deprecate and remove once client code completes.

Production-readiness checklist

  • Auth
    • Use middleware for bearer tokens; refresh tokens on 401 if applicable.
    • Avoid leaking tokens in logs.
  • Security
    • TLS by default with URLSessionTransport.
    • Implement SSL pinning via URLSessionDelegate if mandated.
  • Errors
    • Model typed error responses in the spec; map them to domain errors once (in repositories).
    • Handle .undocumented responses to avoid silent failures on new status codes.
  • Retries
    • Retry idempotent requests on 429/5xx with exponential backoff and jitter.
    • Don’t retry non-idempotent writes unless designed.
  • Timeouts
    • Set reasonable request/resource timeouts per feature (e.g., search vs. background sync).
  • Observability
    • Add request IDs and correlation headers if your backend supports them.
    • Log with levels and sanitize PII.
  • Performance
    • Prefer async/await over Combine for request concurrency.
    • Reuse URLSession; tune caching and request coalescing where it helps.
  • App Store
    • Avoid using private APIs; generated code uses public Apple frameworks.
    • Scrub any verbose logging in Release builds.

Common pitfalls and troubleshooting

  • The plugin doesn’t run or no generated code appears:
    • Ensure the target includes .plugin(name: "OpenAPIGenerator", package: "swift-openapi-generator").
    • Make sure your openapi.yaml/openapi.json lives in the target’s source directory (or configure the plugin with a config file).
    • Clean build (Xcode: Product > Clean Build Folder).
  • “No such module OpenAPIRuntime”:
    • Add .product(name: "OpenAPIRuntime", package: "swift-openapi-generator") and .product(name: "OpenAPIURLSession", package: "swift-openapi-generator") to target dependencies.
  • Invalid or unsupported OpenAPI:
    • Validate with a linter (Spectral). Fix schema issues early (e.g., missing content, incorrect schema).
  • Decoding errors:
    • Mismatched Content-Type or schema. Ensure backend returns application/json if that’s what the spec says.
    • Watch out for anyOf/oneOf - model them carefully and test representative payloads.
  • Operation names don’t match expectations:
    • Add or fix operationId to stabilize function names.
  • Auth not applied:
    • Verify middleware order and that tokens are available when building requests.
    • If using OpenAPI security schemes, ensure the spec accurately declares them.

Frequently Asked Questions About Swift OpenAPI Generator

  • Is this an “Xcode plugin”?
    • No, it is a Swift Package Manager (SPM) build plugin managed by Xcode automatically during builds.
  • Should I commit generated sources?
    • No. The build plugin generates code into your build directory dynamically. Only commit your openapi.yaml spec and config files.
  • Can I use Combine instead of async/await?
    • Yes. While async/await is native to the generated client, you can wrap async calls in custom Future or AnyPublisher wrappers if your app relies on Combine.
  • How do I manage multi-environment configurations (Dev, Staging, Prod)?
    • Pass a dynamic serverURL into your APIEnvironment struct at runtime based on your build targets or launch flags.

Conclusion

This swift openapi generator tutorial showed how to generate a robust, type safe networking swift layer from an OpenAPI spec, integrate it into a modular iOS architecture, and scale it across teams and features. By relying on the swift-openapi-generator xcode plugin (SwiftPM build plugin), you can generate networking layer swift code that stays in sync with your backend, reduces boilerplate, and catches API drift at compile time.

Next steps:

  • Add your project’s real openapi.yaml.
  • Stand up a NetworkingAPI module with URLSession transport, auth, logging, and retries.
  • Wrap the generated client behind repositories and map DTOs to domain models.
  • Add CI validation for the spec and a few high-signal integration tests.

Once in place, you’ll spend less time on plumbing and more time on product - exactly what “openapi client swift ios” adoption should deliver.

Key resources: