Bootstrap copied BFF and local accounts with public Sendico contracts (#34)

This commit is contained in:
Stephan D committed 2026-09-12 13:37:48 +02:00
1 parent 67dc5b5b1e
commit 7062672a87
556 files changed
+42291 -777

No files matched your search

+36
View File
@@ -0,0 +1,36 @@
package api
import "fmt"
type HTTPMethod int
const (
Get HTTPMethod = iota
Post
Put
Patch
Delete
Options
Head
)
func HTTPMethod2String(method HTTPMethod) string {
switch method {
case Get:
return "GET"
case Post:
return "POST"
case Put:
return "PUT"
case Delete:
return "DELETE"
case Patch:
return "PATCH"
case Options:
return "OPTIONS"
case Head:
return "HEAD"
default:
return fmt.Sprintf("unknown: %d", method)
}
}
+228
View File
@@ -0,0 +1,228 @@
package response
import (
"encoding/json"
"errors"
"fmt"
"net/http"
api "git.sendico.io/tech/sxchnge/bff/pkg/api/http"
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
"go.uber.org/zap"
)
// BaseResponse is a general structure for all API responses.
type BaseResponse struct {
Status string `json:"status"` // "success" or "error"
Data any `json:"data"` // The actual data payload or the error details
}
// ErrorResponse provides more details about an error.
type ErrorResponse struct {
Code int `json:"code"` // A unique identifier for the error type, useful for client handling
Error string `json:"error"`
Source string `json:"source"`
Details any `json:"details"` // Additional details or hints about the error, if necessary
}
func errMessage(err error) string {
if err != nil {
return err.Error()
}
return ""
}
func logRequest(logger mlogger.Logger, r *http.Request, message string) {
logger.Debug(
message,
zap.String("host", r.Host),
zap.String("address", r.RemoteAddr),
zap.String("method", r.Method),
zap.String("request_uri", r.RequestURI),
zap.String("proto", r.Proto),
zap.String("user_agent", r.UserAgent()),
)
}
func writeJSON(logger mlogger.Logger, w http.ResponseWriter, r *http.Request, code int, payload any) {
w.Header().Set("Content-Type", "application/json; charset=UTF-8")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Pragma", "no-cache")
w.WriteHeader(code)
if err := json.NewEncoder(w).Encode(&payload); err != nil {
logger.Warn("Failed to encode JSON response",
zap.Error(err),
zap.String("host", r.Host),
zap.String("address", r.RemoteAddr),
zap.String("method", r.Method),
zap.String("request_uri", r.RequestURI),
zap.String("proto", r.Proto),
zap.String("user_agent", r.UserAgent()))
}
}
func errorf(
logger mlogger.Logger,
w http.ResponseWriter, r *http.Request,
source mservice.Type, code int, message string, details any,
) {
logRequest(logger, r, message)
errorMessage := BaseResponse{
Status: api.MSError,
Data: ErrorResponse{
Code: code,
Details: details,
Source: source,
Error: message,
},
}
writeJSON(logger, w, r, code, errorMessage)
}
func Accepted(logger mlogger.Logger, data any) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
resp := BaseResponse{
Status: api.MSSuccess,
Data: data,
}
writeJSON(logger, w, r, http.StatusAccepted, resp)
}
}
func Ok(logger mlogger.Logger, data any) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
resp := BaseResponse{
Status: api.MSSuccess,
Data: data,
}
writeJSON(logger, w, r, http.StatusOK, resp)
}
}
func Created(logger mlogger.Logger, data any) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
resp := BaseResponse{
Status: api.MSSuccess,
Data: data,
}
writeJSON(logger, w, r, http.StatusCreated, resp)
}
}
func Auto(logger mlogger.Logger, source mservice.Type, err error) http.HandlerFunc {
if err == nil {
return Success(logger)
}
if errors.Is(err, merrors.ErrAccessDenied) {
return AccessDenied(logger, source, errMessage(err))
}
if errors.Is(err, merrors.ErrDocumentsReportOwned) {
return Error(logger, source, http.StatusConflict, "documents_report_owned", errMessage(err))
}
if errors.Is(err, merrors.ErrDataConflict) {
return DataConflict(logger, source, errMessage(err))
}
if errors.Is(err, merrors.ErrInvalidArg) {
return BadRequest(logger, source, "invalid_argument", errMessage(err))
}
if errors.Is(err, merrors.ErrNoData) {
return NotFound(logger, source, errMessage(err))
}
if errors.Is(err, merrors.ErrUnauthorized) {
return Unauthorized(logger, source, errMessage(err))
}
if errors.Is(err, merrors.ErrServiceUnavailable) {
return Error(logger, source, http.StatusServiceUnavailable, "service_unavailable", errMessage(err))
}
return Internal(logger, source, err)
}
func Internal(logger mlogger.Logger, source mservice.Type, err error) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
errorf(logger, w, r, source, http.StatusInternalServerError, "internal_error", errMessage(err))
}
}
func NotImplemented(logger mlogger.Logger, source mservice.Type, hint string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
errorf(logger, w, r, source, http.StatusNotImplemented, "not_implemented", hint)
}
}
func BadRequest(logger mlogger.Logger, source mservice.Type, err, hint string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
errorf(logger, w, r, source, http.StatusBadRequest, err, hint)
}
}
func Gone(logger mlogger.Logger, source mservice.Type, err, hint string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
errorf(logger, w, r, source, http.StatusGone, err, hint)
}
}
func BadQueryParam(logger mlogger.Logger, source mservice.Type, param string, err error) http.HandlerFunc {
return BadRequest(logger, source, "invalid_query_parameter", fmt.Sprintf("Failed to parse '%s': %v", param, err))
}
func BadReference(logger mlogger.Logger, source mservice.Type, refName, refVal string, err error) http.HandlerFunc {
return BadRequest(logger, source, "broken_reference",
fmt.Sprintf("broken object reference: %s = %s, error: %v", refName, refVal, err))
}
func BadPayload(logger mlogger.Logger, source mservice.Type, err error) http.HandlerFunc {
return BadRequest(logger, source, "broken_payload",
fmt.Sprintf("broken '%s' object payload, error: %v", source, err))
}
func DataConflict(logger mlogger.Logger, source mservice.Type, hint string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
errorf(logger, w, r, source, http.StatusConflict, "data_conflict", hint)
}
}
func Error(logger mlogger.Logger, source mservice.Type, code int, errType, hint string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
errorf(logger, w, r, source, code, errType, hint)
}
}
func ErrorWithDetails(logger mlogger.Logger, source mservice.Type, code int, errType string, details any) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
errorf(logger, w, r, source, code, errType, details)
}
}
func AccessDenied(logger mlogger.Logger, source mservice.Type, hint string) http.HandlerFunc {
return Error(logger, source, http.StatusForbidden, "access_denied", hint)
}
func Forbidden(logger mlogger.Logger, source mservice.Type, errType, hint string) http.HandlerFunc {
return Error(logger, source, http.StatusForbidden, errType, hint)
}
func TooManyRequests(logger mlogger.Logger, source mservice.Type, hint string) http.HandlerFunc {
return Error(logger, source, http.StatusTooManyRequests, "too_many_requests", hint)
}
func LicenseRequired(logger mlogger.Logger, source mservice.Type, hint string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
errorf(logger, w, r, source, http.StatusPaymentRequired, "license_required", hint)
}
}
func Unauthorized(logger mlogger.Logger, source mservice.Type, hint string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
errorf(logger, w, r, source, http.StatusUnauthorized, "unauthorized", hint)
}
}
func NotFound(logger mlogger.Logger, source mservice.Type, hint string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
errorf(logger, w, r, source, http.StatusNotFound, "not_found", hint)
}
}
@@ -0,0 +1,57 @@
package response
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
)
func TestAutoMapsServiceUnavailable(t *testing.T) {
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/health", nil)
recorder := httptest.NewRecorder()
Auto(zap.NewNop(), mservice.Type("test"), merrors.ServiceUnavailable("dependency down")).ServeHTTP(recorder, req)
require.Equal(t, http.StatusServiceUnavailable, recorder.Code)
require.True(t, strings.Contains(recorder.Body.String(), "service_unavailable"))
}
func TestAutoMapsDocumentsReportOwned(t *testing.T) {
req := httptest.NewRequestWithContext(context.Background(), http.MethodPut, "/reports", nil)
recorder := httptest.NewRecorder()
Auto(zap.NewNop(), mservice.Type("documents_report"), merrors.DocumentsReportOwned("report has an active payout")).ServeHTTP(recorder, req)
var payload struct {
Data ErrorResponse `json:"data"`
}
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &payload))
require.Equal(t, http.StatusConflict, recorder.Code)
require.Equal(t, "documents_report_owned", payload.Data.Error)
require.Equal(t, "report has an active payout", payload.Data.Details)
}
func TestErrorWithDetailsPreservesStructuredPayload(t *testing.T) {
req := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/actions", nil)
recorder := httptest.NewRecorder()
ErrorWithDetails(zap.NewNop(), mservice.Type("test"), http.StatusConflict, "conflict", map[string]string{
"operation_ref": "operation-1",
}).ServeHTTP(recorder, req)
var payload struct {
Data struct {
Details map[string]string `json:"details"`
} `json:"data"`
}
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &payload))
require.Equal(t, "operation-1", payload.Data.Details["operation_ref"])
}
+19
View File
@@ -0,0 +1,19 @@
package response
import (
"net/http"
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
)
type Result struct {
Result bool `json:"result"`
}
func Success(logger mlogger.Logger) http.HandlerFunc {
return Ok(logger, Result{Result: true})
}
func Failed(logger mlogger.Logger) http.HandlerFunc {
return Accepted(logger, Result{Result: false})
}
+8
View File
@@ -0,0 +1,8 @@
package api
const (
MSSuccess string = "success"
MSProcessed string = "processed"
MSError string = "error"
MSRequest string = "request"
)
+18
View File
@@ -0,0 +1,18 @@
package routers
import (
"git.sendico.io/tech/sxchnge/bff/pkg/api/routers/health"
"git.sendico.io/tech/sxchnge/bff/pkg/api/routers/internal/healthimp"
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
"github.com/go-chi/chi/v5"
)
type Health interface {
SetStatus(status health.ServiceStatus)
SetStatusWithReason(status health.ServiceStatus, reason string)
Finish()
}
func NewHealthRouter(logger mlogger.Logger, router chi.Router, endpoint string) (Health, error) {
return healthimp.NewRouter(logger, router, endpoint), nil
}
+10
View File
@@ -0,0 +1,10 @@
package health
type ServiceStatus string
const (
SSCreated ServiceStatus = "created"
SSStarting ServiceStatus = "starting"
SSRunning ServiceStatus = "ok"
SSTerminating ServiceStatus = "deactivating"
)
@@ -0,0 +1,76 @@
package healthimp
import (
"net/http"
"strings"
"git.sendico.io/tech/sxchnge/bff/pkg/api/routers/health"
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
"github.com/go-chi/chi/v5"
"go.uber.org/zap"
)
type Router struct {
logger mlogger.Logger
status *Status
}
func (hr *Router) SetStatus(status health.ServiceStatus) {
hr.SetStatusWithReason(status, defaultReason(status))
}
func (hr *Router) SetStatusWithReason(status health.ServiceStatus, reason string) {
previous, changed := hr.status.transition(status)
if !changed {
return
}
reason = strings.TrimSpace(reason)
if reason == "" {
reason = defaultReason(status)
}
hr.logger.Info("Health status changed",
zap.String("previous_status", string(previous)),
zap.String("status", string(status)),
zap.String("reason", reason),
)
}
func (hr *Router) Finish() {
hr.status.Finish()
hr.logger.Debug("Stopped")
}
func (hr *Router) handle(w http.ResponseWriter, r *http.Request) {
hr.status.healthHandler()(w, r)
}
func NewRouter(logger mlogger.Logger, router chi.Router, endpoint string) *Router {
hr := Router{
logger: logger.Named("health_check"),
}
hr.status = StatusHandler(hr.logger)
logger.Debug("Installing healthcheck middleware...")
router.Group(func(r chi.Router) {
ep := endpoint + "/health"
r.Get(ep, hr.handle)
logger.Info("Health handler installed", zap.String("endpoint", ep))
})
return &hr
}
func defaultReason(status health.ServiceStatus) string {
switch status {
case health.SSCreated:
return "service created"
case health.SSStarting:
return "service is starting"
case health.SSRunning:
return "service is ready"
case health.SSTerminating:
return "service is shutting down"
default:
return "service health status changed"
}
}
@@ -0,0 +1,53 @@
package healthimp
import (
"testing"
"git.sendico.io/tech/sxchnge/bff/pkg/api/routers/health"
"github.com/go-chi/chi/v5"
"go.uber.org/zap"
"go.uber.org/zap/zaptest/observer"
)
func TestSetStatusLogsOnlyTransitionsWithReason(t *testing.T) {
core, observed := observer.New(zap.InfoLevel)
router := NewRouter(zap.New(core), chi.NewRouter(), "")
router.SetStatusWithReason(health.SSStarting, "observability endpoint initialized")
router.SetStatusWithReason(health.SSStarting, "duplicate readiness report")
router.SetStatusWithReason(health.SSRunning, "all configured ingestion drivers are ready")
router.SetStatus(health.SSRunning)
entries := observed.FilterMessage("Health status changed").All()
if len(entries) != 2 {
t.Fatalf("health transition log count = %d, want 2", len(entries))
}
assertTransition(t, entries[0].ContextMap(), "created", "starting", "observability endpoint initialized")
assertTransition(t, entries[1].ContextMap(), "starting", "ok", "all configured ingestion drivers are ready")
}
func TestSetStatusUsesNonEmptyDefaultReason(t *testing.T) {
core, observed := observer.New(zap.InfoLevel)
router := NewRouter(zap.New(core), chi.NewRouter(), "")
router.SetStatusWithReason(health.SSTerminating, " ")
entries := observed.FilterMessage("Health status changed").All()
if len(entries) != 1 {
t.Fatalf("health transition log count = %d, want 1", len(entries))
}
assertTransition(t, entries[0].ContextMap(), "created", "deactivating", "service is shutting down")
}
func assertTransition(t *testing.T, fields map[string]any, previous, status, reason string) {
t.Helper()
for key, want := range map[string]string{
"previous_status": previous,
"status": status,
"reason": reason,
} {
if got, ok := fields[key].(string); !ok || got != want {
t.Fatalf("%s = %v, want %q", key, fields[key], want)
}
}
}
@@ -0,0 +1,50 @@
package healthimp
import (
"net/http"
"sync"
"git.sendico.io/tech/sxchnge/bff/pkg/api/http/response"
"git.sendico.io/tech/sxchnge/bff/pkg/api/routers/health"
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
)
type Status struct {
logger mlogger.Logger
mu sync.RWMutex
status health.ServiceStatus
}
func (hs *Status) healthHandler() http.HandlerFunc {
hs.mu.RLock()
status := hs.status
hs.mu.RUnlock()
return response.Ok(hs.logger, struct {
Status health.ServiceStatus `json:"status"`
}{
status,
})
}
func (hr *Status) Finish() {
hr.logger.Info("Finished")
}
func (hs *Status) transition(status health.ServiceStatus) (health.ServiceStatus, bool) {
hs.mu.Lock()
defer hs.mu.Unlock()
previous := hs.status
if previous == status {
return previous, false
}
hs.status = status
return previous, true
}
func StatusHandler(logger mlogger.Logger) *Status {
hs := Status{
status: health.SSCreated,
logger: logger.Named("status"),
}
return &hs
}