fix: ensure _webbricks_=1 is sent for all backend resource paths including /public
- isWebBricksBackendResource() now matches /public and / as WebBricks backend resources - BricksHttp.kt switched from manual URL query string to Ktor parameter() API - Added test/generic-client/build.sh for one-click build/run/package
This commit is contained in:
parent
43f416a6f0
commit
d4f7e39834
@ -4,15 +4,16 @@ import com.bricks.mp.dev.DevHttpInterceptor
|
||||
import com.bricks.mp.dev.DevLogSource
|
||||
import com.bricks.mp.dev.DevLogLevel
|
||||
import com.bricks.mp.dev.DevLogStore
|
||||
import kotlinx.datetime.Clock
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.engine.cio.*
|
||||
import io.ktor.client.plugins.cookies.*
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.client.statement.*
|
||||
import io.ktor.http.*
|
||||
import io.ktor.http.encodeURLParameter
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlinx.serialization.json.*
|
||||
import kotlin.time.DurationUnit.MILLISECONDS
|
||||
import kotlin.time.toDuration
|
||||
|
||||
/**
|
||||
* HTTP error surfaced by BricksHttp before the response body is parsed.
|
||||
@ -37,6 +38,9 @@ class BricksHttpException(
|
||||
* Bricks HTTP 客户端 - 对应 JS 版 HttpJson/HttpText
|
||||
* 支持 session cookie 管理和 Bearer token 认证,并将 .ui/.dspy 请求自动追加
|
||||
* WebBricks 参数:_webbricks_、_width、_height、_is_mobile、_lang。
|
||||
*
|
||||
* 所有请求统一使用 Ktor 的 HttpRequestBuilder.parameter() 添加查询参数,
|
||||
* 而非手动拼接 URL 字符串,确保编码和传递可靠。
|
||||
*/
|
||||
class BricksHttp(private val context: BricksContext? = null) {
|
||||
|
||||
@ -106,12 +110,12 @@ class BricksHttp(private val context: BricksContext? = null) {
|
||||
authToken: String = ""
|
||||
): JsonObject {
|
||||
val requestParams = params.withBackendContextIfNeeded(url)
|
||||
val requestUrl = url.withQueryParameters(requestParams)
|
||||
val requestBodyStr = Json { encodeDefaults = false }.encodeToString(JsonObject.serializer(), body)
|
||||
val devEntry = DevHttpInterceptor.recordRequest(requestUrl, "POST", requestBodyStr)
|
||||
val devEntry = DevHttpInterceptor.recordRequest(url, "POST", requestBodyStr)
|
||||
val startTime = Clock.System.now()
|
||||
return try {
|
||||
val response = client.post(requestUrl) {
|
||||
val response = client.post(url) {
|
||||
requestParams.forEach { (key, value) -> parameter(key, value) }
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(body)
|
||||
if (authToken.isNotEmpty()) {
|
||||
@ -121,7 +125,7 @@ class BricksHttp(private val context: BricksContext? = null) {
|
||||
val text = response.bodyAsText()
|
||||
val duration = Clock.System.now() - startTime
|
||||
DevHttpInterceptor.recordResponse(devEntry, response.status.value, text, duration.inWholeMilliseconds)
|
||||
response.throwIfHttpError(text, requestUrl)
|
||||
response.throwIfHttpError(text, url)
|
||||
parseJsonObjectOrError(text)
|
||||
} catch (e: BricksHttpException) {
|
||||
val duration = Clock.System.now() - startTime
|
||||
@ -143,16 +147,13 @@ class BricksHttp(private val context: BricksContext? = null) {
|
||||
authToken: String = ""
|
||||
): String {
|
||||
val requestParams = emptyMap<String, String>().withBackendContextIfNeeded(url)
|
||||
val requestUrl = url.withQueryParameters(requestParams)
|
||||
val formBody = form.entries.joinToString("&") { (k, v) ->
|
||||
"${k.encodeURLParameter()}=${v.encodeURLParameter()}"
|
||||
}
|
||||
val devEntry = DevHttpInterceptor.recordRequest(requestUrl, "POST", formBody)
|
||||
val devEntry = DevHttpInterceptor.recordRequest(url, "POST", form.entries.joinToString("&") { "${it.key}=${it.value}" })
|
||||
val startTime = Clock.System.now()
|
||||
return try {
|
||||
val response = client.post(requestUrl) {
|
||||
val response = client.post(url) {
|
||||
requestParams.forEach { (key, value) -> parameter(key, value) }
|
||||
contentType(ContentType.Application.FormUrlEncoded)
|
||||
setBody(formBody)
|
||||
setBody(Parameters.build { form.forEach { (k, v) -> append(k, v) } })
|
||||
if (authToken.isNotEmpty()) {
|
||||
header(HttpHeaders.Authorization, "Bearer $authToken")
|
||||
}
|
||||
@ -160,7 +161,7 @@ class BricksHttp(private val context: BricksContext? = null) {
|
||||
val text = response.bodyAsText()
|
||||
val duration = Clock.System.now() - startTime
|
||||
DevHttpInterceptor.recordResponse(devEntry, response.status.value, text, duration.inWholeMilliseconds)
|
||||
response.throwIfHttpError(text, requestUrl)
|
||||
response.throwIfHttpError(text, url)
|
||||
text
|
||||
} catch (e: BricksHttpException) {
|
||||
val duration = Clock.System.now() - startTime
|
||||
@ -183,11 +184,11 @@ class BricksHttp(private val context: BricksContext? = null) {
|
||||
skipLog: Boolean = false
|
||||
): String {
|
||||
val requestParams = params.withBackendContextIfNeeded(url)
|
||||
val requestUrl = url.withQueryParameters(requestParams)
|
||||
val devEntry = if (skipLog) null else DevHttpInterceptor.recordRequest(requestUrl, "GET")
|
||||
val devEntry = if (skipLog) null else DevHttpInterceptor.recordRequest(url, "GET")
|
||||
val startTime = Clock.System.now()
|
||||
return try {
|
||||
val response = client.get(requestUrl) {
|
||||
val response = client.get(url) {
|
||||
requestParams.forEach { (key, value) -> parameter(key, value) }
|
||||
if (authToken.isNotEmpty()) {
|
||||
header(HttpHeaders.Authorization, "Bearer $authToken")
|
||||
}
|
||||
@ -197,7 +198,7 @@ class BricksHttp(private val context: BricksContext? = null) {
|
||||
if (devEntry != null) {
|
||||
DevHttpInterceptor.recordResponse(devEntry, response.status.value, text, duration.inWholeMilliseconds)
|
||||
}
|
||||
response.throwIfHttpError(text, requestUrl)
|
||||
response.throwIfHttpError(text, url)
|
||||
text
|
||||
} catch (e: BricksHttpException) {
|
||||
val duration = Clock.System.now() - startTime
|
||||
@ -259,25 +260,6 @@ class BricksHttp(private val context: BricksContext? = null) {
|
||||
private fun Map<String, String>.withBackendContextIfNeeded(url: String): Map<String, String> =
|
||||
if (url.isWebBricksBackendResource()) withWebBricksRequestContext(this, requestContext) else this
|
||||
|
||||
private fun String.withQueryParameters(params: Map<String, String>): String {
|
||||
if (params.isEmpty()) return this
|
||||
val fragmentIndex = indexOf('#')
|
||||
val baseAndQuery = if (fragmentIndex >= 0) substring(0, fragmentIndex) else this
|
||||
val fragment = if (fragmentIndex >= 0) substring(fragmentIndex) else ""
|
||||
val path = baseAndQuery.substringBefore('?')
|
||||
val existingQuery = baseAndQuery.substringAfter('?', missingDelimiterValue = "")
|
||||
val encodedOverrideKeys = params.keys.map { it.encodeURLParameter() }.toSet()
|
||||
val preservedQuery = existingQuery
|
||||
.split('&')
|
||||
.filter { it.isNotBlank() }
|
||||
.filter { entry -> entry.substringBefore('=').substringBefore('&') !in encodedOverrideKeys }
|
||||
val appendedQuery = params.entries.map { (key, value) ->
|
||||
"${key.encodeURLParameter()}=${value.encodeURLParameter()}"
|
||||
}
|
||||
val query = (preservedQuery + appendedQuery).joinToString("&")
|
||||
return if (query.isBlank()) "$path$fragment" else "$path?$query$fragment"
|
||||
}
|
||||
|
||||
private suspend fun HttpResponse.throwIfHttpError(body: String, requestUrl: String) {
|
||||
val code = status.value
|
||||
if (code in 300..399 || code == 401 || code == 403 || code >= 400) {
|
||||
|
||||
@ -50,7 +50,20 @@ fun withWebBricksRequestContext(
|
||||
return merged
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect URLs that need WebBricks backend context (_webbricks_=1).
|
||||
* Covers both direct .ui/.dspy paths and index fallback paths that the
|
||||
* server resolves to .ui files (e.g. "/" → index.ui, "/public" → index.ui).
|
||||
*/
|
||||
fun String.isWebBricksBackendResource(): Boolean {
|
||||
val path = substringBefore('?').substringBefore('#')
|
||||
return path.endsWith(".ui") || path.endsWith(".dspy")
|
||||
val normalized = path.trimEnd('/')
|
||||
|
||||
// Direct .ui/.dspy resource
|
||||
if (path.endsWith(".ui") || path.endsWith(".dspy")) return true
|
||||
|
||||
// Index fallback paths resolved by the server to .ui files
|
||||
if (normalized.isEmpty() || normalized == "/" || normalized == "/public") return true
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
37
test/generic-client/build.sh
Executable file
37
test/generic-client/build.sh
Executable file
@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
MODE="${1:-run}"
|
||||
|
||||
case "$MODE" in
|
||||
run)
|
||||
echo "=== Building and running generic-client ==="
|
||||
./gradlew run --no-daemon
|
||||
;;
|
||||
build)
|
||||
echo "=== Building generic-client ==="
|
||||
./gradlew createDistributable --no-daemon
|
||||
echo "Output: build/compose/binaries/main/"
|
||||
;;
|
||||
package)
|
||||
echo "=== Packaging generic-client ==="
|
||||
./gradlew packageDmg --no-daemon 2>/dev/null || \
|
||||
./gradlew packageDeb --no-daemon 2>/dev/null || \
|
||||
./gradlew packageMsi --no-daemon 2>/dev/null || \
|
||||
echo "Note: Packaging requires the target OS (macOS/Windows/Linux)"
|
||||
;;
|
||||
clean)
|
||||
echo "=== Cleaning ==="
|
||||
./gradlew clean --no-daemon
|
||||
;;
|
||||
*)
|
||||
echo "Usage: $0 {run|build|package|clean}"
|
||||
echo " run - Build and run the app (default)"
|
||||
echo " build - Create distributable app bundle"
|
||||
echo " package - Create OS-specific installer (dmg/deb/msi)"
|
||||
echo " clean - Clean build artifacts"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
Loading…
x
Reference in New Issue
Block a user