gerald burke

Me, but on the internet.

The Swift OpenAPI Generator is an open-source tool from Apple that takes an OpenAPI spec as input and generates code for server and client applications. OpenAPI is an industry standard format for HTTP-based APIs. To learn more about the specification, see the OpenAPI documentation:

This article describes how to create server and client applications, and connect them with a ReST API defined in an OpenAPI 3.1 spec. Our app will be a Todo app called “Tasks”.

Table of Contents


Overview

In this exercise, we'll make an iOS application and a Vapor server. Each application will depend upon a Swift package encapsulating generated API code.

We'll be using Xcode and Swift Package Manager, so be warned that at various points you might need to clean the project, delete derived data, or restart Xcode to resolve mystery failures.

Setup

Download the API spec from here: Todo OpenAPI 3.1 Spec. It's a gist, so the download will unzip to a directory containing the file you want. Move the file up out of the unzipped directory but leave it in Downloads. We'll be copying it to more than one location.

Create a new directory to contain the libraries and applications for this exercise. These instructions utilize local package dependencies and assume that our packages exist in the same directory as the applications. Open a terminal in your new directory.

Create Client and Server Libraries

First, we need to create two Swift packages.

mkdir TasksAPIClient
cd TasksAPIClient
swift package init
cd ..

mkdir TasksAPIServer
cd TasksAPIServer
swift package init
cd ..

Add OpenAPI Spec

Each package needs a copy of the spec.

cp ~/Downloads/openapi.yaml TasksAPIClient/Sources/TasksAPIClient/.
cp ~/Downloads/openapi.yaml TasksAPIServer/Sources/TasksAPIServer/.

Add Configurations

The swift-openapi-generator tool requires a configuration file in the same directory as the spec. This file must be named openapi-generator-config.yaml.

# TasksAPIClient/Sources/TasksAPIClient/openapi-generator-config.yaml
generate:
  - types
  - client
accessModifier:
  public
# TasksAPIServer/Sources/TasksAPIServer/openapi-generator-config.yaml
generate:
  - types
  - server
accessModifier:
  public

Note the accessModifier option. The default value for this option is internal. We need to use public because the generated code will not be part of our application targets. Instead, our iOS and Vapor applications will access the generated code via the packages we're creating.

Update Package Descriptions

Now that we have our spec and configurations in place, we can update our package manifests. Here's a quick look at what we're adding:

Platform Client Server
macOS 10.15+ 10.15+
iOS 13+ N/A
Products Type Name
Client Library TasksAPIClient
Server Library TasksAPIServer
Package Dependency Client Server
https://github.com/apple/swift-openapi-generator
https://github.com/apple/swift-openapi-runtime
https://github.com/swift-server/swift-openapi-vapor
https://github.com/apple/swift-openapi-urlsession
Target Dependency Client Server
OpenAPIRuntime
OpenAPIVapor
OpenAPIURLSession
Target Plugins Client Server
OpenAPIGenerator

Full package descriptions for your copy-pasting enjoyment:

Client Package Description

let package = Package(
    name: "TasksAPIClient",
    platforms: [.macOS(.v10_15), .iOS(.v13)],
    products: [
        .library(
            name: "TasksAPIClient",
            targets: ["TasksAPIClient"]
        ),
    ],
    dependencies: [
        .package(url: "https://github.com/apple/swift-openapi-generator", from: "1.0.0"),
        .package(url: "https://github.com/apple/swift-openapi-runtime", from: "1.0.0"),
        .package(url: "https://github.com/apple/swift-openapi-urlsession", from: "1.0.0"),
    ],
    targets: [
        .target(
            name: "TasksAPIClient",
            dependencies: [
                .product(name: "OpenAPIRuntime", package: "swift-openapi-runtime"),
                .product(name: "OpenAPIURLSession", package: "swift-openapi-urlsession"),
            ],
            plugins: [
                .plugin(name: "OpenAPIGenerator", package: "swift-openapi-generator"),
            ]
        ),
        .testTarget(
            name: "TasksAPIClientTests",
            dependencies: ["TasksAPIClient"])
        ,
    ]
)

Server Package Description


