// Package httpapi exposes the internal full-sync control API through contract // interfaces. Persistence and replay execution belong to the injected services. package httpapi import ( "bytes" "crypto/subtle" "encoding/json" "errors" "io" "mime" "net/http" "net/url" "strconv" "strings" "unicode" "unicode/utf8" "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/fullsync/contract" fullsyncruntime "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/fullsync/runtime" "github.com/gogf/gf/v2/errors/gerror" ) const maxReplayBodyBytes = 64 << 10 // Dependencies are the only application services used by the HTTP handlers. type Dependencies struct { Runs contract.RunReader List contract.RunLister Failures contract.FailurePager Replays contract.ReplayRequester Actions contract.ActionReader Simple *fullsyncruntime.SimpleReplay } // ErrorResponse is shared by authentication, routing, validation and service // failures. Messages deliberately exclude underlying storage error details. type ErrorResponse struct { Error APIError `json:"error"` } type APIError struct { Code string `json:"code"` Message string `json:"message"` } // NewHandler exposes the control routes on the private Docker network. Admin // performs the user JWT and permission checks before calling this listener. func NewHandler(bearerToken string, deps Dependencies) (http.Handler, error) { if strings.ContainsFunc(bearerToken, func(r rune) bool { return unicode.IsSpace(r) || unicode.IsControl(r) }) { return nil, gerror.New("invalid full-sync HTTP token") } if deps.Runs == nil || deps.List == nil || deps.Failures == nil || deps.Replays == nil || deps.Actions == nil { return nil, gerror.New("full-sync HTTP requires run list, run, failure, replay and action services") } mux := http.NewServeMux() mux.HandleFunc(contract.ListRunsRoute, method(http.MethodGet, func(w http.ResponseWriter, r *http.Request) { query, err := url.ParseQuery(r.URL.RawQuery) if err != nil || len(query["limit"]) > 1 || len(query["syncKind"]) > 1 { writeError(w, contract.ErrInvalidArgument) return } filter := contract.RunListFilter{SyncKind: contract.SyncKind(query.Get("syncKind"))} if filter.SyncKind != "" && !filter.SyncKind.Valid() { writeError(w, contract.ErrInvalidArgument) return } if query.Get("limit") != "" { filter.Limit, err = strconv.Atoi(query.Get("limit")) if err != nil || filter.Limit < 1 || filter.Limit > 100 { writeError(w, contract.ErrInvalidArgument) return } } result, err := deps.List.ListRuns(r.Context(), filter) if err != nil { writeError(w, err) return } writeJSON(w, http.StatusOK, result) })) mux.HandleFunc(contract.GetRunRoute, method(http.MethodGet, func(w http.ResponseWriter, r *http.Request) { id := r.PathValue("runId") if err := contract.ValidateID(id); err != nil { writeError(w, err) return } view, err := deps.Runs.GetRun(r.Context(), id) if err != nil { writeError(w, err) return } writeJSON(w, http.StatusOK, view) })) mux.HandleFunc(contract.GetFailuresRoute, method(http.MethodGet, func(w http.ResponseWriter, r *http.Request) { id := r.PathValue("runId") if err := contract.ValidateID(id); err != nil { writeError(w, err) return } page, err := parsePage(r.URL.RawQuery) if err != nil { writeError(w, err) return } result, err := deps.Failures.ListFailures(r.Context(), id, page) if err != nil { writeError(w, err) return } if result.Items == nil { result.Items = []contract.Failure{} } writeJSON(w, http.StatusOK, result) })) mux.HandleFunc(contract.PostReplayRoute, method(http.MethodPost, func(w http.ResponseWriter, r *http.Request) { if deps.Simple != nil { id := r.PathValue("runId") if err := contract.ValidateID(id); err != nil { writeError(w, err) return } var request struct { RequestedBy string `json:"requestedBy"` MaxRounds int `json:"maxRounds"` } decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxReplayBodyBytes)) decoder.DisallowUnknownFields() if err := decoder.Decode(&request); err != nil { writeError(w, contract.ErrInvalidArgument) return } if err := decoder.Decode(new(any)); err != io.EOF { writeError(w, contract.ErrInvalidArgument) return } accepted, err := deps.Simple.Request(r.Context(), id, request.RequestedBy, request.MaxRounds) if errors.Is(err, fullsyncruntime.ErrNoFailures) { writeAPIError(w, http.StatusUnprocessableEntity, "no_failures", "Run has no failures to replay") return } if err != nil { writeError(w, err) return } writeJSON(w, http.StatusAccepted, accepted) return } request, err := parseReplay(w, r) if err != nil { var tooLarge *http.MaxBytesError if errors.As(err, &tooLarge) { writeAPIError(w, http.StatusRequestEntityTooLarge, "body_too_large", "Replay request body is too large") } else { writeError(w, err) } return } accepted, err := deps.Replays.RequestReplay(r.Context(), request) if err != nil { writeError(w, err) return } // Construct a local status URL from the returned identity, never an // externally supplied redirect. Invalid service output is a server error. accepted.StatusURL, err = contract.ActionStatusURL(accepted.ActionID) if err != nil || contract.ValidateID(accepted.TargetRunID) != nil || !accepted.Status.Valid() { writeAPIError(w, http.StatusInternalServerError, "internal_error", "Internal server error") return } w.Header().Set("Location", accepted.StatusURL) writeJSON(w, http.StatusAccepted, accepted) })) mux.HandleFunc("/internal/v1/full-sync-replays/{replayRunId}", method(http.MethodGet, func(w http.ResponseWriter, r *http.Request) { if deps.Simple == nil { writeError(w, contract.ErrNotFound) return } status, err := deps.Simple.Status(r.Context(), r.PathValue("replayRunId")) if err != nil { writeError(w, err) return } writeJSON(w, http.StatusOK, status) })) mux.HandleFunc(contract.GetActionRoute, method(http.MethodGet, func(w http.ResponseWriter, r *http.Request) { if deps.Simple != nil { writeError(w, contract.ErrNotFound) return } id := r.PathValue("actionId") if err := contract.ValidateID(id); err != nil { writeError(w, err) return } action, err := deps.Actions.GetReplayAction(r.Context(), id) if err != nil { writeError(w, err) return } writeJSON(w, http.StatusOK, action) })) mux.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) { writeError(w, contract.ErrNotFound) }) if bearerToken == "" { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "no-store") mux.ServeHTTP(w, r) }), nil } return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "no-store") values := r.Header.Values("Authorization") if len(values) == 1 { parts := strings.Fields(values[0]) if len(parts) == 2 && strings.EqualFold(parts[0], "Bearer") && subtle.ConstantTimeCompare([]byte(parts[1]), []byte(bearerToken)) == 1 { mux.ServeHTTP(w, r) return } } w.Header().Set("WWW-Authenticate", `Bearer realm="full-sync"`) writeAPIError(w, http.StatusUnauthorized, "unauthorized", "Valid bearer authentication is required") }), nil } func method(expected string, next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method != expected { w.Header().Set("Allow", expected) writeAPIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed") return } next(w, r) } } func parsePage(rawQuery string) (contract.PageRequest, error) { query, err := url.ParseQuery(rawQuery) if err != nil || len(query["cursor"]) > 1 || len(query["limit"]) > 1 { return contract.PageRequest{}, gerror.Wrap(contract.ErrInvalidArgument, "invalid pagination query") } page := contract.PageRequest{Cursor: contract.Cursor(query.Get("cursor"))} if value := query.Get("limit"); value != "" { page.Limit, err = strconv.Atoi(value) if err != nil { return contract.PageRequest{}, gerror.Wrap(contract.ErrInvalidArgument, "limit must be an integer") } } return page.Normalized() } func parseReplay(w http.ResponseWriter, r *http.Request) (contract.ReplayRequest, error) { var request contract.ReplayRequest if value := r.Header.Get("Content-Type"); value != "" { mediaType, _, err := mime.ParseMediaType(value) if err != nil || mediaType != "application/json" { return request, gerror.Wrap(contract.ErrInvalidArgument, "replay body must be JSON") } } data, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxReplayBodyBytes)) if err != nil { var tooLarge *http.MaxBytesError if errors.As(err, &tooLarge) { return request, err } return request, gerror.Wrap(contract.ErrInvalidArgument, "cannot read replay body") } if !utf8.Valid(data) { return request, gerror.Wrap(contract.ErrInvalidArgument, "replay body must be UTF-8") } decoder := json.NewDecoder(bytes.NewReader(data)) decoder.DisallowUnknownFields() if err := decoder.Decode(&request); err != nil { return request, gerror.Wrap(contract.ErrInvalidArgument, "invalid replay JSON") } if err := decoder.Decode(new(any)); err != io.EOF { return request, gerror.Wrap(contract.ErrInvalidArgument, "replay body must contain exactly one JSON object") } if len(r.Header.Values(contract.IdempotencyHeader)) != 1 { return request, gerror.Wrap(contract.ErrInvalidArgument, "exactly one Idempotency-Key is required") } request.SourceRunID = r.PathValue("runId") request.IdempotencyKey = r.Header.Get(contract.IdempotencyHeader) return request, request.Validate() } func writeError(w http.ResponseWriter, err error) { switch { case errors.Is(err, contract.ErrInvalidArgument): writeAPIError(w, http.StatusBadRequest, "invalid_argument", "Invalid request") case errors.Is(err, contract.ErrNotFound): writeAPIError(w, http.StatusNotFound, "not_found", "Resource not found") case errors.Is(err, contract.ErrFailureDetailsUnavailable): writeAPIError(w, http.StatusGone, "failure_details_unavailable", "Live failure details are no longer available") case errors.Is(err, contract.ErrConflict), errors.Is(err, contract.ErrLeaseLost): writeAPIError(w, http.StatusConflict, "conflict", "Request conflicts with the current resource") case errors.Is(err, contract.ErrBusy): writeAPIError(w, http.StatusServiceUnavailable, "busy", "Resource is busy; retry later") default: writeAPIError(w, http.StatusInternalServerError, "internal_error", "Internal server error") } } func writeAPIError(w http.ResponseWriter, status int, code, message string) { writeJSON(w, status, ErrorResponse{Error: APIError{Code: code, Message: message}}) } func writeJSON(w http.ResponseWriter, status int, value any) { // Marshal before writing headers so an invalid service payload cannot // produce a successful status with truncated JSON. data, err := json.Marshal(value) if err != nil { status = http.StatusInternalServerError data = []byte(`{"error":{"code":"internal_error","message":"Internal server error"}}`) } w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(status) _, _ = w.Write(append(data, '\n')) }