Skip to content

kylebegeman/comet

Repository files navigation

Comet logo

Comet

Typed networking for Swift apps and services.

CI Latest release Swift 6.2 Supported platforms License

Comet turns API endpoints into Swift types. It ships with a URLSession-backed live HTTP client, middleware for production behavior, opt-in response caching, deterministic testing and contract transports, cassette recording and replay, OpenAPI request generation, request activity and trace streams, response streaming, transfer progress hooks, and resilient WebSocket sessions.

The latest published release is 0.4.5, the V3 foundation plus stale-while-revalidate caching and observability, proof bundle exports, the SwiftPM OpenAPI command plugin, expanded OpenAPI schema and request generation, YAML input, optional SQLiteData persistence, TCA playground coverage, cache and middleware hardening, server Swift live HTTP support, hardened OpenAPI output, LocalMockServer IPv6 handling, and tracked TCA cancellation.

At A Glance

Surface What It Provides
Comet Typed HTTP requests, WebSocket sessions, serializers, middleware, retry, cache, deduplication, activity events, traces, streaming, and progress primitives
CometTesting Mock transports, strict contracts, mock-server scenarios, cassette recording, replay transports, and mock WebSocket sessions
CometOpenAPIGenerator JSON and YAML OpenAPI 3.x request generator core plus the comet-openapi-generate executable
CometOpenAPIPlugin SwiftPM command plugin for package-root OpenAPI generation
CometSQLiteData Optional SQLiteData tables, migrations, and storage helpers for Comet activity events and generated artifacts
CometTCA Lightweight Composable Architecture helpers for request effects, tracked request effects, tracked cancellation, and request state
CometPlayground iPhone-first verification app for HTTP, cache, contracts, replay, activity, and realtime flows

Toolchain And Platforms

  • Swift 6.2
  • iOS 18+
  • macOS 15+
  • visionOS 2+
  • Linux builds for the core Comet target

The shipped live HTTP transport is URLSession-backed and imports FoundationNetworking where available, so server-side Swift can use URLSessionTransport for live HTTP requests without an additional Comet dependency. URLSessionWebSocketTransport uses URLSessionWebSocketTask and is available only on Apple platforms; server apps can provide a custom WebSocketTransport.

Install

.package(url: "https://github.com/mrbagels/comet.git", from: "0.4.5")

Import the target you need:

import Comet
import CometTesting
import CometOpenAPIGenerator
import CometSQLiteData

Quick Start

Authenticated JSON

import Comet
import HTTPTypes

struct User: Decodable, Sendable {
  let id: Int
  let name: String
}

struct GetUser: APIRequest {
  let userID: Int

  var path: Path { "users" / self.userID }
  let method: HTTPMethod = .get
  let responseSerializer: ResponseSerializer<User> = .json(User.self)

  // API versioning is opt-in. Add it only when your server expects it.
  var options: RequestOptions {
    .init(apiVersion: "v1")
  }
}

let client = HTTPClient.live(
  configuration: ClientConfiguration(
    baseURL: URL(string: "https://api.example.com")!,
    middleware: [
      BearerTokenMiddleware {
        await authStore.accessToken
      }
    ]
  ),
  transport: URLSessionTransport()
)

let user = try await client.send(GetUser(userID: 42))

For refresh and 401 replay, configure the auth coordinator once:

let auth = AuthenticationCoordinator.bearer(
  token: { await authStore.accessToken },
  refresh: { try await authStore.refreshAccessToken() }
)

let client = HTTPClient.live(
  configuration: ClientConfiguration(
    baseURL: URL(string: "https://api.example.com")!,
    middleware: [AuthenticationMiddleware(coordinator: auth)]
  ),
  transport: URLSessionTransport()
)

Retries, Metadata, And Activity

import Comet

let client = HTTPClient.live(
  configuration: ClientConfiguration(
    baseURL: URL(string: "https://api.example.com")!,
    middleware: [
      TracePropagationMiddleware(),
      RetryMiddleware(maxAttempts: 3),
      LoggingMiddleware(logLevel: .verbose)
    ]
  ),
  transport: URLSessionTransport()
)

Task {
  for await event in client.activity {
    print(event)
  }
}

Requests can carry metadata into logs and activity events. Retry behavior is conservative by default: RetryMiddleware retries safe methods such as GET automatically, while write requests need an idempotency key or an explicit retry policy.

var options: RequestOptions {
  .init(
    idempotencyKey: "create-user-\(draft.id)",
    metadata: RequestMetadata(name: "CreateUser", tags: ["users"]),
    statusValidation: .successOrNotModified
  )
}

Activity events also expose diagnostic helpers for UI and logging code:

for await event in client.activity {
  print(event.kind)
  print(event.diagnosticSummary)
}

Trace Propagation

