feat: deliver authenticated task APIs, SDK and P1 regression

This commit is contained in:
2026-09-17 07:41:32 +08:00
parent 4f2935e36f
commit 9701b7a071
37 changed files with 3169 additions and 67 deletions

View File

@@ -1,17 +1,17 @@
// Package httpserver exposes bootstrap health endpoints only. Task, workflow,
// authentication and WebSocket APIs will be implemented in subsequent slices.
// Package httpserver exposes health and authenticated task-management endpoints.
package httpserver
import (
"context"
"encoding/json"
"git.yuntuo.hk/k2safe/orbit/pulse/service"
"net/http"
"time"
)
type Checker func(context.Context) error
func New(addr string, ready Checker) *http.Server {
func New(addr string, ready Checker, tasks ...*service.Service) *http.Server {
mux := http.NewServeMux()
write := func(w http.ResponseWriter, status int, body map[string]string) {
w.Header().Set("Content-Type", "application/json")
@@ -30,5 +30,8 @@ func New(addr string, ready Checker) *http.Server {
}
write(w, http.StatusOK, map[string]string{"status": "ready", "scope": "database_and_schema"})
})
return &http.Server{Addr: addr, Handler: mux, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, IdleTimeout: 60 * time.Second, MaxHeaderBytes: 16 << 10}
if len(tasks) > 0 && tasks[0] != nil {
registerTasks(mux, tasks[0])
}
return &http.Server{Addr: addr, Handler: mux, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 15 * time.Second, IdleTimeout: 60 * time.Second, MaxHeaderBytes: 16 << 10}
}

View File

@@ -0,0 +1,241 @@
package httpserver
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"log/slog"
"mime"
"net/http"
"net/url"
"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"
"git.yuntuo.hk/k2safe/orbit/platform/auth"
pulse "git.yuntuo.hk/k2safe/orbit/pulse/model"
"git.yuntuo.hk/k2safe/orbit/pulse/service"
)
type endpoint func(*http.Request, auth.Token) (any, int, error)
func registerTasks(mux *http.ServeMux, s *service.Service) {
mux.HandleFunc("POST /v1/tasks", wrap(func(r *http.Request, t auth.Token) (any, int, error) {
var in service.SubmitInput
if err := decodeBody(r, &in); err != nil {
return nil, 0, err
}
key, err := idempotencyKey(r)
if err != nil {
return nil, 0, err
}
out, err := s.Submit(r.Context(), t, key, in)
return out, createdStatus(out.Deduplicated), err
}))
mux.HandleFunc("GET /v1/tasks/{id}", wrap(func(r *http.Request, t auth.Token) (any, int, error) {
out, err := s.GetTask(r.Context(), t, core.ID(r.PathValue("id")))
return out, 200, err
}))
mux.HandleFunc("GET /v1/tasks", wrap(func(r *http.Request, t auth.Token) (any, int, error) {
q, queryErr := url.ParseQuery(r.URL.RawQuery)
if queryErr != nil {
return nil, 0, fault.Invalid("invalid query encoding")
}
for k, v := range q {
if len(v) != 1 || (k != "queue_id" && k != "state" && k != "limit" && k != "cursor") {
return nil, 0, fault.Invalid("invalid query parameter")
}
}
limit := 0
if q.Has("limit") {
var err error
limit, err = strconv.Atoi(q.Get("limit"))
if err != nil || limit <= 0 {
return nil, 0, fault.Invalid("invalid limit")
}
}
out, err := s.ListTasks(r.Context(), t, service.ListInput{QueueID: core.ID(q.Get("queue_id")), State: pulse.TaskState(q.Get("state")), Limit: limit, Cursor: q.Get("cursor")})
return out, 200, err
}))
mux.HandleFunc("POST /v1/tasks/{command}", wrap(func(r *http.Request, t auth.Token) (any, int, error) {
command := r.PathValue("command")
if !strings.HasSuffix(command, ":cancel") {
return nil, 0, fault.NotFound
}
var in service.CancelInput
if err := decodeBody(r, &in); err != nil {
return nil, 0, err
}
key, err := idempotencyKey(r)
if err != nil {
return nil, 0, err
}
out, err := s.CancelTask(r.Context(), t, core.ID(strings.TrimSuffix(command, ":cancel")), key, in)
return out, 200, err
}))
mux.HandleFunc("POST /v1/queues", wrap(func(r *http.Request, t auth.Token) (any, int, error) {
var in service.QueueInput
if err := decodeBody(r, &in); err != nil {
return nil, 0, err
}
key, err := idempotencyKey(r)
if err != nil {
return nil, 0, err
}
out, err := s.CreateQueue(r.Context(), t, key, in)
return out, createdStatus(out.Deduplicated), err
}))
mux.HandleFunc("GET /v1/queues/{id}", wrap(func(r *http.Request, t auth.Token) (any, int, error) {
out, err := s.GetQueue(r.Context(), t, core.ID(r.PathValue("id")))
return out, 200, err
}))
}
func createdStatus(duplicate bool) int {
if duplicate {
return http.StatusOK
}
return http.StatusCreated
}
func idempotencyKey(r *http.Request) (string, error) {
values := r.Header.Values("Idempotency-Key")
if len(values) != 1 {
return "", fault.Invalid("exactly one Idempotency-Key is required")
}
return values[0], nil
}
func wrap(next endpoint) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id, err := core.NewID()
if err != nil {
writeError(w, "", fault.Unavailable)
return
}
w.Header().Set("X-Request-ID", string(id))
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("X-Content-Type-Options", "nosniff")
values := r.Header.Values("Authorization")
var token auth.Token
if len(values) != 1 {
writeError(w, string(id), fault.Unauthorized)
return
}
scheme, value, ok := strings.Cut(values[0], " ")
if !ok || !strings.EqualFold(scheme, "Bearer") {
writeError(w, string(id), fault.Unauthorized)
return
}
token, err = auth.Parse(value)
if err != nil {
writeError(w, string(id), fault.Unauthorized)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second)
defer cancel()
data, status, err := next(r.WithContext(ctx), token)
if err != nil {
if fault.Code(err) == "UNAVAILABLE" {
slog.Error("Orbit API operation failed", "request_id", id, "code", "UNAVAILABLE")
}
writeError(w, string(id), err)
return
}
writeJSON(w, status, map[string]any{"request_id": id, "data": data})
}
}
func writeJSON(w http.ResponseWriter, status int, body any) {
raw, err := json.Marshal(body)
if err != nil {
status = 503
raw = []byte(`{"error":{"code":"UNAVAILABLE","message":"operation unavailable"}}`)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_, _ = w.Write(append(raw, '\n'))
}
func writeError(w http.ResponseWriter, id string, err error) {
var known *fault.Error
if !errors.As(err, &known) {
known = fault.Unavailable
}
status := map[string]int{"INVALID_ARGUMENT": 400, "UNAUTHENTICATED": 401, "PERMISSION_DENIED": 403, "NOT_FOUND": 404, "STATE_CONFLICT": 409, "IDEMPOTENCY_CONFLICT": 409, "BODY_TOO_LARGE": 413, "UNSUPPORTED_MEDIA_TYPE": 415}[known.Code]
if status == 0 {
status = 503
}
if status == 401 {
w.Header().Set("WWW-Authenticate", `Bearer realm="orbit"`)
}
writeJSON(w, status, map[string]any{"request_id": id, "error": known})
}
func decodeBody(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")
}
const maxBody = 512 << 10
raw, err := io.ReadAll(io.LimitReader(r.Body, maxBody+1))
if err != nil {
return fault.Invalid("cannot read request body")
}
if len(raw) > maxBody {
return fault.New("BODY_TOO_LARGE", "request body exceeds 512 KiB")
}
if _, err := jsonvalue.Canonical(raw); err != nil {
return fault.Invalid("invalid JSON, duplicate keys, or unsupported depth/numeric range")
}
if err := exactFields(raw, reflect.TypeOf(out).Elem()); err != nil {
return fault.Invalid("unknown field, null, or invalid object structure")
}
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.DisallowUnknownFields()
if err := decoder.Decode(out); err != nil {
return fault.Invalid("invalid field value or type")
}
return nil
}
// 精确匹配结构字段,防止 encoding/json 将 Handler 和 handler 解释为同一个字段。
func exactFields(raw []byte, t reflect.Type) error {
if bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
if t.Kind() == reflect.Pointer {
return nil
}
return errors.New("unexpected null")
}
if t.Kind() == reflect.Pointer {
return exactFields(raw, t.Elem())
}
if t == reflect.TypeOf(time.Time{}) || t == reflect.TypeOf(json.RawMessage{}) {
return nil
}
if t.Kind() != reflect.Struct {
return nil
}
var object map[string]json.RawMessage
if err := json.Unmarshal(raw, &object); err != nil {
return err
}
names := map[string]reflect.Type{}
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
name := strings.Split(field.Tag.Get("json"), ",")[0]
if name != "" && name != "-" {
names[name] = field.Type
}
}
for name, value := range object {
field, ok := names[name]
if !ok {
return errors.New("unknown field")
}
if err := exactFields(value, field); err != nil {
return err
}
}
return nil
}

