Swift
Generate a native Swift client from your routes, using URLSession and Codable.
@kizunajs/swift generates a native Swift client from your Kizuna routes. The generated client uses URLSession and Codable, with no third-party Swift dependencies required.
pnpm add @kizunajs/swift@betabun add @kizunajs/swift@betanpm install @kizunajs/swift@betaAdd it to your config
Point swiftClient at the file you want written:
import { swiftClient } from '@kizunajs/swift';
export default defineConfig({
adapter: expressAdapter(),
routes,
clients: [
swiftClient({
output: './ios/MyApp/Generated/APIClient.swift',
namespace: 'API',
}),
],
});kizuna generate| Option | Description |
|---|---|
output | Where the generated .swift file goes |
namespace | Public enum wrapping all generated types, API by default |
camelCaseProperties | Convert wire field names to camelCase properties with CodingKeys |
unknownEnumCase | Emit enums with an unknown(String) fallback so new server values don't break decoding |
Without the config
kizuna-swift generates one client on its own, for a repository that does not run kizuna generate:
kizuna-swift generate --config kizuna.config.ts --output ios/MyApp/Generated/APIClient.swift --namespace-name API--config takes the path to your kizuna.config.ts, suffixed with an export to read something other than the default, as in src/apis.ts:workspace. --camel-case and --unknown-enum-case are the flag forms of the options above, and --export names the export to read when none is suffixed, api by default. The CLI loads the config at runtime using jiti.
Add it to your project
Add the generated file to your Xcode project or Swift package as a regular source file. It needs no third-party Swift dependencies.
Call a route
let client = APIClient(baseURL: URL(string: "https://api.example.com")!)Request inputs are passed as components named after the route's input groups: .params (path), .body, .query, and .headers:
// object body
let created = try await client.users.createUser(
.body(
name: "Ada",
email: "ada@example.com"
)
)
// path param + header
let user = try await client.users.getUser(
.params(id: "42"),
.headers(xRequestId: "trace-1")
)
// query
let page = try await client.users.listUsers(
.query(page: 1, limit: 20)
)
let all = try await client.users.listUsers()
// discriminated-union body
try await client.sendNotification(
.body(
.email(to: "ada@example.com", subject: "Hi")
)
)Use it in a screen
A schema wrapped in Kizuna.model generates a named type, so a view can name it in its signature and every route that returns a user hands back the same API.User:
import SwiftUI
struct UserRow: View {
let user: API.User
var body: some View {
VStack(alignment: .leading) {
Text(user.name)
Text(user.id)
.font(.caption)
}
}
}struct UserList: View {
@State private var users: [API.User] = []
var body: some View {
List(users, id: \.id) { user in
UserRow(user: user)
}
.task {
if let response = try? await client.users.listUsers() {
users = response.body.users
}
}
}
}Property naming
By default, field names are emitted verbatim, so total_count on the wire stays total_count in the generated type.
Pass --camel-case to convert wire fields to camelCase properties (total_count becomes totalCount), preserving the wire name via CodingKeys.
Unknown enum case (open enums)
By default a z.enum is a closed Swift enum that throws on a wire value it doesn't know, failing the whole response. Pass --unknown-enum-case to make enums forward-compatible: each gains an unknown(String) case, so an unrecognised value decodes to .unknown("newValue") instead of throwing:
switch event.kind {
case .login, .logout, .signup:
render(event)
case .unknown(let raw):
log("skipping unknown event kind: \(raw)")
}The raw value is preserved, so re-encoding writes it back unchanged. Discriminated unions still throw on an unknown discriminator.
Generated client
The generated file exports a Sendable client with methods for each route and sub-clients for grouped routes. It is Sendable, so sharing it across tasks needs no actor hop per request. Each request group is a nested struct with a group-named factory:
import Foundation
public final class APIClient: Sendable {
public let baseURL: URL
public let session: URLSession
public var users: APIUsersClient { ... }
public func listUsers(_ query: APIClient.ListUsers.Query = .query()) async throws(APIClient.ListUsers.Failure) -> APIClient.ListUsers.Result {
// ...
}
public enum ListUsers {
public struct Query: Sendable {
public let page: Int?
public let limit: Int?
public init(page: Int? = nil, limit: Int? = nil) { ... }
public static func query(page: Int? = nil, limit: Int? = nil) -> Self { .init(page: page, limit: limit) }
}
// Response, Result, Failure ...
}
}Cancellation
Failure.cancelled covers a cancelled request, which SwiftUI produces routinely: a view carrying a .task cancels it on disappear, so a screen that loads and then closes arrives in your error path. An error path shared across several routes can check (error as? any KizunaFailure)?.isCancelled instead of matching each route's own enum.
Deprecation
Routes with deprecated and fields with .meta({ deprecated: ... }) emit @available(*, deprecated) in the generated Swift code, with the message when one is set:
@available(*, deprecated)
public func deleteUser(_ params: APIClient.DeleteUser.Params) async throws(APIClient.DeleteUser.Failure) {
// ...
}Streams
A route whose response streams returns its body as an AsyncThrowingStream. Named events become an Event enum with one case per event, each carrying its decoded payload:
let result = try await client.assistant.reply(.body(prompt: "hello"))
for try await event in result.body {
switch event {
case .delta(let delta):
text += delta.text
case .done(let done):
print(done.outputTokens)
}
}A stream of one schema yields that type directly, a text/* stream yields one String per line, and a binary stream yields Data chunks. The status and headers are checked before the method returns, so a 400 throws its Failure as on any route. Cancelling the task that reads the stream closes the connection. A route that mixes a streamed status with another 2xx is left out of the client, with a warning.
Tip: automate with a script
You can add a script to your package.json to regenerate the client whenever your routes change:
{
"scripts": {
"generate:swift": "kizuna-swift generate --config kizuna.config.ts --output ../ios/MyApp/Generated/APIClient.swift --namespace-name API"
}
}