Bootstrap copied BFF and local accounts with public Sendico contracts (#34)
This commit is contained in:
1 parent
67dc5b5b1e
commit
7062672a87
556 files changed
+42291
-777
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package api
|
||||
|
||||
const (
|
||||
MSSuccess string = "success"
|
||||
MSProcessed string = "processed"
|
||||
MSError string = "error"
|
||||
MSRequest string = "request"
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user