mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-09-18 06:34:54 +08:00
fix(api): 补齐 bat-api 控制与后端 RPC
This commit is contained in:
+171
-2
@@ -1,6 +1,21 @@
|
||||
package api
|
||||
|
||||
import "net/http"
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"bat-api/internal/backendrpc"
|
||||
)
|
||||
|
||||
const adminControlMaxBodyBytes = 1024
|
||||
|
||||
type adminControlRequest struct {
|
||||
Force bool `json:"force"`
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminIndex(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
@@ -10,7 +25,7 @@ func (s *Server) handleAdminIndex(w http.ResponseWriter, r *http.Request) {
|
||||
body := AdminIndexResponse{
|
||||
Service: "bat-api",
|
||||
Panel: "admin",
|
||||
Status: "reserved",
|
||||
Status: "available",
|
||||
Links: []string{
|
||||
"/healthz",
|
||||
"/readyz",
|
||||
@@ -19,6 +34,15 @@ func (s *Server) handleAdminIndex(w http.ResponseWriter, r *http.Request) {
|
||||
"/v1/resources",
|
||||
"/openapi.yaml",
|
||||
},
|
||||
Controls: []string{
|
||||
"/admin/control/reload",
|
||||
"/admin/control/refresh",
|
||||
"/admin/control/restart",
|
||||
"/admin/control/sync",
|
||||
"/admin/control/verify",
|
||||
"/admin/control/repair",
|
||||
"/admin/control/catalog-refresh",
|
||||
},
|
||||
}
|
||||
if r.Method == http.MethodHead {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
@@ -27,3 +51,148 @@ func (s *Server) handleAdminIndex(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
writeNoStoreJSON(w, http.StatusOK, body)
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminControl(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
|
||||
return
|
||||
}
|
||||
if !s.requireAdminToken(w, r) {
|
||||
return
|
||||
}
|
||||
action := strings.TrimPrefix(r.URL.Path, "/admin/control/")
|
||||
if action == "" || strings.Contains(action, "/") {
|
||||
writeErrorJSON(w, http.StatusNotFound, "control_not_found", "unknown control action")
|
||||
return
|
||||
}
|
||||
request, ok := decodeAdminControlRequest(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
backend, ok := s.backend.(ControlBackend)
|
||||
if !ok || backend == nil {
|
||||
writeErrorJSON(w, http.StatusServiceUnavailable, "control_backend_unavailable", "Rust bat control backend is unavailable")
|
||||
return
|
||||
}
|
||||
|
||||
var (
|
||||
method string
|
||||
result any
|
||||
err error
|
||||
)
|
||||
switch action {
|
||||
case "reload":
|
||||
if request.Force {
|
||||
writeErrorJSON(w, http.StatusBadRequest, "invalid_control_params", "reload does not accept force")
|
||||
return
|
||||
}
|
||||
method = "daemon.reload"
|
||||
result, err = backend.DaemonReload(r.Context())
|
||||
case "refresh":
|
||||
method = "daemon.refresh"
|
||||
result, err = backend.DaemonRefresh(r.Context(), request.Force)
|
||||
case "restart":
|
||||
if request.Force {
|
||||
writeErrorJSON(w, http.StatusBadRequest, "invalid_control_params", "restart does not accept force")
|
||||
return
|
||||
}
|
||||
method = "daemon.restart"
|
||||
result, err = backend.DaemonRestart(r.Context())
|
||||
case "sync":
|
||||
method = "resource.sync"
|
||||
result, err = backend.ResourceSync(r.Context(), request.Force)
|
||||
case "verify":
|
||||
if request.Force {
|
||||
writeErrorJSON(w, http.StatusBadRequest, "invalid_control_params", "verify does not accept force")
|
||||
return
|
||||
}
|
||||
method = "resource.verify"
|
||||
result, err = backend.ResourceVerify(r.Context())
|
||||
case "repair":
|
||||
if request.Force {
|
||||
writeErrorJSON(w, http.StatusBadRequest, "invalid_control_params", "repair does not accept force")
|
||||
return
|
||||
}
|
||||
method = "resource.repair"
|
||||
result, err = backend.ResourceRepair(r.Context())
|
||||
case "catalog-refresh":
|
||||
method = "catalog.refresh"
|
||||
result, err = backend.CatalogRefresh(r.Context(), request.Force)
|
||||
case "stop", "clean-stable":
|
||||
writeErrorJSON(w, http.StatusForbidden, "control_not_allowed", "control action is not exposed by bat-api")
|
||||
return
|
||||
default:
|
||||
writeErrorJSON(w, http.StatusNotFound, "control_not_found", "unknown control action")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
s.writeControlBackendError(w, action, err)
|
||||
return
|
||||
}
|
||||
writeNoStoreJSON(w, http.StatusAccepted, AdminControlResponse{
|
||||
Service: "bat-api",
|
||||
Action: action,
|
||||
RPCMethod: method,
|
||||
Status: "accepted",
|
||||
Result: result,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) requireAdminToken(w http.ResponseWriter, r *http.Request) bool {
|
||||
if s.cfg.AuthToken == "" {
|
||||
writeErrorJSON(w, http.StatusForbidden, "admin_auth_required", "admin controls require BAT_API_AUTH_TOKEN")
|
||||
return false
|
||||
}
|
||||
if !constantTimeTokenEqual(s.requestToken(r), s.cfg.AuthToken) {
|
||||
w.Header().Set("WWW-Authenticate", `Bearer realm="bat-api-admin"`)
|
||||
writeErrorJSON(w, http.StatusUnauthorized, "unauthorized", "missing or invalid access token")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func decodeAdminControlRequest(w http.ResponseWriter, r *http.Request) (adminControlRequest, bool) {
|
||||
var request adminControlRequest
|
||||
if r.Body == nil {
|
||||
return request, true
|
||||
}
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, adminControlMaxBodyBytes))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&request); err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
return request, true
|
||||
}
|
||||
writeErrorJSON(w, http.StatusBadRequest, "invalid_control_params", "control request must be a JSON object with an optional force boolean")
|
||||
return adminControlRequest{}, false
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
|
||||
writeErrorJSON(w, http.StatusBadRequest, "invalid_control_params", "control request must contain exactly one JSON object")
|
||||
return adminControlRequest{}, false
|
||||
}
|
||||
return request, true
|
||||
}
|
||||
|
||||
func (s *Server) writeControlBackendError(w http.ResponseWriter, action string, err error) {
|
||||
status := http.StatusBadGateway
|
||||
code := "control_backend_failed"
|
||||
message := "Rust bat rejected the control request"
|
||||
switch {
|
||||
case errors.Is(err, context.DeadlineExceeded):
|
||||
status = http.StatusGatewayTimeout
|
||||
code = "control_backend_timeout"
|
||||
message = "Rust bat control request timed out"
|
||||
case errors.Is(err, context.Canceled):
|
||||
status = http.StatusRequestTimeout
|
||||
code = "control_request_canceled"
|
||||
message = "control request was canceled"
|
||||
default:
|
||||
var apiErr *backendrpc.APIError
|
||||
if errors.As(err, &apiErr) && apiErr.Kind == "not_implemented" {
|
||||
status = http.StatusNotImplemented
|
||||
code = "control_not_implemented"
|
||||
message = "Rust bat does not implement this control action"
|
||||
}
|
||||
}
|
||||
s.logger.Printf("bat-api control action=%s error=%v", action, err)
|
||||
writeErrorJSON(w, status, code, message)
|
||||
}
|
||||
|
||||
+137
-1
@@ -548,6 +548,46 @@ func (f *fakeBackend) ResourceManifest(ctx context.Context, offset int, limit in
|
||||
return f.manifest, nil
|
||||
}
|
||||
|
||||
type controlBackend struct {
|
||||
*fakeBackend
|
||||
calls []string
|
||||
}
|
||||
|
||||
func (b *controlBackend) DaemonReload(ctx context.Context) (*backendrpc.Ack, error) {
|
||||
b.calls = append(b.calls, "daemon.reload")
|
||||
return &backendrpc.Ack{Command: "reload", Status: "accepted"}, nil
|
||||
}
|
||||
|
||||
func (b *controlBackend) DaemonRestart(ctx context.Context) (*backendrpc.Ack, error) {
|
||||
b.calls = append(b.calls, "daemon.restart")
|
||||
return &backendrpc.Ack{Command: "restart", Status: "accepted"}, nil
|
||||
}
|
||||
|
||||
func (b *controlBackend) DaemonRefresh(ctx context.Context, force bool) (*backendrpc.Ack, error) {
|
||||
b.calls = append(b.calls, "daemon.refresh")
|
||||
return &backendrpc.Ack{Command: "refresh", Status: "accepted", Force: &force}, nil
|
||||
}
|
||||
|
||||
func (b *controlBackend) ResourceSync(ctx context.Context, force bool) (*backendrpc.TaskAccepted, error) {
|
||||
b.calls = append(b.calls, "resource.sync")
|
||||
return &backendrpc.TaskAccepted{TaskID: "task-sync-1", Kind: "resource.sync"}, nil
|
||||
}
|
||||
|
||||
func (b *controlBackend) ResourceVerify(ctx context.Context) (*backendrpc.TaskAccepted, error) {
|
||||
b.calls = append(b.calls, "resource.verify")
|
||||
return &backendrpc.TaskAccepted{TaskID: "task-verify-1", Kind: "resource.verify"}, nil
|
||||
}
|
||||
|
||||
func (b *controlBackend) ResourceRepair(ctx context.Context) (*backendrpc.TaskAccepted, error) {
|
||||
b.calls = append(b.calls, "resource.repair")
|
||||
return &backendrpc.TaskAccepted{TaskID: "task-repair-1", Kind: "resource.repair"}, nil
|
||||
}
|
||||
|
||||
func (b *controlBackend) CatalogRefresh(ctx context.Context, force bool) (*backendrpc.TaskAccepted, error) {
|
||||
b.calls = append(b.calls, "catalog.refresh")
|
||||
return &backendrpc.TaskAccepted{TaskID: "task-catalog-refresh-1", Kind: "catalog.refresh"}, nil
|
||||
}
|
||||
|
||||
func TestDiscoverCallsStatusBeforeDoctor(t *testing.T) {
|
||||
root := fixtureRoot(t)
|
||||
bytes := uint64(20)
|
||||
@@ -974,7 +1014,103 @@ func TestOpenAPIAndAdminReservedEndpoints(t *testing.T) {
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &admin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if admin.Status != "reserved" {
|
||||
if admin.Status != "available" {
|
||||
t.Fatalf("admin=%+v", admin)
|
||||
}
|
||||
if len(admin.Controls) == 0 || admin.Controls[0] != "/admin/control/reload" {
|
||||
t.Fatalf("admin controls=%v", admin.Controls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminControlForwardsAllowlistedActions(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
cfg.AuthToken = "control-token"
|
||||
if err := cfg.Normalize(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
backend := &controlBackend{fakeBackend: &fakeBackend{}}
|
||||
s := NewServer(cfg, backend, nil)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
action string
|
||||
body string
|
||||
rpcMethod string
|
||||
call string
|
||||
}{
|
||||
{name: "reload", action: "reload", rpcMethod: "daemon.reload", call: "daemon.reload"},
|
||||
{name: "restart", action: "restart", rpcMethod: "daemon.restart", call: "daemon.restart"},
|
||||
{name: "force sync", action: "sync", body: `{"force":true}`, rpcMethod: "resource.sync", call: "resource.sync"},
|
||||
{name: "repair", action: "repair", rpcMethod: "resource.repair", call: "resource.repair"},
|
||||
{name: "catalog refresh", action: "catalog-refresh", rpcMethod: "catalog.refresh", call: "catalog.refresh"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
request := httptest.NewRequest(http.MethodPost, "/admin/control/"+tc.action, strings.NewReader(tc.body))
|
||||
request.Header.Set("Authorization", "Bearer control-token")
|
||||
recorder := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusAccepted {
|
||||
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
var response AdminControlResponse
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response.Action != tc.action || response.RPCMethod != tc.rpcMethod || response.Status != "accepted" {
|
||||
t.Fatalf("response=%+v", response)
|
||||
}
|
||||
if len(backend.calls) == 0 || backend.calls[len(backend.calls)-1] != tc.call {
|
||||
t.Fatalf("calls=%v", backend.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminControlRejectsUnauthenticatedDangerousAndUnsupportedActions(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
cfg.AuthToken = "control-token"
|
||||
if err := cfg.Normalize(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
backend := &controlBackend{fakeBackend: &fakeBackend{}}
|
||||
s := NewServer(cfg, backend, nil)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
action string
|
||||
token string
|
||||
body string
|
||||
wantStatus int
|
||||
wantCode string
|
||||
}{
|
||||
{name: "missing token", action: "repair", wantStatus: http.StatusUnauthorized, wantCode: "unauthorized"},
|
||||
{name: "dangerous stop", action: "stop", token: "control-token", wantStatus: http.StatusForbidden, wantCode: "control_not_allowed"},
|
||||
{name: "unknown action", action: "arbitrary-rpc", token: "control-token", wantStatus: http.StatusNotFound, wantCode: "control_not_found"},
|
||||
{name: "invalid parameters", action: "repair", token: "control-token", body: `{"force":true}`, wantStatus: http.StatusBadRequest, wantCode: "invalid_control_params"},
|
||||
{name: "restart invalid parameters", action: "restart", token: "control-token", body: `{"force":true}`, wantStatus: http.StatusBadRequest, wantCode: "invalid_control_params"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
request := httptest.NewRequest(http.MethodPost, "/admin/control/"+tc.action, strings.NewReader(tc.body))
|
||||
if tc.token != "" {
|
||||
request.Header.Set("Authorization", "Bearer "+tc.token)
|
||||
}
|
||||
recorder := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(recorder, request)
|
||||
if recorder.Code != tc.wantStatus {
|
||||
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
var response ErrorResponse
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response.Error.Code != tc.wantCode {
|
||||
t.Fatalf("error=%+v", response.Error)
|
||||
}
|
||||
})
|
||||
}
|
||||
if len(backend.calls) != 0 {
|
||||
t.Fatalf("rejected actions reached backend: %v", backend.calls)
|
||||
}
|
||||
}
|
||||
|
||||
+36
-3
@@ -9,7 +9,7 @@ const openAPISpecYAML = `openapi: 3.0.3
|
||||
info:
|
||||
title: BlueArchive Toolkit bat-api
|
||||
version: 0.1.0
|
||||
description: Resource bootstrap and read-only distribution API.
|
||||
description: Resource bootstrap, read-only distribution, and authenticated Rust bat control proxy.
|
||||
servers:
|
||||
- url: http://127.0.0.1:18080
|
||||
security:
|
||||
@@ -111,10 +111,43 @@ paths:
|
||||
description: OpenAPI YAML.
|
||||
/admin/:
|
||||
get:
|
||||
summary: Reserved admin panel entry
|
||||
summary: Admin control entry
|
||||
responses:
|
||||
"200":
|
||||
description: Admin panel placeholder and links.
|
||||
description: Admin links and allowlisted control actions.
|
||||
/admin/control/{action}:
|
||||
post:
|
||||
summary: Forward an allowlisted control action to Rust bat
|
||||
parameters:
|
||||
- name: action
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
enum: [reload, refresh, restart, sync, verify, repair, catalog-refresh]
|
||||
requestBody:
|
||||
required: false
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
properties:
|
||||
force:
|
||||
type: boolean
|
||||
responses:
|
||||
"202":
|
||||
description: Rust bat accepted the control request.
|
||||
"400":
|
||||
description: Invalid action parameters.
|
||||
"401":
|
||||
description: Missing or invalid admin token.
|
||||
"403":
|
||||
description: Control is not exposed or no admin token is configured.
|
||||
"501":
|
||||
description: Rust bat does not implement the requested control action.
|
||||
"502":
|
||||
description: Rust bat rejected the control request.
|
||||
/prod-clientpatch.bluearchiveyostar.com/{path}:
|
||||
get:
|
||||
summary: CDN-shaped resource bytes
|
||||
|
||||
@@ -162,10 +162,19 @@ type LauncherEnvelope[T any] struct {
|
||||
}
|
||||
|
||||
type AdminIndexResponse struct {
|
||||
Service string `json:"service"`
|
||||
Panel string `json:"panel"`
|
||||
Status string `json:"status"`
|
||||
Links []string `json:"links"`
|
||||
Service string `json:"service"`
|
||||
Panel string `json:"panel"`
|
||||
Status string `json:"status"`
|
||||
Links []string `json:"links"`
|
||||
Controls []string `json:"controls"`
|
||||
}
|
||||
|
||||
type AdminControlResponse struct {
|
||||
Service string `json:"service"`
|
||||
Action string `json:"action"`
|
||||
RPCMethod string `json:"rpc_method"`
|
||||
Status string `json:"status"`
|
||||
Result any `json:"result"`
|
||||
}
|
||||
|
||||
func writeNoStoreJSON(w http.ResponseWriter, status int, body any) {
|
||||
|
||||
@@ -23,6 +23,21 @@ type Backend interface {
|
||||
ResourceManifest(ctx context.Context, offset int, limit int) (*backendrpc.ResourceManifestPage, error)
|
||||
}
|
||||
|
||||
// ControlBackend is the explicitly allowlisted mutation subset exposed through
|
||||
// the authenticated bat-api admin control surface.
|
||||
//
|
||||
// It intentionally does not include daemon.stop, cleanup, or generic RPC calls.
|
||||
// Restart is forwarded only to Rust's lifecycle RPC; Go never execs bat itself.
|
||||
type ControlBackend interface {
|
||||
DaemonRestart(ctx context.Context) (*backendrpc.Ack, error)
|
||||
DaemonReload(ctx context.Context) (*backendrpc.Ack, error)
|
||||
DaemonRefresh(ctx context.Context, force bool) (*backendrpc.Ack, error)
|
||||
ResourceSync(ctx context.Context, force bool) (*backendrpc.TaskAccepted, error)
|
||||
ResourceVerify(ctx context.Context) (*backendrpc.TaskAccepted, error)
|
||||
ResourceRepair(ctx context.Context) (*backendrpc.TaskAccepted, error)
|
||||
CatalogRefresh(ctx context.Context, force bool) (*backendrpc.TaskAccepted, error)
|
||||
}
|
||||
|
||||
// RPCClient adapts *backendrpc.Client to Backend.
|
||||
type RPCClient struct {
|
||||
Client *backendrpc.Client
|
||||
@@ -43,6 +58,48 @@ func (r RPCClient) CatalogStatus(ctx context.Context) (json.RawMessage, error) {
|
||||
func (r RPCClient) ResourceManifest(ctx context.Context, offset int, limit int) (*backendrpc.ResourceManifestPage, error) {
|
||||
return r.Client.ResourceManifest(ctx, offset, limit)
|
||||
}
|
||||
func (r RPCClient) DaemonRestart(ctx context.Context) (*backendrpc.Ack, error) {
|
||||
return r.Client.DaemonRestart(ctx)
|
||||
}
|
||||
func (r RPCClient) DaemonReload(ctx context.Context) (*backendrpc.Ack, error) {
|
||||
return r.Client.DaemonReload(ctx)
|
||||
}
|
||||
func (r RPCClient) DaemonRefresh(ctx context.Context, force bool) (*backendrpc.Ack, error) {
|
||||
return r.Client.DaemonRefresh(ctx, force)
|
||||
}
|
||||
func (r RPCClient) ResourceSync(ctx context.Context, force bool) (*backendrpc.TaskAccepted, error) {
|
||||
return r.Client.ResourceSync(ctx, force)
|
||||
}
|
||||
func (r RPCClient) ResourceVerify(ctx context.Context) (*backendrpc.TaskAccepted, error) {
|
||||
return r.Client.ResourceVerify(ctx)
|
||||
}
|
||||
func (r RPCClient) ResourceRepair(ctx context.Context) (*backendrpc.TaskAccepted, error) {
|
||||
return r.Client.ResourceRepair(ctx)
|
||||
}
|
||||
func (r RPCClient) CatalogRefresh(ctx context.Context, force bool) (*backendrpc.TaskAccepted, error) {
|
||||
return r.Client.CatalogRefresh(ctx, force)
|
||||
}
|
||||
func (r RPCClient) ParseStatus(ctx context.Context) (json.RawMessage, error) {
|
||||
return r.Client.ParseStatus(ctx)
|
||||
}
|
||||
func (r RPCClient) ParseTextUnits(ctx context.Context, query backendrpc.TextUnitQueryParams) (json.RawMessage, error) {
|
||||
return r.Client.ParseTextUnits(ctx, query)
|
||||
}
|
||||
func (r RPCClient) ParseErrors(ctx context.Context, query backendrpc.TextUnitQueryParams) (json.RawMessage, error) {
|
||||
return r.Client.ParseErrors(ctx, query)
|
||||
}
|
||||
func (r RPCClient) LocalizedStatus(ctx context.Context) (json.RawMessage, error) {
|
||||
return r.Client.LocalizedStatus(ctx)
|
||||
}
|
||||
func (r RPCClient) UnityFSPatchTextAsset(ctx context.Context, params backendrpc.UnityFSTextAssetPatchParams) (json.RawMessage, error) {
|
||||
return r.Client.UnityFSPatchTextAsset(ctx, params)
|
||||
}
|
||||
func (r RPCClient) UnityFSPatchStringField(ctx context.Context, params backendrpc.UnityFSStringFieldPatchParams) (json.RawMessage, error) {
|
||||
return r.Client.UnityFSPatchStringField(ctx, params)
|
||||
}
|
||||
func (r RPCClient) UnityFSPatchField(ctx context.Context, params backendrpc.UnityFSFieldPatchParams) (json.RawMessage, error) {
|
||||
return r.Client.UnityFSPatchField(ctx, params)
|
||||
}
|
||||
|
||||
// DiscoverResult is the outcome of talking to the bat daemon.
|
||||
type DiscoverResult struct {
|
||||
|
||||
@@ -61,6 +61,7 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc(launcherHostPath("/api/launcher/advanced/game/download/cdn"), s.handleLauncherCdnConfig)
|
||||
mux.HandleFunc(launcherHostPath("/api/launcher/resource/bootstrap.json"), s.handleLauncherBootstrap)
|
||||
mux.HandleFunc("/openapi.yaml", s.handleOpenAPI)
|
||||
mux.HandleFunc("/admin/control/", s.handleAdminControl)
|
||||
mux.HandleFunc("/admin/", s.handleAdminIndex)
|
||||
mux.HandleFunc("/"+ServerInfoHost+"/", s.handleServerInfoCDN)
|
||||
mux.HandleFunc("/"+ClientPatchHost+"/", s.serveCDN)
|
||||
@@ -124,6 +125,7 @@ func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) {
|
||||
"/" + ServerInfoHost + "/...",
|
||||
"/openapi.yaml",
|
||||
"/admin/",
|
||||
"/admin/control/{action}",
|
||||
},
|
||||
})
|
||||
return
|
||||
|
||||
@@ -190,14 +190,57 @@ type taskIDParam struct {
|
||||
TaskID string `json:"task_id"`
|
||||
}
|
||||
|
||||
type TextUnitQueryParams struct {
|
||||
Offset int `json:"offset,omitempty"`
|
||||
Limit int `json:"limit,omitempty"`
|
||||
Destination string `json:"destination,omitempty"`
|
||||
PathPattern string `json:"path_pattern,omitempty"`
|
||||
ArchiveEntry string `json:"archive_entry,omitempty"`
|
||||
PathID *int64 `json:"path_id,omitempty"`
|
||||
ClassID *int `json:"class_id,omitempty"`
|
||||
FieldPath string `json:"field_path,omitempty"`
|
||||
Format string `json:"format,omitempty"`
|
||||
}
|
||||
|
||||
type UnityFSTextAssetPatchParams struct {
|
||||
BundlePath string `json:"bundle_path"`
|
||||
SerializedFilePath string `json:"serialized_file_path"`
|
||||
PathID int64 `json:"path_id"`
|
||||
ReplacementPath string `json:"replacement_path"`
|
||||
TargetPath string `json:"target_path"`
|
||||
ExpectedName *string `json:"expected_name,omitempty"`
|
||||
}
|
||||
|
||||
type UnityFSStringFieldPatchParams struct {
|
||||
BundlePath string `json:"bundle_path"`
|
||||
SerializedFilePath string `json:"serialized_file_path"`
|
||||
PathID int64 `json:"path_id"`
|
||||
FieldPath string `json:"field_path"`
|
||||
ReplacementText *string `json:"replacement_text,omitempty"`
|
||||
ReplacementPath string `json:"replacement_path,omitempty"`
|
||||
TargetPath string `json:"target_path"`
|
||||
ExpectedValue *string `json:"expected_value,omitempty"`
|
||||
}
|
||||
|
||||
type UnityFSFieldPatchParams struct {
|
||||
BundlePath string `json:"bundle_path"`
|
||||
SerializedFilePath string `json:"serialized_file_path"`
|
||||
PathID int64 `json:"path_id"`
|
||||
FieldPath string `json:"field_path"`
|
||||
Replacement json.RawMessage `json:"replacement"`
|
||||
TargetPath string `json:"target_path"`
|
||||
ExpectedValue json.RawMessage `json:"expected_value,omitempty"`
|
||||
}
|
||||
|
||||
// Ack is returned by accepted daemon control methods.
|
||||
type Ack struct {
|
||||
Command string `json:"command"`
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
StateDir string `json:"state_dir"`
|
||||
SocketPath string `json:"socket_path"`
|
||||
Force *bool `json:"force,omitempty"`
|
||||
Command string `json:"command"`
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
StateDir string `json:"state_dir"`
|
||||
SocketPath string `json:"socket_path"`
|
||||
ControllerPID *int `json:"controller_pid,omitempty"`
|
||||
Force *bool `json:"force,omitempty"`
|
||||
}
|
||||
|
||||
// TaskAccepted is returned when an async backend task is queued.
|
||||
@@ -324,6 +367,12 @@ func (c *Client) DaemonStop(ctx context.Context) (*Ack, error) {
|
||||
return &out, err
|
||||
}
|
||||
|
||||
func (c *Client) DaemonRestart(ctx context.Context) (*Ack, error) {
|
||||
var out Ack
|
||||
_, err := c.Call(ctx, "daemon.restart", nil, &out)
|
||||
return &out, err
|
||||
}
|
||||
|
||||
func (c *Client) DaemonReload(ctx context.Context) (*Ack, error) {
|
||||
var out Ack
|
||||
_, err := c.Call(ctx, "daemon.reload", nil, &out)
|
||||
@@ -396,6 +445,34 @@ func (c *Client) CatalogRefresh(ctx context.Context, force bool) (*TaskAccepted,
|
||||
return &out, err
|
||||
}
|
||||
|
||||
func (c *Client) ParseStatus(ctx context.Context) (json.RawMessage, error) {
|
||||
return c.rawData(ctx, "parse.status", nil)
|
||||
}
|
||||
|
||||
func (c *Client) ParseTextUnits(ctx context.Context, query TextUnitQueryParams) (json.RawMessage, error) {
|
||||
return c.rawData(ctx, "parse.text_units", query)
|
||||
}
|
||||
|
||||
func (c *Client) ParseErrors(ctx context.Context, query TextUnitQueryParams) (json.RawMessage, error) {
|
||||
return c.rawData(ctx, "parse.errors", query)
|
||||
}
|
||||
|
||||
func (c *Client) LocalizedStatus(ctx context.Context) (json.RawMessage, error) {
|
||||
return c.rawData(ctx, "localized.status", nil)
|
||||
}
|
||||
|
||||
func (c *Client) UnityFSPatchTextAsset(ctx context.Context, params UnityFSTextAssetPatchParams) (json.RawMessage, error) {
|
||||
return c.rawData(ctx, "unityfs.patch_text_asset", params)
|
||||
}
|
||||
|
||||
func (c *Client) UnityFSPatchStringField(ctx context.Context, params UnityFSStringFieldPatchParams) (json.RawMessage, error) {
|
||||
return c.rawData(ctx, "unityfs.patch_string_field", params)
|
||||
}
|
||||
|
||||
func (c *Client) UnityFSPatchField(ctx context.Context, params UnityFSFieldPatchParams) (json.RawMessage, error) {
|
||||
return c.rawData(ctx, "unityfs.patch_field", params)
|
||||
}
|
||||
|
||||
func (c *Client) TaskStatus(ctx context.Context, taskID string) (*TaskRecord, error) {
|
||||
var out TaskRecord
|
||||
_, err := c.Call(ctx, "task.status", taskIDParam{TaskID: taskID}, &out)
|
||||
|
||||
@@ -88,6 +88,120 @@ func TestResourceRepairQueuesTask(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaemonRestartSendsControlMethod(t *testing.T) {
|
||||
client := newTestClient(t, func(t *testing.T, req testRequest) testResponse {
|
||||
if req.Method != "daemon.restart" {
|
||||
t.Fatalf("method = %s", req.Method)
|
||||
}
|
||||
return testResponse{
|
||||
Result: testEnvelope{
|
||||
OK: true,
|
||||
Status: "accepted",
|
||||
RequestID: "req-test-restart",
|
||||
Data: map[string]any{
|
||||
"command": "restart",
|
||||
"status": "accepted",
|
||||
"message": "restart accepted",
|
||||
"state_dir": "/tmp/bat-pid",
|
||||
"socket_path": "/tmp/bat-pid/bat.sock",
|
||||
"controller_pid": 4242,
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
ack, err := client.DaemonRestart(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("DaemonRestart error: %v", err)
|
||||
}
|
||||
if ack.Command != "restart" || ack.ControllerPID == nil || *ack.ControllerPID != 4242 {
|
||||
t.Fatalf("unexpected ack: %#v", ack)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTextUnitsSendsQuery(t *testing.T) {
|
||||
pathID := int64(7)
|
||||
classID := 114
|
||||
client := newTestClient(t, func(t *testing.T, req testRequest) testResponse {
|
||||
if req.Method != "parse.text_units" {
|
||||
t.Fatalf("method = %s", req.Method)
|
||||
}
|
||||
var params TextUnitQueryParams
|
||||
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
|
||||
t.Fatalf("decode params: %v", err)
|
||||
}
|
||||
if params.Offset != 3 || params.Limit != 5 || params.Destination != "*Table*" || params.PathID == nil || *params.PathID != pathID || params.ClassID == nil || *params.ClassID != classID {
|
||||
t.Fatalf("params = %#v", params)
|
||||
}
|
||||
return testResponse{
|
||||
Result: testEnvelope{
|
||||
OK: true,
|
||||
Status: "ok",
|
||||
RequestID: "req-test-parse",
|
||||
Data: map[string]any{
|
||||
"available": true,
|
||||
"entries": []any{},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
raw, err := client.ParseTextUnits(context.Background(), TextUnitQueryParams{
|
||||
Offset: 3,
|
||||
Limit: 5,
|
||||
Destination: "*Table*",
|
||||
PathID: &pathID,
|
||||
ClassID: &classID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ParseTextUnits error: %v", err)
|
||||
}
|
||||
if !json.Valid(raw) {
|
||||
t.Fatalf("invalid raw JSON: %s", string(raw))
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnityFSPatchFieldSendsTaggedReplacement(t *testing.T) {
|
||||
client := newTestClient(t, func(t *testing.T, req testRequest) testResponse {
|
||||
if req.Method != "unityfs.patch_field" {
|
||||
t.Fatalf("method = %s", req.Method)
|
||||
}
|
||||
var params UnityFSFieldPatchParams
|
||||
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
|
||||
t.Fatalf("decode params: %v", err)
|
||||
}
|
||||
if params.BundlePath != "/tmp/source.bundle" || params.FieldPath != "m_Name" || string(params.Replacement) != `{"kind":"string","value":"new text"}` {
|
||||
t.Fatalf("params = %#v replacement=%s", params, string(params.Replacement))
|
||||
}
|
||||
return testResponse{
|
||||
Result: testEnvelope{
|
||||
OK: true,
|
||||
Status: "ok",
|
||||
RequestID: "req-test-unityfs",
|
||||
Data: map[string]any{
|
||||
"command": "unityfs.patch_field",
|
||||
"status": "completed",
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
raw, err := client.UnityFSPatchField(context.Background(), UnityFSFieldPatchParams{
|
||||
BundlePath: "/tmp/source.bundle",
|
||||
SerializedFilePath: "CAB-test",
|
||||
PathID: 1,
|
||||
FieldPath: "m_Name",
|
||||
Replacement: json.RawMessage(`{"kind":"string","value":"new text"}`),
|
||||
TargetPath: "/tmp/target.bundle",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("UnityFSPatchField error: %v", err)
|
||||
}
|
||||
if !json.Valid(raw) {
|
||||
t.Fatalf("invalid raw JSON: %s", string(raw))
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourceListSendsPagination(t *testing.T) {
|
||||
client := newTestClient(t, func(t *testing.T, req testRequest) testResponse {
|
||||
if req.Method != "resource.list" {
|
||||
|
||||
Reference in New Issue
Block a user