let package = Package(
    name: "TasksAPIServer",
    platforms: [
        .macOS(.v10_15)
    ],
    products: [
        .library(
            name: "TasksAPIServer",
            targets: ["TasksAPIServer"]
        ),
    ],
    dependencies: [
        .package(url: "https://github.com/apple/swift-openapi-generator", from: "1.0.0"),
        .package(url: "https://github.com/apple/swift-openapi-runtime", from: "1.0.0"),
        .package(url: "https://github.com/swift-server/swift-openapi-vapor", from: "1.0.0"),
    ],
    targets: [
        .target(
            name: "TasksAPIServer",
            dependencies: [
                .product(name: "OpenAPIRuntime", package: "swift-openapi-runtime"),
                .product(name: "OpenAPIVapor", package: "swift-openapi-vapor"),
            ],
            plugins: [
                .plugin(name: "OpenAPIGenerator", package: "swift-openapi-generator"),
            ]
        ),
        .testTarget(
            name: "TasksAPIServerTests",
            dependencies: ["TasksAPIServer"]
        ),
    ]
)

Build Packages

We're ready to build these packages. You can do this in Xcode by hitting ⌘-B but I prefer to use the terminal because it provides better insight into the process.

cd TasksAPIClient && swift build && cd ..

... wait for it ...

cd TasksAPIServer && swift build && cd ..

You'll see a lot of messages in the terminal. First, Swift Package Manager reports progress as it installs dependencies. Then you'll notice OpenAPIGenerator reporting its configuration and output. With these packages built, we're ready to create applications that use them.

On Models

OpenAPIGenerator created model types for us based on the spec we provided but we're going to use those as DTOs and define domain-specific models for our applications. I recommend this approach for a couple of reasons. A domain model is the full definition of a type that may be only partially represented by the API schema for a given request. Many APIs define request and response bodies that only include the information relevant to the particular request (e.g., PATCH requests). And for various reasons like persistence support or backward compatibility, the application may need to define the model in a way that conflicts with the API schema.

Create Server Application

We'll use Vapor to write a server in Swift. If you don't have it installed, follow Vapor's installation instructions

Create a new Vapor application using the default template. Specify options to tell Vapor to generate an index view using the Leaf templating language and to configure the app to use the Fluent ORM backed by a SQLite database.

vapor new Tasks-Web --leaf --fluent.db sqlite
cd Tasks-Web
open Package.swift

File paths in the rest of this section are relative to Tasks-Web unless explicitly marked otherwise.

Try it out

Run the application from Xcode. In the debug pane, you'll see this message: [ NOTICE ] Server starting on http://127.0.0.1:8080

In a terminal, run this:

curl http://localhost:8080/hello

You should see this response:

Hello, world!

You now have a running Vapor server.

Explore the Code

If you're unfamiliar with Vapor, take some time to review the code.

Startup

entrypoint.swift: The main function of the application. This function creates, configures, and runs the application.

configure.swift: Register middleware, database and migrations, and templating engine.

routes.swift: Define routes. This function is invoked from configure.

Models

Model types are stored here. Since we're using Fluent ORM, the generated example conforms to Fluent.Model. We'll use this generated type for persistence but map it to the type generated from the OpenAPI spec when handling ReST requests.

Controllers

Classic MVC controllers. The example controller provides functions for performing CRUD operations. We'll add a controller to this directory for handling ReST API requests.

Migrations

Database migrations. Run these migrations with swift run App migrate.

Resources

This directory contains views. We are using the Leaf templating engine, so the generated view is named index.leaf.

Add ReST API

Set Custom Working Directory

We'll use a local package dependency, so the Vapor app needs a custom working directory. The default working directory of a Vapor application is in DerivedData. If we do not change this, the process will be unable to resolve the relative path to our local package.

  • Select 'Edit Scheme...'
  • Select Run → Options
  • Check the box titled “Use custom working directory:”
  • Select the Vapor application's root directory from the file picker

Update Package

Add a package dependency to Package.swift with the path “../TasksAPIServer”. Then add a target dependency named “TasksAPIServer”.

Server Package Description

