clients/swift: initial Swift SDK for clawdforge
This commit is contained in:
parent
15de6e765f
commit
e4e8192d4d
7 changed files with 1453 additions and 0 deletions
56
clients/swift/Examples/Basic/main.swift
Normal file
56
clients/swift/Examples/Basic/main.swift
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
// Basic example for the Clawdforge SDK.
|
||||
//
|
||||
// Run with: CLAWDFORGE_TOKEN=cf_... swift run ClawdforgeBasicExample
|
||||
//
|
||||
// This file is intentionally named `main.swift` so it can use top-level
|
||||
// `await` rather than wrapping in `@main`. Swift treats a single-file
|
||||
// executable target named `main.swift` as a script.
|
||||
|
||||
import Foundation
|
||||
import Clawdforge
|
||||
|
||||
let baseURL = URL(string: ProcessInfo.processInfo.environment["CLAWDFORGE_URL"] ?? "http://192.168.0.5:8800")!
|
||||
guard let token = ProcessInfo.processInfo.environment["CLAWDFORGE_TOKEN"] else {
|
||||
FileHandle.standardError.write(Data("error: set CLAWDFORGE_TOKEN\n".utf8))
|
||||
exit(1)
|
||||
}
|
||||
|
||||
let client = ForgeClient(baseURL: baseURL, token: token)
|
||||
|
||||
do {
|
||||
// 1. Health check
|
||||
let h = try await client.healthz()
|
||||
print("claude_present: \(h.claudePresent)")
|
||||
print("claude_version: \(h.claudeVersion ?? "unknown")")
|
||||
|
||||
// 2. Run a prompt
|
||||
let res = try await client.run(RunRequest(
|
||||
prompt: #"Reply with JSON: {"hello": "world"}"#,
|
||||
model: "sonnet",
|
||||
timeoutSecs: 60
|
||||
))
|
||||
print("duration: \(res.durationMs)ms")
|
||||
if case .object(let dict) = res.result,
|
||||
case .string(let hello) = dict["hello"] ?? .null {
|
||||
print("hello = \(hello)")
|
||||
} else {
|
||||
print("result: \(res.result)")
|
||||
}
|
||||
|
||||
// 3. Optional file upload + reuse — uncomment to try.
|
||||
//
|
||||
// let recipe = URL(fileURLWithPath: "./recipe.png")
|
||||
// let ft = try await client.uploadFile(at: recipe, ttlSecs: 3600)
|
||||
// let extract = try await client.run(RunRequest(
|
||||
// prompt: "extract recipe data as JSON",
|
||||
// files: [ft.fileToken]
|
||||
// ))
|
||||
// print("extract: \(extract.result)")
|
||||
|
||||
} catch let err as ForgeError {
|
||||
FileHandle.standardError.write(Data("clawdforge error: \(err.localizedDescription)\n".utf8))
|
||||
exit(2)
|
||||
} catch {
|
||||
FileHandle.standardError.write(Data("unexpected error: \(error)\n".utf8))
|
||||
exit(2)
|
||||
}
|
||||
48
clients/swift/Package.swift
Normal file
48
clients/swift/Package.swift
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
// swift-tools-version:5.9
|
||||
//
|
||||
// Clawdforge Swift SDK
|
||||
// MIT License — see clawdforge repository LICENSE.
|
||||
//
|
||||
// Pure-Foundation HTTP client for the LAN-only clawdforge service.
|
||||
// Builds on macOS, iOS, watchOS, tvOS, and Linux (swift-corelibs-foundation).
|
||||
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "Clawdforge",
|
||||
platforms: [
|
||||
.macOS(.v13),
|
||||
.iOS(.v16),
|
||||
.tvOS(.v16),
|
||||
.watchOS(.v9),
|
||||
],
|
||||
products: [
|
||||
.library(
|
||||
name: "Clawdforge",
|
||||
targets: ["Clawdforge"]
|
||||
),
|
||||
.executable(
|
||||
name: "ClawdforgeBasicExample",
|
||||
targets: ["ClawdforgeBasicExample"]
|
||||
),
|
||||
],
|
||||
dependencies: [
|
||||
// Intentionally none. Foundation only.
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
name: "Clawdforge",
|
||||
path: "Sources/Clawdforge"
|
||||
),
|
||||
.executableTarget(
|
||||
name: "ClawdforgeBasicExample",
|
||||
dependencies: ["Clawdforge"],
|
||||
path: "Examples/Basic"
|
||||
),
|
||||
.testTarget(
|
||||
name: "ClawdforgeTests",
|
||||
dependencies: ["Clawdforge"],
|
||||
path: "Tests/ClawdforgeTests"
|
||||
),
|
||||
]
|
||||
)
|
||||
214
clients/swift/README.md
Normal file
214
clients/swift/README.md
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
# Clawdforge — Swift SDK
|
||||
|
||||
Swift client for [clawdforge], a LAN-only HTTP service that wraps `claude -p`
|
||||
subprocess calls behind a bearer-token-gated REST API.
|
||||
|
||||
- **Pure Foundation** — no `Alamofire`, no third-party HTTP libs.
|
||||
- **Async/await throughout** — every I/O method is `async throws`.
|
||||
- **Apple platforms + Linux** — same source builds on macOS/iOS/tvOS/watchOS
|
||||
and on Linux via `swift-corelibs-foundation`.
|
||||
- **MIT licensed.**
|
||||
|
||||
## Requirements
|
||||
|
||||
| Platform | Minimum |
|
||||
|---|---|
|
||||
| Swift toolchain | 5.9 |
|
||||
| macOS | 13 |
|
||||
| iOS | 16 |
|
||||
| tvOS | 16 |
|
||||
| watchOS | 9 |
|
||||
| Linux | any distro with Swift 5.9+ |
|
||||
|
||||
## Install
|
||||
|
||||
Add to your `Package.swift`:
|
||||
|
||||
```swift
|
||||
dependencies: [
|
||||
.package(url: "http://192.168.0.5:3001/Sulkta-Coop/clawdforge.git", from: "0.1.0"),
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
name: "MyApp",
|
||||
dependencies: [
|
||||
.product(name: "Clawdforge", package: "clawdforge"),
|
||||
]
|
||||
),
|
||||
]
|
||||
```
|
||||
|
||||
In an Xcode project: **File → Add Package Dependencies…**, paste the URL,
|
||||
select the `Clawdforge` library product.
|
||||
|
||||
## Quickstart
|
||||
|
||||
```swift
|
||||
import Clawdforge
|
||||
|
||||
let client = ForgeClient(
|
||||
baseURL: URL(string: "http://192.168.0.5:8800")!,
|
||||
token: ProcessInfo.processInfo.environment["CLAWDFORGE_TOKEN"]!
|
||||
)
|
||||
|
||||
// Health check (no token strictly required, but bearer is sent anyway).
|
||||
let h = try await client.healthz()
|
||||
print(h.claudeVersion ?? "unknown")
|
||||
|
||||
// Run a prompt.
|
||||
let res = try await client.run(RunRequest(
|
||||
prompt: #"Reply with JSON: {"hello": "world"}"#,
|
||||
model: "sonnet",
|
||||
timeoutSecs: 60
|
||||
))
|
||||
print("duration: \(res.durationMs)ms")
|
||||
|
||||
// Narrow the heterogeneous result.
|
||||
if case .object(let dict) = res.result,
|
||||
case .string(let hello) = dict["hello"] ?? .null {
|
||||
print(hello) // "world"
|
||||
}
|
||||
```
|
||||
|
||||
## Public API
|
||||
|
||||
### `ForgeClient`
|
||||
|
||||
A `Sendable` struct. Build once, share everywhere — `URLSession` underneath
|
||||
is thread-safe.
|
||||
|
||||
```swift
|
||||
public struct ForgeClient: Sendable {
|
||||
public init(baseURL: URL, token: String, session: URLSession = .shared)
|
||||
|
||||
public func healthz() async throws -> HealthStatus
|
||||
public func run(_ request: RunRequest) async throws -> RunResult
|
||||
public func uploadFile(at fileURL: URL, ttlSecs: Int = 3600) async throws -> FileToken
|
||||
|
||||
public func createToken(_ request: CreateTokenRequest) async throws -> AppToken
|
||||
public func listTokens() async throws -> [AppToken]
|
||||
public func revokeToken(name: String) async throws
|
||||
}
|
||||
```
|
||||
|
||||
### File upload + reuse
|
||||
|
||||
```swift
|
||||
let png = URL(fileURLWithPath: "./recipe.png")
|
||||
let ft = try await client.uploadFile(at: png, ttlSecs: 3600)
|
||||
|
||||
let res = try await client.run(RunRequest(
|
||||
prompt: "Extract recipe data as JSON.",
|
||||
files: [ft.fileToken]
|
||||
))
|
||||
```
|
||||
|
||||
The upload streams from disk via `URLSession.upload(for:fromFile:)` — no
|
||||
load-into-`Data()` for the file payload, so multi-megabyte files are fine.
|
||||
|
||||
### Admin endpoints
|
||||
|
||||
Pass the `ADMIN_BOOTSTRAP_TOKEN` as the client's `token` to use these.
|
||||
|
||||
```swift
|
||||
let admin = ForgeClient(baseURL: url, token: adminToken)
|
||||
|
||||
let new = try await admin.createToken(
|
||||
CreateTokenRequest(name: "myapp", ipCidrs: ["10.0.0.0/8"])
|
||||
)
|
||||
print("save this:", new.token!) // shown ONCE
|
||||
|
||||
let all = try await admin.listTokens()
|
||||
try await admin.revokeToken(name: "myapp")
|
||||
```
|
||||
|
||||
### `JSONValue`
|
||||
|
||||
`RunResult.result` is a `JSONValue`. `claude -p --output-format json` may
|
||||
emit either parsed JSON or a plain string when the model didn't return
|
||||
JSON; `JSONValue` represents both losslessly.
|
||||
|
||||
```swift
|
||||
public enum JSONValue: Codable, Sendable, Equatable {
|
||||
case object([String: JSONValue])
|
||||
case array([JSONValue])
|
||||
case string(String)
|
||||
case number(Double)
|
||||
case bool(Bool)
|
||||
case null
|
||||
}
|
||||
```
|
||||
|
||||
Convenience accessors: `.stringValue`, `.numberValue`, `.boolValue`,
|
||||
`.arrayValue`, `.objectValue` — each returns `nil` if the case doesn't
|
||||
match.
|
||||
|
||||
## Errors
|
||||
|
||||
All thrown errors are the single `ForgeError` enum:
|
||||
|
||||
```swift
|
||||
public enum ForgeError: Error {
|
||||
case auth(statusCode: Int, body: String) // 401 / 403
|
||||
case api(statusCode: Int, body: String) // any other non-2xx
|
||||
case transport(URLError) // DNS, TLS, EOF, cancellation
|
||||
case decoding(DecodingError) // server/client schema skew
|
||||
case invalidArgument(String) // SDK precondition
|
||||
}
|
||||
```
|
||||
|
||||
Conforms to `LocalizedError`, so `error.localizedDescription` and SwiftUI's
|
||||
`.alert(error:)` modifier produce sensible messages out of the box.
|
||||
|
||||
`/run` failures with HTTP 502 surface as `.api(statusCode: 502, body: ...)`.
|
||||
Decode the body as `RunFailure` to inspect `error`/`stderr`/`durationMs`/`stopReason`.
|
||||
|
||||
```swift
|
||||
do {
|
||||
_ = try await client.run(RunRequest(prompt: "..."))
|
||||
} catch let ForgeError.api(502, body) {
|
||||
if let data = body.data(using: .utf8),
|
||||
let failure = try? JSONDecoder().decode(RunFailure.self, from: data) {
|
||||
print("claude failed: \(failure.error)")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Cancellation
|
||||
|
||||
`Task` cancellation propagates into `URLSession`. The SDK also calls
|
||||
`Task.checkCancellation()` before the network hop so a cancel before the
|
||||
request is dispatched throws `CancellationError` immediately.
|
||||
|
||||
```swift
|
||||
let handle = Task {
|
||||
try await client.run(RunRequest(prompt: "long prompt"))
|
||||
}
|
||||
// ...
|
||||
handle.cancel()
|
||||
```
|
||||
|
||||
## Linux
|
||||
|
||||
Builds on Linux via `swift-corelibs-foundation`. Async `URLSession` methods
|
||||
(`data(for:)`, `upload(for:fromFile:)`) are available there from Swift 5.9.
|
||||
|
||||
```bash
|
||||
swift build -c release
|
||||
swift test
|
||||
```
|
||||
|
||||
## Tests
|
||||
|
||||
Unit tests use a `URLProtocol` stub — no network, no clawdforge instance
|
||||
required. Run:
|
||||
|
||||
```bash
|
||||
swift test
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT.
|
||||
|
||||
[clawdforge]: http://192.168.0.5:3001/Sulkta-Coop/clawdforge
|
||||
66
clients/swift/Sources/Clawdforge/Errors.swift
Normal file
66
clients/swift/Sources/Clawdforge/Errors.swift
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
// Errors.swift
|
||||
//
|
||||
// One enum to rule them all. Conforms to LocalizedError so error messages
|
||||
// surface cleanly in `print()`, `localizedDescription`, and SwiftUI's
|
||||
// `.alert(error:)` modifier.
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Every error this SDK throws.
|
||||
public enum ForgeError: Error, Sendable {
|
||||
/// Server returned 401 or 403. Token is missing, wrong, or the caller
|
||||
/// IP isn't on the global / per-app allowlist.
|
||||
case auth(statusCode: Int, body: String)
|
||||
|
||||
/// Any other non-2xx response. ``body`` is the raw response body
|
||||
/// (truncated to a sane size). For `/run` 502 failures, decode
|
||||
/// ``body`` as ``RunFailure`` to inspect `error`/`stderr`/`duration_ms`.
|
||||
case api(statusCode: Int, body: String)
|
||||
|
||||
/// Underlying transport problem: DNS, connect refused, TLS, EOF,
|
||||
/// timeout from `URLSessionConfiguration.timeoutIntervalForRequest`,
|
||||
/// or `Task` cancellation surfaced as `URLError(.cancelled)`.
|
||||
case transport(URLError)
|
||||
|
||||
/// JSON decoding of a successful response failed. Almost always
|
||||
/// indicates a server/client version skew.
|
||||
case decoding(DecodingError)
|
||||
|
||||
/// A precondition guarded by the SDK was violated (e.g. empty prompt,
|
||||
/// nonexistent file path on upload, invalid base URL).
|
||||
case invalidArgument(String)
|
||||
}
|
||||
|
||||
extension ForgeError: LocalizedError {
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .auth(let code, let body):
|
||||
let snippet = Self.snippet(body)
|
||||
return "clawdforge: authentication failed (HTTP \(code))\(snippet.isEmpty ? "" : ": \(snippet)")"
|
||||
case .api(let code, let body):
|
||||
let snippet = Self.snippet(body)
|
||||
return "clawdforge: HTTP \(code)\(snippet.isEmpty ? "" : ": \(snippet)")"
|
||||
case .transport(let urlError):
|
||||
return "clawdforge: transport error: \(urlError.localizedDescription)"
|
||||
case .decoding(let decErr):
|
||||
return "clawdforge: response decoding failed: \(decErr)"
|
||||
case .invalidArgument(let msg):
|
||||
return "clawdforge: invalid argument: \(msg)"
|
||||
}
|
||||
}
|
||||
|
||||
/// Trim/clean a body for inclusion in error messages. Tries to pull
|
||||
/// `error` / `detail` / `message` out of a JSON envelope, otherwise
|
||||
/// returns the first ~500 chars.
|
||||
private static func snippet(_ body: String) -> String {
|
||||
guard !body.isEmpty else { return "" }
|
||||
if let data = body.data(using: .utf8),
|
||||
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
|
||||
for key in ["error", "detail", "message"] {
|
||||
if let v = obj[key] as? String, !v.isEmpty { return v }
|
||||
}
|
||||
}
|
||||
let trimmed = body.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.count > 500 ? String(trimmed.prefix(500)) + "..." : trimmed
|
||||
}
|
||||
}
|
||||
375
clients/swift/Sources/Clawdforge/ForgeClient.swift
Normal file
375
clients/swift/Sources/Clawdforge/ForgeClient.swift
Normal file
|
|
@ -0,0 +1,375 @@
|
|||
// ForgeClient.swift
|
||||
//
|
||||
// Foundation-only async/await client for clawdforge.
|
||||
//
|
||||
// Design choice: ForgeClient is a Sendable struct, not an actor. The client
|
||||
// holds an immutable URL, an immutable bearer token, and a URLSession (which
|
||||
// is itself thread-safe by Apple's contract). There is no mutable state to
|
||||
// guard, so an actor would just add hop overhead for every call.
|
||||
//
|
||||
// All public methods are `async throws`. Cancellation propagates from the
|
||||
// caller's Task into URLSession via the structured-concurrency bridge in
|
||||
// `URLSession.data(for:)` / `URLSession.upload(for:fromFile:)`, so wrapping
|
||||
// a call in `Task { ... }.cancel()` cleanly aborts the in-flight request.
|
||||
//
|
||||
// Linux: builds against swift-corelibs-foundation. The async URLSession
|
||||
// methods used here (`data(for:)`, `upload(for:fromFile:)`) are available
|
||||
// on Linux as of Swift 5.9.
|
||||
|
||||
import Foundation
|
||||
|
||||
#if canImport(FoundationNetworking)
|
||||
import FoundationNetworking
|
||||
#endif
|
||||
|
||||
/// Thread-safe client for the clawdforge REST API.
|
||||
///
|
||||
/// Construct once per (baseURL, token) pair and share across the app.
|
||||
/// Instances are cheap and `Sendable`.
|
||||
public struct ForgeClient: Sendable {
|
||||
|
||||
// MARK: Stored
|
||||
|
||||
/// Base URL, e.g. `http://192.168.0.5:8800`. Trailing slashes are trimmed.
|
||||
public let baseURL: URL
|
||||
|
||||
/// Bearer token. Sent as `Authorization: Bearer <token>` on every request.
|
||||
/// For admin endpoints, pass the `ADMIN_BOOTSTRAP_TOKEN` here.
|
||||
public let token: String
|
||||
|
||||
/// Underlying URLSession. Defaults to `.shared`. Inject a configured
|
||||
/// session for custom timeouts, proxies, or test harnesses.
|
||||
public let session: URLSession
|
||||
|
||||
/// JSON encoder used for all request bodies. Pre-configured for the
|
||||
/// snake_case wire format via per-field `CodingKeys` on each model.
|
||||
private let encoder: JSONEncoder
|
||||
|
||||
/// JSON decoder used for all response bodies.
|
||||
private let decoder: JSONDecoder
|
||||
|
||||
// MARK: Init
|
||||
|
||||
/// Create a client.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - baseURL: Root URL of the clawdforge instance. e.g.
|
||||
/// `URL(string: "http://192.168.0.5:8800")!`.
|
||||
/// - token: Bearer token (`cf_…` for app endpoints, the admin
|
||||
/// bootstrap token for `/admin/*`).
|
||||
/// - session: Optional `URLSession`. Defaults to `.shared`. Provide
|
||||
/// a custom one for timeouts or for tests using `URLProtocol`
|
||||
/// stubs.
|
||||
public init(
|
||||
baseURL: URL,
|
||||
token: String,
|
||||
session: URLSession = .shared
|
||||
) {
|
||||
// Trim a trailing slash so path joins are predictable.
|
||||
var s = baseURL.absoluteString
|
||||
while s.hasSuffix("/") { s.removeLast() }
|
||||
self.baseURL = URL(string: s) ?? baseURL
|
||||
self.token = token
|
||||
self.session = session
|
||||
|
||||
let enc = JSONEncoder()
|
||||
enc.outputFormatting = [.withoutEscapingSlashes]
|
||||
self.encoder = enc
|
||||
|
||||
self.decoder = JSONDecoder()
|
||||
}
|
||||
|
||||
// MARK: Public API
|
||||
|
||||
/// `GET /healthz`. Liveness + `claude --version` smoke check.
|
||||
///
|
||||
/// Does not require a bearer token, but the caller IP must satisfy
|
||||
/// the global allowlist on the server.
|
||||
public func healthz() async throws -> HealthStatus {
|
||||
let req = try makeRequest(method: "GET", path: "/healthz")
|
||||
return try await send(req, decode: HealthStatus.self)
|
||||
}
|
||||
|
||||
/// `POST /run`. Run a prompt through `claude -p` on the server.
|
||||
///
|
||||
/// On HTTP 200 returns ``RunResult``. On HTTP 502 the underlying
|
||||
/// `claude` invocation failed and ``ForgeError/api(statusCode:body:)``
|
||||
/// is thrown with a JSON body matching ``RunFailure``.
|
||||
public func run(_ request: RunRequest) async throws -> RunResult {
|
||||
guard !request.prompt.isEmpty else {
|
||||
throw ForgeError.invalidArgument("prompt must not be empty")
|
||||
}
|
||||
let req = try makeRequest(
|
||||
method: "POST",
|
||||
path: "/run",
|
||||
jsonBody: request
|
||||
)
|
||||
return try await send(req, decode: RunResult.self)
|
||||
}
|
||||
|
||||
/// `POST /files`. Stream a file from disk to the server, receive an
|
||||
/// `ff_…` token suitable for ``RunRequest/files``.
|
||||
///
|
||||
/// Uses `URLSession.upload(for:fromFile:)` so the file body is streamed,
|
||||
/// not buffered in memory.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - fileURL: Local file URL. Must be a regular readable file.
|
||||
/// - ttlSecs: Server clamps to `[60, 86400]`. Defaults to `3600`.
|
||||
public func uploadFile(at fileURL: URL, ttlSecs: Int = 3600) async throws -> FileToken {
|
||||
guard fileURL.isFileURL else {
|
||||
throw ForgeError.invalidArgument("uploadFile: URL is not a file URL")
|
||||
}
|
||||
let path = fileURL.path
|
||||
guard FileManager.default.fileExists(atPath: path) else {
|
||||
throw ForgeError.invalidArgument("uploadFile: no file at \(path)")
|
||||
}
|
||||
|
||||
// Stage the multipart body to a temp file, then `upload(for:fromFile:)`
|
||||
// streams it from disk — no Data() blob in memory.
|
||||
let boundary = "clawdforge-\(UUID().uuidString)"
|
||||
let tempURL = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("clawdforge-upload-\(UUID().uuidString).bin")
|
||||
try writeMultipartBody(
|
||||
to: tempURL,
|
||||
boundary: boundary,
|
||||
ttlSecs: ttlSecs,
|
||||
fileURL: fileURL
|
||||
)
|
||||
defer {
|
||||
try? FileManager.default.removeItem(at: tempURL)
|
||||
}
|
||||
|
||||
var req = try makeRequest(method: "POST", path: "/files")
|
||||
req.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
|
||||
|
||||
try Task.checkCancellation()
|
||||
|
||||
let (data, response): (Data, URLResponse)
|
||||
do {
|
||||
(data, response) = try await session.upload(for: req, fromFile: tempURL)
|
||||
} catch let urlError as URLError {
|
||||
throw ForgeError.transport(urlError)
|
||||
} catch {
|
||||
throw ForgeError.transport(URLError(.unknown))
|
||||
}
|
||||
|
||||
return try decodeOrThrow(FileToken.self, data: data, response: response)
|
||||
}
|
||||
|
||||
/// `POST /admin/tokens`. Mint a new per-app token. The plaintext is
|
||||
/// returned in ``AppToken/token`` and is **not retrievable later**.
|
||||
///
|
||||
/// Requires the admin bootstrap token in ``token``.
|
||||
public func createToken(_ request: CreateTokenRequest) async throws -> AppToken {
|
||||
guard !request.name.isEmpty else {
|
||||
throw ForgeError.invalidArgument("createToken: name must not be empty")
|
||||
}
|
||||
let req = try makeRequest(
|
||||
method: "POST",
|
||||
path: "/admin/tokens",
|
||||
jsonBody: request
|
||||
)
|
||||
return try await send(req, decode: AppToken.self)
|
||||
}
|
||||
|
||||
/// `GET /admin/tokens`. Returns the configured tokens (no plaintexts).
|
||||
///
|
||||
/// Requires the admin bootstrap token.
|
||||
public func listTokens() async throws -> [AppToken] {
|
||||
let req = try makeRequest(method: "GET", path: "/admin/tokens")
|
||||
let envelope = try await send(req, decode: TokenList.self)
|
||||
return envelope.tokens
|
||||
}
|
||||
|
||||
/// `DELETE /admin/tokens/{name}`. Revoke a token by app name.
|
||||
///
|
||||
/// Requires the admin bootstrap token.
|
||||
public func revokeToken(name: String) async throws {
|
||||
guard !name.isEmpty else {
|
||||
throw ForgeError.invalidArgument("revokeToken: name must not be empty")
|
||||
}
|
||||
let escaped = name.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? name
|
||||
let req = try makeRequest(method: "DELETE", path: "/admin/tokens/\(escaped)")
|
||||
_ = try await sendVoid(req)
|
||||
}
|
||||
|
||||
// MARK: Internals
|
||||
|
||||
/// Build a `URLRequest` with auth + accept headers. If `jsonBody` is
|
||||
/// non-nil it is encoded and `Content-Type: application/json` is set.
|
||||
private func makeRequest<Body: Encodable>(
|
||||
method: String,
|
||||
path: String,
|
||||
jsonBody: Body
|
||||
) throws -> URLRequest {
|
||||
var req = try makeRequest(method: method, path: path)
|
||||
do {
|
||||
req.httpBody = try encoder.encode(jsonBody)
|
||||
} catch {
|
||||
throw ForgeError.invalidArgument("failed to encode request body: \(error)")
|
||||
}
|
||||
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
return req
|
||||
}
|
||||
|
||||
private func makeRequest(method: String, path: String) throws -> URLRequest {
|
||||
guard let url = URL(string: baseURL.absoluteString + path) else {
|
||||
throw ForgeError.invalidArgument("could not form URL for path \(path)")
|
||||
}
|
||||
var req = URLRequest(url: url)
|
||||
req.httpMethod = method
|
||||
if !token.isEmpty {
|
||||
req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
}
|
||||
req.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
return req
|
||||
}
|
||||
|
||||
/// Execute a request and decode JSON into `T`. Cancellation-safe.
|
||||
private func send<T: Decodable>(_ req: URLRequest, decode: T.Type) async throws -> T {
|
||||
try Task.checkCancellation()
|
||||
|
||||
let (data, response): (Data, URLResponse)
|
||||
do {
|
||||
(data, response) = try await session.data(for: req)
|
||||
} catch let urlError as URLError {
|
||||
throw ForgeError.transport(urlError)
|
||||
} catch {
|
||||
throw ForgeError.transport(URLError(.unknown))
|
||||
}
|
||||
|
||||
return try decodeOrThrow(T.self, data: data, response: response)
|
||||
}
|
||||
|
||||
/// Like ``send(_:decode:)`` but ignores response body. Used for DELETE.
|
||||
private func sendVoid(_ req: URLRequest) async throws -> Void {
|
||||
try Task.checkCancellation()
|
||||
|
||||
let (data, response): (Data, URLResponse)
|
||||
do {
|
||||
(data, response) = try await session.data(for: req)
|
||||
} catch let urlError as URLError {
|
||||
throw ForgeError.transport(urlError)
|
||||
} catch {
|
||||
throw ForgeError.transport(URLError(.unknown))
|
||||
}
|
||||
try ensure2xx(data: data, response: response)
|
||||
}
|
||||
|
||||
/// Validate a 2xx, then decode JSON into `T`. Maps non-2xx into
|
||||
/// ``ForgeError`` variants and decoding failure into
|
||||
/// ``ForgeError/decoding(_:)``.
|
||||
private func decodeOrThrow<T: Decodable>(_ type: T.Type, data: Data, response: URLResponse) throws -> T {
|
||||
try ensure2xx(data: data, response: response)
|
||||
do {
|
||||
return try decoder.decode(T.self, from: data)
|
||||
} catch let decErr as DecodingError {
|
||||
throw ForgeError.decoding(decErr)
|
||||
} catch {
|
||||
// Foundation should always raise DecodingError, but be defensive.
|
||||
throw ForgeError.api(statusCode: -1, body: String(data: data, encoding: .utf8) ?? "")
|
||||
}
|
||||
}
|
||||
|
||||
private func ensure2xx(data: Data, response: URLResponse) throws {
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw ForgeError.api(statusCode: -1, body: "non-HTTP response")
|
||||
}
|
||||
let code = http.statusCode
|
||||
guard !(200..<300).contains(code) else { return }
|
||||
|
||||
// Cap error-body size at 8 MiB so a misbehaving server can't fill RAM.
|
||||
let cap = 8 * 1024 * 1024
|
||||
let bodyData = data.count > cap ? data.prefix(cap) : data
|
||||
let body = String(data: bodyData, encoding: .utf8) ?? ""
|
||||
|
||||
if code == 401 || code == 403 {
|
||||
throw ForgeError.auth(statusCode: code, body: body)
|
||||
}
|
||||
throw ForgeError.api(statusCode: code, body: body)
|
||||
}
|
||||
|
||||
/// Stream a multipart/form-data body into a temp file. Stays disk-bound:
|
||||
/// reads the source file in 1 MiB chunks rather than slurping it.
|
||||
private func writeMultipartBody(
|
||||
to dest: URL,
|
||||
boundary: String,
|
||||
ttlSecs: Int,
|
||||
fileURL: URL
|
||||
) throws {
|
||||
FileManager.default.createFile(atPath: dest.path, contents: nil)
|
||||
guard let out = FileHandle(forWritingAtPath: dest.path) else {
|
||||
throw ForgeError.invalidArgument("cannot open temp upload at \(dest.path)")
|
||||
}
|
||||
defer { try? out.close() }
|
||||
|
||||
// ttl_secs field
|
||||
let ttlPart = """
|
||||
--\(boundary)\r
|
||||
Content-Disposition: form-data; name="ttl_secs"\r
|
||||
\r
|
||||
\(ttlSecs)\r
|
||||
|
||||
"""
|
||||
try out.write(contentsOf: Data(ttlPart.utf8))
|
||||
|
||||
// file field header
|
||||
let filename = fileURL.lastPathComponent
|
||||
let mime = mimeType(for: fileURL)
|
||||
let fileHeader = """
|
||||
--\(boundary)\r
|
||||
Content-Disposition: form-data; name="file"; filename="\(escapeFilename(filename))"\r
|
||||
Content-Type: \(mime)\r
|
||||
\r
|
||||
|
||||
"""
|
||||
try out.write(contentsOf: Data(fileHeader.utf8))
|
||||
|
||||
// file body — chunked
|
||||
guard let input = FileHandle(forReadingAtPath: fileURL.path) else {
|
||||
throw ForgeError.invalidArgument("cannot open \(fileURL.path) for reading")
|
||||
}
|
||||
defer { try? input.close() }
|
||||
|
||||
let chunkSize = 1 * 1024 * 1024
|
||||
while true {
|
||||
let chunk = input.readData(ofLength: chunkSize)
|
||||
if chunk.isEmpty { break }
|
||||
try out.write(contentsOf: chunk)
|
||||
}
|
||||
|
||||
// closing boundary
|
||||
let trailer = "\r\n--\(boundary)--\r\n"
|
||||
try out.write(contentsOf: Data(trailer.utf8))
|
||||
}
|
||||
|
||||
/// Best-effort MIME type from extension. Defaults to
|
||||
/// `application/octet-stream`. Server treats files as opaque blobs so
|
||||
/// the value rarely matters; we just want to avoid sending nothing.
|
||||
private func mimeType(for url: URL) -> String {
|
||||
switch url.pathExtension.lowercased() {
|
||||
case "txt", "md", "log": return "text/plain"
|
||||
case "json": return "application/json"
|
||||
case "html", "htm": return "text/html"
|
||||
case "csv": return "text/csv"
|
||||
case "png": return "image/png"
|
||||
case "jpg", "jpeg": return "image/jpeg"
|
||||
case "gif": return "image/gif"
|
||||
case "webp": return "image/webp"
|
||||
case "pdf": return "application/pdf"
|
||||
default: return "application/octet-stream"
|
||||
}
|
||||
}
|
||||
|
||||
/// RFC 7578 says backslash and double-quote should be escaped in the
|
||||
/// `filename=` parameter. Newlines we just strip — no legitimate file
|
||||
/// has them on POSIX.
|
||||
private func escapeFilename(_ name: String) -> String {
|
||||
name
|
||||
.replacingOccurrences(of: "\\", with: "\\\\")
|
||||
.replacingOccurrences(of: "\"", with: "\\\"")
|
||||
.replacingOccurrences(of: "\n", with: " ")
|
||||
.replacingOccurrences(of: "\r", with: " ")
|
||||
}
|
||||
}
|
||||
321
clients/swift/Sources/Clawdforge/Models.swift
Normal file
321
clients/swift/Sources/Clawdforge/Models.swift
Normal file
|
|
@ -0,0 +1,321 @@
|
|||
// Models.swift
|
||||
//
|
||||
// Codable wire models for the clawdforge HTTP API.
|
||||
//
|
||||
// Wire format note: clawdforge speaks snake_case end-to-end (Python /
|
||||
// Pydantic conventions). Swift convention is camelCase, so each model
|
||||
// declares explicit `CodingKeys` to bridge the two. We deliberately do
|
||||
// NOT use `JSONDecoder.KeyDecodingStrategy.convertFromSnakeCase` — the
|
||||
// per-field map is more legible and survives renames cleanly.
|
||||
|
||||
import Foundation
|
||||
|
||||
// MARK: - Health
|
||||
|
||||
/// Response from `GET /healthz`.
|
||||
public struct HealthStatus: Codable, Sendable, Equatable {
|
||||
/// Always `true` if the server returned 200.
|
||||
public let ok: Bool
|
||||
/// Whether the `claude` binary was found on `PATH` inside the container.
|
||||
public let claudePresent: Bool
|
||||
/// First line of `claude --version`, or `nil` if the smoke check failed.
|
||||
public let claudeVersion: String?
|
||||
|
||||
public init(ok: Bool, claudePresent: Bool, claudeVersion: String?) {
|
||||
self.ok = ok
|
||||
self.claudePresent = claudePresent
|
||||
self.claudeVersion = claudeVersion
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case ok
|
||||
case claudePresent = "claude_present"
|
||||
case claudeVersion = "claude_version"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Run
|
||||
|
||||
/// Body for `POST /run`.
|
||||
///
|
||||
/// Only ``prompt`` is required. Fields left `nil` are omitted from the
|
||||
/// wire payload via custom encoding so the server sees the same shape
|
||||
/// the Python/Go/Rust clients send.
|
||||
public struct RunRequest: Codable, Sendable, Equatable {
|
||||
/// The user prompt to feed to `claude -p`.
|
||||
public let prompt: String
|
||||
/// Override the server's default model (e.g. `"sonnet"`, `"opus"`). `nil` uses default.
|
||||
public let model: String?
|
||||
/// Optional system prompt prepended to the run.
|
||||
public let system: String?
|
||||
/// `ff_…` file tokens previously returned by ``ForgeClient/uploadFile(at:ttlSecs:)``.
|
||||
public let files: [String]?
|
||||
/// Per-request timeout. Server clamps to `[5, 600]`. `nil` uses default.
|
||||
public let timeoutSecs: Int?
|
||||
|
||||
public init(
|
||||
prompt: String,
|
||||
model: String? = nil,
|
||||
system: String? = nil,
|
||||
files: [String]? = nil,
|
||||
timeoutSecs: Int? = nil
|
||||
) {
|
||||
self.prompt = prompt
|
||||
self.model = model
|
||||
self.system = system
|
||||
self.files = files
|
||||
self.timeoutSecs = timeoutSecs
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case prompt
|
||||
case model
|
||||
case system
|
||||
case files
|
||||
case timeoutSecs = "timeout_secs"
|
||||
}
|
||||
}
|
||||
|
||||
/// Successful response from `POST /run` (HTTP 200).
|
||||
public struct RunResult: Codable, Sendable, Equatable {
|
||||
/// Always `true` on success.
|
||||
public let ok: Bool
|
||||
/// The inner `result` from `claude -p --output-format json`. May be a
|
||||
/// JSON object/array/scalar OR a plain string if the model didn't
|
||||
/// return JSON. See ``JSONValue`` for narrowing helpers.
|
||||
public let result: JSONValue
|
||||
/// Wall-clock duration of the underlying `claude` subprocess.
|
||||
public let durationMs: Int
|
||||
/// Why the model stopped (e.g. `"end_turn"`, `"max_tokens"`).
|
||||
public let stopReason: String?
|
||||
|
||||
public init(ok: Bool, result: JSONValue, durationMs: Int, stopReason: String?) {
|
||||
self.ok = ok
|
||||
self.result = result
|
||||
self.durationMs = durationMs
|
||||
self.stopReason = stopReason
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case ok
|
||||
case result
|
||||
case durationMs = "duration_ms"
|
||||
case stopReason = "stop_reason"
|
||||
}
|
||||
}
|
||||
|
||||
/// Error-shape body returned by `POST /run` when `claude` failed (HTTP 502).
|
||||
///
|
||||
/// Surfaced inside ``ForgeError/api(statusCode:body:)`` for inspection.
|
||||
public struct RunFailure: Codable, Sendable, Equatable {
|
||||
public let ok: Bool
|
||||
public let error: String
|
||||
public let stderr: String?
|
||||
public let durationMs: Int?
|
||||
public let stopReason: String?
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case ok
|
||||
case error
|
||||
case stderr
|
||||
case durationMs = "duration_ms"
|
||||
case stopReason = "stop_reason"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Files
|
||||
|
||||
/// Response from `POST /files`.
|
||||
public struct FileToken: Codable, Sendable, Equatable {
|
||||
/// Opaque `ff_…` token to pass in ``RunRequest/files``.
|
||||
public let fileToken: String
|
||||
/// Server-acknowledged TTL (clamped to `[60, 86400]`).
|
||||
public let ttlSecs: Int
|
||||
/// Size of the uploaded file in bytes.
|
||||
public let size: Int
|
||||
|
||||
public init(fileToken: String, ttlSecs: Int, size: Int) {
|
||||
self.fileToken = fileToken
|
||||
self.ttlSecs = ttlSecs
|
||||
self.size = size
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case fileToken = "file_token"
|
||||
case ttlSecs = "ttl_secs"
|
||||
case size
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Admin tokens
|
||||
|
||||
/// Body for `POST /admin/tokens`.
|
||||
public struct CreateTokenRequest: Codable, Sendable, Equatable {
|
||||
/// Lowercase alphanumerics, `-`, `_`. Must start with `[a-z0-9]`. Max 64 chars.
|
||||
public let name: String
|
||||
/// Optional per-app CIDR allowlist on top of the global allowlist.
|
||||
public let ipCidrs: [String]
|
||||
|
||||
public init(name: String, ipCidrs: [String] = []) {
|
||||
self.name = name
|
||||
self.ipCidrs = ipCidrs
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case name
|
||||
case ipCidrs = "ip_cidrs"
|
||||
}
|
||||
}
|
||||
|
||||
/// One row from `GET /admin/tokens`. Also returned by `POST /admin/tokens`
|
||||
/// with the plaintext ``token`` populated; on `list`, `token` is `nil`.
|
||||
public struct AppToken: Codable, Sendable, Equatable {
|
||||
public let name: String
|
||||
/// Plaintext token. **Only populated on create** — the server hashes it
|
||||
/// and never exposes plaintext on subsequent list calls.
|
||||
public let token: String?
|
||||
public let ipCidrs: [String]?
|
||||
public let createdAt: Int?
|
||||
public let lastUsed: Int?
|
||||
public let enabled: Int?
|
||||
|
||||
public init(
|
||||
name: String,
|
||||
token: String? = nil,
|
||||
ipCidrs: [String]? = nil,
|
||||
createdAt: Int? = nil,
|
||||
lastUsed: Int? = nil,
|
||||
enabled: Int? = nil
|
||||
) {
|
||||
self.name = name
|
||||
self.token = token
|
||||
self.ipCidrs = ipCidrs
|
||||
self.createdAt = createdAt
|
||||
self.lastUsed = lastUsed
|
||||
self.enabled = enabled
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case name
|
||||
case token
|
||||
case ipCidrs = "ip_cidrs"
|
||||
case createdAt = "created_at"
|
||||
case lastUsed = "last_used"
|
||||
case enabled
|
||||
}
|
||||
|
||||
/// The server returns `ip_cidrs` as a comma-joined string in list
|
||||
/// responses but as an array on create. Decode handles both.
|
||||
public init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.name = try c.decode(String.self, forKey: .name)
|
||||
self.token = try c.decodeIfPresent(String.self, forKey: .token)
|
||||
self.createdAt = try c.decodeIfPresent(Int.self, forKey: .createdAt)
|
||||
self.lastUsed = try c.decodeIfPresent(Int.self, forKey: .lastUsed)
|
||||
self.enabled = try c.decodeIfPresent(Int.self, forKey: .enabled)
|
||||
|
||||
if let arr = try? c.decodeIfPresent([String].self, forKey: .ipCidrs) {
|
||||
self.ipCidrs = arr
|
||||
} else if let s = try c.decodeIfPresent(String.self, forKey: .ipCidrs) {
|
||||
let trimmed = s.split(separator: ",").map { String($0) }.filter { !$0.isEmpty }
|
||||
self.ipCidrs = trimmed.isEmpty ? nil : trimmed
|
||||
} else {
|
||||
self.ipCidrs = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Wire envelope for `GET /admin/tokens`.
|
||||
struct TokenList: Codable {
|
||||
let tokens: [AppToken]
|
||||
}
|
||||
|
||||
// MARK: - JSONValue
|
||||
|
||||
/// Heterogeneous JSON value used for ``RunResult/result``.
|
||||
///
|
||||
/// `claude -p --output-format json` returns either parsed JSON (object,
|
||||
/// array, scalar) or a plain string when the model's reply wasn't valid
|
||||
/// JSON. ``JSONValue`` represents both shapes losslessly.
|
||||
///
|
||||
/// Narrowing example:
|
||||
/// ```swift
|
||||
/// if case .object(let dict) = res.result,
|
||||
/// case .string(let hello) = dict["hello"] {
|
||||
/// print(hello)
|
||||
/// }
|
||||
/// ```
|
||||
public enum JSONValue: Codable, Sendable, Equatable {
|
||||
case object([String: JSONValue])
|
||||
case array([JSONValue])
|
||||
case string(String)
|
||||
case number(Double)
|
||||
case bool(Bool)
|
||||
case null
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let c = try decoder.singleValueContainer()
|
||||
if c.decodeNil() {
|
||||
self = .null
|
||||
} else if let b = try? c.decode(Bool.self) {
|
||||
self = .bool(b)
|
||||
} else if let n = try? c.decode(Double.self) {
|
||||
self = .number(n)
|
||||
} else if let s = try? c.decode(String.self) {
|
||||
self = .string(s)
|
||||
} else if let arr = try? c.decode([JSONValue].self) {
|
||||
self = .array(arr)
|
||||
} else if let obj = try? c.decode([String: JSONValue].self) {
|
||||
self = .object(obj)
|
||||
} else {
|
||||
throw DecodingError.dataCorruptedError(
|
||||
in: c,
|
||||
debugDescription: "JSONValue: unsupported JSON shape"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var c = encoder.singleValueContainer()
|
||||
switch self {
|
||||
case .null: try c.encodeNil()
|
||||
case .bool(let b): try c.encode(b)
|
||||
case .number(let n): try c.encode(n)
|
||||
case .string(let s): try c.encode(s)
|
||||
case .array(let a): try c.encode(a)
|
||||
case .object(let o): try c.encode(o)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Convenience accessors
|
||||
|
||||
/// Returns the string payload if this is `.string`, else `nil`.
|
||||
public var stringValue: String? {
|
||||
if case .string(let s) = self { return s }
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Returns the numeric payload if this is `.number`, else `nil`.
|
||||
public var numberValue: Double? {
|
||||
if case .number(let n) = self { return n }
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Returns the boolean payload if this is `.bool`, else `nil`.
|
||||
public var boolValue: Bool? {
|
||||
if case .bool(let b) = self { return b }
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Returns the array payload if this is `.array`, else `nil`.
|
||||
public var arrayValue: [JSONValue]? {
|
||||
if case .array(let a) = self { return a }
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Returns the object payload if this is `.object`, else `nil`.
|
||||
public var objectValue: [String: JSONValue]? {
|
||||
if case .object(let o) = self { return o }
|
||||
return nil
|
||||
}
|
||||
}
|
||||
373
clients/swift/Tests/ClawdforgeTests/ForgeClientTests.swift
Normal file
373
clients/swift/Tests/ClawdforgeTests/ForgeClientTests.swift
Normal file
|
|
@ -0,0 +1,373 @@
|
|||
// ForgeClientTests.swift
|
||||
//
|
||||
// XCTest suite for ForgeClient. All HTTP I/O is stubbed via URLProtocol so
|
||||
// tests run hermetically (no network, no clawdforge required) on macOS,
|
||||
// iOS simulator, and Linux via swift-corelibs-foundation.
|
||||
|
||||
import XCTest
|
||||
@testable import Clawdforge
|
||||
|
||||
#if canImport(FoundationNetworking)
|
||||
import FoundationNetworking
|
||||
#endif
|
||||
|
||||
// MARK: - URLProtocol stub
|
||||
|
||||
/// Minimal `URLProtocol` subclass for canned responses + assertions on the
|
||||
/// outgoing request. Set ``handler`` before each test; the handler is
|
||||
/// called once per request and returns `(HTTPURLResponse, Data)`.
|
||||
///
|
||||
/// Test-only — Swift 5.9 strict-concurrency does not flag these statics
|
||||
/// when the package is built at language mode 5.
|
||||
final class URLProtocolMock: URLProtocol, @unchecked Sendable {
|
||||
static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))?
|
||||
static var lastRequest: URLRequest?
|
||||
static var lastBody: Data?
|
||||
|
||||
static func reset() {
|
||||
handler = nil
|
||||
lastRequest = nil
|
||||
lastBody = nil
|
||||
}
|
||||
|
||||
override class func canInit(with request: URLRequest) -> Bool { true }
|
||||
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
|
||||
|
||||
override func startLoading() {
|
||||
// Capture the body. URLProtocol receives it via either httpBody or
|
||||
// httpBodyStream, depending on whether `upload(for:fromFile:)` or
|
||||
// `data(for:)` was used.
|
||||
Self.lastRequest = request
|
||||
if let body = request.httpBody {
|
||||
Self.lastBody = body
|
||||
} else if let stream = request.httpBodyStream {
|
||||
stream.open()
|
||||
defer { stream.close() }
|
||||
var collected = Data()
|
||||
let bufSize = 64 * 1024
|
||||
let buffer = UnsafeMutablePointer<UInt8>.allocate(capacity: bufSize)
|
||||
defer { buffer.deallocate() }
|
||||
while stream.hasBytesAvailable {
|
||||
let n = stream.read(buffer, maxLength: bufSize)
|
||||
if n <= 0 { break }
|
||||
collected.append(buffer, count: n)
|
||||
}
|
||||
Self.lastBody = collected
|
||||
} else {
|
||||
Self.lastBody = nil
|
||||
}
|
||||
|
||||
guard let h = Self.handler else {
|
||||
client?.urlProtocol(
|
||||
self,
|
||||
didFailWithError: URLError(.badServerResponse)
|
||||
)
|
||||
return
|
||||
}
|
||||
do {
|
||||
let (resp, data) = try h(request)
|
||||
client?.urlProtocol(self, didReceive: resp, cacheStoragePolicy: .notAllowed)
|
||||
client?.urlProtocol(self, didLoad: data)
|
||||
client?.urlProtocolDidFinishLoading(self)
|
||||
} catch {
|
||||
client?.urlProtocol(self, didFailWithError: error)
|
||||
}
|
||||
}
|
||||
|
||||
override func stopLoading() {}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func makeClient() -> ForgeClient {
|
||||
let config = URLSessionConfiguration.ephemeral
|
||||
config.protocolClasses = [URLProtocolMock.self]
|
||||
let session = URLSession(configuration: config)
|
||||
return ForgeClient(
|
||||
baseURL: URL(string: "http://forge.test")!,
|
||||
token: "cf_test_abcdef",
|
||||
session: session
|
||||
)
|
||||
}
|
||||
|
||||
private func makeResponse(
|
||||
url: URL,
|
||||
status: Int,
|
||||
headers: [String: String] = ["Content-Type": "application/json"]
|
||||
) -> HTTPURLResponse {
|
||||
HTTPURLResponse(url: url, statusCode: status, httpVersion: "HTTP/1.1", headerFields: headers)!
|
||||
}
|
||||
|
||||
// MARK: - Tests
|
||||
|
||||
final class ForgeClientTests: XCTestCase {
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
URLProtocolMock.reset()
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
URLProtocolMock.reset()
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
// 1. Healthz
|
||||
func testHealthzDecodesAndOmitsAuthHeader() async throws {
|
||||
URLProtocolMock.handler = { req in
|
||||
XCTAssertEqual(req.url?.path, "/healthz")
|
||||
XCTAssertEqual(req.httpMethod, "GET")
|
||||
// Auth header is sent as-is — server's healthz simply ignores it.
|
||||
// We just verify the request is well-formed.
|
||||
XCTAssertEqual(req.value(forHTTPHeaderField: "Authorization"), "Bearer cf_test_abcdef")
|
||||
let body = #"{"ok": true, "claude_present": true, "claude_version": "1.2.3"}"#
|
||||
return (makeResponse(url: req.url!, status: 200), Data(body.utf8))
|
||||
}
|
||||
let h = try await makeClient().healthz()
|
||||
XCTAssertTrue(h.ok)
|
||||
XCTAssertTrue(h.claudePresent)
|
||||
XCTAssertEqual(h.claudeVersion, "1.2.3")
|
||||
}
|
||||
|
||||
// 2. Run — JSON object result
|
||||
func testRunObjectResult() async throws {
|
||||
URLProtocolMock.handler = { req in
|
||||
XCTAssertEqual(req.url?.path, "/run")
|
||||
XCTAssertEqual(req.httpMethod, "POST")
|
||||
XCTAssertEqual(req.value(forHTTPHeaderField: "Content-Type"), "application/json")
|
||||
|
||||
// Verify the body uses snake_case keys.
|
||||
let body = URLProtocolMock.lastBody ?? Data()
|
||||
let json = try JSONSerialization.jsonObject(with: body) as! [String: Any]
|
||||
XCTAssertEqual(json["prompt"] as? String, "Reply with JSON")
|
||||
XCTAssertEqual(json["timeout_secs"] as? Int, 60)
|
||||
|
||||
let resp = #"""
|
||||
{
|
||||
"ok": true,
|
||||
"result": {"hello": "world", "n": 42, "ok": true},
|
||||
"duration_ms": 1234,
|
||||
"stop_reason": "end_turn"
|
||||
}
|
||||
"""#
|
||||
return (makeResponse(url: req.url!, status: 200), Data(resp.utf8))
|
||||
}
|
||||
|
||||
let res = try await makeClient().run(RunRequest(
|
||||
prompt: "Reply with JSON",
|
||||
model: "sonnet",
|
||||
timeoutSecs: 60
|
||||
))
|
||||
XCTAssertEqual(res.durationMs, 1234)
|
||||
XCTAssertEqual(res.stopReason, "end_turn")
|
||||
|
||||
guard case .object(let dict) = res.result else {
|
||||
return XCTFail("expected object")
|
||||
}
|
||||
XCTAssertEqual(dict["hello"]?.stringValue, "world")
|
||||
XCTAssertEqual(dict["n"]?.numberValue, 42)
|
||||
XCTAssertEqual(dict["ok"]?.boolValue, true)
|
||||
}
|
||||
|
||||
// 3. Run — string fallback when claude returned non-JSON
|
||||
func testRunStringResult() async throws {
|
||||
URLProtocolMock.handler = { req in
|
||||
let resp = #"""
|
||||
{"ok": true, "result": "hello world", "duration_ms": 10, "stop_reason": "end_turn"}
|
||||
"""#
|
||||
return (makeResponse(url: req.url!, status: 200), Data(resp.utf8))
|
||||
}
|
||||
let res = try await makeClient().run(RunRequest(prompt: "say hi"))
|
||||
XCTAssertEqual(res.result.stringValue, "hello world")
|
||||
}
|
||||
|
||||
// 4. Run — empty prompt rejected client-side
|
||||
func testRunEmptyPromptRejected() async {
|
||||
do {
|
||||
_ = try await makeClient().run(RunRequest(prompt: ""))
|
||||
XCTFail("expected ForgeError.invalidArgument")
|
||||
} catch ForgeError.invalidArgument {
|
||||
// expected
|
||||
} catch {
|
||||
XCTFail("wrong error: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Run — 502 surfaces as .api with parseable RunFailure body
|
||||
func testRun502SurfacesAsAPIError() async throws {
|
||||
URLProtocolMock.handler = { req in
|
||||
let body = #"""
|
||||
{"ok": false, "error": "claude exited 1", "stderr": "boom", "duration_ms": 9, "stop_reason": "error"}
|
||||
"""#
|
||||
return (makeResponse(url: req.url!, status: 502), Data(body.utf8))
|
||||
}
|
||||
do {
|
||||
_ = try await makeClient().run(RunRequest(prompt: "x"))
|
||||
XCTFail("expected throw")
|
||||
} catch ForgeError.api(let code, let body) {
|
||||
XCTAssertEqual(code, 502)
|
||||
let data = Data(body.utf8)
|
||||
let failure = try JSONDecoder().decode(RunFailure.self, from: data)
|
||||
XCTAssertEqual(failure.error, "claude exited 1")
|
||||
XCTAssertEqual(failure.stderr, "boom")
|
||||
} catch {
|
||||
XCTFail("wrong error: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Auth — 401 maps to ForgeError.auth
|
||||
func testAuthErrorOn401() async {
|
||||
URLProtocolMock.handler = { req in
|
||||
let body = #"{"detail":"bad token"}"#
|
||||
return (makeResponse(url: req.url!, status: 401), Data(body.utf8))
|
||||
}
|
||||
do {
|
||||
_ = try await makeClient().run(RunRequest(prompt: "x"))
|
||||
XCTFail("expected throw")
|
||||
} catch ForgeError.auth(let code, _) {
|
||||
XCTAssertEqual(code, 401)
|
||||
} catch {
|
||||
XCTFail("wrong error: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Admin — create token
|
||||
func testCreateTokenReturnsPlaintext() async throws {
|
||||
URLProtocolMock.handler = { req in
|
||||
XCTAssertEqual(req.url?.path, "/admin/tokens")
|
||||
XCTAssertEqual(req.httpMethod, "POST")
|
||||
|
||||
let body = URLProtocolMock.lastBody ?? Data()
|
||||
let json = try JSONSerialization.jsonObject(with: body) as! [String: Any]
|
||||
XCTAssertEqual(json["name"] as? String, "cauldron")
|
||||
XCTAssertEqual(json["ip_cidrs"] as? [String], ["10.0.0.0/8"])
|
||||
|
||||
let resp = #"""
|
||||
{"name": "cauldron", "token": "cf_NEW_xyz", "ip_cidrs": ["10.0.0.0/8"]}
|
||||
"""#
|
||||
return (makeResponse(url: req.url!, status: 200), Data(resp.utf8))
|
||||
}
|
||||
let tk = try await makeClient().createToken(
|
||||
CreateTokenRequest(name: "cauldron", ipCidrs: ["10.0.0.0/8"])
|
||||
)
|
||||
XCTAssertEqual(tk.name, "cauldron")
|
||||
XCTAssertEqual(tk.token, "cf_NEW_xyz")
|
||||
XCTAssertEqual(tk.ipCidrs, ["10.0.0.0/8"])
|
||||
}
|
||||
|
||||
// 8. Admin — list tokens (server returns ip_cidrs as comma-string)
|
||||
func testListTokensHandlesStringCidrs() async throws {
|
||||
URLProtocolMock.handler = { req in
|
||||
XCTAssertEqual(req.url?.path, "/admin/tokens")
|
||||
XCTAssertEqual(req.httpMethod, "GET")
|
||||
let resp = #"""
|
||||
{
|
||||
"tokens": [
|
||||
{"name": "cauldron", "ip_cidrs": "10.0.0.0/8,172.24.0.0/16", "created_at": 1700000000, "last_used": null, "enabled": 1},
|
||||
{"name": "petalparse", "ip_cidrs": "", "created_at": 1700000001, "last_used": 1700000999, "enabled": 1}
|
||||
]
|
||||
}
|
||||
"""#
|
||||
return (makeResponse(url: req.url!, status: 200), Data(resp.utf8))
|
||||
}
|
||||
let toks = try await makeClient().listTokens()
|
||||
XCTAssertEqual(toks.count, 2)
|
||||
XCTAssertEqual(toks[0].name, "cauldron")
|
||||
XCTAssertEqual(toks[0].ipCidrs, ["10.0.0.0/8", "172.24.0.0/16"])
|
||||
XCTAssertNil(toks[1].ipCidrs) // empty string -> nil
|
||||
}
|
||||
|
||||
// 9. Admin — revoke token issues DELETE with URL-encoded name
|
||||
func testRevokeTokenDelete() async throws {
|
||||
URLProtocolMock.handler = { req in
|
||||
XCTAssertEqual(req.httpMethod, "DELETE")
|
||||
XCTAssertTrue(req.url!.path.hasPrefix("/admin/tokens/"))
|
||||
return (makeResponse(url: req.url!, status: 200), Data(#"{"ok": true}"#.utf8))
|
||||
}
|
||||
try await makeClient().revokeToken(name: "cauldron")
|
||||
XCTAssertEqual(URLProtocolMock.lastRequest?.url?.path, "/admin/tokens/cauldron")
|
||||
}
|
||||
|
||||
// 10. Upload — multipart body wraps the file with ttl_secs field
|
||||
func testUploadFileMultipart() async throws {
|
||||
let tmp = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("clawdforge-test-\(UUID().uuidString).txt")
|
||||
try Data("hello upload".utf8).write(to: tmp)
|
||||
defer { try? FileManager.default.removeItem(at: tmp) }
|
||||
|
||||
URLProtocolMock.handler = { req in
|
||||
XCTAssertEqual(req.url?.path, "/files")
|
||||
XCTAssertEqual(req.httpMethod, "POST")
|
||||
let ctype = req.value(forHTTPHeaderField: "Content-Type") ?? ""
|
||||
XCTAssertTrue(ctype.hasPrefix("multipart/form-data; boundary="))
|
||||
|
||||
let body = URLProtocolMock.lastBody ?? Data()
|
||||
let bodyStr = String(data: body, encoding: .utf8) ?? ""
|
||||
XCTAssertTrue(bodyStr.contains("name=\"ttl_secs\""))
|
||||
XCTAssertTrue(bodyStr.contains("7200"))
|
||||
XCTAssertTrue(bodyStr.contains("name=\"file\""))
|
||||
XCTAssertTrue(bodyStr.contains("hello upload"))
|
||||
|
||||
let resp = #"""
|
||||
{"file_token": "ff_abc123", "ttl_secs": 7200, "size": 12}
|
||||
"""#
|
||||
return (makeResponse(url: req.url!, status: 200), Data(resp.utf8))
|
||||
}
|
||||
|
||||
let ft = try await makeClient().uploadFile(at: tmp, ttlSecs: 7200)
|
||||
XCTAssertEqual(ft.fileToken, "ff_abc123")
|
||||
XCTAssertEqual(ft.ttlSecs, 7200)
|
||||
XCTAssertEqual(ft.size, 12)
|
||||
}
|
||||
|
||||
// 11. JSONValue — round-trips array/null/nested
|
||||
func testJSONValueRoundTrip() throws {
|
||||
let json = #"""
|
||||
{
|
||||
"result": [1, "two", null, {"k": true}, [false, 3.14]]
|
||||
}
|
||||
"""#
|
||||
struct W: Codable { let result: JSONValue }
|
||||
let decoded = try JSONDecoder().decode(W.self, from: Data(json.utf8))
|
||||
guard case .array(let arr) = decoded.result else {
|
||||
return XCTFail("expected array")
|
||||
}
|
||||
XCTAssertEqual(arr.count, 5)
|
||||
XCTAssertEqual(arr[0].numberValue, 1)
|
||||
XCTAssertEqual(arr[1].stringValue, "two")
|
||||
if case .null = arr[2] {} else { XCTFail("expected null") }
|
||||
XCTAssertEqual(arr[3].objectValue?["k"]?.boolValue, true)
|
||||
XCTAssertEqual(arr[4].arrayValue?[1].numberValue, 3.14)
|
||||
|
||||
// re-encode and ensure stable shape
|
||||
let re = try JSONEncoder().encode(decoded)
|
||||
let reDecoded = try JSONDecoder().decode(W.self, from: re)
|
||||
XCTAssertEqual(reDecoded, decoded)
|
||||
}
|
||||
|
||||
// 12. Cancellation propagates from Task to URLSession
|
||||
func testCancellationThrows() async {
|
||||
URLProtocolMock.handler = { _ in
|
||||
// Block "forever" so the cancel path is the only way out. We
|
||||
// simulate by throwing a cancellation-like URLError if the test
|
||||
// harness doesn't tear down quickly enough.
|
||||
Thread.sleep(forTimeInterval: 0.5)
|
||||
throw URLError(.timedOut)
|
||||
}
|
||||
let client = makeClient()
|
||||
let task = Task {
|
||||
try await client.run(RunRequest(prompt: "x"))
|
||||
}
|
||||
task.cancel()
|
||||
do {
|
||||
_ = try await task.value
|
||||
XCTFail("expected throw")
|
||||
} catch is CancellationError {
|
||||
// hit the early Task.checkCancellation in send(_:decode:)
|
||||
} catch ForgeError.transport {
|
||||
// hit URLSession's cancellation pathway
|
||||
} catch {
|
||||
XCTFail("wrong error: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue