feat: expose result subscription HTTP and WebSocket APIs

This commit is contained in:
2026-09-17 19:57:55 +08:00
parent 1c3300cc5b
commit 8485a662b2
38 changed files with 3462 additions and 26 deletions

View File

@@ -0,0 +1,175 @@
package httpserver
import (
"bytes"
"encoding/json"
"io"
"mime"
"net/http"
"reflect"
"strconv"
"strings"
"time"
"git.yuntuo.hk/k2safe/orbit/internal/fault"
"git.yuntuo.hk/k2safe/orbit/internal/jsonvalue"
core "git.yuntuo.hk/k2safe/orbit/model"
)
const EventSubprotocol = "orbit.events.v1"
const eventMaxFrame = 512 << 10
type eventFrame struct {
Version string `json:"protocol_version"`
Type string `json:"type"`
MessageID core.ID `json:"message_id"`
ReplyTo core.ID `json:"reply_to,omitempty"`
SessionID core.ID `json:"session_id,omitempty"`
Body json.RawMessage `json:"body,omitempty"`
Error *fault.Error `json:"error,omitempty"`
}
type eventHello struct {
SubscriptionID core.ID `json:"subscription_id"`
Capacity uint32 `json:"capacity"`
}
type eventWelcome struct {
SessionID core.ID `json:"session_id"`
SubscriptionID core.ID `json:"subscription_id"`
Capacity uint32 `json:"capacity"`
LeaseDurationMS int64 `json:"lease_duration_ms"`
HeartbeatIntervalMS int64 `json:"heartbeat_interval_ms"`
ServerTime time.Time `json:"server_time"`
Enabled bool `json:"enabled"`
}
type eventPong struct {
ServerTime time.Time `json:"server_time"`
Enabled bool `json:"enabled"`
}
func decodeEventBody(r *http.Request, out any) error {
content, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
if err != nil || content != "application/json" {
return fault.New("UNSUPPORTED_MEDIA_TYPE", "Content-Type must be application/json")
}
raw, err := io.ReadAll(io.LimitReader(r.Body, eventMaxFrame+1))
if err != nil {
return fault.Invalid("cannot read request body")
}
if len(raw) > eventMaxFrame {
return fault.New("BODY_TOO_LARGE", "request body exceeds 512 KiB")
}
return decodeEventJSON(raw, out)
}
func decodeEventJSON(raw []byte, out any) error {
if len(raw) == 0 || len(raw) > eventMaxFrame {
return fault.Invalid("invalid event message size")
}
if _, err := jsonvalue.Canonical(raw); err != nil {
return fault.Invalid("invalid event JSON")
}
typ := reflect.TypeOf(out).Elem()
if exactFields(raw, typ) != nil || requiredEventFields(raw, typ) != nil {
return fault.Invalid("missing, unknown or invalid event field")
}
d := json.NewDecoder(bytes.NewReader(raw))
d.DisallowUnknownFields()
if d.Decode(out) != nil {
return fault.Invalid("invalid event field value")
}
return nil
}
// requiredEventFields 同时验证显式控制意图及规范十进制字符串;可选历史游标允许0和null。
func requiredEventFields(raw []byte, typ reflect.Type) error {
if typ == reflect.TypeOf(json.RawMessage{}) || typ == reflect.TypeOf(time.Time{}) {
return nil
}
if typ.Kind() == reflect.Pointer {
if bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
return nil
}
return requiredEventFields(raw, typ.Elem())
}
if typ.Kind() == reflect.Array || typ.Kind() == reflect.Slice {
var values []json.RawMessage
if json.Unmarshal(raw, &values) != nil {
return fault.Invalid("invalid event array")
}
for _, v := range values {
if err := requiredEventFields(v, typ.Elem()); err != nil {
return err
}
}
return nil
}
if typ.Kind() != reflect.Struct {
return nil
}
var fields map[string]json.RawMessage
if json.Unmarshal(raw, &fields) != nil {
return fault.Invalid("invalid event object")
}
for i := 0; i < typ.NumField(); i++ {
field := typ.Field(i)
tags := strings.Split(field.Tag.Get("json"), ",")
if tags[0] == "" || tags[0] == "-" {
continue
}
optional, numberString := false, false
for _, v := range tags[1:] {
optional = optional || v == "omitempty"
numberString = numberString || v == "string"
}
value, found := fields[tags[0]]
if !found {
if optional {
continue
}
return fault.Invalid("missing event field")
}
ft := field.Type
nullable := ft.Kind() == reflect.Pointer
if nullable {
if bytes.Equal(bytes.TrimSpace(value), []byte("null")) {
continue
}
ft = ft.Elem()
}
if ft.Kind() == reflect.Uint64 && numberString {
var text string
if json.Unmarshal(value, &text) != nil {
return fault.Invalid("event counter must be a decimal string")
}
n, err := strconv.ParseUint(text, 10, 64)
if err != nil || strconv.FormatUint(n, 10) != text || (!nullable && n == 0) {
return fault.Invalid("invalid event counter")
}
}
if err := requiredEventFields(value, field.Type); err != nil {
return err
}
}
return nil
}
func decodeEventRequest(raw []byte, hello bool) (eventFrame, error) {
var frame eventFrame
if err := decodeEventJSON(raw, &frame); err != nil {
return frame, err
}
if frame.Version != "1" || frame.MessageID.Validate() != nil || len(frame.Body) == 0 {
return frame, fault.Invalid("invalid event envelope")
}
var fields map[string]json.RawMessage
_ = json.Unmarshal(raw, &fields)
if fields["error"] != nil || fields["reply_to"] != nil {
return frame, fault.Invalid("response fields are not allowed in event requests")
}
if hello {
if frame.Type != "events.hello" || fields["session_id"] != nil {
return frame, fault.Invalid("invalid event hello")
}
} else if frame.SessionID.Validate() != nil || (frame.Type != "event.ack" && frame.Type != "events.ping") {
return frame, fault.Invalid("invalid event session or type")
}
return frame, nil
}