let package = Package(
    name: "Tasks-Web",
    platforms: [
       .macOS(.v13)
    ],
    dependencies: [
        // 💧 A server-side Swift web framework.
        .package(url: "https://github.com/vapor/vapor.git", from: "4.89.0"),
        // 🗄 An ORM for SQL and NoSQL databases.
        .package(url: "https://github.com/vapor/fluent.git", from: "4.8.0"),
        // 🪶 Fluent driver for SQLite.
        .package(url: "https://github.com/vapor/fluent-sqlite-driver.git", from: "4.0.0"),
        // 🍃 An expressive, performant, and extensible templating language built for Swift.
        .package(url: "https://github.com/vapor/leaf.git", from: "4.2.4"),
        // 📲 ReST API
        .package(path: "../TasksAPIServer")
    ],
    targets: [
        .executableTarget(
            name: "App",
            dependencies: [
                .product(name: "Fluent", package: "fluent"),
                .product(name: "FluentSQLiteDriver", package: "fluent-sqlite-driver"),
                .product(name: "Leaf", package: "leaf"),
                .product(name: "Vapor", package: "vapor"),
                "TasksAPIServer",
            ]
        ),
        .testTarget(name: "AppTests", dependencies: [
            .target(name: "App"),
            .product(name: "XCTVapor", package: "vapor"),

            // Workaround for https://github.com/apple/swift-package-manager/issues/6940
            .product(name: "Vapor", package: "vapor"),
            .product(name: "Fluent", package: "Fluent"),
            .product(name: "FluentSQLiteDriver", package: "fluent-sqlite-driver"),
            .product(name: "Leaf", package: "leaf"),
        ])
    ]
)

Conform to APIProtocol

Your server application needs a type that conforms to the generated protocol named APIProtocol. For our purpose, we'll call this new type “RestController” and place it in the Controllers directory. Create a new file at Sources/App/Controllers/RestController.swift

import Fluent
import TasksAPIServer
import Vapor

struct RestController: APIProtocol {
    // Expect compile-time errors indicating that 'RestController' does not conform to 'APIProtocol'.
}

Xcode will show you an error with a “Fix” button. If you're lucky, clicking “Fix” will stub out all of the functions required by the protocol. More likely, Xcode will insert one (1) function stub when you click “Fix” and then show another error for missing protocol conformance. Just keep clicking “Fix” until there are no more errors. For now, replace the placeholder in the body of each function with fatalError(). Before we implement the required methods, we need to add a completed property to the Todo model.

final class Todo: Model, Content {
    static let schema = "todos"
    
    @ID(key: .id)
    var id: UUID?

    @Field(key: "title")
    var title: String
    
    @Field(key: "completed")
    var completed: Bool

    init() { }
    
    init(id: UUID? = nil, title: String, completed: Bool) {
        self.id = id
        self.title = title
        self.completed = completed
    }
}

Update the database migration CreateTodo.swift to add this new field. Modify prepare(on database:) to match this:

func prepare(on database: Database) async throws {
    try await database.schema("todos")
        .id()
        .field("title", .string, .required)
        .field("completed", .bool, .required)
        .create()
}

Now run the migration.

swift run App migrate

Back in RestController.swift, add a private extension to Components.Schemas.Todo. We will frequently need to create values of this type from a Todo model.

// MARK: - Private

private extension Components.Schemas.Todo {
    init(todo: Todo) {
        self.init(
            completed: todo.completed,
            description: todo.title,
            id: todo.id?.uuidString
        )
    }
}

Now let's implement the protocol's requirements. I won't go through this step-by-step but I encourage you to take some time and read the function bodies to get a sense of how we query with Fluent, create responses, and handle errors.

Complete RestController

struct RestController: APIProtocol {
    // RestController will store a reference to the app for database access, unlike a
    // typical Vapor controller that uses the request parameter passed to its functions.
    let app: Application
    
    // MARK: - Index
    
    func todosGetAll(_ input: TasksAPIServer.Operations.todosGetAll.Input) async throws -> TasksAPIServer.Operations.todosGetAll.Output {
        let all = try await Todo.query(on: app.db)
            .all()
            .map(Components.Schemas.Todo.init)
        return .ok(TasksAPIServer.Operations.todosGetAll.Output.Ok(body: .json(all)))
    }
    
    // MARK: - Create
    
    func todosCreate(_ input: TasksAPIServer.Operations.todosCreate.Input) async throws -> TasksAPIServer.Operations.todosCreate.Output {
        guard case .json(let dto) = input.body else {
            throw Abort(.badRequest)
        }
        let todo = Todo()
        todo.title = dto.description
        todo.completed = dto.completed ?? false
        try await todo.save(on: app.db)
        return .ok(Operations.todosCreate.Output.Ok(body: .json(Components.Schemas.Todo(todo: todo))))
    }
    
    // MARK: - Read
    