TracePropagationMiddleware writes the W3C traceparent header and completed RequestTrace values expose the propagated trace ID.

let context = TraceContext(
  traceID: "4bf92f3577b34da6a3ce929d0e0e4736",
  parentID: "00f067aa0ba902b7",
  flags: "01"
)!

var options: RequestOptions {
  .init(
    metadata: RequestMetadata(
      name: "GetUser",
      operationID: "users.get",
      traceContext: context
    )
  )
}

for await trace in client.traces {
  print(trace.traceID as Any)
}

Opt-In Response Cache

let cache = MemoryHTTPCacheStore()

let client = HTTPClient.live(
  configuration: ClientConfiguration(
    baseURL: URL(string: "https://api.example.com")!,
    middleware: [
      CacheMiddleware(store: cache)
    ]
  ),
  transport: URLSessionTransport()
)

var options: RequestOptions {
  RequestOptions(cachePolicy: .returnCacheElseLoad)
}

Use FileHTTPCacheStore when responses should survive process restarts:

let cache = FileHTTPCacheStore(
  namespace: "api-v1",
  maximumSizeBytes: 25 * 1024 * 1024
)

returnCacheElseLoad serves fresh cached responses and revalidates stale entries when ETag or Last-Modified validators are available. Use .revalidate to force a conditional request, .staleWhileRevalidate to return stale cached data immediately while a background refresh updates the store, .cacheOnly for offline reads, .networkOnly to avoid reading or writing the cache, or .reloadIgnoringCache to fetch and store a replacement.

Stale-while-revalidate foreground requests record the stale hit and refresh scheduling decision on their completed RequestTrace. The background refresh emits its own lifecycle activity and completed trace so cache updates and refresh failures are observable without adding cache-specific event cases.

staleWhileRevalidate respects HTTP cache guardrails: entries marked no-store, no-cache, must-revalidate, or shared-cache proxy-revalidate fall back to synchronous revalidation instead of serving stale data.

For offline-tolerant reads, opt in to stale fallback when the network request fails:

RequestOptions(
  cachePolicy: HTTPCachePolicy(
    strategy: .returnCacheElseLoad,
    allowsStaleIfError: true
  )
)

Cache decisions are included in completed traces:

for await trace in client.traces {
  print(trace.cacheEvents.map(\.kind))
}

Query Items

QueryItem includes helpers for common optional, boolean, collection, joined, and date parameters.

var queryItems: [QueryItem] {
  QueryItems {
    QueryItem("search", searchTerm)
    QueryItem.optional("limit", limit)
    QueryItem.bool("includeArchived", includeArchived)
    QueryItem.items("tag", values: tags)
    QueryItem.joined("ids", values: selectedIDs)
    QueryItem.date("createdAfter", cutoffDate, style: .iso8601)
  }
}

Typed API Errors

Requests can opt into decoding structured HTTP error bodies while preserving the raw NetworkError.http information.

struct APIError: Decodable, Sendable {
  let code: String
  let message: String
}

struct CreateUser: APIRequestWithErrorResponse {
  typealias Response = User
  typealias ErrorResponse = APIError

  let path: Path = "users"
  let method: HTTPMethod = .post
  let responseSerializer: ResponseSerializer<User> = .json(User.self)
  let errorResponseSerializer: ErrorResponseSerializer<APIError> = .json(APIError.self)
}

do {
  let user = try await client.sendWithTypedErrors(CreateUser())
} catch let error as APIClientError<APIError> {
  print(error.decodedErrorBody?.message ?? error.networkError.debugSummary)
}

cURL Output

Prepared requests can produce shell-safe cURL output. Use HTTPClient.prepare(_:) when you want to inspect the exact transport-ready request before sending it. Multiline is the default for logs, while compact output is useful for copying into single-line fields. JSON request bodies can also be pretty-printed when multiline readability matters.

let preparedRequest = try client.prepare(CreateTodoRequest())
let curl = preparedRequest.curlCommand(style: .compact)

let readableCurl = preparedRequest.curlCommand(
  options: CURLCommandOptions(
    style: .multiline,
    bodyFormatting: .prettyPrintedJSON
  )
)

Verbose request logging uses the same options:

LoggingMiddleware(
  logLevel: .verbose,
  curlCommandOptions: CURLCommandOptions(style: .compact)
)

WebSocket Sessions

import Comet

let sockets = WebSocketClient.live(
  transport: URLSessionWebSocketTransport()
)

let connection = try await sockets.connect(
  WebSocketRequest(
    url: URL(string: "wss://ws.postman-echo.com/raw")!,
    timeout: .seconds(10)
  )
)

