Move swift client to /client/swift/trailbase and update references.

This commit is contained in:
Sebastian Jeltsch
2025-07-24 15:07:53 +02:00
parent 6d5c2473a6
commit bdc31890a8
13 changed files with 721 additions and 711 deletions

View File

@@ -148,14 +148,15 @@ repos:
# NOTE: `swift-format` always exits with code 0, meaning we depend on
# "--in-place" and the pre-commit framework's "Post Run" hook to fail
# if files were altered.
entry: swift format -r --in-place client/trailbase-swift/**/*.swift
entry: swift format -r --in-place client/swift/trailbase/**/*.swift
language: system
types: [swift]
pass_filenames: false
- id: swift_test
name: Swift test
entry: swift test --package-path client/trailbase-swift
# NOTE: cannot test `docs/examples/record_api_swift`, since not hermetic
entry: swift test --package-path client/swift/trailbase
language: system
types: [swift]
pass_filenames: false

View File

@@ -12,7 +12,7 @@ format:
dotnet format client/trailbase-dotnet/src; \
dotnet format client/trailbase-dotnet/test; \
poetry -C client/trailbase-py run black --config pyproject.toml .; \
swift format -r -i client/trailbase-swift/**/*.swift; \
swift format -r -i client/swift/trailbase/**/*.swift; \
gofmt -w **/*.go;
check:

View File

@@ -81,7 +81,8 @@ Client packages for various languages are available via:
- [Dart/Flutter](https://pub.dev/packages/trailbase)
- [Rust](https://crates.io/crates/trailbase-client)
- [C#/.Net](https://www.nuget.org/packages/TrailBase/)
- [Swift](https://github.com/trailbaseio/trailbase/tree/main/client/trailbase-swift)
- [Swift](https://github.com/trailbaseio/trailbase/tree/main/client/swift/trailbase)
- [Go](https://github.com/trailbaseio/trailbase/tree/main/client/go/trailbase)
- [Python](https://pypi.org/project/trailbase/)
## Getting Started

View File

@@ -0,0 +1,507 @@
import Foundation
import FoundationNetworking
import Synchronization
public struct User: Hashable, Equatable {
let sub: String
let email: String
}
// NOTE: Making this explicitly public breaks compiler.
public struct Tokens: Codable, Hashable, Equatable, Sendable {
let auth_token: String
let refresh_token: String?
let csrf_token: String?
}
public struct Pagination {
public var cursor: String? = nil
public var limit: UInt? = nil
public var offset: UInt? = nil
public init(cursor: String? = nil, limit: UInt? = nil, offset: UInt? = nil) {
self.cursor = cursor
self.limit = limit
self.offset = offset
}
}
private struct JwtTokenClaims: Decodable, Hashable {
let sub: String
let iat: Int64
let exp: Int64
let email: String
let csrf_token: String
}
private struct TokenState {
let state: (Tokens, JwtTokenClaims)?
let headers: [(String, String)]
init(tokens: Tokens?) throws {
if let t = tokens {
guard let claims = decodeJwtTokenClaims(t.auth_token) else {
throw ClientError.invalidJwt
}
self.state = (t, claims)
self.headers = build_headers(tokens: tokens)
return
}
self.state = nil
self.headers = build_headers(tokens: tokens)
}
}
public enum RecordId: CustomStringConvertible {
case string(String)
case int(Int64)
public var description: String {
return switch self {
case .string(let id): id
case .int(let id): id.description
}
}
}
private struct RecordIdResponse: Codable {
public let ids: [String]
}
public struct ListResponse<T: Decodable>: Decodable {
public let cursor: String?
public let total_count: Int64?
public let records: [T]
}
public enum CompareOp {
case Equal
case NotEqual
case LessThan
case LessThanEqual
case GreaterThan
case GreaterThanEqual
case Like
case Regexp
}
extension CompareOp {
func op() -> String {
return switch self {
case .Equal: "$eq"
case .NotEqual: "$ne"
case .LessThan: "$lt"
case .LessThanEqual: "$lte"
case .GreaterThan: "$gt"
case .GreaterThanEqual: "$gte"
case .Like: "$like"
case .Regexp: "$re"
}
}
}
public enum Filter {
case Filter(column: String, op: CompareOp? = nil, value: String)
case And(filters: [Filter])
case Or(filters: [Filter])
}
public class RecordApi {
let client: Client
let name: String
public init(client: Client, name: String) {
self.client = client
self.name = name
}
public func list<T: Decodable>(
pagination: Pagination? = nil,
order: [String]? = nil,
filters: [Filter]? = nil,
expand: [String]? = nil,
count: Bool = false,
) async throws -> ListResponse<T> {
var queryParams: [URLQueryItem] = []
if let p = pagination {
if let cursor = p.cursor {
queryParams.append(URLQueryItem(name: "cursor", value: cursor))
}
if let limit = p.limit {
queryParams.append(URLQueryItem(name: "limit", value: "\(limit)"))
}
if let offset = p.offset {
queryParams.append(URLQueryItem(name: "offset", value: "\(offset)"))
}
}
if let o = order {
if !o.isEmpty {
queryParams.append(URLQueryItem(name: "order", value: o.joined(separator: ",")))
}
}
if let e = expand {
if !e.isEmpty {
queryParams.append(URLQueryItem(name: "expand", value: e.joined(separator: ",")))
}
}
if count {
queryParams.append(URLQueryItem(name: "count", value: "true"))
}
func traverseFilters(path: String, filter: Filter) {
switch filter {
case .Filter(let column, let op, let value):
if op != nil {
queryParams.append(
URLQueryItem(name: "\(path)[\(column)][\(op!.op())]", value: value))
} else {
queryParams.append(
URLQueryItem(name: "\(path)[\(column)]", value: value))
}
break
case .And(let filters):
for (i, filter) in filters.enumerated() {
traverseFilters(path: "\(path)[$and][\(i)]", filter: filter)
}
break
case .Or(let filters):
for (i, filter) in filters.enumerated() {
traverseFilters(path: "\(path)[$or][\(i)]", filter: filter)
}
break
}
}
if let f = filters {
for filter in f {
traverseFilters(path: "filter", filter: filter)
}
}
let (_, data) = try await self.client.fetch(
path: "/\(RECORD_API)/\(name)",
method: "GET",
body: nil,
queryParams: queryParams
)
return try JSONDecoder().decode(ListResponse.self, from: data)
}
public func read<T: Decodable>(recordId: RecordId, expand: [String]? = nil) async throws -> T {
let queryParams: [URLQueryItem]? =
if let e = expand {
[URLQueryItem(name: "expand", value: e.joined(separator: ","))]
} else {
nil
}
let (_, data) = try await self.client.fetch(
path: "/\(RECORD_API)/\(name)/\(recordId)", method: "GET", queryParams: queryParams)
return try JSONDecoder().decode(T.self, from: data)
}
// TODO: Implement bulk creation.
public func create<T: Encodable>(record: T) async throws -> RecordId {
let body = try JSONEncoder().encode(record)
let (_, data) = try await self.client.fetch(
path: "/\(RECORD_API)/\(name)", method: "POST", body: body)
let response = try JSONDecoder().decode(RecordIdResponse.self, from: data)
if response.ids.count != 1 {
throw ClientError.invalidResponse("expected one id")
}
return RecordId.string(response.ids[0])
}
public func update<T: Encodable>(recordId: RecordId, record: T) async throws {
let body = try JSONEncoder().encode(record)
let _ = try await self.client.fetch(
path: "/\(RECORD_API)/\(name)/\(recordId)", method: "PATCH", body: body)
}
public func delete(recordId: RecordId) async throws {
let _ = try await self.client.fetch(
path: "/\(RECORD_API)/\(name)/\(recordId)", method: "DELETE")
}
// TODO: Implement subscriptions. It seems that Swift's Foundation doesn't
// support streaming HTTP on Linux :/.
}
public enum ClientError: Error {
case invalidUrl
case invalidStatusCode(code: Int, body: String? = nil)
case invalidResponse(String?)
case invalidJwt
case unauthenticated
case invalidFilter(String)
}
private class ThinClient {
private let base: URL
private let session: URLSession
init(base: URL) {
self.base = base
self.session = URLSession(configuration: URLSessionConfiguration.default)
}
func fetch(
path: String,
headers: [(String, String)],
method: String,
body: Data? = nil,
queryParams: [URLQueryItem]? = nil,
) async throws -> (HTTPURLResponse, Data) {
assert(path.starts(with: "/"))
guard var url = URL(string: path, relativeTo: self.base) else {
throw ClientError.invalidUrl
}
if let params = queryParams {
url.append(queryItems: params)
}
var request = URLRequest(url: url)
for (name, value) in headers {
request.setValue(value, forHTTPHeaderField: name)
}
request.httpMethod = method
request.httpBody = body
let (data, response) = try await self.session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw ClientError.invalidStatusCode(code: -1)
}
guard (200...299).contains(httpResponse.statusCode) else {
throw ClientError.invalidStatusCode(
code: httpResponse.statusCode, body: String(data: data, encoding: .utf8))
}
return (httpResponse, data)
}
}
public class Client {
private let base: URL
private let client: ThinClient
private let tokenState: Mutex<TokenState>
public init(site: URL, tokens: Tokens? = nil) throws {
self.base = site
self.client = ThinClient(base: site)
self.tokenState = Mutex(try TokenState(tokens: tokens))
}
public var site: URL {
return self.base
}
public var tokens: Tokens? {
return self.tokenState.withLock({ (state) in
if let tokens = state.state?.0 {
return tokens
}
return nil
})
}
public var user: User? {
return self.tokenState.withLock({ (state) in
if let claims = state.state?.1 {
return User(sub: claims.sub, email: claims.email)
}
return nil
})
}
public func records(_ name: String) -> RecordApi {
return RecordApi(client: self, name: name)
}
public func refresh() async throws {
guard let (headers, refreshToken) = getHeaderAndRefreshToken() else {
throw ClientError.unauthenticated
}
let newTokens = try await Client.doRefreshToken(
client: self.client, headers: headers, refreshToken: refreshToken)
self.tokenState.withLock({ (tokens) in
tokens = newTokens
})
}
public func login(email: String, password: String) async throws -> Tokens {
struct Credentials: Codable {
let email: String
let password: String
}
let body = try JSONEncoder().encode(Credentials(email: email, password: password))
let (_, data) = try await self.fetch(
path: "/\(AUTH_API)/login", method: "POST", body: body)
let tokens = try JSONDecoder().decode(Tokens.self, from: data)
let _ = try updateTokens(tokens: tokens)
return tokens
}
public func logout() async throws {
struct LogoutRequest: Encodable {
let refresh_token: String
}
if let (_, refreshToken) = getHeaderAndRefreshToken() {
let body = try JSONEncoder().encode(LogoutRequest(refresh_token: refreshToken))
let _ = try await self.fetch(
path: "/\(AUTH_API)/logout", method: "POST", body: body)
} else {
let _ = try await self.fetch(
path: "/\(AUTH_API)/logout", method: "GET")
}
let _ = try self.updateTokens(tokens: nil)
}
private func updateTokens(tokens: Tokens?) throws -> TokenState {
let state = try TokenState(tokens: tokens)
self.tokenState.withLock({ (tokens) in
tokens = state
})
return state
}
fileprivate func fetch(
path: String,
method: String,
body: Data? = nil,
queryParams: [URLQueryItem]? = nil,
) async throws -> (HTTPURLResponse, Data) {
var (headers, refreshToken) = getHeadersAndRefreshTokenIfExpired()
if let rt = refreshToken {
let newTokens = try await Client.doRefreshToken(
client: self.client, headers: headers, refreshToken: rt)
headers = newTokens.headers
self.tokenState.withLock({ (tokens) in
tokens = newTokens
})
}
return try await client.fetch(
path: path, headers: headers, method: method, body: body, queryParams: queryParams)
}
private func getHeaderAndRefreshToken() -> ([(String, String)], String)? {
return self.tokenState.withLock({ (tokens) in
if let s = tokens.state {
if let refreshToken = s.0.refresh_token {
return (tokens.headers, refreshToken)
}
}
return nil
})
}
private func getHeadersAndRefreshTokenIfExpired() -> ([(String, String)], String?) {
func shouldRefresh(exp: Int64) -> Bool {
Double(exp) - 60 < NSDate().timeIntervalSince1970
}
return self.tokenState.withLock({ (tokens) in
if let s = tokens.state {
if shouldRefresh(exp: s.1.exp) {
return (tokens.headers, s.0.refresh_token)
}
}
return (tokens.headers, nil)
})
}
private static func doRefreshToken(
client: ThinClient, headers: [(String, String)], refreshToken: String
) async throws -> TokenState {
struct RefreshRequest: Encodable {
let refresh_token: String
}
let body = try JSONEncoder().encode(RefreshRequest(refresh_token: refreshToken))
let (_, data) = try await client.fetch(
path: "/\(AUTH_API)/refresh", headers: headers, method: "POST", body: body)
struct RefreshResponse: Decodable {
let auth_token: String
let csrf_token: String?
}
let refreshResponse = try JSONDecoder().decode(RefreshResponse.self, from: data)
let tokens = Tokens(
auth_token: refreshResponse.auth_token,
refresh_token: refreshToken,
csrf_token: refreshResponse.csrf_token,
)
return try TokenState(tokens: tokens)
}
}
private func build_headers(tokens: Tokens?) -> [(String, String)] {
var headers: [(String, String)] = [
("Content-Type", "application/json")
]
if let t = tokens {
headers.append(("Authorization", "Bearer \(t.auth_token)"))
if let rt = t.refresh_token {
headers.append(("Refresh-Token", rt))
}
if let csrf = t.csrf_token {
headers.append(("CSRF-Token", csrf))
}
}
return headers
}
private func base64URLDecode(_ value: String) -> Data? {
var base64 = value.replacingOccurrences(of: "-", with: "+")
.replacingOccurrences(of: "_", with: "/")
let length = Double(base64.lengthOfBytes(using: .utf8))
let requiredLength = 4 * ceil(length / 4.0)
let paddingLength = requiredLength - length
if paddingLength > 0 {
let padding = "".padding(toLength: Int(paddingLength), withPad: "=", startingAt: 0)
base64 = base64 + padding
}
return Data(base64Encoded: base64, options: .ignoreUnknownCharacters)
}
private func decodeJwtTokenClaims(_ jwt: String) -> JwtTokenClaims? {
let parts = jwt.split(separator: ".")
guard parts.count == 3 else {
return nil
}
let payload = String(parts[1])
guard let data = base64URLDecode(payload) else {
return nil
}
do {
let claims = try JSONDecoder().decode(JwtTokenClaims.self, from: data)
return claims
} catch {
return nil
}
}
private let AUTH_API = "api/auth/v1"
private let RECORD_API = "api/records/v1"

View File

@@ -0,0 +1,202 @@
import Foundation
import FoundationNetworking
import Subprocess
import SystemPackage
import Testing
@testable import TrailBase
let PORT: UInt16 = 4058
func panic(_ msg: String) -> Never {
print("ABORT: \(msg)", FileHandle.standardError)
abort()
}
struct SimpleStrict: Codable, Equatable {
var id: String? = nil
var text_null: String? = nil
var text_default: String? = nil
let text_not_null: String
}
func connect() async throws -> Client {
let client = try Client(site: URL(string: "http://127.0.0.1:\(PORT)")!, tokens: nil)
let _ = try await client.login(email: "admin@localhost", password: "secret")
return client
}
public enum StartupError: Error {
case configNotFound(path: String)
case buildFailed(stdout: String?, stderr: String?)
case startupTimeout
}
func startTrailBase() async throws -> ProcessIdentifier {
let cwd = FilePath("../../../")
let depotPath = "client/testfixture"
let traildepot = cwd.appending(depotPath).string
if !FileManager.default.fileExists(atPath: traildepot) {
throw StartupError.configNotFound(path: traildepot)
}
let build = try await Subprocess.run(
.name("cargo"), arguments: ["build"], workingDirectory: cwd, output: .string, error: .string
)
if !build.terminationStatus.isSuccess {
throw StartupError.buildFailed(stdout: build.standardOutput, stderr: build.standardError)
}
let arguments: Arguments = [
"run",
"--",
"--data-dir=\(depotPath)",
"run",
"--address=127.0.0.1:\(PORT)",
"--js-runtime-threads=2",
]
let process = try Subprocess.runDetached(
.name("cargo"),
arguments: arguments,
workingDirectory: cwd,
output: .standardOutput,
error: .standardError,
)
// Make sure it's up and running.
let request = URLRequest(url: URL(string: "http://127.0.0.1:\(PORT)/api/healthcheck")!)
for _ in 0...100 {
do {
let (data, _) = try await URLSession.shared.data(for: request)
let body = String(data: data, encoding: .utf8)!
if body.uppercased() == "OK" {
print("Started TrailBase")
return process
}
} catch {
}
usleep(500 * 1000)
}
kill(process.value, SIGKILL)
throw StartupError.startupTimeout
}
final class SetupTrailBaseTrait: SuiteTrait, TestScoping {
// Only apply to Suite and not recursively to tests (also is default).
public var isRecursive: Bool { false }
func provideScope(
for test: Test,
testCase: Test.Case?,
performing: () async throws -> Void
) async throws {
// Setup
print("Starting TrailBase \(test.name)")
let process = try await startTrailBase()
// Run the actual test suite, i.e. all tests:
do {
try await performing()
} catch {
}
// Tear-down
print("Killing TrailBase \(test.name)")
kill(process.value, SIGKILL)
}
}
extension Trait where Self == SetupTrailBaseTrait {
static var setupTrailBase: Self { Self() }
}
@Suite(.setupTrailBase) struct ClientTestSuite {
@Test("Test Authentication") func testAuth() async throws {
let client = try await connect()
#expect(client.tokens?.refresh_token != nil)
#expect(client.user!.email == "admin@localhost")
try await client.refresh()
try await client.logout()
#expect(client.tokens == nil)
#expect(client.user == nil)
}
@Test func recordTest() async throws {
let client = try await connect()
let api = client.records("simple_strict_table")
let now = NSDate().timeIntervalSince1970
let messages = [
"swift client test 0: =?&\(now)",
"swift client test 1: =?&\(now)",
]
var ids: [RecordId] = []
for message in messages {
ids.append(try await api.create(record: SimpleStrict(text_not_null: message)))
}
// Read
let record0Read: SimpleStrict = try await api.read(recordId: ids[0])
assert(record0Read.text_not_null == messages[0])
// List a specific message
if true {
let filter = Filter.Filter(column: "text_not_null", value: messages[0])
let response: ListResponse<SimpleStrict> = try await api.list(filters: [filter])
assert(response.records.count == 1)
let secondResponse: ListResponse<SimpleStrict> = try await api.list(
pagination: Pagination(cursor: response.cursor), filters: [filter])
assert(secondResponse.records.count == 0)
}
// List all the messages
if true {
let filter = Filter.Filter(
column: "text_not_null", op: CompareOp.Like, value: "% =?&\(now)")
let ascending: ListResponse<SimpleStrict> = try await api.list(
order: ["+text_not_null"], filters: [filter], count: true)
assert(
ascending.records.map({ record in
return record.text_not_null
}) == messages)
assert(ascending.total_count == 2)
let descending: ListResponse<SimpleStrict> = try await api.list(
order: ["-text_not_null"], filters: [filter], count: true)
assert(
descending.records.map({ record in
return record.text_not_null
}) == messages.reversed())
assert(descending.total_count == 2)
}
// Update
let updatedMessage = "swift client updated test 0: =?&\(now)"
try await api.update(recordId: ids[0], record: SimpleStrict(text_not_null: updatedMessage))
let record0Update: SimpleStrict = try await api.read(recordId: ids[0])
assert(record0Update.text_not_null == updatedMessage)
// Delete
try await api.delete(recordId: ids[0])
do {
let _: SimpleStrict = try await api.read(recordId: ids[0])
assert(false)
} catch {
}
}
}

View File

@@ -1,507 +0,0 @@
import Foundation
import FoundationNetworking
import Synchronization
public struct User: Hashable, Equatable {
let sub: String
let email: String
}
// NOTE: Making this explicitly public breaks compiler.
public struct Tokens: Codable, Hashable, Equatable, Sendable {
let auth_token: String
let refresh_token: String?
let csrf_token: String?
}
public struct Pagination {
public var cursor: String? = nil
public var limit: UInt? = nil
public var offset: UInt? = nil
public init(cursor: String? = nil, limit: UInt? = nil, offset: UInt? = nil) {
self.cursor = cursor
self.limit = limit
self.offset = offset
}
}
private struct JwtTokenClaims: Decodable, Hashable {
let sub: String
let iat: Int64
let exp: Int64
let email: String
let csrf_token: String
}
private struct TokenState {
let state: (Tokens, JwtTokenClaims)?
let headers: [(String, String)]
init(tokens: Tokens?) throws {
if let t = tokens {
guard let claims = decodeJwtTokenClaims(t.auth_token) else {
throw ClientError.invalidJwt
}
self.state = (t, claims)
self.headers = build_headers(tokens: tokens)
return
}
self.state = nil
self.headers = build_headers(tokens: tokens)
}
}
public enum RecordId: CustomStringConvertible {
case string(String)
case int(Int64)
public var description: String {
return switch self {
case .string(let id): id
case .int(let id): id.description
}
}
}
private struct RecordIdResponse: Codable {
public let ids: [String]
}
public struct ListResponse<T: Decodable>: Decodable {
public let cursor: String?
public let total_count: Int64?
public let records: [T]
}
public enum CompareOp {
case Equal
case NotEqual
case LessThan
case LessThanEqual
case GreaterThan
case GreaterThanEqual
case Like
case Regexp
}
extension CompareOp {
func op() -> String {
return switch self {
case .Equal: "$eq"
case .NotEqual: "$ne"
case .LessThan: "$lt"
case .LessThanEqual: "$lte"
case .GreaterThan: "$gt"
case .GreaterThanEqual: "$gte"
case .Like: "$like"
case .Regexp: "$re"
}
}
}
public enum Filter {
case Filter(column: String, op: CompareOp? = nil, value: String)
case And(filters: [Filter])
case Or(filters: [Filter])
}
public class RecordApi {
let client: Client
let name: String
public init(client: Client, name: String) {
self.client = client
self.name = name
}
public func list<T: Decodable>(
pagination: Pagination? = nil,
order: [String]? = nil,
filters: [Filter]? = nil,
expand: [String]? = nil,
count: Bool = false,
) async throws -> ListResponse<T> {
var queryParams: [URLQueryItem] = []
if let p = pagination {
if let cursor = p.cursor {
queryParams.append(URLQueryItem(name: "cursor", value: cursor))
}
if let limit = p.limit {
queryParams.append(URLQueryItem(name: "limit", value: "\(limit)"))
}
if let offset = p.offset {
queryParams.append(URLQueryItem(name: "offset", value: "\(offset)"))
}
}
if let o = order {
if !o.isEmpty {
queryParams.append(URLQueryItem(name: "order", value: o.joined(separator: ",")))
}
}
if let e = expand {
if !e.isEmpty {
queryParams.append(URLQueryItem(name: "expand", value: e.joined(separator: ",")))
}
}
if count {
queryParams.append(URLQueryItem(name: "count", value: "true"))
}
func traverseFilters(path: String, filter: Filter) {
switch filter {
case .Filter(let column, let op, let value):
if op != nil {
queryParams.append(
URLQueryItem(name: "\(path)[\(column)][\(op!.op())]", value: value))
} else {
queryParams.append(
URLQueryItem(name: "\(path)[\(column)]", value: value))
}
break
case .And(let filters):
for (i, filter) in filters.enumerated() {
traverseFilters(path: "\(path)[$and][\(i)]", filter: filter)
}
break
case .Or(let filters):
for (i, filter) in filters.enumerated() {
traverseFilters(path: "\(path)[$or][\(i)]", filter: filter)
}
break
}
}
if let f = filters {
for filter in f {
traverseFilters(path: "filter", filter: filter)
}
}
let (_, data) = try await self.client.fetch(
path: "/\(RECORD_API)/\(name)",
method: "GET",
body: nil,
queryParams: queryParams
)
return try JSONDecoder().decode(ListResponse.self, from: data)
}
public func read<T: Decodable>(recordId: RecordId, expand: [String]? = nil) async throws -> T {
let queryParams: [URLQueryItem]? =
if let e = expand {
[URLQueryItem(name: "expand", value: e.joined(separator: ","))]
} else {
nil
}
let (_, data) = try await self.client.fetch(
path: "/\(RECORD_API)/\(name)/\(recordId)", method: "GET", queryParams: queryParams)
return try JSONDecoder().decode(T.self, from: data)
}
// TODO: Implement bulk creation.
public func create<T: Encodable>(record: T) async throws -> RecordId {
let body = try JSONEncoder().encode(record)
let (_, data) = try await self.client.fetch(
path: "/\(RECORD_API)/\(name)", method: "POST", body: body)
let response = try JSONDecoder().decode(RecordIdResponse.self, from: data)
if response.ids.count != 1 {
throw ClientError.invalidResponse("expected one id")
}
return RecordId.string(response.ids[0])
}
public func update<T: Encodable>(recordId: RecordId, record: T) async throws {
let body = try JSONEncoder().encode(record)
let _ = try await self.client.fetch(
path: "/\(RECORD_API)/\(name)/\(recordId)", method: "PATCH", body: body)
}
public func delete(recordId: RecordId) async throws {
let _ = try await self.client.fetch(
path: "/\(RECORD_API)/\(name)/\(recordId)", method: "DELETE")
}
// TODO: Implement subscriptions. It seems that Swift's Foundation doesn't
// support streaming HTTP on Linux :/.
}
public enum ClientError: Error {
case invalidUrl
case invalidStatusCode(code: Int, body: String? = nil)
case invalidResponse(String?)
case invalidJwt
case unauthenticated
case invalidFilter(String)
}
private class ThinClient {
private let base: URL
private let session: URLSession
init(base: URL) {
self.base = base
self.session = URLSession(configuration: URLSessionConfiguration.default)
}
func fetch(
path: String,
headers: [(String, String)],
method: String,
body: Data? = nil,
queryParams: [URLQueryItem]? = nil,
) async throws -> (HTTPURLResponse, Data) {
assert(path.starts(with: "/"))
guard var url = URL(string: path, relativeTo: self.base) else {
throw ClientError.invalidUrl
}
if let params = queryParams {
url.append(queryItems: params)
}
var request = URLRequest(url: url)
for (name, value) in headers {
request.setValue(value, forHTTPHeaderField: name)
}
request.httpMethod = method
request.httpBody = body
let (data, response) = try await self.session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw ClientError.invalidStatusCode(code: -1)
}
guard (200...299).contains(httpResponse.statusCode) else {
throw ClientError.invalidStatusCode(
code: httpResponse.statusCode, body: String(data: data, encoding: .utf8))
}
return (httpResponse, data)
}
}
public class Client {
private let base: URL
private let client: ThinClient
private let tokenState: Mutex<TokenState>
public init(site: URL, tokens: Tokens? = nil) throws {
self.base = site
self.client = ThinClient(base: site)
self.tokenState = Mutex(try TokenState(tokens: tokens))
}
public var site: URL {
return self.base
}
public var tokens: Tokens? {
return self.tokenState.withLock({ (state) in
if let tokens = state.state?.0 {
return tokens
}
return nil
})
}
public var user: User? {
return self.tokenState.withLock({ (state) in
if let claims = state.state?.1 {
return User(sub: claims.sub, email: claims.email)
}
return nil
})
}
public func records(_ name: String) -> RecordApi {
return RecordApi(client: self, name: name)
}
public func refresh() async throws {
guard let (headers, refreshToken) = getHeaderAndRefreshToken() else {
throw ClientError.unauthenticated
}
let newTokens = try await Client.doRefreshToken(
client: self.client, headers: headers, refreshToken: refreshToken)
self.tokenState.withLock({ (tokens) in
tokens = newTokens
})
}
public func login(email: String, password: String) async throws -> Tokens {
struct Credentials: Codable {
let email: String
let password: String
}
let body = try JSONEncoder().encode(Credentials(email: email, password: password))
let (_, data) = try await self.fetch(
path: "/\(AUTH_API)/login", method: "POST", body: body)
let tokens = try JSONDecoder().decode(Tokens.self, from: data)
let _ = try updateTokens(tokens: tokens)
return tokens
}
public func logout() async throws {
struct LogoutRequest: Encodable {
let refresh_token: String
}
if let (_, refreshToken) = getHeaderAndRefreshToken() {
let body = try JSONEncoder().encode(LogoutRequest(refresh_token: refreshToken))
let _ = try await self.fetch(
path: "/\(AUTH_API)/logout", method: "POST", body: body)
} else {
let _ = try await self.fetch(
path: "/\(AUTH_API)/logout", method: "GET")
}
let _ = try self.updateTokens(tokens: nil)
}
private func updateTokens(tokens: Tokens?) throws -> TokenState {
let state = try TokenState(tokens: tokens)
self.tokenState.withLock({ (tokens) in
tokens = state
})
return state
}
fileprivate func fetch(
path: String,
method: String,
body: Data? = nil,
queryParams: [URLQueryItem]? = nil,
) async throws -> (HTTPURLResponse, Data) {
var (headers, refreshToken) = getHeadersAndRefreshTokenIfExpired()
if let rt = refreshToken {
let newTokens = try await Client.doRefreshToken(
client: self.client, headers: headers, refreshToken: rt)
headers = newTokens.headers
self.tokenState.withLock({ (tokens) in
tokens = newTokens
})
}
return try await client.fetch(
path: path, headers: headers, method: method, body: body, queryParams: queryParams)
}
private func getHeaderAndRefreshToken() -> ([(String, String)], String)? {
return self.tokenState.withLock({ (tokens) in
if let s = tokens.state {
if let refreshToken = s.0.refresh_token {
return (tokens.headers, refreshToken)
}
}
return nil
})
}
private func getHeadersAndRefreshTokenIfExpired() -> ([(String, String)], String?) {
func shouldRefresh(exp: Int64) -> Bool {
Double(exp) - 60 < NSDate().timeIntervalSince1970
}
return self.tokenState.withLock({ (tokens) in
if let s = tokens.state {
if shouldRefresh(exp: s.1.exp) {
return (tokens.headers, s.0.refresh_token)
}
}
return (tokens.headers, nil)
})
}
private static func doRefreshToken(
client: ThinClient, headers: [(String, String)], refreshToken: String
) async throws -> TokenState {
struct RefreshRequest: Encodable {
let refresh_token: String
}
let body = try JSONEncoder().encode(RefreshRequest(refresh_token: refreshToken))
let (_, data) = try await client.fetch(
path: "/\(AUTH_API)/refresh", headers: headers, method: "POST", body: body)
struct RefreshResponse: Decodable {
let auth_token: String
let csrf_token: String?
}
let refreshResponse = try JSONDecoder().decode(RefreshResponse.self, from: data)
let tokens = Tokens(
auth_token: refreshResponse.auth_token,
refresh_token: refreshToken,
csrf_token: refreshResponse.csrf_token,
)
return try TokenState(tokens: tokens)
}
}
private func build_headers(tokens: Tokens?) -> [(String, String)] {
var headers: [(String, String)] = [
("Content-Type", "application/json")
]
if let t = tokens {
headers.append(("Authorization", "Bearer \(t.auth_token)"))
if let rt = t.refresh_token {
headers.append(("Refresh-Token", rt))
}
if let csrf = t.csrf_token {
headers.append(("CSRF-Token", csrf))
}
}
return headers
}
private func base64URLDecode(_ value: String) -> Data? {
var base64 = value.replacingOccurrences(of: "-", with: "+")
.replacingOccurrences(of: "_", with: "/")
let length = Double(base64.lengthOfBytes(using: .utf8))
let requiredLength = 4 * ceil(length / 4.0)
let paddingLength = requiredLength - length
if paddingLength > 0 {
let padding = "".padding(toLength: Int(paddingLength), withPad: "=", startingAt: 0)
base64 = base64 + padding
}
return Data(base64Encoded: base64, options: .ignoreUnknownCharacters)
}
private func decodeJwtTokenClaims(_ jwt: String) -> JwtTokenClaims? {
let parts = jwt.split(separator: ".")
guard parts.count == 3 else {
return nil
}
let payload = String(parts[1])
guard let data = base64URLDecode(payload) else {
return nil
}
do {
let claims = try JSONDecoder().decode(JwtTokenClaims.self, from: data)
return claims
} catch {
return nil
}
}
private let AUTH_API = "api/auth/v1"
private let RECORD_API = "api/records/v1"

View File

@@ -1,196 +0,0 @@
import Foundation
import FoundationNetworking
import Subprocess
import SystemPackage
import Testing
@testable import TrailBase
let PORT: UInt16 = 4058
func panic(_ msg: String) -> Never {
print("ABORT: \(msg)", FileHandle.standardError)
abort()
}
struct SimpleStrict: Codable, Equatable {
var id: String? = nil
var text_null: String? = nil
var text_default: String? = nil
let text_not_null: String
}
func connect() async throws -> Client {
let client = try Client(site: URL(string: "http://localhost:\(PORT)")!, tokens: nil)
let _ = try await client.login(email: "admin@localhost", password: "secret")
return client
}
public enum StartupError: Error {
case buildFailed(stdout: String?, stderr: String?)
case startupTimeout
}
func startTrailBase() async throws -> ProcessIdentifier {
let cwd = FilePath("../..")
let depotPath = "client/testfixture"
let build = try await Subprocess.run(
.name("cargo"), arguments: ["build"], workingDirectory: cwd, output: .string, error: .string
)
if !build.terminationStatus.isSuccess {
throw StartupError.buildFailed(stdout: build.standardOutput, stderr: build.standardError)
}
let arguments: Arguments = [
"run",
"--",
"--data-dir=\(depotPath)",
"run",
"--address=127.0.0.1:\(PORT)",
"--js-runtime-threads=2",
]
let process = try Subprocess.runDetached(
.name("cargo"),
arguments: arguments,
workingDirectory: cwd,
output: .standardOutput,
error: .standardError,
)
// Make sure it's up and running.
let request = URLRequest(url: URL(string: "http://127.0.0.1:\(PORT)/api/healthcheck")!)
for _ in 0...100 {
do {
let (data, _) = try await URLSession.shared.data(for: request)
let body = String(data: data, encoding: .utf8)!
if body.uppercased() == "OK" {
return process
}
} catch {
}
usleep(500 * 1000)
}
kill(process.value, SIGKILL)
throw StartupError.startupTimeout
}
final class SetupTrailBaseTrait: SuiteTrait, TestScoping {
// Only apply to Suite and not recursively to tests (also is default).
public var isRecursive: Bool { false }
func provideScope(
for test: Test,
testCase: Test.Case?,
performing: () async throws -> Void
) async throws {
// Setup
print("Starting TrailBase \(test.name)")
let process = try await startTrailBase()
// Run the actual test suite, i.e. all tests:
do {
try await performing()
} catch {
}
// Tear-down
print("Killing TrailBase \(test.name)")
kill(process.value, SIGKILL)
}
}
extension Trait where Self == SetupTrailBaseTrait {
static var setupTrailBase: Self { Self() }
}
@Suite(.setupTrailBase) struct ClientTestSuite {
@Test("Test Authentication") func testAuth() async throws {
let client = try await connect()
#expect(client.tokens?.refresh_token != nil)
#expect(client.user!.email == "admin@localhost")
try await client.refresh()
try await client.logout()
#expect(client.tokens == nil)
#expect(client.user == nil)
}
@Test func recordTest() async throws {
let client = try await connect()
let api = client.records("simple_strict_table")
let now = NSDate().timeIntervalSince1970
let messages = [
"swift client test 0: =?&\(now)",
"swift client test 1: =?&\(now)",
]
var ids: [RecordId] = []
for message in messages {
ids.append(try await api.create(record: SimpleStrict(text_not_null: message)))
}
// Read
let record0Read: SimpleStrict = try await api.read(recordId: ids[0])
assert(record0Read.text_not_null == messages[0])
// List a specific message
if true {
let filter = Filter.Filter(column: "text_not_null", value: messages[0])
let response: ListResponse<SimpleStrict> = try await api.list(filters: [filter])
assert(response.records.count == 1)
let secondResponse: ListResponse<SimpleStrict> = try await api.list(
pagination: Pagination(cursor: response.cursor), filters: [filter])
assert(secondResponse.records.count == 0)
}
// List all the messages
if true {
let filter = Filter.Filter(
column: "text_not_null", op: CompareOp.Like, value: "% =?&\(now)")
let ascending: ListResponse<SimpleStrict> = try await api.list(
order: ["+text_not_null"], filters: [filter], count: true)
assert(
ascending.records.map({ record in
return record.text_not_null
}) == messages)
assert(ascending.total_count == 2)
let descending: ListResponse<SimpleStrict> = try await api.list(
order: ["-text_not_null"], filters: [filter], count: true)
assert(
descending.records.map({ record in
return record.text_not_null
}) == messages.reversed())
assert(descending.total_count == 2)
}
// Update
let updatedMessage = "swift client updated test 0: =?&\(now)"
try await api.update(recordId: ids[0], record: SimpleStrict(text_not_null: updatedMessage))
let record0Update: SimpleStrict = try await api.read(recordId: ids[0])
assert(record0Update.text_not_null == updatedMessage)
// Delete
try await api.delete(recordId: ids[0])
do {
let _: SimpleStrict = try await api.read(recordId: ids[0])
assert(false)
} catch {
}
}
}

View File

@@ -11,13 +11,13 @@ let package = Package(
targets: ["RecordApiDocs"])
],
dependencies: [
.package(path: "../../../client/trailbase-swift")
.package(path: "../../../client/swift/trailbase")
],
targets: [
.target(
name: "RecordApiDocs",
dependencies: [
.product(name: "TrailBase", package: "trailbase-swift")
.product(name: "TrailBase", package: "trailbase")
]
),
.testTarget(

View File

@@ -17,7 +17,9 @@ struct SimpleStrict: Codable, Equatable {
let _ = try await client.login(email: "admin@localhost", password: "secret")
let movies = try await list(client: client)
#expect(movies.records.count == 3)
let _ = movies
// TODO: Non-hermetic instance may not have movies initialized.
// #expect(movies.records.count == 3)
let id = try await create(client: client)
try await update(client: client, id: id)

View File

@@ -119,7 +119,7 @@ export const demoLink = "https://demo.trailbase.io";
<Image margin={0} class="p-0 m-0" width={42} height={52} src={flutterLogo} alt="Flutter" />
</a>
<a href="https://github.com/trailbaseio/trailbase/tree/main/client/trailbase-swift">
<a href="https://github.com/trailbaseio/trailbase/tree/main/client/swift/trailbase">
<Image margin={0} class="p-0 m-0" width={52} height={52} src={swiftLogo} alt="Swift" />
</a>