    func todosRead(_ input: TasksAPIServer.Operations.todosRead.Input) async throws -> TasksAPIServer.Operations.todosRead.Output {
        let id = input.path.todoId
        guard let record = try await Todo.find(UUID(id), on: app.db) else {
            throw Abort(.notFound)
        }
        let dto = Components.Schemas.Todo(todo: record)
        return .ok(Operations.todosRead.Output.Ok(body: .json(dto)))
    }
    
    // MARK: - Update
    
    func todosUpdate(_ input: TasksAPIServer.Operations.todosUpdate.Input) async throws -> TasksAPIServer.Operations.todosUpdate.Output {
        guard case .json(let todo) = input.body else {
            throw Abort(.badRequest)
        }
        guard let id = todo.id, let record = try await Todo.find(UUID(id), on: app.db) else {
            throw Abort(.notFound)
        }
        record.title = todo.description
        if let completed = todo.completed {
            record.completed = completed
        }
        try await record.update(on: app.db)
        return .ok(Operations.todosUpdate.Output.Ok(body: .json(Components.Schemas.Todo(todo: record))))
    }
    
    // MARK: - Delete
    
    func todosDelete(_ input: TasksAPIServer.Operations.todosDelete.Input) async throws -> TasksAPIServer.Operations.todosDelete.Output {
        let id = input.path.todoId
        guard let record = try await Todo.find(UUID(id), on: app.db) else {
            throw Abort(.notFound)
        }
        try await record.delete(on: app.db)
        return .noContent(Operations.todosDelete.Output.NoContent())
    }
}

Register RestController

In entrypoint.swift, import OpenAPIVapor and TasksAPIServer. Then add the following lines above try await app.execute():

// Create a Vapor OpenAPI Transport using your application.
let transport = VaporTransport(routesBuilder: app)

// Create an instance of RestController.
let restController = RestController(app: app)

// Call the generated function on your implementation to add its request handlers to the app.
try restController.registerHandlers(on: transport, serverURL: Servers.server1())

Test with cURL

Run your server from Xcode with ⌘-R. Now open a terminal.

First, we'll try to get a list of Todos.

curl http://localhost:8080/todoapp/todo

The response should be an empty array. Let's make our first Todo.

curl --json '{"description": "My First Todo"}' http://localhost:8080/todoapp/todo

The response here is a DTO representation of our newly created Todo. Note that we only provided a description but the backend filled in the identifier and the initial value for completed.

Let's confirm that our entry was persisted. Run the GET request again.

curl http://localhost:8080/todoapp/todo

Now the array is no longer empty and the value it contains matches the one you just created. Yay, it works!

Create Client Application

Open Xcode and create a new iOS application named “Tasks-iOS”. Place it in the same directory that contains your packages.

File paths in the rest of this section are relative to Tasks-iOS unless explicitly marked otherwise.

Add Package Dependency

  • Select Tasks-iOS in the Project Navigator
  • Select Tasks-iOS in the Project section of the settings pane
  • Select the Package Dependencies tab
  • Click the + button under the empty Packages list
  • In the dialog that appears, click the “Add Local...” button
  • In the file picker, select TasksAPIClient and click “Add Package”

You should see a Package Dependencies section in the Project Navigator now. Since this is a Local Package, we also need to manually add it to Target → General → Frameworks, Libraries, and Embedded Content. Press the + button and select the TasksAPIClient framework.

Add Todo Model

Make a new directory at Tasks-iOS/Models and add a new file to that directory named Todo.swift.

import Foundation
import SwiftData

@Model
class Todo {
  var id: UUID?
  var title: String
  var completed: Bool
  init(id: UUID? = nil, title: String, completed: Bool) {
      self.id = id
      self.title = title
      self.completed = completed
  }
}

Now, add the modelContainer(for:) modifier to the application's WindowGroup so our new type is registered for SwiftData. Update Tasks_iOSApp.swift to look like this:

import SwiftUI
import SwiftData
@main
struct Tasks_iOSApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(for: Todo.self)
    }
}

Add an Aggregate Model

An aggregate model is part of the SwiftUI (sometimes called “MV”) architecture. See this article on SwiftUI App Architecture and the WWDC Session “Data Essentials in SwiftUI” for more info. The aggregate model accesses services to fetch and modify content. It conforms to ObservableObject and publishes properties observed by the view layer.

Make a new file at Tasks-iOS/Models/AppModel.swift.

class AppModel: ObservableObject {
    /// A simple wrapper for errors, meant for use as a `Published` property.
    struct Failure: LocalizedError {
        let message: String
        var errorDescription: String? { message }
    }
    /// The Todo items to list
    @Published var todos: [Todo] = []
    /// Errors are wrapped in a Failure and published for presentation
    @Published var failure: Failure?

    /// An instance of the generated Client type
    private var client: Client {
        get throws {
            try TasksAPIClient.Client(
                serverURL: Servers.server1(),
                transport: URLSessionTransport()
            )
        }
    }

    /// This initializer fetches todos so we don't have to 
    /// call getAllTodos from the view's onAppear.
    init() {
        Task {
            await getAllTodos()
        }
    }
   
    @MainActor
    func getAllTodos() {
        Task {
            do {
                todos = try await client.todosGetAll().ok.body.json.map(Todo.init)
            } catch {
                failure = Failure(message: error.localizedDescription)
            }
        }
    }
}

// Add a convenience initializer for the Todo model to create one from a DTO.
private extension Todo {
    convenience init(dto: Components.Schemas.Todo) throws {
        guard let id = dto.id, let uuid = UUID(uuidString: id) else {
            throw Failure(message: "Invalid or missing ID for Todo")
        }
        guard let completed = dto.completed else {
            throw Failure(message: "Todo values from the server should always have a `completed` value.")
        }
        self.init(id: uuid, title: dto.description, completed: completed)
    }
}

To finish our client implementation, add methods to find by id, update, and delete Todos.

Note, these additional methods are not fully tested yet.

Complete AppModel

class AppModel: ObservableObject {
    @Published var todos: [Todo] = []
    
    private var client: Client {
        get throws {
            try TasksAPIClient.Client(
                serverURL: Servers.server1(),
                transport: URLSessionTransport()
            )
        }
    }

    @MainActor
    func getAllTodos() throws {
        Task {
            todos = try await client.todosGetAll().ok.body.json.map(Todo.init)
        }
    }
    
    func createTodo(_ title: String) async throws -> Todo {
        let dto = Components.Schemas.Todo(description: title)
        let input = Operations.todosCreate.Input(body: .json(dto))
        let new = try await client.todosCreate(input).ok.body.json
        return try Todo(dto: new)
    }
    
    func findTodo(_ id: UUID) async throws -> Todo {
        let path = Operations.todosRead.Input.Path(todoId: id.uuidString)
        let input = Operations.todosRead.Input(path: path)
        let dto = try await client.todosRead(input).ok.body.json
        return try Todo(dto: dto)
    }
    
    func updateTodo(_ todo: Todo) async throws -> Todo {
        guard let id = todo.id?.uuidString else {
            throw Failure(message: "Cannot perform this operation on a Todo with no ID.")
        }
        let dto = Components.Schemas.Todo(completed: todo.completed, description: todo.title, id: id)
        let path = Operations.todosUpdate.Input.Path(todoId: id)
        let updated = try await client.todosUpdate(path: path, body: .json(dto)).ok.body.json
        return try Todo(dto: updated)
    }
    
    func deleteTodo(_ todo: Todo) async throws {
        guard let id = todo.id?.uuidString else {
            throw Failure(message: "Cannot perform this operation on a Todo with no ID.")
        }
        _ = try await client.todosDelete(path: .init(todoId: id)).noContent
    }
}

Create User Interface

For UI, we'll just show a list of all Todos. Update ContentView.swift.

struct ContentView: View {
    @EnvironmentObject var appModel: AppModel
    
    var body: some View {
        List {
            ForEach(appModel.todos) { todo in
                Text(todo.title)
            }
        }
    }
}

Finally, we have a couple of things to take care of in Tasks_iOSApp.swift.

  • Inject an instance of AppModel as an EnvironmentObject.
  • Register the Todo model with SwiftData by calling the modelContainer modifier.
@main
struct Tasks_iOSApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .environmentObject(AppModel())
        .modelContainer(for: Todo.self)
    }
}

Make sure your server is still running. Now, build and run the iOS app on the simulator. You should see a list with a single Todo named “My First Todo”. This is the value we added via cURL earlier. You can add another Todo via cURL and re-launch the app to see the content update.

Next Steps

Add a form to the web app for listing and managing Todo items. Add UI to the client app for adding, updating, and deleting Todo items.

Conclusion

In this exercise, we learned how to use swift-openapi-generator to create client and server packages from an OpenAPI 3.1 spec. We also created a server application and a client application that use these packages.