iOS new: ApiClient.swift
This commit is contained in:
parent
dfed314ac3
commit
e025f23bcf
229
Montana-iOS/Montana Messenger/Montana Messenger/ApiClient.swift
Normal file
229
Montana-iOS/Montana Messenger/Montana Messenger/ApiClient.swift
Normal file
@ -0,0 +1,229 @@
|
|||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
import CryptoKit
|
||||||
|
|
||||||
|
enum MontanaAPI {
|
||||||
|
static let baseURL = URL(string: "https://mess.montana.quest:8443/api")!
|
||||||
|
static let wsURL = URL(string: "wss://mess.montana.quest:8443/api/ws")!
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ApiAccount: Decodable {
|
||||||
|
let account_id: String
|
||||||
|
let name: String
|
||||||
|
let ed_pub: String
|
||||||
|
let x_pub: String
|
||||||
|
let created_at: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ApiEnvelope: Decodable, Identifiable {
|
||||||
|
let id: Int
|
||||||
|
let from: String
|
||||||
|
let to: String
|
||||||
|
let nonce: String
|
||||||
|
let ciphertext: String
|
||||||
|
let sent_at: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ApiInbox: Decodable {
|
||||||
|
let messages: [ApiEnvelope]
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ApiError: LocalizedError {
|
||||||
|
case http(Int, String)
|
||||||
|
case bad
|
||||||
|
case noIdentity
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .http(let c, let m): return "HTTP \(c): \(m)"
|
||||||
|
case .bad: return "Bad response"
|
||||||
|
case .noIdentity: return "Не создана идентичность"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class ApiClient {
|
||||||
|
static let shared = ApiClient()
|
||||||
|
private let session: URLSession
|
||||||
|
|
||||||
|
init() {
|
||||||
|
let cfg = URLSessionConfiguration.default
|
||||||
|
cfg.waitsForConnectivity = true
|
||||||
|
cfg.timeoutIntervalForRequest = 20
|
||||||
|
self.session = URLSession(configuration: cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func register(name: String, identity: Identity) async throws {
|
||||||
|
let body: [String: String] = [
|
||||||
|
"name": name,
|
||||||
|
"ed_pub": identity.edPublicKeyB64,
|
||||||
|
"x_pub": identity.xPublicKeyB64,
|
||||||
|
]
|
||||||
|
let url = MontanaAPI.baseURL.appendingPathComponent("accounts")
|
||||||
|
var req = URLRequest(url: url)
|
||||||
|
req.httpMethod = "POST"
|
||||||
|
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
req.httpBody = try JSONEncoder().encode(body)
|
||||||
|
let (_, resp) = try await session.data(for: req)
|
||||||
|
try Self.checkOK(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getAccount(_ accountID: String) async throws -> ApiAccount {
|
||||||
|
let url = MontanaAPI.baseURL.appendingPathComponent("accounts").appendingPathComponent(accountID)
|
||||||
|
let (data, resp) = try await session.data(from: url)
|
||||||
|
try Self.checkOK(resp)
|
||||||
|
return try JSONDecoder().decode(ApiAccount.self, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func send(to: String, nonce: String, ciphertext: String, identity: Identity) async throws -> ApiEnvelope {
|
||||||
|
let payload: [String: String] = ["to": to, "nonce": nonce, "ciphertext": ciphertext]
|
||||||
|
let body = try Self.canonicalJSON(payload)
|
||||||
|
let ts = String(Int(Date().timeIntervalSince1970))
|
||||||
|
let sig = try identity.signingKey.signature(for: Data((ts + "\n").utf8) + body)
|
||||||
|
|
||||||
|
var req = URLRequest(url: MontanaAPI.baseURL.appendingPathComponent("messages/send"))
|
||||||
|
req.httpMethod = "POST"
|
||||||
|
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
req.setValue(identity.accountID, forHTTPHeaderField: "X-Montana-Account")
|
||||||
|
req.setValue("\(ts):\(sig.b64uNoPad)", forHTTPHeaderField: "X-Montana-Auth")
|
||||||
|
req.httpBody = body
|
||||||
|
let (data, resp) = try await session.data(for: req)
|
||||||
|
try Self.checkOK(resp)
|
||||||
|
return try JSONDecoder().decode(ApiEnvelope.self, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func inbox(since: Int, identity: Identity) async throws -> [ApiEnvelope] {
|
||||||
|
let ts = String(Int(Date().timeIntervalSince1970))
|
||||||
|
let body = Data("since=\(since)".utf8)
|
||||||
|
let sig = try identity.signingKey.signature(for: Data((ts + "\n").utf8) + body)
|
||||||
|
|
||||||
|
var comps = URLComponents(url: MontanaAPI.baseURL.appendingPathComponent("messages/inbox"), resolvingAgainstBaseURL: false)!
|
||||||
|
comps.queryItems = [URLQueryItem(name: "since", value: String(since))]
|
||||||
|
var req = URLRequest(url: comps.url!)
|
||||||
|
req.setValue(identity.accountID, forHTTPHeaderField: "X-Montana-Account")
|
||||||
|
req.setValue("\(ts):\(sig.b64uNoPad)", forHTTPHeaderField: "X-Montana-Auth")
|
||||||
|
let (data, resp) = try await session.data(for: req)
|
||||||
|
try Self.checkOK(resp)
|
||||||
|
return try JSONDecoder().decode(ApiInbox.self, from: data).messages
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func checkOK(_ resp: URLResponse) throws {
|
||||||
|
guard let h = resp as? HTTPURLResponse else { throw ApiError.bad }
|
||||||
|
if !(200...299).contains(h.statusCode) {
|
||||||
|
throw ApiError.http(h.statusCode, h.url?.absoluteString ?? "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func canonicalJSON(_ dict: [String: String]) throws -> Data {
|
||||||
|
let sorted = dict.sorted { $0.key < $1.key }
|
||||||
|
var s = "{"
|
||||||
|
for (i, (k, v)) in sorted.enumerated() {
|
||||||
|
if i > 0 { s += "," }
|
||||||
|
s += "\"\(k)\":\"\(escapeJSON(v))\""
|
||||||
|
}
|
||||||
|
s += "}"
|
||||||
|
return Data(s.utf8)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func escapeJSON(_ s: String) -> String {
|
||||||
|
var out = ""
|
||||||
|
for c in s {
|
||||||
|
switch c {
|
||||||
|
case "\\": out += "\\\\"
|
||||||
|
case "\"": out += "\\\""
|
||||||
|
case "\n": out += "\\n"
|
||||||
|
case "\r": out += "\\r"
|
||||||
|
case "\t": out += "\\t"
|
||||||
|
case "\u{08}": out += "\\b"
|
||||||
|
case "\u{0C}": out += "\\f"
|
||||||
|
default:
|
||||||
|
if c.asciiValue != nil && c.asciiValue! < 0x20 {
|
||||||
|
out += String(format: "\\u%04x", c.asciiValue!)
|
||||||
|
} else {
|
||||||
|
out.append(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class WebSocketLink: NSObject, ObservableObject, URLSessionWebSocketDelegate {
|
||||||
|
static let shared = WebSocketLink()
|
||||||
|
@Published private(set) var isConnected = false
|
||||||
|
|
||||||
|
private var task: URLSessionWebSocketTask?
|
||||||
|
private var pingTimer: Timer?
|
||||||
|
var onEnvelope: ((ApiEnvelope) -> Void)?
|
||||||
|
|
||||||
|
func connect(identity: Identity) {
|
||||||
|
disconnect()
|
||||||
|
let cfg = URLSessionConfiguration.default
|
||||||
|
let session = URLSession(configuration: cfg, delegate: self, delegateQueue: .main)
|
||||||
|
let task = session.webSocketTask(with: MontanaAPI.wsURL)
|
||||||
|
self.task = task
|
||||||
|
task.resume()
|
||||||
|
|
||||||
|
let ts = String(Int(Date().timeIntervalSince1970))
|
||||||
|
do {
|
||||||
|
let sig = try identity.signingKey.signature(for: Data((ts + "\nws").utf8))
|
||||||
|
let hello = ["account_id": identity.accountID, "ts": ts, "sig": sig.b64uNoPad]
|
||||||
|
let helloData = try JSONSerialization.data(withJSONObject: hello)
|
||||||
|
let helloStr = String(data: helloData, encoding: .utf8) ?? ""
|
||||||
|
task.send(.string(helloStr)) { [weak self] err in
|
||||||
|
if err != nil { self?.disconnect() }
|
||||||
|
}
|
||||||
|
receive()
|
||||||
|
startPing()
|
||||||
|
} catch {
|
||||||
|
disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func disconnect() {
|
||||||
|
task?.cancel(with: .normalClosure, reason: nil)
|
||||||
|
task = nil
|
||||||
|
pingTimer?.invalidate()
|
||||||
|
pingTimer = nil
|
||||||
|
isConnected = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func receive() {
|
||||||
|
task?.receive { [weak self] result in
|
||||||
|
guard let self else { return }
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
switch result {
|
||||||
|
case .success(let m):
|
||||||
|
if case .string(let s) = m, let data = s.data(using: .utf8) {
|
||||||
|
if let env = try? JSONDecoder().decode(ApiEnvelope.self, from: data) {
|
||||||
|
self.onEnvelope?(env)
|
||||||
|
} else if let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any], dict["hello"] != nil {
|
||||||
|
self.isConnected = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.receive()
|
||||||
|
case .failure:
|
||||||
|
self.isConnected = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startPing() {
|
||||||
|
pingTimer?.invalidate()
|
||||||
|
pingTimer = Timer.scheduledTimer(withTimeInterval: 25, repeats: true) { [weak self] _ in
|
||||||
|
self?.task?.send(.string("ping")) { _ in }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask,
|
||||||
|
didOpenWithProtocol protocol: String?) {
|
||||||
|
Task { @MainActor in self.isConnected = true }
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask,
|
||||||
|
didCloseWith closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
|
||||||
|
Task { @MainActor in self.isConnected = false }
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user