This is an automated email from the ASF dual-hosted git repository.
hubcio pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/iggy.git
The following commit(s) were added to refs/heads/master by this push:
new b8bf85fd9 feat(swift): scaffold the Swift SDK package and CI lanes
(#4190)
b8bf85fd9 is described below
commit b8bf85fd93a56c77b643c67d633b6e7795b428ca
Author: RustToMetal <[email protected]>
AuthorDate: Sat Sep 26 04:22:14 2026 -0500
feat(swift): scaffold the Swift SDK package and CI lanes (#4190)
---
.github/actions/swift/pre-merge/action.yml | 75 +++
.github/actions/utils/setup-swift/action.yml | 56 +++
.github/config/components.yml | 14 +
.github/workflows/_detect.yml | 11 +-
.github/workflows/_test.yml | 8 +
.github/workflows/pre-merge.yml | 21 +-
.gitignore | 8 +
foreign/swift/.swift-format | 22 +
foreign/swift/Package.swift | 41 ++
foreign/swift/README.md | 87 ++++
foreign/swift/Sources/Iggy/Errors/IggyError.swift | 78 ++++
.../swift/Sources/Iggy/Errors/IggyErrorCode.swift | 515 +++++++++++++++++++++
foreign/swift/Sources/Iggy/Iggy.swift | 25 +
.../Sources/Iggy/Utilities/UInt128Value.swift | 156 +++++++
foreign/swift/Sources/Iggy/Wire/ByteCodec.swift | 218 +++++++++
foreign/swift/Tests/IggyTests/ByteCodecTests.swift | 120 +++++
foreign/swift/Tests/IggyTests/ErrorCodeTests.swift | 55 +++
licenserc.toml | 7 +
18 files changed, 1515 insertions(+), 2 deletions(-)
diff --git a/.github/actions/swift/pre-merge/action.yml
b/.github/actions/swift/pre-merge/action.yml
new file mode 100644
index 000000000..02f383ada
--- /dev/null
+++ b/.github/actions/swift/pre-merge/action.yml
@@ -0,0 +1,75 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+name: swift-pre-merge
+description: Swift pre-merge testing github iggy actions
+
+inputs:
+ task:
+ description: "Task to run (lint, test, build, build-macos)"
+ required: true
+
+runs:
+ using: "composite"
+ steps:
+ # The macOS lane uses the Swift that ships with Xcode on the runner.
+ - name: Setup Swift
+ if: inputs.task != 'build-macos'
+ uses: ./.github/actions/utils/setup-swift
+
+ # swift-format ships with the toolchain; the configuration lives in
+ # foreign/swift/.swift-format.
+ - name: Format Check
+ shell: bash
+ if: inputs.task == 'lint'
+ run: |
+ cd foreign/swift
+ swift format lint --strict --recursive Sources Tests Package.swift
+
+ # A warning-free build is the lint gate the Swift compiler provides: the
+ # SDK builds in Swift 6 language mode with strict concurrency checking.
+ - name: Build with warnings as errors
+ shell: bash
+ if: inputs.task == 'lint'
+ run: |
+ cd foreign/swift
+ swift build -Xswiftc -warnings-as-errors
+
+ - name: Test
+ shell: bash
+ if: inputs.task == 'test'
+ run: |
+ cd foreign/swift
+ swift test --parallel
+
+ - name: Build
+ shell: bash
+ if: inputs.task == 'build'
+ run: |
+ cd foreign/swift
+ swift build -c release
+
+ # The manifest declares the Apple platforms, so one lane compiles and tests
+ # on macOS with Xcode's toolchain rather than trusting the Linux build.
+ - name: Build and test on macOS
+ shell: bash
+ if: inputs.task == 'build-macos'
+ run: |
+ cd foreign/swift
+ swift --version
+ swift build -Xswiftc -warnings-as-errors
+ swift test --parallel
diff --git a/.github/actions/utils/setup-swift/action.yml
b/.github/actions/utils/setup-swift/action.yml
new file mode 100644
index 000000000..02d07c20f
--- /dev/null
+++ b/.github/actions/utils/setup-swift/action.yml
@@ -0,0 +1,56 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+name: setup-swift
+description: Install the Swift toolchain and cache the SwiftPM dependency
checkouts and build products
+inputs:
+ swift-version:
+ description: "Swift toolchain version to install"
+ required: false
+ default: "6.1"
+ cache-key:
+ description: "Cache key segment naming the package that builds in the job;
one constant per package so every lane shares one cache"
+ required: false
+ default: "sdk"
+
+runs:
+ using: "composite"
+ steps:
+ - name: Install Swift
+ uses: swift-actions/setup-swift@v2
+ with:
+ swift-version: ${{ inputs.swift-version }}
+
+ - name: Print Swift version
+ shell: bash
+ run: swift --version
+
+ # Package.resolved pins the dependency graph, so it keys the cache; the
+ # build directories hold the compiled dependencies, and SwiftPM's
+ # incremental build reuses them across source changes.
+ - name: Cache SwiftPM dependencies and build products
+ uses: actions/[email protected]
+ with:
+ path: |
+ foreign/swift/.build/checkouts
+ foreign/swift/.build/repositories
+ foreign/swift/.build/artifacts
+ foreign/swift/.build/debug
+ foreign/swift/.build/release
+ key: swiftpm-${{ runner.os }}-${{ inputs.swift-version }}-${{
inputs.cache-key }}-${{ hashFiles('foreign/swift/Package.resolved') }}
+ restore-keys: |
+ swiftpm-${{ runner.os }}-${{ inputs.swift-version }}-${{
inputs.cache-key }}-
diff --git a/.github/config/components.yml b/.github/config/components.yml
index 9ed475e6c..eb77a9764 100644
--- a/.github/config/components.yml
+++ b/.github/config/components.yml
@@ -336,6 +336,20 @@ components:
- "foreign/cpp/**"
tasks: ["lint", "build", "test"]
+ sdk-swift:
+ depends_on:
+ - "rust-sdk" # Swift SDK depends on core SDK
+ - "rust-server" # For integration tests
+ - "ci-infrastructure" # CI changes trigger full regression
+ paths:
+ - "foreign/swift/**"
+ - "bdd/swift/**"
+ - "examples/swift/**"
+ # The SDK compiles against the VSR wire contract, so a change to the
+ # protocol crate must rerun it.
+ - "core/binary_protocol/**"
+ tasks: ["lint", "test", "build", "build-macos"]
+
# Shared BDD infrastructure that affects all BDD tests
bdd-infrastructure:
paths:
diff --git a/.github/workflows/_detect.yml b/.github/workflows/_detect.yml
index a3add24f8..c60e27ee5 100644
--- a/.github/workflows/_detect.yml
+++ b/.github/workflows/_detect.yml
@@ -44,6 +44,9 @@ on:
cpp_matrix:
description: "Matrix for C++ SDK"
value: ${{ jobs.detect.outputs.cpp_matrix }}
+ swift_matrix:
+ description: "Matrix for Swift SDK"
+ value: ${{ jobs.detect.outputs.swift_matrix }}
bdd_matrix:
description: "Matrix for BDD tests"
value: ${{ jobs.detect.outputs.bdd_matrix }}
@@ -69,6 +72,7 @@ jobs:
java_matrix: ${{ steps.mk.outputs.java_matrix }}
csharp_matrix: ${{ steps.mk.outputs.csharp_matrix }}
cpp_matrix: ${{ steps.mk.outputs.cpp_matrix }}
+ swift_matrix: ${{ steps.mk.outputs.swift_matrix }}
bdd_matrix: ${{ steps.mk.outputs.bdd_matrix }}
examples_matrix: ${{ steps.mk.outputs.examples_matrix }}
other_matrix: ${{ steps.mk.outputs.other_matrix }}
@@ -237,7 +241,7 @@ jobs:
console.log(`Total files changed: ${files.length}`);
}
- const groups = { rust:[], python:[], php:[], node:[], go:[],
java:[], csharp:[], cpp:[], bdd:[], examples:[], other:[] };
+ const groups = { rust:[], python:[], php:[], node:[], go:[],
java:[], csharp:[], cpp:[], swift:[], bdd:[], examples:[], other:[] };
// Process affected components and generate tasks
console.log('');
@@ -266,6 +270,7 @@ jobs:
else if (name === 'sdk-java') groups.java.push(...entries);
else if (name === 'sdk-csharp') groups.csharp.push(...entries);
else if (name === 'sdk-cpp') groups.cpp.push(...entries);
+ else if (name === 'sdk-swift') groups.swift.push(...entries);
else if (name.startsWith('bdd-')) {
// Individual BDD tests should run separately with proper
Docker setup
groups.bdd.push(...entries);
@@ -313,6 +318,7 @@ jobs:
groups.java = [];
groups.csharp = [];
groups.cpp = [];
+ groups.swift = [];
groups.bdd = [];
groups.examples = [];
groups.other = [];
@@ -328,6 +334,7 @@ jobs:
else if (name === 'sdk-java') groups.java.push(...entries);
else if (name === 'sdk-csharp') groups.csharp.push(...entries);
else if (name === 'sdk-cpp') groups.cpp.push(...entries);
+ else if (name === 'sdk-swift') groups.swift.push(...entries);
else if (name.startsWith('bdd-')) groups.bdd.push(...entries);
else if (name.startsWith('examples-'))
groups.examples.push(...entries);
else groups.other.push(...entries);
@@ -365,6 +372,7 @@ jobs:
{ name: 'Java SDK', tasks: groups.java },
{ name: 'C# SDK', tasks: groups.csharp },
{ name: 'C++ SDK', tasks: groups.cpp },
+ { name: 'Swift SDK', tasks: groups.swift },
{ name: 'BDD Tests', tasks: groups.bdd },
{ name: 'Examples', tasks: groups.examples },
{ name: 'Other', tasks: groups.other }
@@ -398,6 +406,7 @@ jobs:
setOutput('java_matrix', JSON.stringify(matrix(groups.java)));
setOutput('csharp_matrix', JSON.stringify(matrix(groups.csharp)));
setOutput('cpp_matrix', JSON.stringify(matrix(groups.cpp)));
+ setOutput('swift_matrix', JSON.stringify(matrix(groups.swift)));
setOutput('bdd_matrix', JSON.stringify(matrix(groups.bdd)));
setOutput('examples_matrix',
JSON.stringify(matrix(groups.examples)));
setOutput('other_matrix', JSON.stringify(matrix(groups.other)));
diff --git a/.github/workflows/_test.yml b/.github/workflows/_test.yml
index 58ec5a3cc..4af5337f9 100644
--- a/.github/workflows/_test.yml
+++ b/.github/workflows/_test.yml
@@ -43,6 +43,7 @@ jobs:
runs-on: ${{
(inputs.task == 'build-aarch64-gnu' || inputs.task ==
'build-aarch64-musl') && 'ubuntu-24.04-arm' ||
(inputs.task == 'build-macos-aarch64' || inputs.task ==
'clippy-macos-aarch64') && 'macos-14' ||
+ inputs.task == 'build-macos' && 'macos-15' ||
inputs.task == 'build-windows-sdk' && 'windows-latest' ||
'ubuntu-latest'
}}
@@ -231,6 +232,13 @@ jobs:
with:
task: ${{ inputs.task }}
+ # Swift SDK
+ - name: Run Swift SDK task
+ if: inputs.component == 'sdk-swift'
+ uses: ./.github/actions/swift/pre-merge
+ with:
+ task: ${{ inputs.task }}
+
- name: Upload Java coverage to Codecov
if: inputs.component == 'sdk-java' && inputs.task == 'test'
uses: codecov/[email protected]
diff --git a/.github/workflows/pre-merge.yml b/.github/workflows/pre-merge.yml
index 196755934..c7e4a9034 100644
--- a/.github/workflows/pre-merge.yml
+++ b/.github/workflows/pre-merge.yml
@@ -166,6 +166,19 @@ jobs:
component: ${{ matrix.component }}
task: ${{ matrix.task }}
+ # Swift SDK
+ test-swift:
+ name: Swift • ${{ matrix.task }}
+ needs: detect
+ if: ${{ fromJson(needs.detect.outputs.swift_matrix).include[0].component
!= 'noop' }}
+ strategy:
+ fail-fast: true
+ matrix: ${{ fromJson(needs.detect.outputs.swift_matrix) }}
+ uses: ./.github/workflows/_test.yml
+ with:
+ component: ${{ matrix.component }}
+ task: ${{ matrix.task }}
+
# Other components
test-other:
name: Other • ${{ matrix.component }}/${{ matrix.task }}
@@ -208,7 +221,7 @@ jobs:
# Final status check
finalize_pr:
runs-on: ubuntu-24.04-arm
- needs: [common, detect, test-rust, test-python, test-php, test-node,
test-go, test-java, test-csharp, test-cpp, test-bdd, test-examples, test-other]
+ needs: [common, detect, test-rust, test-python, test-php, test-node,
test-go, test-java, test-csharp, test-cpp, test-swift, test-bdd, test-examples,
test-other]
if: ${{ !cancelled() }}
steps:
- name: Get job execution times
@@ -292,6 +305,7 @@ jobs:
const java = findJobInfo('Java •');
const csharp = findJobInfo('C# •');
const cpp = findJobInfo('C++ •');
+ const swift = findJobInfo('Swift •');
const bdd = findJobInfo('BDD •');
const examples = findJobInfo('Examples •');
const other = findJobInfo('Other •');
@@ -313,6 +327,7 @@ jobs:
core.setOutput('java_time', formatJobDuration(java));
core.setOutput('csharp_time', formatJobDuration(csharp));
core.setOutput('cpp_time', formatJobDuration(cpp));
+ core.setOutput('swift_time', formatJobDuration(swift));
core.setOutput('bdd_time', formatJobDuration(bdd));
core.setOutput('examples_time', formatJobDuration(examples));
core.setOutput('other_time', formatJobDuration(other));
@@ -404,6 +419,7 @@ jobs:
java_status=$(format_status "${{ needs.test-java.result }}" "${{
steps.times.outputs.java_time }}")
csharp_status=$(format_status "${{ needs.test-csharp.result }}" "${{
steps.times.outputs.csharp_time }}")
cpp_status=$(format_status "${{ needs.test-cpp.result }}" "${{
steps.times.outputs.cpp_time }}")
+ swift_status=$(format_status "${{ needs.test-swift.result }}" "${{
steps.times.outputs.swift_time }}")
bdd_status=$(format_status "${{ needs.test-bdd.result }}" "${{
steps.times.outputs.bdd_time }}")
examples_status=$(format_status "${{ needs.test-examples.result }}"
"${{ steps.times.outputs.examples_time }}")
other_status=$(format_status "${{ needs.test-other.result }}" "${{
steps.times.outputs.other_time }}")
@@ -416,6 +432,7 @@ jobs:
echo "| ☕ Java | $java_status | ${{ steps.times.outputs.java_time }}
|" >> "$GITHUB_STEP_SUMMARY"
echo "| 🔷 C# | $csharp_status | ${{ steps.times.outputs.csharp_time
}} |" >> "$GITHUB_STEP_SUMMARY"
echo "| 🔶 C++ | $cpp_status | ${{ steps.times.outputs.cpp_time }} |"
>> "$GITHUB_STEP_SUMMARY"
+ echo "| 🐦 Swift | $swift_status | ${{ steps.times.outputs.swift_time
}} |" >> "$GITHUB_STEP_SUMMARY"
echo "| 🧪 BDD | $bdd_status | ${{ steps.times.outputs.bdd_time }} |"
>> "$GITHUB_STEP_SUMMARY"
echo "| 📚 Examples | $examples_status | ${{
steps.times.outputs.examples_time }} |" >> "$GITHUB_STEP_SUMMARY"
echo "| 📦 Other | $other_status | ${{ steps.times.outputs.other_time
}} |" >> "$GITHUB_STEP_SUMMARY"
@@ -433,6 +450,7 @@ jobs:
[[ "${{ needs.test-java.result }}" == "failure" ]] || \
[[ "${{ needs.test-csharp.result }}" == "failure" ]] || \
[[ "${{ needs.test-cpp.result }}" == "failure" ]] || \
+ [[ "${{ needs.test-swift.result }}" == "failure" ]] || \
[[ "${{ needs.test-bdd.result }}" == "failure" ]] || \
[[ "${{ needs.test-examples.result }}" == "failure" ]] || \
[[ "${{ needs.test-other.result }}" == "failure" ]]; then
@@ -449,6 +467,7 @@ jobs:
[[ "${{ needs.test-java.result }}" == "cancelled" ]] || \
[[ "${{ needs.test-csharp.result }}" == "cancelled" ]] || \
[[ "${{ needs.test-cpp.result }}" == "cancelled" ]] || \
+ [[ "${{ needs.test-swift.result }}" == "cancelled" ]] || \
[[ "${{ needs.test-bdd.result }}" == "cancelled" ]] || \
[[ "${{ needs.test-examples.result }}" == "cancelled" ]] || \
[[ "${{ needs.test-other.result }}" == "cancelled" ]]; then
diff --git a/.gitignore b/.gitignore
index 4d402ce50..4c5ecbe94 100644
--- a/.gitignore
+++ b/.gitignore
@@ -24,6 +24,14 @@ foreign/cpp/build
foreign/java/**/build
foreign/java/**/out
foreign/node/**/dist
+foreign/swift/.build
+foreign/swift/.swiftpm
+examples/swift/.build
+examples/swift/**/.build
+examples/swift/**/.swiftpm
+bdd/swift/.build
+bdd/swift/**/.build
+bdd/swift/**/.swiftpm
foreign/node/npm-debug.log*
foreign/node/.npm
**/node_modules
diff --git a/foreign/swift/.swift-format b/foreign/swift/.swift-format
new file mode 100644
index 000000000..2580675bb
--- /dev/null
+++ b/foreign/swift/.swift-format
@@ -0,0 +1,22 @@
+{
+ "version": 1,
+ "lineLength": 160,
+ "indentation": { "spaces": 4 },
+ "maximumBlankLines": 1,
+ "respectsExistingLineBreaks": true,
+ "lineBreakBeforeControlFlowKeywords": false,
+ "lineBreakBeforeEachArgument": false,
+ "prioritizeKeepingFunctionOutputTogether": true,
+ "rules": {
+ "AlwaysUseLowerCamelCase": false,
+ "AmbiguousTrailingClosureOverload": true,
+ "NeverForceUnwrap": false,
+ "NeverUseForceTry": false,
+ "NeverUseImplicitlyUnwrappedOptionals": false,
+ "OrderedImports": true,
+ "UseLetInEveryBoundCaseVariable": false,
+ "NoBlockComments": true,
+ "OmitExplicitReturns": false,
+ "ValidateDocumentationComments": false
+ }
+}
diff --git a/foreign/swift/Package.swift b/foreign/swift/Package.swift
new file mode 100644
index 000000000..c644c9a27
--- /dev/null
+++ b/foreign/swift/Package.swift
@@ -0,0 +1,41 @@
+// swift-tools-version: 6.0
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+import PackageDescription
+
+let package = Package(
+ name: "apache-iggy",
+ platforms: [
+ .macOS(.v13),
+ .iOS(.v16),
+ .tvOS(.v16),
+ .watchOS(.v9),
+ .visionOS(.v1),
+ ],
+ products: [
+ .library(name: "Iggy", targets: ["Iggy"])
+ ],
+ targets: [
+ .target(name: "Iggy"),
+ .testTarget(
+ name: "IggyTests",
+ dependencies: ["Iggy"]
+ ),
+ ],
+ swiftLanguageModes: [.v6]
+)
diff --git a/foreign/swift/README.md b/foreign/swift/README.md
new file mode 100644
index 000000000..1b2d243d4
--- /dev/null
+++ b/foreign/swift/README.md
@@ -0,0 +1,87 @@
+<div align="center">
+ <picture>
+ <source media="(prefers-color-scheme: dark)"
srcset="https://raw.githubusercontent.com/apache/iggy/refs/heads/master/assets/logo/SVG/iggy-apache-color-darkbg.svg">
+ <source media="(prefers-color-scheme: light)"
srcset="https://raw.githubusercontent.com/apache/iggy/refs/heads/master/assets/logo/SVG/iggy-apache-color-lightbg.svg">
+ <img alt="Apache Iggy"
src="https://raw.githubusercontent.com/apache/iggy/refs/heads/master/assets/logo/SVG/iggy-apache-color-lightbg.svg"
width="320">
+ </picture>
+</div>
+
+# Swift SDK for Iggy
+
+Official Swift client SDK for [Apache Iggy](https://iggy.apache.org) message
streaming.
+
+The SDK is built up in stages. This stage carries the package scaffold, the
error table,
+and the byte codec the wire protocol is encoded with. The protocol layer, the
TCP and TLS
+client, the producer and consumer, examples, and BDD scenarios follow in later
changes.
+
+## Requirements
+
+- Swift 6.0 or later (Swift 6 language mode, strict concurrency)
+- macOS 13, iOS 16, tvOS 16, watchOS 9, visionOS 1, or Linux with a Swift 6
toolchain
+
+## Installation
+
+Swift Package Manager fetches a package from the root of a git repository, so
the SDK
+cannot be depended on straight from this monorepo's subdirectory. Add it
through the
+package mirror, which carries every release as a `v<version>` tag:
+
+```swift
+// Package.swift
+dependencies: [
+ .package(url: "https://github.com/apache/iggy-swift.git", from: "0.1.0")
+],
+targets: [
+ .target(name: "MyApp", dependencies: [.product(name: "Iggy", package:
"iggy-swift")])
+]
+```
+
+From a checkout of this repository, or a vendored copy, depend on it by path:
+
+```swift
+dependencies: [
+ .package(name: "apache-iggy", path: "../iggy/foreign/swift")
+],
+targets: [
+ .target(name: "MyApp", dependencies: [.product(name: "Iggy", package:
"apache-iggy")])
+]
+```
+
+## Usage
+
+Every operation of the SDK throws `IggyError`, a value carrying the typed
`code`
+(`IggyErrorCode`, one case per server error code), the raw wire code, and
optional
+context:
+
+```swift
+import Iggy
+
+func handle(_ error: IggyError) {
+ switch error.code {
+ case .streamNameNotFound:
+ print("create the stream first")
+ default:
+ print("iggy failed: \(error)")
+ }
+}
+```
+
+The client that raises these errors lands in the next changes of the series;
see the
+[Rust SDK](../../core/sdk) for the operations it mirrors.
+
+## Testing
+
+```bash
+cd foreign/swift
+swift test
+```
+
+## Contributing
+
+Format the sources with the toolchain's formatter before opening a pull
request; CI runs it
+in lint mode and builds with warnings as errors:
+
+```bash
+swift format format --in-place --recursive Sources Tests Package.swift
+swift format lint --strict --recursive Sources Tests Package.swift
+swift build -Xswiftc -warnings-as-errors
+```
diff --git a/foreign/swift/Sources/Iggy/Errors/IggyError.swift
b/foreign/swift/Sources/Iggy/Errors/IggyError.swift
new file mode 100644
index 000000000..96d4460bc
--- /dev/null
+++ b/foreign/swift/Sources/Iggy/Errors/IggyError.swift
@@ -0,0 +1,78 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+/// Error raised by every SDK operation.
+///
+/// The `code` is what the server answered with, or what the SDK decided
+/// locally (a dropped connection, a rejected argument). Match on it:
+///
+/// ```swift
+/// do {
+/// _ = try await client.getStream("orders")
+/// } catch let error as IggyError where error.code == .streamNameNotFound {
+/// // create it
+/// }
+/// ```
+///
+/// `context` carries whatever extra detail was available where the error was
+/// raised, such as the transport failure message or the protocol window the
+/// server accepts. It never carries credentials, and it takes no part in
+/// equality: two errors are equal when their codes are, as in the Rust SDK.
+public struct IggyError: Error, Sendable, Hashable, CustomStringConvertible {
+ /// The classified code.
+ public let code: IggyErrorCode
+ /// The exact code that arrived on the wire. Equal to `code.rawValue`
unless
+ /// the server answered with a code this SDK does not know, in which case
+ /// `code` is ``IggyErrorCode/error`` and this keeps the original value.
+ public let rawCode: UInt32
+ /// Extra detail about the failure, when any was available.
+ public let context: String?
+
+ public init(_ code: IggyErrorCode, context: String? = nil) {
+ self.code = code
+ self.rawCode = code.rawValue
+ self.context = context
+ }
+
+ /// Maps a code received from the server, keeping unknown codes visible.
+ public init(wireCode: UInt32) {
+ if let code = IggyErrorCode(rawValue: wireCode) {
+ self.code = code
+ self.context = nil
+ } else {
+ self.code = .error
+ self.context = "unknown error code \(wireCode)"
+ }
+ self.rawCode = wireCode
+ }
+
+ public var description: String {
+ if let context {
+ return "\(code.name) (\(rawCode)): \(context)"
+ }
+ return "\(code.name) (\(rawCode))"
+ }
+
+ public static func == (lhs: IggyError, rhs: IggyError) -> Bool {
+ lhs.code == rhs.code && lhs.rawCode == rhs.rawCode
+ }
+
+ public func hash(into hasher: inout Hasher) {
+ hasher.combine(code)
+ hasher.combine(rawCode)
+ }
+}
diff --git a/foreign/swift/Sources/Iggy/Errors/IggyErrorCode.swift
b/foreign/swift/Sources/Iggy/Errors/IggyErrorCode.swift
new file mode 100644
index 000000000..1b6940d1f
--- /dev/null
+++ b/foreign/swift/Sources/Iggy/Errors/IggyErrorCode.swift
@@ -0,0 +1,515 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+// Every discriminant the server can answer with, transcribed from
+// `core/common/src/error/iggy_error.rs`. The golden-vector generator that
+// follows in a later change dumps the same table and the tests compare.
+
+/// Numeric error code shared with every other Apache Iggy SDK and the server.
+///
+/// The raw value is the code that travels on the wire. Codes are stable across
+/// releases; a gap in the numbering is a retired code, never free space.
+public enum IggyErrorCode: UInt32, Sendable, Hashable, CaseIterable, Codable {
+ case error = 1
+ case invalidConfiguration = 2
+ case invalidCommand = 3
+ case invalidFormat = 4
+ case featureUnavailable = 5
+ case invalidIdentifier = 6
+ case invalidVersion = 7
+ case disconnected = 8
+ case cannotEstablishConnection = 9
+ case cannotCreateBaseDirectory = 10
+ case cannotCreateRuntimeDirectory = 11
+ case cannotRemoveRuntimeDirectory = 12
+ case cannotCreateStateDirectory = 13
+ case stateFileNotFound = 14
+ case stateFileCorrupted = 15
+ case invalidStateEntryChecksum = 16
+ case cannotOpenDatabase = 19
+ case resourceNotFound = 20
+ case cannotCloseWebSocketConnection = 21
+ case staleClient = 30
+ case tcpError = 31
+ case quicError = 32
+ case invalidServerAddress = 33
+ case invalidClientAddress = 34
+ case invalidIpAddress = 35
+ case httpError = 36
+ case invalidApiUrl = 37
+ case unauthenticated = 40
+ case unauthorized = 41
+ case invalidCredentials = 42
+ case invalidUsername = 43
+ case invalidPassword = 44
+ case invalidUserStatus = 45
+ case userAlreadyExists = 46
+ case userInactive = 47
+ case cannotDeleteUser = 48
+ case cannotChangePermissions = 49
+ case invalidPersonalAccessTokenName = 50
+ case personalAccessTokenAlreadyExists = 51
+ case personalAccessTokensLimitReached = 52
+ case invalidPersonalAccessToken = 53
+ case personalAccessTokenExpired = 54
+ case usersLimitReached = 55
+ case invalidPersonalAccessTokenExpiry = 56
+ case transientNotCommitted = 57
+ case transientNotAccepted = 58
+ case requestAlreadyApplied = 59
+ case notConnected = 61
+ case clientShutdown = 63
+ case invalidTlsDomain = 64
+ case invalidTlsCertificatePath = 65
+ case invalidTlsCertificate = 66
+ case failedToAddCertificate = 67
+ case invalidEncryptionKey = 70
+ case cannotEncryptData = 71
+ case cannotDecryptData = 72
+ case invalidJwtAlgorithm = 73
+ case invalidJwtSecret = 74
+ case jwtMissing = 75
+ case cannotGenerateJwt = 76
+ case accessTokenMissing = 77
+ case invalidAccessToken = 78
+ case cannotFetchJwks = 79
+ case invalidSizeBytes = 80
+ case invalidUtf8 = 81
+ case invalidNumberEncoding = 82
+ case invalidBooleanValue = 83
+ case invalidNumberValue = 84
+ case clientNotFound = 100
+ case invalidClientId = 101
+ case connectionClosed = 206
+ case cannotParseHeaderKind = 209
+ case httpResponseError = 300
+ case invalidHttpRequest = 301
+ case invalidJsonResponse = 302
+ case invalidBytesResponse = 303
+ case emptyResponse = 304
+ case cannotCreateEndpoint = 305
+ case cannotParseUrl = 306
+ case webSocketError = 400
+ case webSocketConnectionError = 401
+ case webSocketCloseError = 402
+ case webSocketReceiveError = 403
+ case webSocketSendError = 404
+ case cannotCreateStreamsDirectory = 1000
+ case cannotCreateStreamDirectory = 1001
+ case cannotCreateStreamInfo = 1002
+ case cannotUpdateStreamInfo = 1003
+ case cannotOpenStreamInfo = 1004
+ case cannotReadStreamInfo = 1005
+ case cannotCreateStream = 1006
+ case cannotDeleteStream = 1007
+ case cannotDeleteStreamDirectory = 1008
+ case streamIdNotFound = 1009
+ case streamNameNotFound = 1010
+ case streamDirectoryNotFound = 1011
+ case streamNameAlreadyExists = 1012
+ case invalidStreamName = 1013
+ case invalidStreamId = 1014
+ case cannotReadStreams = 1015
+ case invalidTopicSize = 1019
+ case tooManyStreams = 1020
+ case cannotCreateTopicsDirectory = 2000
+ case cannotCreateTopicDirectory = 2001
+ case cannotCreateTopicInfo = 2002
+ case cannotUpdateTopicInfo = 2003
+ case cannotOpenTopicInfo = 2004
+ case cannotReadTopicInfo = 2005
+ case cannotCreateTopic = 2006
+ case cannotDeleteTopic = 2007
+ case cannotDeleteTopicDirectory = 2008
+ case cannotPollTopic = 2009
+ case topicIdNotFound = 2010
+ case topicNameNotFound = 2011
+ case topicNameAlreadyExists = 2013
+ case invalidTopicName = 2014
+ case tooManyPartitions = 2015
+ case invalidTopicId = 2016
+ case cannotReadTopics = 2017
+ case invalidReplicationFactor = 2018
+ case invalidPartitionsCount = 2019
+ case topicDirectoryNotFound = 2020
+ case tooManyTopics = 2021
+ case cannotCreatePartition = 3000
+ case cannotCreatePartitionsDirectory = 3001
+ case cannotCreatePartitionDirectory = 3002
+ case cannotOpenPartitionLogFile = 3003
+ case cannotReadPartitions = 3004
+ case cannotDeletePartition = 3005
+ case cannotDeletePartitionDirectory = 3006
+ case partitionNotFound = 3007
+ case noPartitions = 3008
+ case topicFull = 3009
+ case cannotDeleteConsumerOffsetsDirectory = 3010
+ case cannotDeleteConsumerOffsetFile = 3011
+ case cannotCreateConsumerOffsetsDirectory = 3012
+ case partitionIdSpaceExhausted = 3013
+ case cannotReadConsumerOffsets = 3020
+ case consumerOffsetNotFound = 3021
+ case notResolvedConsumer = 3022
+ case cannotOpenConsumerOffsetsFile = 3023
+ case tooManyConsumerOffsets = 3024
+ case segmentNotFound = 4000
+ case segmentClosed = 4001
+ case invalidSegmentSize = 4002
+ case cannotCreateSegmentLogFile = 4003
+ case cannotCreateSegmentIndexFile = 4004
+ case cannotCreateSegmentTimeIndexFile = 4005
+ case cannotSaveMessagesToSegment = 4006
+ case cannotSaveIndexToSegment = 4007
+ case cannotSaveTimeIndexToSegment = 4008
+ case invalidMessagesCount = 4009
+ case cannotAppendMessage = 4010
+ case cannotReadMessage = 4011
+ case cannotReadMessageId = 4012
+ case cannotReadMessageState = 4013
+ case cannotReadMessageTimestamp = 4014
+ case cannotReadHeadersLength = 4015
+ case cannotReadHeadersPayload = 4016
+ case tooBigUserHeaders = 4017
+ case invalidHeaderKey = 4018
+ case invalidHeaderValue = 4019
+ case cannotReadMessageLength = 4020
+ case cannotReadMessagePayload = 4021
+ case tooBigMessagePayload = 4022
+ case tooManyMessages = 4023
+ case emptyMessagePayload = 4024
+ case invalidMessagePayloadLength = 4025
+ case cannotReadMessageChecksum = 4026
+ case invalidMessageChecksum = 4027
+ case invalidKeyValueLength = 4028
+ case commandLengthError = 4029
+ case invalidSegmentsCount = 4030
+ case nonZeroOffset = 4031
+ case nonZeroTimestamp = 4032
+ case missingIndex = 4033
+ case invalidIndexesByteSize = 4034
+ case invalidIndexesCount = 4035
+ case invalidMessagesSize = 4036
+ case tooSmallMessage = 4037
+ case invalidMessageTimestampDelta = 4038
+ case invalidBatchChecksum = 4039
+ case invalidHeaderKind = 4040
+ case unsupportedOptionKey = 4041
+ case invalidOptionValue = 4042
+ case optionsBlockTooLarge = 4043
+ case cannotSendMessagesDueToClientDisconnection = 4050
+ case backgroundSendError = 4051
+ case backgroundSendTimeout = 4052
+ case backgroundSendBufferFull = 4053
+ case backgroundWorkerDisconnected = 4054
+ case backgroundSendBufferOverflow = 4055
+ case producerSendFailed = 4056
+ case producerClosed = 4057
+ case invalidOffset = 4100
+ case invalidReservedField = 4101
+ case segmentSizeMismatchAtOpen = 4102
+ case consumerGroupIdNotFound = 5000
+ case invalidConsumerGroupId = 5002
+ case consumerGroupNameNotFound = 5003
+ case consumerGroupNameAlreadyExists = 5004
+ case invalidConsumerGroupName = 5005
+ case consumerGroupMemberNotFound = 5006
+ case cannotCreateConsumerGroupInfo = 5007
+ case cannotDeleteConsumerGroupInfo = 5008
+ case consumerGroupPartitionNotOwned = 5009
+ case missingBaseOffsetRetainedMessageBatch = 6000
+ case missingLastOffsetDeltaRetainedMessageBatch = 6001
+ case missingMaxTimestampRetainedMessageBatch = 6002
+ case missingLengthRetainedMessageBatch = 6003
+ case missingPayloadRetainedMessageBatch = 6004
+ case cannotReadBatchBaseOffset = 7000
+ case cannotReadBatchLength = 7001
+ case cannotReadLastOffsetDelta = 7002
+ case cannotReadMaxTimestamp = 7003
+ case cannotReadBatchPayload = 7004
+ case invalidConnectionString = 8000
+ case snapshotFileCompletionFailed = 9000
+ case cannotSerializeResource = 10000
+ case cannotDeserializeResource = 10001
+ case cannotReadFile = 10002
+ case cannotReadFileMetadata = 10003
+ case cannotSeekFile = 10004
+ case cannotAppendToFile = 10005
+ case cannotWriteToFile = 10006
+ case cannotOverwriteFile = 10007
+ case cannotDeleteFile = 10008
+ case cannotSyncFile = 10009
+ case cannotReadIndexOffset = 10010
+ case cannotReadIndexPosition = 10011
+ case cannotReadIndexTimestamp = 10012
+ case timestampOutOfRange = 10013
+ case shardNotFound = 11000
+ case shardCommunicationError = 11001
+ case cannotBindToSocket = 12000
+ case taskTimeout = 12001
+ case ioError = 13000
+ case alreadyAuthenticated = 14000
+ case invalidSession = 14001
+ case incompatibleProtocolVersion = 14003
+}
+
+extension IggyErrorCode {
+ /// Snake-case name of the code, identical to the Rust SDK spelling.
+ public var name: String {
+ switch self {
+ case .error: "error"
+ case .invalidConfiguration: "invalid_configuration"
+ case .invalidCommand: "invalid_command"
+ case .invalidFormat: "invalid_format"
+ case .featureUnavailable: "feature_unavailable"
+ case .invalidIdentifier: "invalid_identifier"
+ case .invalidVersion: "invalid_version"
+ case .disconnected: "disconnected"
+ case .cannotEstablishConnection: "cannot_establish_connection"
+ case .cannotCreateBaseDirectory: "cannot_create_base_directory"
+ case .cannotCreateRuntimeDirectory: "cannot_create_runtime_directory"
+ case .cannotRemoveRuntimeDirectory: "cannot_remove_runtime_directory"
+ case .cannotCreateStateDirectory: "cannot_create_state_directory"
+ case .stateFileNotFound: "state_file_not_found"
+ case .stateFileCorrupted: "state_file_corrupted"
+ case .invalidStateEntryChecksum: "invalid_state_entry_checksum"
+ case .cannotOpenDatabase: "cannot_open_database"
+ case .resourceNotFound: "resource_not_found"
+ case .cannotCloseWebSocketConnection:
"cannot_close_web_socket_connection"
+ case .staleClient: "stale_client"
+ case .tcpError: "tcp_error"
+ case .quicError: "quic_error"
+ case .invalidServerAddress: "invalid_server_address"
+ case .invalidClientAddress: "invalid_client_address"
+ case .invalidIpAddress: "invalid_ip_address"
+ case .httpError: "http_error"
+ case .invalidApiUrl: "invalid_api_url"
+ case .unauthenticated: "unauthenticated"
+ case .unauthorized: "unauthorized"
+ case .invalidCredentials: "invalid_credentials"
+ case .invalidUsername: "invalid_username"
+ case .invalidPassword: "invalid_password"
+ case .invalidUserStatus: "invalid_user_status"
+ case .userAlreadyExists: "user_already_exists"
+ case .userInactive: "user_inactive"
+ case .cannotDeleteUser: "cannot_delete_user"
+ case .cannotChangePermissions: "cannot_change_permissions"
+ case .invalidPersonalAccessTokenName:
"invalid_personal_access_token_name"
+ case .personalAccessTokenAlreadyExists:
"personal_access_token_already_exists"
+ case .personalAccessTokensLimitReached:
"personal_access_tokens_limit_reached"
+ case .invalidPersonalAccessToken: "invalid_personal_access_token"
+ case .personalAccessTokenExpired: "personal_access_token_expired"
+ case .usersLimitReached: "users_limit_reached"
+ case .invalidPersonalAccessTokenExpiry:
"invalid_personal_access_token_expiry"
+ case .transientNotCommitted: "transient_not_committed"
+ case .transientNotAccepted: "transient_not_accepted"
+ case .requestAlreadyApplied: "request_already_applied"
+ case .notConnected: "not_connected"
+ case .clientShutdown: "client_shutdown"
+ case .invalidTlsDomain: "invalid_tls_domain"
+ case .invalidTlsCertificatePath: "invalid_tls_certificate_path"
+ case .invalidTlsCertificate: "invalid_tls_certificate"
+ case .failedToAddCertificate: "failed_to_add_certificate"
+ case .invalidEncryptionKey: "invalid_encryption_key"
+ case .cannotEncryptData: "cannot_encrypt_data"
+ case .cannotDecryptData: "cannot_decrypt_data"
+ case .invalidJwtAlgorithm: "invalid_jwt_algorithm"
+ case .invalidJwtSecret: "invalid_jwt_secret"
+ case .jwtMissing: "jwt_missing"
+ case .cannotGenerateJwt: "cannot_generate_jwt"
+ case .accessTokenMissing: "access_token_missing"
+ case .invalidAccessToken: "invalid_access_token"
+ case .cannotFetchJwks: "cannot_fetch_jwks"
+ case .invalidSizeBytes: "invalid_size_bytes"
+ case .invalidUtf8: "invalid_utf8"
+ case .invalidNumberEncoding: "invalid_number_encoding"
+ case .invalidBooleanValue: "invalid_boolean_value"
+ case .invalidNumberValue: "invalid_number_value"
+ case .clientNotFound: "client_not_found"
+ case .invalidClientId: "invalid_client_id"
+ case .connectionClosed: "connection_closed"
+ case .cannotParseHeaderKind: "cannot_parse_header_kind"
+ case .httpResponseError: "http_response_error"
+ case .invalidHttpRequest: "invalid_http_request"
+ case .invalidJsonResponse: "invalid_json_response"
+ case .invalidBytesResponse: "invalid_bytes_response"
+ case .emptyResponse: "empty_response"
+ case .cannotCreateEndpoint: "cannot_create_endpoint"
+ case .cannotParseUrl: "cannot_parse_url"
+ case .webSocketError: "web_socket_error"
+ case .webSocketConnectionError: "web_socket_connection_error"
+ case .webSocketCloseError: "web_socket_close_error"
+ case .webSocketReceiveError: "web_socket_receive_error"
+ case .webSocketSendError: "web_socket_send_error"
+ case .cannotCreateStreamsDirectory: "cannot_create_streams_directory"
+ case .cannotCreateStreamDirectory: "cannot_create_stream_directory"
+ case .cannotCreateStreamInfo: "cannot_create_stream_info"
+ case .cannotUpdateStreamInfo: "cannot_update_stream_info"
+ case .cannotOpenStreamInfo: "cannot_open_stream_info"
+ case .cannotReadStreamInfo: "cannot_read_stream_info"
+ case .cannotCreateStream: "cannot_create_stream"
+ case .cannotDeleteStream: "cannot_delete_stream"
+ case .cannotDeleteStreamDirectory: "cannot_delete_stream_directory"
+ case .streamIdNotFound: "stream_id_not_found"
+ case .streamNameNotFound: "stream_name_not_found"
+ case .streamDirectoryNotFound: "stream_directory_not_found"
+ case .streamNameAlreadyExists: "stream_name_already_exists"
+ case .invalidStreamName: "invalid_stream_name"
+ case .invalidStreamId: "invalid_stream_id"
+ case .cannotReadStreams: "cannot_read_streams"
+ case .invalidTopicSize: "invalid_topic_size"
+ case .tooManyStreams: "too_many_streams"
+ case .cannotCreateTopicsDirectory: "cannot_create_topics_directory"
+ case .cannotCreateTopicDirectory: "cannot_create_topic_directory"
+ case .cannotCreateTopicInfo: "cannot_create_topic_info"
+ case .cannotUpdateTopicInfo: "cannot_update_topic_info"
+ case .cannotOpenTopicInfo: "cannot_open_topic_info"
+ case .cannotReadTopicInfo: "cannot_read_topic_info"
+ case .cannotCreateTopic: "cannot_create_topic"
+ case .cannotDeleteTopic: "cannot_delete_topic"
+ case .cannotDeleteTopicDirectory: "cannot_delete_topic_directory"
+ case .cannotPollTopic: "cannot_poll_topic"
+ case .topicIdNotFound: "topic_id_not_found"
+ case .topicNameNotFound: "topic_name_not_found"
+ case .topicNameAlreadyExists: "topic_name_already_exists"
+ case .invalidTopicName: "invalid_topic_name"
+ case .tooManyPartitions: "too_many_partitions"
+ case .invalidTopicId: "invalid_topic_id"
+ case .cannotReadTopics: "cannot_read_topics"
+ case .invalidReplicationFactor: "invalid_replication_factor"
+ case .invalidPartitionsCount: "invalid_partitions_count"
+ case .topicDirectoryNotFound: "topic_directory_not_found"
+ case .tooManyTopics: "too_many_topics"
+ case .cannotCreatePartition: "cannot_create_partition"
+ case .cannotCreatePartitionsDirectory:
"cannot_create_partitions_directory"
+ case .cannotCreatePartitionDirectory:
"cannot_create_partition_directory"
+ case .cannotOpenPartitionLogFile: "cannot_open_partition_log_file"
+ case .cannotReadPartitions: "cannot_read_partitions"
+ case .cannotDeletePartition: "cannot_delete_partition"
+ case .cannotDeletePartitionDirectory:
"cannot_delete_partition_directory"
+ case .partitionNotFound: "partition_not_found"
+ case .noPartitions: "no_partitions"
+ case .topicFull: "topic_full"
+ case .cannotDeleteConsumerOffsetsDirectory:
"cannot_delete_consumer_offsets_directory"
+ case .cannotDeleteConsumerOffsetFile:
"cannot_delete_consumer_offset_file"
+ case .cannotCreateConsumerOffsetsDirectory:
"cannot_create_consumer_offsets_directory"
+ case .partitionIdSpaceExhausted: "partition_id_space_exhausted"
+ case .cannotReadConsumerOffsets: "cannot_read_consumer_offsets"
+ case .consumerOffsetNotFound: "consumer_offset_not_found"
+ case .notResolvedConsumer: "not_resolved_consumer"
+ case .cannotOpenConsumerOffsetsFile:
"cannot_open_consumer_offsets_file"
+ case .tooManyConsumerOffsets: "too_many_consumer_offsets"
+ case .segmentNotFound: "segment_not_found"
+ case .segmentClosed: "segment_closed"
+ case .invalidSegmentSize: "invalid_segment_size"
+ case .cannotCreateSegmentLogFile: "cannot_create_segment_log_file"
+ case .cannotCreateSegmentIndexFile: "cannot_create_segment_index_file"
+ case .cannotCreateSegmentTimeIndexFile:
"cannot_create_segment_time_index_file"
+ case .cannotSaveMessagesToSegment: "cannot_save_messages_to_segment"
+ case .cannotSaveIndexToSegment: "cannot_save_index_to_segment"
+ case .cannotSaveTimeIndexToSegment: "cannot_save_time_index_to_segment"
+ case .invalidMessagesCount: "invalid_messages_count"
+ case .cannotAppendMessage: "cannot_append_message"
+ case .cannotReadMessage: "cannot_read_message"
+ case .cannotReadMessageId: "cannot_read_message_id"
+ case .cannotReadMessageState: "cannot_read_message_state"
+ case .cannotReadMessageTimestamp: "cannot_read_message_timestamp"
+ case .cannotReadHeadersLength: "cannot_read_headers_length"
+ case .cannotReadHeadersPayload: "cannot_read_headers_payload"
+ case .tooBigUserHeaders: "too_big_user_headers"
+ case .invalidHeaderKey: "invalid_header_key"
+ case .invalidHeaderValue: "invalid_header_value"
+ case .cannotReadMessageLength: "cannot_read_message_length"
+ case .cannotReadMessagePayload: "cannot_read_message_payload"
+ case .tooBigMessagePayload: "too_big_message_payload"
+ case .tooManyMessages: "too_many_messages"
+ case .emptyMessagePayload: "empty_message_payload"
+ case .invalidMessagePayloadLength: "invalid_message_payload_length"
+ case .cannotReadMessageChecksum: "cannot_read_message_checksum"
+ case .invalidMessageChecksum: "invalid_message_checksum"
+ case .invalidKeyValueLength: "invalid_key_value_length"
+ case .commandLengthError: "command_length_error"
+ case .invalidSegmentsCount: "invalid_segments_count"
+ case .nonZeroOffset: "non_zero_offset"
+ case .nonZeroTimestamp: "non_zero_timestamp"
+ case .missingIndex: "missing_index"
+ case .invalidIndexesByteSize: "invalid_indexes_byte_size"
+ case .invalidIndexesCount: "invalid_indexes_count"
+ case .invalidMessagesSize: "invalid_messages_size"
+ case .tooSmallMessage: "too_small_message"
+ case .invalidMessageTimestampDelta: "invalid_message_timestamp_delta"
+ case .invalidBatchChecksum: "invalid_batch_checksum"
+ case .invalidHeaderKind: "invalid_header_kind"
+ case .unsupportedOptionKey: "unsupported_option_key"
+ case .invalidOptionValue: "invalid_option_value"
+ case .optionsBlockTooLarge: "options_block_too_large"
+ case .cannotSendMessagesDueToClientDisconnection:
"cannot_send_messages_due_to_client_disconnection"
+ case .backgroundSendError: "background_send_error"
+ case .backgroundSendTimeout: "background_send_timeout"
+ case .backgroundSendBufferFull: "background_send_buffer_full"
+ case .backgroundWorkerDisconnected: "background_worker_disconnected"
+ case .backgroundSendBufferOverflow: "background_send_buffer_overflow"
+ case .producerSendFailed: "producer_send_failed"
+ case .producerClosed: "producer_closed"
+ case .invalidOffset: "invalid_offset"
+ case .invalidReservedField: "invalid_reserved_field"
+ case .segmentSizeMismatchAtOpen: "segment_size_mismatch_at_open"
+ case .consumerGroupIdNotFound: "consumer_group_id_not_found"
+ case .invalidConsumerGroupId: "invalid_consumer_group_id"
+ case .consumerGroupNameNotFound: "consumer_group_name_not_found"
+ case .consumerGroupNameAlreadyExists:
"consumer_group_name_already_exists"
+ case .invalidConsumerGroupName: "invalid_consumer_group_name"
+ case .consumerGroupMemberNotFound: "consumer_group_member_not_found"
+ case .cannotCreateConsumerGroupInfo:
"cannot_create_consumer_group_info"
+ case .cannotDeleteConsumerGroupInfo:
"cannot_delete_consumer_group_info"
+ case .consumerGroupPartitionNotOwned:
"consumer_group_partition_not_owned"
+ case .missingBaseOffsetRetainedMessageBatch:
"missing_base_offset_retained_message_batch"
+ case .missingLastOffsetDeltaRetainedMessageBatch:
"missing_last_offset_delta_retained_message_batch"
+ case .missingMaxTimestampRetainedMessageBatch:
"missing_max_timestamp_retained_message_batch"
+ case .missingLengthRetainedMessageBatch:
"missing_length_retained_message_batch"
+ case .missingPayloadRetainedMessageBatch:
"missing_payload_retained_message_batch"
+ case .cannotReadBatchBaseOffset: "cannot_read_batch_base_offset"
+ case .cannotReadBatchLength: "cannot_read_batch_length"
+ case .cannotReadLastOffsetDelta: "cannot_read_last_offset_delta"
+ case .cannotReadMaxTimestamp: "cannot_read_max_timestamp"
+ case .cannotReadBatchPayload: "cannot_read_batch_payload"
+ case .invalidConnectionString: "invalid_connection_string"
+ case .snapshotFileCompletionFailed: "snapshot_file_completion_failed"
+ case .cannotSerializeResource: "cannot_serialize_resource"
+ case .cannotDeserializeResource: "cannot_deserialize_resource"
+ case .cannotReadFile: "cannot_read_file"
+ case .cannotReadFileMetadata: "cannot_read_file_metadata"
+ case .cannotSeekFile: "cannot_seek_file"
+ case .cannotAppendToFile: "cannot_append_to_file"
+ case .cannotWriteToFile: "cannot_write_to_file"
+ case .cannotOverwriteFile: "cannot_overwrite_file"
+ case .cannotDeleteFile: "cannot_delete_file"
+ case .cannotSyncFile: "cannot_sync_file"
+ case .cannotReadIndexOffset: "cannot_read_index_offset"
+ case .cannotReadIndexPosition: "cannot_read_index_position"
+ case .cannotReadIndexTimestamp: "cannot_read_index_timestamp"
+ case .timestampOutOfRange: "timestamp_out_of_range"
+ case .shardNotFound: "shard_not_found"
+ case .shardCommunicationError: "shard_communication_error"
+ case .cannotBindToSocket: "cannot_bind_to_socket"
+ case .taskTimeout: "task_timeout"
+ case .ioError: "io_error"
+ case .alreadyAuthenticated: "already_authenticated"
+ case .invalidSession: "invalid_session"
+ case .incompatibleProtocolVersion: "incompatible_protocol_version"
+ }
+ }
+}
diff --git a/foreign/swift/Sources/Iggy/Iggy.swift
b/foreign/swift/Sources/Iggy/Iggy.swift
new file mode 100644
index 000000000..bddbbbe7e
--- /dev/null
+++ b/foreign/swift/Sources/Iggy/Iggy.swift
@@ -0,0 +1,25 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+/// Version identity the SDK sends to the server during the login handshake.
+public enum IggyVersion {
+ /// Name of this SDK as reported to the server.
+ public static let sdkName = "swift-sdk"
+ /// Version of this SDK as reported to the server. Provisional until the
+ /// release wiring lands; the release tooling reads this constant.
+ public static let sdkVersion = "0.1.0"
+}
diff --git a/foreign/swift/Sources/Iggy/Utilities/UInt128Value.swift
b/foreign/swift/Sources/Iggy/Utilities/UInt128Value.swift
new file mode 100644
index 000000000..0aaf5fcbc
--- /dev/null
+++ b/foreign/swift/Sources/Iggy/Utilities/UInt128Value.swift
@@ -0,0 +1,156 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+import Foundation
+
+/// An unsigned 128-bit integer split into two 64-bit halves.
+///
+/// Message identifiers and 128-bit user headers travel as `u128` on the wire.
+/// The standard library's `UInt128` is only available from macOS 15 / iOS 18,
+/// so the SDK carries its own representation that works on every supported
+/// platform. `low` holds the least significant 64 bits.
+public struct UInt128Value: Sendable, Hashable, Codable {
+ public var low: UInt64
+ public var high: UInt64
+
+ public init(low: UInt64, high: UInt64) {
+ self.low = low
+ self.high = high
+ }
+
+ public init(_ value: UInt64) {
+ self.init(low: value, high: 0)
+ }
+
+ /// The 16 bytes of a UUID interpreted as a little-endian integer, which is
+ /// how the Rust SDK derives message identifiers from `Uuid::to_u128_le`.
+ public init(uuid: UUID) {
+ (low, high) = withUnsafeBytes(of: uuid.uuid) { raw in
+ (UInt64(littleEndian: raw.loadUnaligned(as: UInt64.self)),
UInt64(littleEndian: raw.loadUnaligned(fromByteOffset: 8, as: UInt64.self)))
+ }
+ }
+
+ public static let zero = UInt128Value(low: 0, high: 0)
+
+ /// A random identifier, never zero.
+ public static func random() -> UInt128Value {
+ var generator = SystemRandomNumberGenerator()
+ var value = UInt128Value(low: generator.next(), high: generator.next())
+ if value == .zero {
+ value.low = 1
+ }
+ return value
+ }
+
+ public var isZero: Bool { low == 0 && high == 0 }
+
+ /// Little-endian wire bytes: the low half first, then the high half.
+ public var littleEndianBytes: [UInt8] {
+ withUnsafeBytes(of: (low.littleEndian, high.littleEndian)) { Array($0)
}
+ }
+
+ /// Decodes 16 little-endian bytes. `bytes` must hold exactly 16 bytes;
+ /// any other length is a programming error and traps, since every
+ /// caller slices a bounds-checked buffer.
+ public init(littleEndianBytes bytes: ArraySlice<UInt8>) {
+ precondition(bytes.count == 16, "a 128-bit value needs exactly 16
bytes")
+ (low, high) = bytes.withUnsafeBytes { raw in
+ (UInt64(littleEndian: raw.loadUnaligned(as: UInt64.self)),
UInt64(littleEndian: raw.loadUnaligned(fromByteOffset: 8, as: UInt64.self)))
+ }
+ }
+
+ /// The value reinterpreted as a UUID (bytes in little-endian order).
+ public var uuid: UUID {
+ withUnsafeBytes(of: (low.littleEndian, high.littleEndian)) { raw in
+ UUID(uuid: raw.load(as: uuid_t.self))
+ }
+ }
+}
+
+extension UInt128Value: ExpressibleByIntegerLiteral {
+ public init(integerLiteral value: UInt64) {
+ self.init(value)
+ }
+}
+
+extension UInt128Value: Comparable {
+ public static func < (lhs: UInt128Value, rhs: UInt128Value) -> Bool {
+ if lhs.high != rhs.high {
+ return lhs.high < rhs.high
+ }
+ return lhs.low < rhs.low
+ }
+}
+
+extension UInt128Value: CustomStringConvertible {
+ /// Decimal rendering, matching how the Rust SDK prints a `u128`.
+ public var description: String {
+ if high == 0 {
+ return String(low)
+ }
+ var digits: [UInt8] = []
+ var currentHigh = high
+ var currentLow = low
+ while currentHigh != 0 || currentLow != 0 {
+ let (quotientHigh, remainderHigh) =
currentHigh.quotientAndRemainder(dividingBy: 10)
+ let (quotientLow, remainderLow) = UInt64.divide(high:
remainderHigh, low: currentLow, by: 10)
+ digits.append(UInt8(remainderLow))
+ currentHigh = quotientHigh
+ currentLow = quotientLow
+ }
+ return String(digits.reversed().map { Character(String($0)) })
+ }
+
+ /// Parses the decimal rendering produced by ``description``.
+ public init?(_ text: String) {
+ guard !text.isEmpty, text.utf8.allSatisfy({ $0 >= 48 && $0 <= 57 })
else {
+ return nil
+ }
+ var value = UInt128Value.zero
+ for digit in text.utf8 {
+ let (mulHigh, mulLow, mulOverflow) = value.multipliedBy10()
+ if mulOverflow {
+ return nil
+ }
+ let (low, carry) = mulLow.addingReportingOverflow(UInt64(digit -
48))
+ let (high, overflow) = mulHigh.addingReportingOverflow(carry ? 1 :
0)
+ if overflow {
+ return nil
+ }
+ value = UInt128Value(low: low, high: high)
+ }
+ self = value
+ }
+
+ private func multipliedBy10() -> (high: UInt64, low: UInt64, overflow:
Bool) {
+ let (lowHigh, lowLow) = low.multipliedFullWidth(by: 10)
+ let (highHigh, highLow) = high.multipliedFullWidth(by: 10)
+ let (newHigh, carry) = highLow.addingReportingOverflow(lowHigh)
+ return (newHigh, lowLow, highHigh != 0 || carry)
+ }
+}
+
+extension UInt64 {
+ /// Divides the 128-bit value `high:low` by `divisor`, assuming the
quotient
+ /// fits in 64 bits (`high < divisor`).
+ fileprivate static func divide(high: UInt64, low: UInt64, by divisor:
UInt64) -> (quotient: UInt64, remainder: UInt64) {
+ divisor.dividingFullWidth((high: high, low: low))
+ }
+}
+
+/// Identifier of a message, unique within a partition.
+public typealias MessageID = UInt128Value
diff --git a/foreign/swift/Sources/Iggy/Wire/ByteCodec.swift
b/foreign/swift/Sources/Iggy/Wire/ByteCodec.swift
new file mode 100644
index 000000000..6233b1cef
--- /dev/null
+++ b/foreign/swift/Sources/Iggy/Wire/ByteCodec.swift
@@ -0,0 +1,218 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+import Foundation
+
+/// Failure while encoding or decoding wire bytes. Internal to the codec; the
+/// protocol layer maps it onto an ``IggyError`` where a reply is decoded.
+enum WireError: Error, Equatable {
+ case truncated(offset: Int, need: Int, have: Int)
+ case invalidUTF8(offset: Int)
+ case validation(String)
+}
+
+/// Little-endian append-only encoder over a byte array.
+///
+/// Pass the encoded size to ``init(capacity:)`` when it is known up front,
+/// as the batch encoder does, so the buffer is allocated once.
+struct ByteWriter {
+ private(set) var bytes: [UInt8]
+
+ init(capacity: Int = 0) {
+ bytes = []
+ bytes.reserveCapacity(capacity)
+ }
+
+ mutating func write(_ value: UInt8) {
+ bytes.append(value)
+ }
+
+ mutating func write(_ value: Bool) {
+ bytes.append(value ? 1 : 0)
+ }
+
+ mutating func write(_ value: UInt16) {
+ withUnsafeBytes(of: value.littleEndian) { bytes.append(contentsOf: $0)
}
+ }
+
+ mutating func write(_ value: UInt32) {
+ withUnsafeBytes(of: value.littleEndian) { bytes.append(contentsOf: $0)
}
+ }
+
+ mutating func write(_ value: UInt64) {
+ withUnsafeBytes(of: value.littleEndian) { bytes.append(contentsOf: $0)
}
+ }
+
+ mutating func write(_ value: Float) {
+ write(value.bitPattern)
+ }
+
+ mutating func write(_ value: UInt128Value) {
+ write(value.low)
+ write(value.high)
+ }
+
+ mutating func write(_ value: [UInt8]) {
+ bytes.append(contentsOf: value)
+ }
+
+ mutating func write(_ value: ArraySlice<UInt8>) {
+ bytes.append(contentsOf: value)
+ }
+
+ mutating func write(_ value: String) {
+ bytes.append(contentsOf: value.utf8)
+ }
+
+ /// `[len: u8][utf8]`, the layout of every wire name, which must be 1 to
+ /// 255 bytes long.
+ mutating func writeName(_ value: String) throws {
+ let length = value.utf8.count
+ guard length >= 1, length <= Int(UInt8.max) else {
+ throw WireError.validation("wire name must be 1-255 bytes, got
\(length)")
+ }
+ bytes.append(UInt8(length))
+ bytes.append(contentsOf: value.utf8)
+ }
+
+ /// `[len: u32][utf8]`, the layout of longer free-form strings.
+ mutating func writeLongString(_ value: String) throws {
+ guard let length = UInt32(exactly: value.utf8.count) else {
+ throw WireError.validation("string of \(value.utf8.count) bytes
exceeds the u32 length prefix")
+ }
+ write(length)
+ bytes.append(contentsOf: value.utf8)
+ }
+}
+
+/// Little-endian cursor over a byte slice. Every read is bounds-checked and
+/// fails with ``WireError/truncated(offset:need:have:)`` instead of trapping,
+/// because the bytes come from the network.
+struct ByteReader {
+ let bytes: ArraySlice<UInt8>
+ private(set) var offset: Int
+
+ init(_ bytes: ArraySlice<UInt8>) {
+ self.bytes = bytes
+ self.offset = bytes.startIndex
+ }
+
+ init(_ bytes: [UInt8]) {
+ self.init(bytes[...])
+ }
+
+ var isAtEnd: Bool { offset >= bytes.endIndex }
+ var remaining: Int { bytes.endIndex - offset }
+ /// Position relative to the start of the buffer handed to the reader.
+ var position: Int { offset - bytes.startIndex }
+
+ private func require(_ count: Int) throws {
+ if remaining < count {
+ throw WireError.truncated(offset: position, need: count, have:
remaining)
+ }
+ }
+
+ mutating func readUInt8() throws -> UInt8 {
+ try require(1)
+ let value = bytes[offset]
+ offset += 1
+ return value
+ }
+
+ mutating func readBool() throws -> Bool {
+ try readUInt8() != 0
+ }
+
+ mutating func readUInt16() throws -> UInt16 {
+ UInt16(littleEndianBytes: try readBytes(2))
+ }
+
+ mutating func readUInt32() throws -> UInt32 {
+ UInt32(littleEndianBytes: try readBytes(4))
+ }
+
+ mutating func readUInt64() throws -> UInt64 {
+ UInt64(littleEndianBytes: try readBytes(8))
+ }
+
+ mutating func readFloat() throws -> Float {
+ Float(bitPattern: try readUInt32())
+ }
+
+ mutating func readUInt128() throws -> UInt128Value {
+ let low = try readUInt64()
+ let high = try readUInt64()
+ return UInt128Value(low: low, high: high)
+ }
+
+ mutating func readBytes(_ count: Int) throws -> ArraySlice<UInt8> {
+ try require(count)
+ let slice = bytes[offset..<offset + count]
+ offset += count
+ return slice
+ }
+
+ mutating func readString(_ count: Int) throws -> String {
+ let start = position
+ let slice = try readBytes(count)
+ guard let value = String(bytes: slice, encoding: .utf8) else {
+ throw WireError.invalidUTF8(offset: start)
+ }
+ return value
+ }
+
+ /// `[len: u8][utf8]` with the 1...255 byte bound every wire name carries.
+ mutating func readName() throws -> String {
+ let length = Int(try readUInt8())
+ if length == 0 {
+ throw WireError.validation("wire name must be 1-255 bytes, got 0")
+ }
+ return try readString(length)
+ }
+
+ /// `[len: u32][utf8]`.
+ mutating func readLongString() throws -> String {
+ let declared = try readUInt32()
+ // A length that does not fit the platform's Int cannot be in the
+ // buffer either; report it as truncation rather than trapping.
+ guard let length = Int(exactly: declared) else {
+ throw WireError.truncated(offset: position, need: Int.max, have:
remaining)
+ }
+ return try readString(length)
+ }
+}
+
+extension UInt16 {
+ /// Decodes the first two bytes of `bytes`, which the caller has
bounds-checked.
+ init(littleEndianBytes bytes: ArraySlice<UInt8>) {
+ self = bytes.withUnsafeBytes { UInt16(littleEndian:
$0.loadUnaligned(as: UInt16.self)) }
+ }
+}
+
+extension UInt32 {
+ /// Decodes the first four bytes of `bytes`, which the caller has
bounds-checked.
+ init(littleEndianBytes bytes: ArraySlice<UInt8>) {
+ self = bytes.withUnsafeBytes { UInt32(littleEndian:
$0.loadUnaligned(as: UInt32.self)) }
+ }
+}
+
+extension UInt64 {
+ /// Decodes the first eight bytes of `bytes`, which the caller has
bounds-checked.
+ init(littleEndianBytes bytes: ArraySlice<UInt8>) {
+ self = bytes.withUnsafeBytes { UInt64(littleEndian:
$0.loadUnaligned(as: UInt64.self)) }
+ }
+}
diff --git a/foreign/swift/Tests/IggyTests/ByteCodecTests.swift
b/foreign/swift/Tests/IggyTests/ByteCodecTests.swift
new file mode 100644
index 000000000..e293cc2f7
--- /dev/null
+++ b/foreign/swift/Tests/IggyTests/ByteCodecTests.swift
@@ -0,0 +1,120 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+import Foundation
+import Testing
+
+@testable import Iggy
+
+@Suite("Byte codec")
+struct ByteCodecTests {
+ @Test func integersRoundTripLittleEndian() throws {
+ var writer = ByteWriter()
+ writer.write(UInt8(0xAB))
+ writer.write(UInt16(0x0102))
+ writer.write(UInt32(0x0304_0506))
+ writer.write(UInt64(0x0708_090A_0B0C_0D0E))
+ writer.write(UInt128Value(low: 1, high: 2))
+ writer.write(Float(1.5))
+ writer.write(true)
+ try writer.writeName("ab")
+ try writer.writeLongString("cd")
+ #expect(writer.bytes[0..<7] == [0xAB, 0x02, 0x01, 0x06, 0x05, 0x04,
0x03])
+
+ var reader = ByteReader(writer.bytes)
+ #expect(try reader.readUInt8() == 0xAB)
+ #expect(try reader.readUInt16() == 0x0102)
+ #expect(try reader.readUInt32() == 0x0304_0506)
+ #expect(try reader.readUInt64() == 0x0708_090A_0B0C_0D0E)
+ #expect(try reader.readUInt128() == UInt128Value(low: 1, high: 2))
+ #expect(try reader.readFloat() == 1.5)
+ #expect(try reader.readBool())
+ #expect(try reader.readName() == "ab")
+ #expect(try reader.readLongString() == "cd")
+ #expect(reader.isAtEnd)
+ }
+
+ @Test func truncatedReadsFailInsteadOfTrapping() {
+ var reader = ByteReader([1, 2, 3])
+ #expect(throws: WireError.truncated(offset: 0, need: 4, have: 3)) {
+ try reader.readUInt32()
+ }
+ #expect(throws: WireError.self) {
+ try reader.readBytes(4)
+ }
+ var name = ByteReader([5, 0x61])
+ #expect(throws: WireError.self) {
+ try name.readName()
+ }
+ var empty = ByteReader([0])
+ #expect(throws: WireError.self) {
+ try empty.readName()
+ }
+ }
+
+ @Test func namesOutsideTheWireBoundAreRejected() {
+ var writer = ByteWriter()
+ #expect(throws: WireError.self) { try writer.writeName("") }
+ #expect(throws: WireError.self) { try
writer.writeName(String(repeating: "x", count: 256)) }
+ #expect(writer.bytes.isEmpty)
+ #expect(throws: Never.self) { try writer.writeName(String(repeating:
"x", count: 255)) }
+ #expect(writer.bytes.count == 256)
+ }
+
+ @Test func invalidUTF8IsRejected() {
+ var reader = ByteReader([2, 0xFF, 0xFE])
+ #expect(throws: WireError.invalidUTF8(offset: 1)) {
+ try reader.readName()
+ }
+ }
+
+ @Test func slicesReadRelativeToTheirStart() throws {
+ let bytes: [UInt8] = [9, 9, 9, 1, 0, 0, 0]
+ var reader = ByteReader(bytes[3...])
+ #expect(try reader.readUInt32() == 1)
+ #expect(reader.position == 4)
+ }
+}
+
+@Suite("128-bit values")
+struct UInt128ValueTests {
+ @Test func decimalRenderingAndParsing() {
+ #expect(UInt128Value(low: 0, high: 0).description == "0")
+ #expect(UInt128Value(low: UInt64.max, high: 0).description ==
"18446744073709551615")
+ #expect(UInt128Value(low: 0, high: 1).description ==
"18446744073709551616")
+ #expect(UInt128Value(low: UInt64.max, high: UInt64.max).description ==
"340282366920938463463374607431768211455")
+ #expect(UInt128Value("340282366920938463463374607431768211455") ==
UInt128Value(low: UInt64.max, high: UInt64.max))
+ #expect(UInt128Value("18446744073709551616") == UInt128Value(low: 0,
high: 1))
+ #expect(UInt128Value("340282366920938463463374607431768211456") == nil)
+ #expect(UInt128Value("") == nil)
+ #expect(UInt128Value("12a") == nil)
+ }
+
+ @Test func uuidRoundTrip() {
+ let uuid = UUID()
+ let value = UInt128Value(uuid: uuid)
+ #expect(value.uuid == uuid)
+ #expect(UInt128Value(littleEndianBytes: value.littleEndianBytes[...])
== value)
+ #expect(!UInt128Value.random().isZero)
+ #expect(UInt128Value.random() != UInt128Value.random())
+ }
+
+ @Test func ordering() {
+ #expect(UInt128Value(low: 0, high: 1) > UInt128Value(low: UInt64.max,
high: 0))
+ #expect(UInt128Value(low: 2, high: 1) > UInt128Value(low: 1, high: 1))
+ }
+}
diff --git a/foreign/swift/Tests/IggyTests/ErrorCodeTests.swift
b/foreign/swift/Tests/IggyTests/ErrorCodeTests.swift
new file mode 100644
index 000000000..50750bf33
--- /dev/null
+++ b/foreign/swift/Tests/IggyTests/ErrorCodeTests.swift
@@ -0,0 +1,55 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+import Testing
+
+@testable import Iggy
+
+@Suite("Error codes")
+struct ErrorCodeTests {
+ /// Pins the table to the server's `IggyError` discriminants: the count,
+ /// both ends, and a code from every band.
+ @Test func tableMatchesTheServerCodes() {
+ #expect(IggyErrorCode.allCases.count == 240)
+ #expect(IggyErrorCode.error.rawValue == 1)
+ #expect(IggyErrorCode.allCases.map(\.rawValue).min() == 1)
+ #expect(IggyErrorCode.allCases.map(\.rawValue).max() == 14003)
+ #expect(IggyErrorCode.incompatibleProtocolVersion.rawValue == 14003)
+ #expect(IggyErrorCode(rawValue: 57) == .transientNotCommitted)
+ #expect(IggyErrorCode(rawValue: 58) == .transientNotAccepted)
+ #expect(IggyErrorCode(rawValue: 1009) == .streamIdNotFound)
+ #expect(IggyErrorCode(rawValue: 2010) == .topicIdNotFound)
+ #expect(IggyErrorCode(rawValue: 4042) == .invalidOptionValue)
+ #expect(IggyErrorCode(rawValue: 5006) == .consumerGroupMemberNotFound)
+ #expect(IggyErrorCode.streamIdNotFound.name == "stream_id_not_found")
+ #expect(Set(IggyErrorCode.allCases.map(\.name)).count ==
IggyErrorCode.allCases.count)
+ }
+
+ @Test func unknownWireCodesKeepTheRawValue() {
+ let error = IggyError(wireCode: 65_535)
+ #expect(error.code == .error)
+ #expect(error.rawCode == 65_535)
+ #expect(IggyError(wireCode:
IggyErrorCode.streamIdNotFound.rawValue).code == .streamIdNotFound)
+ }
+
+ @Test func equalityIgnoresContext() {
+ #expect(IggyError(.invalidFormat, context: "x") ==
IggyError(.invalidFormat))
+ #expect(IggyError(.invalidFormat) != IggyError(.invalidCommand))
+ #expect(IggyError(wireCode: 65_535) != IggyError(.error))
+ #expect(IggyError(.invalidFormat, context: "x").hashValue ==
IggyError(.invalidFormat).hashValue)
+ }
+}
diff --git a/licenserc.toml b/licenserc.toml
index 7339eac25..4957b49f8 100644
--- a/licenserc.toml
+++ b/licenserc.toml
@@ -79,6 +79,12 @@ excludes = [
"**/META-INF/services/**",
".github/config/hawkeye.version",
"**/.python-version",
+ "**/Package.resolved",
+ "**/.swift-format",
+ # SwiftPM requires `// swift-tools-version` as the very first line, which
the
+ # header parser reads as part of the header. The header is still present.
+ "**/Package.swift",
+ "**/.build/**",
"**/gradle/wrapper/**",
"**/.clang-tidy",
# cargo-php regenerates this file with an extra separator after the header.
@@ -93,6 +99,7 @@ extensions = [
"php",
"rs",
"js",
+ "swift",
"ts",
]
style_out = "doubleslash"