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