try await connection.send(.text(#"{"kind":"echo","library":"Comet"}"#))
let reply = try await connection.receive()
try await connection.close(code: .normalClosure)

For long-lived readers, use the message stream and stop iterating when the connection closes or the surrounding task is cancelled:

for try await message in connection.messages() {
  // Handle .text or .data frames.
}

For lifecycle events and bounded reconnect attempts, use a session:

let session = sockets.session(
  for: WebSocketRequest(url: URL(string: "wss://ws.postman-echo.com/raw")!),
  configuration: WebSocketSessionConfiguration(maximumReconnectAttempts: 3)
)

for try await event in session.events() {
  // Handle .connected, .message, .disconnected, and .reconnecting.
}

Streaming And Progress

for try await line in client.lines(StreamEvents()) {
  print(line)
}

for try await event in client.serverSentEvents(StreamEvents()) {
  print(event.data)
}

let response = try await client.sendRaw(UploadAsset()) { progress in
  print(progress.kind, progress.completedBytes, progress.totalBytes as Any)
}

Deterministic Testing And Replay

import Comet
import CometTesting

let recorder = RecordingTransport(base: URLSessionTransport())
let liveClient = HTTPClient.live(
  configuration: .default(baseURL: URL(string: "https://api.example.com")!),
  transport: recorder
)

_ = try await liveClient.send(GetUser(userID: 42))

let cassetteURL = URL(fileURLWithPath: "Tests/Fixtures/get-user-42.json")
let cassette = await recorder.cassette()
try cassette.write(to: cassetteURL)

let replay = try ReplayTransport(contentsOf: cassetteURL)
let replayClient = HTTPClient.live(
  configuration: .default(baseURL: URL(string: "https://api.example.com")!),
  transport: replay
)

let recordedUser = try await replayClient.send(GetUser(userID: 42))

MockTransport is the fastest path for fully in-memory tests. RecordingTransport and ReplayTransport are for higher-fidelity fixture workflows when you want to capture live traffic once and replay it deterministically later.

Recorded cassettes can include URLs, headers, request bodies, response bodies, cookies, and authorization data. RecordingTransport redacts common sensitive headers by default and supports custom request and response body redaction. Review generated fixtures before committing them.

let recorder = RecordingTransport(
  base: URLSessionTransport(),
  redaction: RecordingRedaction(
    redactRequestBody: { request in request.url.path.contains("sessions") },
    redactResponseBody: { response in response.headers[.contentType] == "application/json" }
  )
)

RecordingRedaction is an alias for Comet's shared RedactionPolicy, so the same policy shape can be used for cassettes, logging, and cURL output.

Contract Testing And Mock Servers

Use ContractTransport when a test should fail on the first request shape drift:

let transport = ContractTransport(
  expectations: [
    ContractExpectation(
      id: "get-user",
      method: .get,
      path: "/users/42",
      headers: [
        ContractHeaderExpectation(name: "accept", value: .exact("application/json"))
      ],
      outcome: .response(
        RawResponse(data: Data(#"{"id":42}"#.utf8), statusCode: 200)
      )
    )
  ]
)

let client = HTTPClient.live(
  configuration: .default(baseURL: URL(string: "https://api.example.com")!),
  transport: transport
)

_ = try await client.send(GetUser(userID: 42))
try await transport.verifyComplete()

MockServer wraps the same contracts for demo and UI-test scenarios, and reports can be exported as JSON:

let report = await transport.report()
try report.write(to: URL(fileURLWithPath: "contract-report.json"))

Recorded cassettes can become strict contracts:

let expectations = try cassette.contractExpectations()
let mockServer = MockServer(expectations: expectations)

Use LocalMockServer when a demo, integration test, or UI test should hit the same contracts through a real URLSessionTransport:

let localServer = try await LocalMockServer.start(expectations: expectations)
defer { localServer.stop() }

let client = HTTPClient.live(
  configuration: .default(baseURL: localServer.baseURL),
  transport: URLSessionTransport()
)

OpenAPI Generation

Generate Comet request types from JSON or YAML OpenAPI 3.x documents:

swift run comet-openapi-generate --input openapi.json --output GeneratedAPI.swift

From a package checkout, use the SwiftPM command plugin when generated sources should be written relative to the package root:

swift package --allow-writing-to-package-directory comet-openapi-generate \
  --input openapi.yaml \
  --output Sources/API/GeneratedAPI.swift

The generator supports path, query, header, and cookie parameters, reusable component parameters, component schema models, nested inline object structs, typed and free-form additionalProperties dictionaries, object models that combine named properties with additionalProperties, simple allOf object composition, oneOf and anyOf union enums, discriminator decoding for component unions, component alias $refs, local schema $refs, reusable request bodies and responses, JSON and +json media types, plain-text media types, form URL-encoded and multipart form-data request bodies, dictionary-backed form bodies, typed JSON and string serializers, operation metadata, security requirement tags, and typed error-response hooks. Unsupported features fail with diagnostics instead of silently generating partial behavior.

Reachability Hints

Reachability is modeled as a hint, not a correctness boundary:

let provider = StaticReachabilityHintProvider(
  ReachabilitySnapshot(status: .reachable, isExpensive: false)
)

let snapshot = await provider.currentSnapshot()

Use this to inform UI or retry choices, but keep transport errors as the source of truth.

Optional SQLiteData Persistence

CometSQLiteData is a separate product for apps that already use SQLiteData or want persisted diagnostics without pulling TCA into the graph.

Create a migrated database during app bootstrap:

import CometSQLiteData
import Dependencies

extension DependencyValues {
  mutating func bootstrapDatabase() throws {
    defaultDatabase = try CometSQLiteDataSchema.defaultDatabase()
  }
}

If you already own the SQLiteData migrator, call CometSQLiteDataSchema.registerMigrations(&migrator) instead.

Then record activity events or artifacts from the configured database:

@Dependency(\.defaultDatabase) var database

let store = CometSQLiteDataStore(database: database)

Task {
  for await event in client.activity {
    try await store.record(event: event)
  }
}

The target stores generic artifact rows, so cassette JSON, contract reports, generated schema snapshots, and app-specific diagnostics can share one persistence surface.

Example App

Examples/CometPlayground is an iPhone-first verification app generated with XcodeGen. It provides:

  • a focused smoke test target: CometPlaygroundTests
  • deterministic mock verification with CometTesting.MockTransport
  • deterministic contract verification with CometTesting.MockServer
  • deterministic socket verification with CometTesting.MockWebSocketTransport
  • persisted activity history through the optional CometSQLiteData product
  • a reducer-backed request tab through the optional CometTCA product
  • live transport checks through URLSessionTransport and URLSessionWebSocketTransport
  • proof, structured activity, failure-gallery, request-inspector, and detail flows showing which APIs are exercised and what output to verify

The full walkthrough lives in Examples/CometPlayground/README.md.

Documentation

The DocC catalog includes workflow articles for authenticated JSON requests, retries and activity, request tracing, streaming and progress, typed API errors, testing, cassettes, contracts, WebSockets, OpenAPI generation, and TCA integration.

Verification

Run the package tests:

swift test --disable-xctest

Generate the example Xcode project:

cd Examples/CometPlayground
xcodegen generate

Run the example smoke tests:

SIMULATOR_ID="$(../../.github/scripts/select-ios-simulator.sh)"
xcodebuild test \
  -project CometPlayground.xcodeproj \
  -scheme CometPlaygroundApp \
  -destination "platform=iOS Simulator,id=$SIMULATOR_ID" \
  -skipMacroValidation \
  -skipPackagePluginValidation \
  SWIFT_ENABLE_EXPLICIT_MODULES=NO

GitHub Actions runs the Swift package suite, a Linux core-target build, secret scanning, public API break gating, and the iOS example smoke tests on every push to next and master.

Check for public API changes against the latest release:

.github/scripts/check-api-breaking-changes.sh v0.4.5

Run a fresh external client smoke check:

.github/scripts/fresh-client-smoke.sh

Branching

  • next is the default integration branch for upcoming work.
  • master is the stable release branch.
  • Short-lived feat/, fix/, refactor/, docs/, chore/, spike/, and hotfix/ branches should branch from next.
  • Normal work merges back into next, and releases promote from master.

Brand Assets

SVG brand assets live in Resources/Brand. The README uses the gradient icon directly from that folder, and the playground app bundles the same mark through its asset catalog and app icon set.

Repository Layout

  • Sources/: package source targets
  • Tests/: package test targets
  • Examples/CometPlayground/: XcodeGen-driven iOS demo app
  • Sources/CometOpenAPIGenerator/: JSON and YAML OpenAPI generator core
  • Resources/Brand/: SVG logo and icon files for docs, README, and app assets
  • .github/scripts/fresh-client-smoke.sh: external package integration smoke check
  • .github/scripts/select-ios-simulator.sh: local and CI iPhone simulator selection
  • .github/workflows/ci.yml: package and iOS smoke test automation
  • docs/ARCHITECTURE.md: architecture notes
  • docs/technical/SERVER_TRANSPORT_DECISION.md: server transport support decision
  • docs/IMPLEMENTATION_PLAN.md: implementation plan and rollout notes
  • docs/PRODUCT_ROADMAP.md: product roadmap and feature planning
  • docs/RELEASE_PLAN_0_3.md: completed release plan from 0.2.x to 0.3.0

What To Open First

If you want to understand or modify the current core flows, start here:

About

Comet is a modern Swift networking library with testing utilities, TCA integration, and an iOS demo app.

Resources

License

Contributing

Security policy

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages