feat: 自定义 FromBody 绑定——simapi_serialization 按运行时类型反序列化,DTO 免 @Serialization 宏

- SimApiRequestDelegateFactory:无注解参数(FromBody)用 JsonSerializer.Deserialize(typeInfo, body)
  反序列化(免宏、免约束),显式注解参数(Query/Form/Route/Header/Services)委托 soulsoft binder
- 请求体只读一次并缓存到 context.items["SimApi:BodyCache"](body 流不可重读,
  请求日志中间件与 FromBody 共用缓存)
- 验证:/auth/login/admin 的 LoginAdminRequest(无宏)反序列化成功,走到业务密码校验
This commit is contained in:
2026-08-18 00:27:32 +08:00
parent 73f60f6f15
commit e9978ea5e0
3 changed files with 100 additions and 11 deletions
+9 -9
View File
@@ -1,20 +1,20 @@
version = 0
[requires]
soulsoft_extensions_hosting = {version = "1.0.20260528"}
soulsoft_web_http = {version = "1.0.20260528"}
soulsoft_web_hosting = {version = "1.0.20260528"}
soulsoft_web_routing = {version = "1.0.20260528"}
soulsoft_extensions_hosting = {version = "1.0.20260528"}
soulsoft_extensions_options_configuration = {version = "1.0.20260528"}
soulsoft_serialization = {version = "1.0.20260528"}
soulsoft_web_mvc = {version = "1.0.20260528"}
soulsoft_extensions_options = {version = "1.0.20260528"}
redis = {version = "1.0.20260627"}
soulsoft_web_routing = {version = "1.0.20260528"}
soulsoft_net_http = {version = "1.0.20260528"}
soulsoft_web_hosting = {version = "1.0.20260528"}
soulsoft_web_cors = {version = "1.0.20260528"}
soulsoft_identity_claims = {version = "1.0.20260528"}
soulsoft_extensions_logging_console = {version = "1.0.20260528"}
soulsoft_extensions_logging = {version = "1.0.20260528"}
soulsoft_extensions_logging_configuration = {version = "1.0.20260528"}
soulsoft_serialization = {version = "1.0.20260528"}
redis = {version = "1.0.20260627"}
soulsoft_extensions_configuration = {version = "1.0.20260528"}
soulsoft_net_http = {version = "1.0.20260528"}
soulsoft_extensions_logging_console = {version = "1.0.20260528"}
soulsoft_extensions_options = {version = "1.0.20260528"}
soulsoft_extensions_injection = {version = "1.0.20260528"}
soulsoft_extensions_logging = {version = "1.0.20260528"}
+89 -1
View File
@@ -14,8 +14,10 @@
package simapi.helpers
import std.collection.*
import std.io.*
import std.reflect.*
import soulsoft_web_http.*
import soulsoft_web_mvc.annotations.*
import soulsoft_web_mvc.core.*
import soulsoft_web_mvc.routing.*
import soulsoft_web_mvc.controllers.*
@@ -23,6 +25,7 @@ import soulsoft_web_mvc.modelBindings.*
import soulsoft_web_mvc.abstractions.*
import soulsoft_extensions_options.*
import soulsoft_extensions_injection.*
import simapi_serialization.*
import simapi.attributes.{SimApiAuth as SimApiAuthAttribute, OriginResponse}
import simapi.communications.*
import simapi.configurations.*
@@ -60,8 +63,14 @@ struct SimApiActionInvoker {
public func apply(): Unit {
let controller = createControllerInstance()
checkSimApiAuth()
// 预读并缓存请求体(body 流不可重读;若请求日志中间件已读,直接用其缓存)
if (!context.items.contains(BODY_CACHE_KEY)) {
context.items[BODY_CACHE_KEY] = readBody()
}
let modelBindingContext = ActionBindingContext(context, actionDescriptor.actionFunction.parameters)
let boundParameters = modelBinder.bind(modelBindingContext)
// 自定义绑定:无注解参数(FromBody)用 simapi_serialization 反序列化,
// 显式注解参数(Query/Form/Route/Header/Services)委托 soulsoft binder。
let boundParameters = bindParameters(modelBindingContext)
if (!modelBindingContext.modelState.isValid) {
handleInvalidModelState(modelBindingContext)
} else {
@@ -70,6 +79,85 @@ struct SimApiActionInvoker {
}
}
/// 请求体缓存键(body 流不可重读,读一次后缓存)
private static let BODY_CACHE_KEY = "SimApi:BodyCache"
/// 绑定全部参数:FromBody 自实现(simapi_serialization),其余委托 soulsoft
private func bindParameters(context: ActionBindingContext): Array<Any> {
let params = context.parameters
if (params.size == 0) {
return []
}
let bound = Array<Any>(params.size, repeat: ())
// 存在显式参数时才委托 soulsoft 绑定(Query/Form/Route/Header/Services
var hasExplicit = false
for (parameter in params) {
if (isExplicitlyBound(parameter)) {
hasExplicit = true
break
}
}
let soulsoftBound = if (hasExplicit) { modelBinder.bind(context) } else { Array<Any>(params.size, repeat: ()) }
for ((index, parameter) in params |> enumerate) {
if (isExplicitlyBound(parameter)) {
// Query/Form/Route/Header/Services → soulsoft
bound[index] = soulsoftBound[index]
} else {
// FromBody → simapi_serialization 按运行时类型反序列化(免 @Serialization 宏)
bound[index] = bindFromBody(context, parameter)
}
}
bound
}
/// 参数是否显式指定绑定源(FromQuery/FromForm/FromRoute/FromHeader/FromServices
private func isExplicitlyBound(parameter: ParameterInfo): Bool {
parameter.findAnnotation<FromQuery>().isSome() ||
parameter.findAnnotation<FromForm>().isSome() ||
parameter.findAnnotation<FromRoute>().isSome() ||
parameter.findAnnotation<FromHeader>().isSome() ||
parameter.findAnnotation<FromServices>().isSome()
}
/// 从请求体反序列化(simapi_serialization 按运行时类型,DTO 免标注)
private func bindFromBody(context: ActionBindingContext, parameter: ParameterInfo): Any {
let body = match (context.httpContext.items.get(BODY_CACHE_KEY)) {
case Some(v) => if (let s: String <- v) { s } else { "" }
case None => ""
}
if (body.isEmpty()) {
SimApiError.error(code: 400, message: "请求体不能为空")
}
try {
return JsonSerializer.Deserialize(parameter.typeInfo, body)
} catch (ex: Exception) {
SimApiError.error(code: 400, message: "请求体反序列化失败: ${ex.message}")
}
()
}
/// 读取并重置请求体流(供后续业务读取)
private func readBody(): String {
try {
context.request.enableBuffering()
var buffer = Array<Byte>(4096, repeat: 0)
var sb = StringBuilder()
var read = context.request.body.read(buffer)
while (read > 0) {
sb.appendFromUtf8(buffer.slice(0, read))
read = context.request.body.read(buffer)
}
let text = sb.toString()
if (let seekable: Seekable <- context.request.body) {
seekable.seek(SeekPosition.Begin(0))
}
text
} catch (ex: Exception) {
SimApiError.error(code: 400, message: "读取请求体失败: ${ex.message}")
}
""
}
/// 检查 @SimApiAuth 注解并执行鉴权(对齐 C# SimApiAuthAttribute.OnActionExecuting):
/// 未登录 401 → 类型权限 403 → 遍历执行 ISimApiAuthChecker
private func checkSimApiAuth() {
@@ -122,10 +122,11 @@ public class SimApiRequestLogMiddleware <: IMiddleware {
read = context.request.body.read(buffer)
}
let bodyText = sb.toString()
// 重置流位置,供后续业务读取
// 重置流位置,供后续业务读取;同时缓存 body(流可能不可重读)
if (let seekable: Seekable <- context.request.body) {
seekable.seek(SeekPosition.Begin(0))
}
context.items["SimApi:BodyCache"] = bodyText
return truncateBody(bodyText)
} catch (_: Exception) {
return "(读取请求体失败)\n"