mirror of
https://github.com/trailbaseio/trailbase.git
synced 2025-12-16 23:26:18 -06:00
Move swift client to /client/swift/trailbase and update references.
This commit is contained in:
@@ -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
|
||||
|
||||
2
Makefile
2
Makefile
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
507
client/swift/trailbase/Sources/TrailBase/TrailBase.swift
Normal file
507
client/swift/trailbase/Sources/TrailBase/TrailBase.swift
Normal 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"
|
||||
202
client/swift/trailbase/Tests/TrailBaseTests/TrailBaseTests.swift
Normal file
202
client/swift/trailbase/Tests/TrailBaseTests/TrailBaseTests.swift
Normal 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 {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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 {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user