View File

@@ -0,0 +1,44 @@
package httpserver
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.yuntuo.hk/k2safe/orbit/internal/fault"
"git.yuntuo.hk/k2safe/orbit/pulse/service"
)
func TestBodyAndErrorBoundary(t *testing.T) {
for _, test := range []struct{ body, content, code string }{
{`{"payload":{"n":1,"n":2}}`, "application/json", "INVALID_ARGUMENT"},
{`{"retry":{"MaxAttempts":2}}`, "application/json", "INVALID_ARGUMENT"},
{`{"retry":{"max_attempts":1},"Retry":{"max_attempts":2}}`, "application/json", "INVALID_ARGUMENT"},
{`{}`, "text/plain", "UNSUPPORTED_MEDIA_TYPE"},
{`{"payload":{"arbitrary_key":9007199254740993}}`, "application/json", ""},
} {
r := httptest.NewRequest(http.MethodPost, "/v1/tasks", strings.NewReader(test.body))
r.Header.Set("Content-Type", test.content)
var in service.SubmitInput
err := decodeBody(r, &in)
if test.code == "" {
if err != nil {
t.Fatal(err)
}
} else if fault.Code(err) != test.code {
t.Fatalf("body %s: %v", test.body, err)
}
}
w := httptest.NewRecorder()
writeError(w, "request", fault.Forbidden)
var envelope struct {
Error struct {
Code string `json:"code"`
} `json:"error"`
}
if err := json.Unmarshal(w.Body.Bytes(), &envelope); err != nil || envelope.Error.Code != "PERMISSION_DENIED" || w.Code != 403 {
t.Fatalf("bad error contract %s", w.Body.String())
}
}