feat: deliver authenticated task APIs, SDK and P1 regression
This commit is contained in:
241
internal/transport/httpserver/tasks.go
Normal file
241
internal/transport/httpserver/tasks.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user