Files
sdk-for-apple/Sources/Appwrite/Client.swift
T
2026-03-03 10:07:37 +00:00

665 lines
20 KiB
Swift

import NIO
import NIOCore
import NIOFoundationCompat
import NIOSSL
import Foundation
import AsyncHTTPClient
@_exported import AppwriteModels
@_exported import JSONCodable
let DASHDASH = "--"
let CRLF = "\r\n"
open class Client {
// MARK: Properties
public static var chunkSize = 5 * 1024 * 1024 // 5MB
open var endPoint = "https://cloud.appwrite.io/v1"
open var endPointRealtime: String? = nil
open var headers: [String: String] = [
"content-type": "application/json",
"x-sdk-name": "Apple",
"x-sdk-platform": "client",
"x-sdk-language": "apple",
"x-sdk-version": "15.0.0",
"x-appwrite-response-format": "1.8.0"
]
internal var config: [String: String] = [:]
internal var selfSigned: Bool = false
internal var http: HTTPClient
private static let boundaryChars = "abcdefghijklmnopqrstuvwxyz1234567890"
private static let boundary = randomBoundary()
private static var eventLoopGroupProvider = HTTPClient.EventLoopGroupProvider.singleton
// MARK: Methods
public init() {
http = Client.createHTTP()
addUserAgentHeader()
addOriginHeader()
}
private static func createHTTP(
selfSigned: Bool = false,
maxRedirects: Int = 5,
alloweRedirectCycles: Bool = false,
connectTimeout: TimeAmount = .seconds(30),
readTimeout: TimeAmount = .seconds(30)
) -> HTTPClient {
let timeout = HTTPClient.Configuration.Timeout(
connect: connectTimeout,
read: readTimeout
)
let redirect = HTTPClient.Configuration.RedirectConfiguration.follow(
max: 5,
allowCycles: false
)
var tls = TLSConfiguration
.makeClientConfiguration()
if selfSigned {
tls.certificateVerification = .none
}
return HTTPClient(
eventLoopGroupProvider: eventLoopGroupProvider,
configuration: HTTPClient.Configuration(
tlsConfiguration: tls,
redirectConfiguration: redirect,
timeout: timeout,
decompression: .enabled(limit: .none)
)
)
}
deinit {
do {
try http.syncShutdown()
} catch {
print(error)
}
}
///
/// Set Project
///
/// Your project ID
///
/// @param String value
///
/// @return Client
///
open func setProject(_ value: String) -> Client {
config["project"] = value
_ = addHeader(key: "X-Appwrite-Project", value: value)
return self
}
///
/// Set JWT
///
/// Your secret JSON Web Token
///
/// @param String value
///
/// @return Client
///
open func setJWT(_ value: String) -> Client {
config["jwt"] = value
_ = addHeader(key: "X-Appwrite-JWT", value: value)
return self
}
///
/// Set Locale
///
/// @param String value
///
/// @return Client
///
open func setLocale(_ value: String) -> Client {
config["locale"] = value
_ = addHeader(key: "X-Appwrite-Locale", value: value)
return self
}
///
/// Set Session
///
/// The user session to authenticate with
///
/// @param String value
///
/// @return Client
///
open func setSession(_ value: String) -> Client {
config["session"] = value
_ = addHeader(key: "X-Appwrite-Session", value: value)
return self
}
///
/// Set DevKey
///
/// Your secret dev API key
///
/// @param String value
///
/// @return Client
///
open func setDevKey(_ value: String) -> Client {
config["devkey"] = value
_ = addHeader(key: "X-Appwrite-Dev-Key", value: value)
return self
}
///
/// Set self signed
///
/// @param Bool status
///
/// @return Client
///
open func setSelfSigned(_ status: Bool = true) -> Client {
self.selfSigned = status
try! http.syncShutdown()
http = Client.createHTTP(selfSigned: status)
return self
}
///
/// Set endpoint
///
/// @param String endPoint
///
/// @return Client
///
open func setEndpoint(_ endPoint: String) -> Client {
if !endPoint.hasPrefix("http://") && !endPoint.hasPrefix("https://") {
fatalError("Invalid endpoint URL: \(endPoint)")
}
self.endPoint = endPoint
self.endPointRealtime = endPoint
.replacingOccurrences(of: "http://", with: "ws://")
.replacingOccurrences(of: "https://", with: "wss://")
return self
}
///
/// Set realtime endpoint.
///
/// @param String endPoint
///
/// @return Client
///
open func setEndpointRealtime(_ endPoint: String) -> Client {
if !endPoint.hasPrefix("ws://") && !endPoint.hasPrefix("wss://") {
fatalError("Invalid realtime endpoint URL: \(endPoint)")
}
self.endPointRealtime = endPoint
return self
}
///
/// Add header
///
/// @param String key
/// @param String value
///
/// @return Client
///
open func addHeader(key: String, value: String) -> Client {
self.headers[key] = value
return self
}
///
/// Builds a query string from parameters
///
/// @param Dictionary<String, Any?> params
/// @param String prefix
///
/// @return String
///
open func parametersToQueryString(params: [String: Any?]) -> String {
var output: String = ""
func appendWhenNotLast(_ index: Int, ofTotal count: Int, outerIndex: Int? = nil, outerCount: Int? = nil) {
if (index != count - 1 || (outerIndex != nil
&& outerCount != nil
&& index == count - 1
&& outerIndex! != outerCount! - 1)) {
output += "&"
}
}
for (parameterIndex, element) in params.enumerated() {
switch element.value {
case nil:
break
case is Array<Any?>:
let list = element.value as! Array<Any?>
for (nestedIndex, item) in list.enumerated() {
output += "\(element.key)[]=\(item!)"
appendWhenNotLast(nestedIndex, ofTotal: list.count, outerIndex: parameterIndex, outerCount: params.count)
}
appendWhenNotLast(parameterIndex, ofTotal: params.count)
default:
output += "\(element.key)=\(element.value!)"
appendWhenNotLast(parameterIndex, ofTotal: params.count)
}
}
return output.addingPercentEncoding(
withAllowedCharacters: .urlHostAllowed
)?.replacingOccurrences(of: "+", with: "%2B") ?? "" // since urlHostAllowed doesn't include +
}
///
/// Sends a "ping" request to Appwrite to verify connectivity.
///
/// @return String
/// @throws Exception
///
open func ping() async throws -> String {
let apiPath: String = "/ping"
let apiHeaders: [String: String] = [
"content-type": "application/json"
]
return try await call(
method: "GET",
path: apiPath,
headers: apiHeaders
)
}
///
/// Make an API call
///
/// @param String method
/// @param String path
/// @param Dictionary<String, Any?> params
/// @param Dictionary<String, String> headers
/// @return Response
/// @throws Exception
///
open func call<T>(
method: String,
path: String = "",
headers: [String: String] = [:],
params: [String: Any?] = [:],
sink: ((ByteBuffer) -> Void)? = nil,
converter: ((Any) -> T)? = nil
) async throws -> T {
let validParams = params.filter { $0.value != nil }
let queryParameters = method == "GET" && !validParams.isEmpty
? "?" + parametersToQueryString(params: validParams)
: ""
var request = HTTPClientRequest(url: endPoint + path + queryParameters)
request.method = .RAW(value: method)
for (key, value) in self.headers.merging(headers, uniquingKeysWith: { $1 }) {
request.headers.add(name: key, value: value)
}
request.addDomainCookies()
if "GET" == method {
return try await execute(request, converter: converter)
}
try buildBody(for: &request, with: validParams)
return try await execute(request, withSink: sink, converter: converter)
}
private func buildBody(
for request: inout HTTPClientRequest,
with params: [String: Any?]
) throws {
if request.headers["content-type"][0] == "multipart/form-data" {
buildMultipart(&request, with: params, chunked: !request.headers["content-range"].isEmpty)
} else {
try buildJSON(&request, with: params)
}
}
private func execute<T>(
_ request: HTTPClientRequest,
withSink bufferSink: ((ByteBuffer) -> Void)? = nil,
converter: ((Any) -> T)? = nil
) async throws -> T {
let response = try await http.execute(
request,
timeout: .seconds(30)
)
if let warning = response.headers["x-appwrite-warning"].first {
warning.split(separator: ";").forEach { warning in
fputs("Warning: \(warning)\n", stderr)
}
}
var data = try await response.body.collect(upTo: Int.max)
switch response.status.code {
case 0..<400:
if response.headers["Set-Cookie"].count > 0 {
let domain = URL(string: request.url)!.host!
let new = response.headers["Set-Cookie"]
UserDefaults.standard.set(new, forKey: domain)
}
switch T.self {
case is Bool.Type:
return true as! T
case is String.Type:
return (data.readString(length: data.readableBytes) ?? "") as! T
case is ByteBuffer.Type:
return data as! T
default:
if data.readableBytes == 0 {
return true as! T
}
let dict = try JSONSerialization.jsonObject(with: data) as? [String: Any]
return converter?(dict!) ?? dict! as! T
}
default:
var message = ""
var type = ""
var responseString = ""
do {
let dict = try JSONSerialization.jsonObject(with: data) as? [String: Any]
message = dict?["message"] as? String ?? response.status.reasonPhrase
type = dict?["type"] as? String ?? ""
responseString = String(decoding: data.readableBytesView, as: UTF8.self)
} catch {
message = data.readString(length: data.readableBytes)!
responseString = message
}
throw AppwriteError(
message: message,
code: Int(response.status.code),
type: type,
response: responseString
)
}
}
func chunkedUpload<T>(
path: String,
headers: inout [String: String],
params: inout [String: Any?],
paramName: String,
idParamName: String? = nil,
converter: ((Any) -> T)? = nil,
onProgress: ((UploadProgress) -> Void)? = nil
) async throws -> T {
let input = params[paramName] as! InputFile
switch(input.sourceType) {
case "path":
input.data = ByteBuffer(data: try! Data(contentsOf: URL(fileURLWithPath: input.path)))
case "data":
input.data = ByteBuffer(data: input.data as! Data)
default:
break
}
let size = (input.data as! ByteBuffer).readableBytes
if size < Client.chunkSize {
params[paramName] = input
return try await call(
method: "POST",
path: path,
headers: headers,
params: params,
converter: converter
)
}
var offset = 0
var result = [String:Any]()
if idParamName != nil {
// Make a request to check if a file already exists
do {
let map = try await call(
method: "GET",
path: path + "/" + (params[idParamName!] as! String),
headers: headers,
params: [:],
converter: { return $0 as! [String: Any] }
)
let chunksUploaded = map["chunksUploaded"] as! Int
offset = chunksUploaded * Client.chunkSize
} catch {
// File does not exist yet, swallow exception
}
}
while offset < size {
let slice = (input.data as! ByteBuffer).getSlice(at: offset, length: Client.chunkSize)
?? (input.data as! ByteBuffer).getSlice(at: offset, length: Int(size - offset))
params[paramName] = InputFile.fromBuffer(slice!, filename: input.filename, mimeType: input.mimeType)
headers["content-range"] = "bytes \(offset)-\(min((offset + Client.chunkSize) - 1, size - 1))/\(size)"
result = try await call(
method: "POST",
path: path,
headers: headers,
params: params,
converter: { return $0 as! [String: Any] }
)
offset += Client.chunkSize
headers["x-appwrite-id"] = result["$id"] as? String
onProgress?(UploadProgress(
id: result["$id"] as? String ?? "",
progress: Double(min(offset, size))/Double(size) * 100.0,
sizeUploaded: min(offset, size),
chunksTotal: result["chunksTotal"] as? Int ?? -1,
chunksUploaded: result["chunksUploaded"] as? Int ?? -1
))
}
return converter!(result)
}
private static func randomBoundary() -> String {
var string = ""
for _ in 0..<16 {
string.append(Client.boundaryChars.randomElement()!)
}
return string
}
private func buildJSON(
_ request: inout HTTPClientRequest,
with params: [String: Any?] = [:]
) throws {
var encodedParams = [String:Any]()
for (key, param) in params {
if param is String
|| param is Int
|| param is Float
|| param is Double
|| param is Bool
|| param is [String]
|| param is [Int]
|| param is [Float]
|| param is [Double]
|| param is [Bool]
|| param is [String: Any]
|| param is [Int: Any]
|| param is [Float: Any]
|| param is [Double: Any]
|| param is [Bool: Any] {
encodedParams[key] = param
} else if let encodable = param as? Encodable {
encodedParams[key] = try encodable.toJson()
} else if let param = param {
encodedParams[key] = String(describing: param)
}
}
let json = try JSONSerialization.data(withJSONObject: encodedParams, options: [])
request.body = .bytes(json)
}
private func buildMultipart(
_ request: inout HTTPClientRequest,
with params: [String: Any?] = [:],
chunked: Bool = false
) {
func addPart(name: String, value: Any) {
bodyBuffer.writeString(DASHDASH)
bodyBuffer.writeString(Client.boundary)
bodyBuffer.writeString(CRLF)
bodyBuffer.writeString("Content-Disposition: form-data; name=\"\(name)\"")
if let file = value as? InputFile {
bodyBuffer.writeString("; filename=\"\(file.filename)\"")
bodyBuffer.writeString(CRLF)
bodyBuffer.writeString("Content-Length: \(bodyBuffer.readableBytes)")
bodyBuffer.writeString(CRLF+CRLF)
var buffer = file.data! as! ByteBuffer
bodyBuffer.writeBuffer(&buffer)
bodyBuffer.writeString(CRLF)
return
}
let string = String(describing: value)
bodyBuffer.writeString(CRLF)
bodyBuffer.writeString("Content-Length: \(string.count)")
bodyBuffer.writeString(CRLF+CRLF)
bodyBuffer.writeString(string)
bodyBuffer.writeString(CRLF)
}
var bodyBuffer = ByteBuffer()
for (key, value) in params {
switch key {
case "file":
addPart(name: key, value: value!)
default:
if let list = value as? [Any] {
for listValue in list {
addPart(name: "\(key)[]", value: listValue)
}
continue
}
addPart(name: key, value: value!)
}
}
bodyBuffer.writeString(DASHDASH)
bodyBuffer.writeString(Client.boundary)
bodyBuffer.writeString(DASHDASH)
bodyBuffer.writeString(CRLF)
request.headers.remove(name: "content-type")
if !chunked {
request.headers.add(name: "Content-Length", value: bodyBuffer.readableBytes.description)
}
request.headers.add(name: "Content-Type", value: "multipart/form-data;boundary=\"\(Client.boundary)\"")
request.body = .bytes(bodyBuffer)
}
private func addUserAgentHeader() {
let packageInfo = OSPackageInfo.get()
let device = Client.getDevice()
#if !os(Linux) && !os(Windows)
_ = addHeader(
key: "user-agent",
value: "\(packageInfo.packageName)/\(packageInfo.version) \(device)"
)
#endif
}
private func addOriginHeader() {
let packageInfo = OSPackageInfo.get()
let operatingSystem = Client.getOperatingSystem()
_ = addHeader(
key: "origin",
value: "appwrite-\(operatingSystem)://\(packageInfo.packageName)"
)
}
}
extension Client {
private static func getOperatingSystem() -> String {
#if os(iOS)
return "ios"
#elseif os(watchOS)
return "watchos"
#elseif os(tvOS)
return "tvos"
#elseif os(macOS)
return "macos"
#elseif os(visionOS)
return "visionos"
#elseif os(Linux)
return "linux"
#elseif os(Windows)
return "windows"
#endif
}
private static func getDevice() -> String {
let deviceInfo = OSDeviceInfo()
var device = ""
#if os(iOS)
let info = deviceInfo.iOSInfo
device = "\(info!.modelIdentifier) iOS/\(info!.systemVersion)"
#elseif os(watchOS)
let info = deviceInfo.watchOSInfo
device = "\(info!.modelIdentifier) watchOS/\(info!.systemVersion)"
#elseif os(tvOS)
let info = deviceInfo.iOSInfo
device = "\(info!.modelIdentifier) tvOS/\(info!.systemVersion)"
#elseif os(macOS)
let info = deviceInfo.macOSInfo
device = "(Macintosh; \(info!.model))"
#elseif os(Linux)
let info = deviceInfo.linuxInfo
device = "(Linux; U; \(info!.id) \(info!.version))"
#elseif os(Windows)
let info = deviceInfo.windowsInfo
device = "(Windows NT; \(info!.computerName))"
#endif
return device
}
}