Files
simapi-cj/src/helpers/SimApiHttpClient.cj
T

193 lines
7.4 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* Copyright (c) 2025 SimcuTeam. All rights reserved.
* 移植自 C# 项目 SimApiE:\simcu\simapi-net),遵循 MIT 许可证。
*/
package simcu::simapi.helpers
import std.collection.*
import std.io.*
import stdx.net.http.*
import stdx.net.tls.*
import stdx.net.tls.common.*
import simapi_serialization.*
import simcu::simapi.communications.*
import simcu::simapi.configurations.*
import simcu::simapi.exceptions.*
/**
* HTTP 客户端:用于调用其他带签名/AES 的 SimApi 服务。
* 对齐 C# 的 SimApi.Helpers.SimApiHttpClient
* - 内部使用 stdx.net.http 的 HttpClient(等价 .NET 的 System.Net.Http.HttpClient
* - 返回泛型 T(反序列化响应 body 的 data 字段),不再返回 String
* @param T 响应 data 的数据类型(任意类,simapi_serialization 反射反序列化)。
*/
public open class SimApiHttpClient {
public var server: String
public var appId: String
public var appKey: String
public var signName: String = "sign"
public var timestampName: String = "timestamp"
public var nonceName: String = "nonce"
public var appIdName: ?String = Some("appId")
public var signFields: Array<String> = []
public init(options!: SimApiOptions = SimApiOptions()) {
let httpOptions = options.simApiHttpClientOptions
server = httpOptions.server
appId = httpOptions.appId
appKey = httpOptions.appKey
}
/**
* 发起签名请求(GET query 签名 + POST body)。
* 对齐 C# SignQuery<T>query 串 = SignFields + AppId + timestamp + nonce,整体拼 AppKey 取 MD5 作为 sign。
* @param url 请求路径(相对路径,自动拼接 server)。
* @param body 请求体 JSON 字符串(可选)。
* @param queries 额外查询参数(可选)。
* @return 响应 data 字段反序列化后的 T。
*/
public func signQuery<T>(url: String, body!: String = "", queries!: HashMap<String, String> = HashMap<String, String>()): T {
var queryUrl = StringBuilder()
for (field in signFields) {
queryUrl.append("${field}=")
if (let Some(v) <- queries.get(field)) {
queryUrl.append(v)
}
queryUrl.append("&")
}
if (let Some(name) <- appIdName) {
queryUrl.append("${name}=${appId}&")
}
let timestamp = Int64(SimApiUtil.timestampNow)
let nonce = generateNonce()
queryUrl.append("${timestampName}=${timestamp}&${nonceName}=${nonce}")
let signStr = "${queryUrl.toString()}&${appKey}"
var path = "${server}${url}?${queryUrl.toString()}&${signName}=${SimApiUtil.md5(signStr)}"
for ((k, v) in queries) {
if (!signFields.contains(k)) {
path = "${path}&${k}=${v}"
}
}
return query<T>(path, body)
}
/**
* 发起 AES 加密请求:body 加密后放入 {"data": "..."} 提交。
* 对齐 C# AesQuery<T>SimApiOneFieldRequest<string> { Data = Encrypt(body, AppKey) })。
* @param url 请求路径(相对路径,自动拼接 server)。
* @param body 请求体 JSON 字符串。
* @return 响应 data 字段反序列化后的 T。
*/
public func aesQuery<T>(url: String, body: String): T {
var target = "${server}${url}"
if (let Some(name) <- appIdName) {
target = "${target}?${name}=${appId}"
}
let encrypted = aesEncrypt(body)
let req = SimApiUtil.json(Some(SimApiOneFieldRequest<String>(encrypted)))
return query<T>(target, req)
}
/**
* 发起 AES 加密 + 签名请求。
* 对齐 C# AesSignQuery<T>。
* @param url 请求路径(相对路径,自动拼接 server)。
* @param body 请求体 JSON 字符串。
* @param queries 额外查询参数(可选)。
* @return 响应 data 字段反序列化后的 T。
*/
public func aesSignQuery<T>(url: String, body: String, queries!: HashMap<String, String> = HashMap<String, String>()): T {
let encrypted = aesEncrypt(body)
let req = SimApiUtil.json(Some(SimApiOneFieldRequest<String>(encrypted)))
return signQuery<T>(url, body: req, queries: queries)
}
/**
* 发起 POST 请求并反序列化 SimApiResponse<T>,返回 data 字段。
* 对齐 C# Query<T>
* ErrorWhenFalse(IsSuccessStatusCode) → ReadFromJsonAsync<SimApiResponse<T>> → ErrorWhen(Code != 200) → return Data。
* 注意:必须 noProxy(),否则会走系统代理(192.168.0.250:8118)导致连接被拒。
* 反序列化使用 simapi_serializationDeserialize<T> 免约束)。
*/
private func query<T>(url: String, body: String): T {
let client = ClientBuilder().
noProxy().
tlsConfig(buildTlsConfig(url)).
readTimeout(Duration.second * 30).
build()
try {
let request = HttpRequestBuilder().
post().
url(url).
header("Content-Type", "application/json").
body(body).
build()
let response = client.send(request)
try {
SimApiError.errorWhenFalse(isSuccess(response.status), code: Int64(response.status),
message: "HTTP ERROR: ${response.status}")
let json = readBodyText(response.body)
let result = JsonSerializer.Deserialize<SimApiResponse<T>>(json)
SimApiError.errorWhen(result.code != 200, code: result.code, message: result.message)
return result.data.getOrThrow()
} finally {
response.close()
}
} finally {
client.close()
}
}
/// 2xx 视为成功
private static func isSuccess(status: UInt16): Bool {
status >= 200 && status < 300
}
/// 读取响应体 InputStream 为字符串
private static func readBodyText(body: InputStream): String {
var buffer = Array<Byte>(4096, repeat: 0)
var sb = StringBuilder()
var read = body.read(buffer)
while (read > 0) {
sb.appendFromUtf8(buffer.slice(0, read))
read = body.read(buffer)
}
sb.toString()
}
/// 构建 TLS 配置:信任所有证书 + SNI 域名
private static func buildTlsConfig(url: String): TlsClientConfig {
var tls = TlsClientConfig()
tls.verifyMode = CertificateVerifyMode.TrustAll
let host = extractHost(url)
if (!host.isEmpty()) {
tls.serverName = Some(host)
}
tls
}
private func aesEncrypt(plain: String): String {
// 对齐 C#SimApiAesUtil.Encrypt(plain, AppKey)AES-256-CBC + PKCS7Base64(IV + 密文)
SimApiAesUtil.encrypt(plain, appKey)
}
private static func generateNonce(): String {
// 对齐 C#nonce 直接用 Guid.NewGuid()
SimApiUtil.newGuid()
}
/// 从完整 URL 提取 hosthttps://host[:port]/path → host)。
private static func extractHost(fullUrl: String): String {
match (fullUrl.indexOf("://")) {
case Some(i) =>
let rest = fullUrl[i + 3..]
let slash = rest.indexOf("/") ?? rest.size
let q = rest.indexOf("?") ?? rest.size
let end = if (slash < q) { slash } else { q }
return rest[0..end]
case None => return ""
}
}
}