service backend
All checks were successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful

This commit is contained in:
Stephan D
2025-11-07 18:35:26 +01:00
parent 20e8f9acc4
commit 62a6631b9a
537 changed files with 48453 additions and 0 deletions

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)
}
}

View File

@@ -0,0 +1,205 @@
package response
import (
"encoding/json"
"errors"
"fmt"
"net/http"
api "github.com/tech/sendico/pkg/api/http"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/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 string `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.WriteHeader(code)
if err := json.NewEncoder(w).Encode(&payload); err != nil {
logger.Warn("Failed to encode JSON response",
zap.Error(err),
zap.Any("response", payload),
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, details string,
) {
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.MSProcessed,
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.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))
}
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 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 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 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)
}
}

View File

@@ -0,0 +1,19 @@
package response
import (
"net/http"
"github.com/tech/sendico/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})
}

View File

@@ -0,0 +1,8 @@
package api
const (
MSSuccess string = "success"
MSProcessed string = "processed"
MSError string = "error"
MSRequest string = "request"
)

View File

@@ -0,0 +1,61 @@
package routers
import (
"context"
"net"
"github.com/tech/sendico/pkg/api/routers/internal/grpcimp"
"github.com/tech/sendico/pkg/mlogger"
"google.golang.org/grpc"
)
type (
GRPCServiceRegistration = func(grpc.ServiceRegistrar)
)
type GRPC interface {
Register(registration GRPCServiceRegistration) error
Start(ctx context.Context) error
Finish(ctx context.Context) error
Addr() net.Addr
Done() <-chan error
}
type (
GRPCConfig = grpcimp.Config
GRPCTLSConfig = grpcimp.TLSConfig
)
type GRPCOption func(*grpcimp.Options)
func WithUnaryInterceptors(interceptors ...grpc.UnaryServerInterceptor) GRPCOption {
return func(o *grpcimp.Options) {
o.UnaryInterceptors = append(o.UnaryInterceptors, interceptors...)
}
}
func WithStreamInterceptors(interceptors ...grpc.StreamServerInterceptor) GRPCOption {
return func(o *grpcimp.Options) {
o.StreamInterceptors = append(o.StreamInterceptors, interceptors...)
}
}
func WithListener(listener net.Listener) GRPCOption {
return func(o *grpcimp.Options) {
o.Listener = listener
}
}
func WithServerOptions(opts ...grpc.ServerOption) GRPCOption {
return func(o *grpcimp.Options) {
o.ServerOptions = append(o.ServerOptions, opts...)
}
}
func NewGRPCRouter(logger mlogger.Logger, config *GRPCConfig, opts ...GRPCOption) (GRPC, error) {
options := &grpcimp.Options{}
for _, opt := range opts {
opt(options)
}
return grpcimp.NewRouter(logger, config, options)
}

View File

@@ -0,0 +1,149 @@
package gsresponse
import (
"context"
"errors"
"fmt"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice"
"go.uber.org/zap"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// Responder produces a response or a gRPC status error when executed.
type Responder[T any] func(ctx context.Context) (*T, error)
func message(err error) string {
if err == nil {
return ""
}
return err.Error()
}
func Success[T any](resp *T) Responder[T] {
return func(context.Context) (*T, error) {
return resp, nil
}
}
func Empty[T any]() Responder[T] {
return func(context.Context) (*T, error) {
return nil, nil
}
}
func Error[T any](logger mlogger.Logger, service mservice.Type, code codes.Code, hint string, err error) Responder[T] {
return func(ctx context.Context) (*T, error) {
fields := []zap.Field{
zap.String("service", string(service)),
zap.String("status_code", code.String()),
}
if hint != "" {
fields = append(fields, zap.String("error_hint", hint))
}
if err != nil {
fields = append(fields, zap.Error(err))
}
logFn := logger.Warn
switch code {
case codes.Internal, codes.DataLoss, codes.Unavailable:
logFn = logger.Error
}
logFn("gRPC request failed", fields...)
msg := message(err)
switch {
case hint == "" && msg == "":
return nil, status.Error(code, code.String())
case hint == "":
return nil, status.Error(code, msg)
case msg == "":
return nil, status.Error(code, hint)
default:
return nil, status.Error(code, fmt.Sprintf("%s: %s", hint, msg))
}
}
}
func Internal[T any](logger mlogger.Logger, service mservice.Type, err error) Responder[T] {
return Error[T](logger, service, codes.Internal, "internal_error", err)
}
func InvalidArgument[T any](logger mlogger.Logger, service mservice.Type, err error) Responder[T] {
return Error[T](logger, service, codes.InvalidArgument, "invalid_argument", err)
}
func NotFound[T any](logger mlogger.Logger, service mservice.Type, err error) Responder[T] {
return Error[T](logger, service, codes.NotFound, "not_found", err)
}
func Unauthorized[T any](logger mlogger.Logger, service mservice.Type, err error) Responder[T] {
return Error[T](logger, service, codes.Unauthenticated, "unauthorized", err)
}
func PermissionDenied[T any](logger mlogger.Logger, service mservice.Type, err error) Responder[T] {
return Error[T](logger, service, codes.PermissionDenied, "access_denied", err)
}
func FailedPrecondition[T any](logger mlogger.Logger, service mservice.Type, hint string, err error) Responder[T] {
return Error[T](logger, service, codes.FailedPrecondition, hint, err)
}
func Conflict[T any](logger mlogger.Logger, service mservice.Type, err error) Responder[T] {
return Error[T](logger, service, codes.Aborted, "conflict", err)
}
func DeadlineExceeded[T any](logger mlogger.Logger, service mservice.Type, err error) Responder[T] {
return Error[T](logger, service, codes.DeadlineExceeded, "deadline_exceeded", err)
}
func Unavailable[T any](logger mlogger.Logger, service mservice.Type, err error) Responder[T] {
return Error[T](logger, service, codes.Unavailable, "service_unavailable", err)
}
func Unimplemented[T any](logger mlogger.Logger, service mservice.Type, err error) Responder[T] {
return Error[T](logger, service, codes.Unimplemented, "not_implemented", err)
}
func AlreadyExists[T any](logger mlogger.Logger, service mservice.Type, err error) Responder[T] {
return Error[T](logger, service, codes.AlreadyExists, "already_exists", err)
}
func Auto[T any](logger mlogger.Logger, service mservice.Type, err error) Responder[T] {
switch {
case err == nil:
return Empty[T]()
case errors.Is(err, merrors.ErrInvalidArg):
return InvalidArgument[T](logger, service, err)
case errors.Is(err, merrors.ErrAccessDenied):
return PermissionDenied[T](logger, service, err)
case errors.Is(err, merrors.ErrNoData):
return NotFound[T](logger, service, err)
case errors.Is(err, merrors.ErrUnauthorized):
return Unauthorized[T](logger, service, err)
case errors.Is(err, merrors.ErrDataConflict):
return Conflict[T](logger, service, err)
default:
return Internal[T](logger, service, err)
}
}
func Execute[T any](ctx context.Context, responder Responder[T]) (*T, error) {
if responder == nil {
return nil, status.Error(codes.Internal, "missing responder")
}
return responder(ctx)
}
func Unary[TReq any, TResp any](logger mlogger.Logger, service mservice.Type, handler func(context.Context, *TReq) Responder[TResp]) func(context.Context, *TReq) (*TResp, error) {
return func(ctx context.Context, req *TReq) (*TResp, error) {
if handler == nil {
return nil, status.Error(codes.Internal, "missing handler")
}
responder := handler(ctx, req)
return Execute(ctx, responder)
}
}

View File

@@ -0,0 +1,75 @@
package gsresponse
import (
"context"
"errors"
"fmt"
"testing"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mservice"
"github.com/stretchr/testify/require"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.uber.org/zap"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
type testRequest struct {
Value string
}
type testResponse struct {
Result string
}
func TestUnarySuccess(t *testing.T) {
logger := zap.NewNop()
handler := func(ctx context.Context, req *testRequest) Responder[testResponse] {
require.NotNil(t, req)
require.Equal(t, "hello", req.Value)
resp := &testResponse{Result: "ok"}
return Success(resp)
}
unary := Unary[testRequest, testResponse](logger, mservice.Type("test"), handler)
resp, err := unary(context.Background(), &testRequest{Value: "hello"})
require.NoError(t, err)
require.NotNil(t, resp)
require.Equal(t, "ok", resp.Result)
}
func TestAutoMappings(t *testing.T) {
logger := zap.NewNop()
service := mservice.Type("test")
tests := []struct {
name string
err error
code codes.Code
}{
{"invalid_argument", merrors.InvalidArgument("bad"), codes.InvalidArgument},
{"access_denied", merrors.AccessDenied("object", "action", primitive.NilObjectID), codes.PermissionDenied},
{"not_found", merrors.NoData("missing"), codes.NotFound},
{"unauthorized", fmt.Errorf("%w: %s", merrors.ErrUnauthorized, "bad"), codes.Unauthenticated},
{"conflict", merrors.DataConflict("conflict"), codes.Aborted},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
responder := Auto[testResponse](logger, service, tc.err)
_, err := responder(context.Background())
require.Error(t, err)
st, ok := status.FromError(err)
require.True(t, ok)
require.Equal(t, tc.code, st.Code())
})
}
responder := Auto[testResponse](logger, service, errors.New("boom"))
_, err := responder(context.Background())
require.Error(t, err)
st, ok := status.FromError(err)
require.True(t, ok)
require.Equal(t, codes.Internal, st.Code())
}

View File

@@ -0,0 +1,17 @@
package routers
import (
"github.com/go-chi/chi/v5"
"github.com/tech/sendico/pkg/api/routers/health"
"github.com/tech/sendico/pkg/api/routers/internal/healthimp"
"github.com/tech/sendico/pkg/mlogger"
)
type Health interface {
SetStatus(status health.ServiceStatus)
Finish()
}
func NewHealthRouter(logger mlogger.Logger, router chi.Router, endpoint string) (Health, error) {
return healthimp.NewRouter(logger, router, endpoint), nil
}

View File

@@ -0,0 +1,10 @@
package health
type ServiceStatus string
const (
SSCreated ServiceStatus = "created"
SSStarting ServiceStatus = "starting"
SSRunning ServiceStatus = "ok"
SSTerminating ServiceStatus = "deactivating"
)

View File

@@ -0,0 +1,18 @@
package grpcimp
type Config struct {
Network string `yaml:"network"`
Address string `yaml:"address"`
EnableReflection bool `yaml:"enable_reflection"`
EnableHealth bool `yaml:"enable_health"`
MaxRecvMsgSize int `yaml:"max_recv_msg_size"`
MaxSendMsgSize int `yaml:"max_send_msg_size"`
TLS *TLSConfig `yaml:"tls"`
}
type TLSConfig struct {
CertFile string `yaml:"cert_file"`
KeyFile string `yaml:"key_file"`
CAFile string `yaml:"ca_file"`
RequireClientCert bool `yaml:"require_client_cert"`
}

View File

@@ -0,0 +1,103 @@
package grpcimp
import (
"context"
"strings"
"sync"
"time"
"github.com/prometheus/client_golang/prometheus"
"google.golang.org/grpc"
"google.golang.org/grpc/status"
)
var (
metricsOnce sync.Once
grpcServerRequestsTotal *prometheus.CounterVec
grpcServerLatency *prometheus.HistogramVec
)
func initPrometheusMetrics() {
metricsOnce.Do(func() {
grpcServerRequestsTotal = prometheus.NewCounterVec(
prometheus.CounterOpts{
Name: "grpc_server_requests_total",
Help: "Total number of gRPC requests handled by the server.",
},
[]string{"grpc_service", "grpc_method", "grpc_type", "grpc_code"},
)
grpcServerLatency = prometheus.NewHistogramVec(
prometheus.HistogramOpts{
Name: "grpc_server_handling_seconds",
Help: "Duration of gRPC requests handled by the server.",
Buckets: prometheus.DefBuckets,
},
[]string{"grpc_service", "grpc_method", "grpc_type", "grpc_code"},
)
prometheus.MustRegister(grpcServerRequestsTotal, grpcServerLatency)
})
}
func prometheusUnaryInterceptor() grpc.UnaryServerInterceptor {
initPrometheusMetrics()
return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
start := time.Now()
resp, err := handler(ctx, req)
recordMetrics(info.FullMethod, "unary", time.Since(start), err)
return resp, err
}
}
func prometheusStreamInterceptor() grpc.StreamServerInterceptor {
initPrometheusMetrics()
return func(srv interface{}, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error {
start := time.Now()
err := handler(srv, ss)
recordMetrics(info.FullMethod, streamType(info), time.Since(start), err)
return err
}
}
func streamType(info *grpc.StreamServerInfo) string {
if info == nil {
return "stream"
}
if info.IsServerStream && info.IsClientStream {
return "bidi"
}
if info.IsServerStream {
return "server_stream"
}
if info.IsClientStream {
return "client_stream"
}
return "stream"
}
func recordMetrics(fullMethod string, callType string, duration time.Duration, err error) {
service, method := splitMethod(fullMethod)
code := status.Code(err).String()
grpcServerRequestsTotal.WithLabelValues(service, method, callType, code).Inc()
grpcServerLatency.WithLabelValues(service, method, callType, code).Observe(duration.Seconds())
}
func splitMethod(fullMethod string) (string, string) {
if fullMethod == "" {
return "unknown", "unknown"
}
if fullMethod[0] == '/' {
fullMethod = fullMethod[1:]
}
parts := strings.Split(fullMethod, "/")
if len(parts) < 2 {
return fullMethod, "unknown"
}
return parts[0], parts[1]
}

View File

@@ -0,0 +1,14 @@
package grpcimp
import (
"net"
"google.golang.org/grpc"
)
type Options struct {
UnaryInterceptors []grpc.UnaryServerInterceptor
StreamInterceptors []grpc.StreamServerInterceptor
ServerOptions []grpc.ServerOption
Listener net.Listener
}

View File

@@ -0,0 +1,293 @@
package grpcimp
import (
"context"
"crypto/tls"
"crypto/x509"
"errors"
"net"
"os"
"sync"
"github.com/tech/sendico/pkg/mlogger"
"go.uber.org/zap"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/health"
healthpb "google.golang.org/grpc/health/grpc_health_v1"
"google.golang.org/grpc/reflection"
)
type routerError string
func (e routerError) Error() string {
return string(e)
}
type routerErrorWithCause struct {
message string
cause error
}
func (e *routerErrorWithCause) Error() string {
if e == nil {
return ""
}
if e.cause == nil {
return e.message
}
return e.message + ": " + e.cause.Error()
}
func (e *routerErrorWithCause) Unwrap() error {
if e == nil {
return nil
}
return e.cause
}
func newRouterErrorWithCause(message string, cause error) error {
return &routerErrorWithCause{
message: message,
cause: cause,
}
}
const (
errMsgAlreadyStarted = "grpc router already started"
errMsgListenFailed = "failed to listen on requested address"
errMsgNilContext = "nil context"
errMsgTLSMissingCertAndKey = "tls configuration requires cert_file and key_file"
errMsgLoadServerCertificate = "failed to load server certificate"
errMsgReadCAFile = "failed to read CA file"
errMsgAppendCACertificates = "failed to append CA certificates"
errMsgClientCertRequiresCAFile = "client certificate verification requested but ca_file is empty"
)
var (
errAlreadyStarted = routerError(errMsgAlreadyStarted)
errNilContext = routerError(errMsgNilContext)
errTLSMissingCertAndKey = routerError(errMsgTLSMissingCertAndKey)
errAppendCACertificates = routerError(errMsgAppendCACertificates)
errClientCertRequiresCAFile = routerError(errMsgClientCertRequiresCAFile)
)
type Router struct {
logger mlogger.Logger
config Config
server *grpc.Server
listener net.Listener
options *Options
mu sync.RWMutex
started bool
serveErr chan error
healthSrv *health.Server
}
func NewRouter(logger mlogger.Logger, cfg *Config, opts *Options) (*Router, error) {
if cfg == nil {
cfg = &Config{}
}
if opts == nil {
opts = &Options{}
}
network := cfg.Network
if network == "" {
network = "tcp"
}
address := cfg.Address
if address == "" {
address = ":0"
}
listener := opts.Listener
var err error
if listener == nil {
listener, err = net.Listen(network, address)
if err != nil {
return nil, newRouterErrorWithCause(errMsgListenFailed, err)
}
}
serverOpts := make([]grpc.ServerOption, 0, len(opts.ServerOptions)+4)
serverOpts = append(serverOpts, opts.ServerOptions...)
if cfg.MaxRecvMsgSize > 0 {
serverOpts = append(serverOpts, grpc.MaxRecvMsgSize(cfg.MaxRecvMsgSize))
}
if cfg.MaxSendMsgSize > 0 {
serverOpts = append(serverOpts, grpc.MaxSendMsgSize(cfg.MaxSendMsgSize))
}
if creds, err := configureTLS(cfg.TLS); err != nil {
return nil, err
} else if creds != nil {
serverOpts = append(serverOpts, grpc.Creds(creds))
}
unaryInterceptors := append([]grpc.UnaryServerInterceptor{prometheusUnaryInterceptor()}, opts.UnaryInterceptors...)
streamInterceptors := append([]grpc.StreamServerInterceptor{prometheusStreamInterceptor()}, opts.StreamInterceptors...)
if len(unaryInterceptors) > 0 {
serverOpts = append(serverOpts, grpc.ChainUnaryInterceptor(unaryInterceptors...))
}
if len(streamInterceptors) > 0 {
serverOpts = append(serverOpts, grpc.ChainStreamInterceptor(streamInterceptors...))
}
srv := grpc.NewServer(serverOpts...)
r := &Router{
logger: logger.Named("grpc"),
config: *cfg,
server: srv,
listener: listener,
options: opts,
serveErr: make(chan error, 1),
}
if cfg.EnableReflection {
reflection.Register(srv)
}
if cfg.EnableHealth {
r.healthSrv = health.NewServer()
r.healthSrv.SetServingStatus("", healthpb.HealthCheckResponse_NOT_SERVING)
healthpb.RegisterHealthServer(srv, r.healthSrv)
}
return r, nil
}
func (r *Router) Register(registration func(grpc.ServiceRegistrar)) error {
r.mu.Lock()
defer r.mu.Unlock()
if r.started {
return errAlreadyStarted
}
registration(r.server)
return nil
}
func (r *Router) Start(ctx context.Context) error {
if ctx == nil {
return errNilContext
}
r.mu.Lock()
if r.started {
r.mu.Unlock()
return errAlreadyStarted
}
r.started = true
r.mu.Unlock()
if r.healthSrv != nil {
r.healthSrv.SetServingStatus("", healthpb.HealthCheckResponse_SERVING)
}
go func() {
<-ctx.Done()
r.logger.Info("Context cancelled, stopping gRPC server")
r.server.GracefulStop()
}()
go func() {
err := r.server.Serve(r.listener)
if err != nil && !errors.Is(err, grpc.ErrServerStopped) {
select {
case r.serveErr <- err:
default:
r.logger.Error("Failed to report gRPC serve error", zap.Error(err))
}
}
close(r.serveErr)
}()
r.logger.Info("gRPC server started", zap.String("network", r.listener.Addr().Network()), zap.String("address", r.listener.Addr().String()))
return nil
}
func (r *Router) Finish(ctx context.Context) error {
if ctx == nil {
return errNilContext
}
r.mu.RLock()
started := r.started
r.mu.RUnlock()
if !started {
return nil
}
if r.healthSrv != nil {
r.healthSrv.SetServingStatus("", healthpb.HealthCheckResponse_NOT_SERVING)
}
done := make(chan struct{})
go func() {
r.server.GracefulStop()
close(done)
}()
select {
case <-done:
case <-ctx.Done():
r.logger.Warn("Graceful stop timed out, forcing stop", zap.Error(ctx.Err()))
r.server.Stop()
return ctx.Err()
}
if err, ok := <-r.serveErr; ok {
return err
}
return nil
}
func (r *Router) Addr() net.Addr {
return r.listener.Addr()
}
func (r *Router) Done() <-chan error {
return r.serveErr
}
func configureTLS(cfg *TLSConfig) (credentials.TransportCredentials, error) {
if cfg == nil {
return nil, nil
}
if cfg.CertFile == "" || cfg.KeyFile == "" {
return nil, errTLSMissingCertAndKey
}
certificate, err := tls.LoadX509KeyPair(cfg.CertFile, cfg.KeyFile)
if err != nil {
return nil, newRouterErrorWithCause(errMsgLoadServerCertificate, err)
}
tlsCfg := &tls.Config{
Certificates: []tls.Certificate{certificate},
MinVersion: tls.VersionTLS12,
}
if cfg.CAFile != "" {
caPem, err := os.ReadFile(cfg.CAFile)
if err != nil {
return nil, newRouterErrorWithCause(errMsgReadCAFile, err)
}
certPool := x509.NewCertPool()
if ok := certPool.AppendCertsFromPEM(caPem); !ok {
return nil, errAppendCACertificates
}
tlsCfg.ClientCAs = certPool
if cfg.RequireClientCert {
tlsCfg.ClientAuth = tls.RequireAndVerifyClientCert
}
} else if cfg.RequireClientCert {
return nil, errClientCertRequiresCAFile
}
return credentials.NewTLS(tlsCfg), nil
}

View File

@@ -0,0 +1,150 @@
package grpcimp
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
"google.golang.org/grpc"
"google.golang.org/grpc/test/bufconn"
)
const bufconnSize = 1024 * 1024
func newBufferedListener(t *testing.T) *bufconn.Listener {
t.Helper()
listener := bufconn.Listen(bufconnSize)
t.Cleanup(func() {
listener.Close()
})
return listener
}
func newTestRouter(t *testing.T, cfg *Config) *Router {
t.Helper()
logger := zap.NewNop()
if cfg == nil {
cfg = &Config{}
}
router, err := NewRouter(logger, cfg, &Options{Listener: newBufferedListener(t)})
require.NoError(t, err)
return router
}
func TestRouterStartAndFinish(t *testing.T) {
router := newTestRouter(t, &Config{})
doneCh := router.Done()
require.NotNil(t, doneCh)
require.NoError(t, router.Register(func(grpc.ServiceRegistrar) {}))
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
require.NoError(t, router.Start(ctx))
addr := router.Addr()
require.NotNil(t, addr)
require.NotEmpty(t, addr.String())
finishCtx, finishCancel := context.WithTimeout(context.Background(), time.Second)
defer finishCancel()
require.NoError(t, router.Finish(finishCtx))
select {
case err, ok := <-doneCh:
if ok {
require.NoError(t, err)
}
case <-time.After(time.Second):
t.Fatal("timed out waiting for done channel")
}
}
func TestRouterRejectsRegistrationAfterStart(t *testing.T) {
router := newTestRouter(t, &Config{})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
require.NoError(t, router.Start(ctx))
doneCh := router.Done()
err := router.Register(func(grpc.ServiceRegistrar) {})
require.ErrorIs(t, err, errAlreadyStarted)
finishCtx, finishCancel := context.WithTimeout(context.Background(), time.Second)
defer finishCancel()
require.NoError(t, router.Finish(finishCtx))
select {
case <-doneCh:
case <-time.After(time.Second):
t.Fatal("timed out waiting for done channel")
}
}
func TestRouterStartOnlyOnce(t *testing.T) {
router := newTestRouter(t, &Config{})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
require.NoError(t, router.Start(ctx))
require.ErrorIs(t, router.Start(ctx), errAlreadyStarted)
doneCh := router.Done()
finishCtx, finishCancel := context.WithTimeout(context.Background(), time.Second)
defer finishCancel()
require.NoError(t, router.Finish(finishCtx))
select {
case <-doneCh:
case <-time.After(time.Second):
t.Fatal("timed out waiting for done channel")
}
}
func TestRouterUsesProvidedListener(t *testing.T) {
logger := zap.NewNop()
listener := newBufferedListener(t)
cfg := &Config{}
router, err := NewRouter(logger, cfg, &Options{Listener: listener})
require.NoError(t, err)
actualListener, ok := router.listener.(*bufconn.Listener)
require.True(t, ok)
require.Same(t, listener, actualListener)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
require.NoError(t, router.Start(ctx))
doneCh := router.Done()
finishCtx, finishCancel := context.WithTimeout(context.Background(), time.Second)
defer finishCancel()
require.NoError(t, router.Finish(finishCtx))
select {
case <-doneCh:
case <-time.After(time.Second):
t.Fatal("timed out waiting for done channel")
}
}

View File

@@ -0,0 +1,45 @@
package healthimp
import (
"net/http"
"github.com/go-chi/chi/v5"
"github.com/tech/sendico/pkg/api/routers/health"
"github.com/tech/sendico/pkg/mlogger"
"go.uber.org/zap"
)
type Router struct {
logger mlogger.Logger
status *Status
}
func (hr *Router) SetStatus(status health.ServiceStatus) {
hr.status.setStatus(status)
hr.logger.Info("New status set", zap.String("status", string(status)))
}
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
}

View File

@@ -0,0 +1,38 @@
package healthimp
import (
"net/http"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/api/routers/health"
"github.com/tech/sendico/pkg/mlogger"
)
type Status struct {
logger mlogger.Logger
status health.ServiceStatus
}
func (hs *Status) healthHandler() http.HandlerFunc {
return response.Ok(hs.logger, struct {
Status health.ServiceStatus `json:"status"`
}{
hs.status,
})
}
func (hr *Status) Finish() {
hr.logger.Info("Finished")
}
func (hs *Status) setStatus(status health.ServiceStatus) {
hs.status = status
}
func StatusHandler(logger mlogger.Logger) *Status {
hs := Status{
status: health.SSCreated,
logger: logger.Named("status"),
}
return &hs
}

View File

@@ -0,0 +1,66 @@
package messagingimp
import (
"context"
"github.com/tech/sendico/pkg/messaging"
mb "github.com/tech/sendico/pkg/messaging/broker"
me "github.com/tech/sendico/pkg/messaging/envelope"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/model"
"go.uber.org/zap"
)
type ChannelConsumer struct {
logger mlogger.Logger
broker mb.Broker
event model.NotificationEvent
ch <-chan me.Envelope
ctx context.Context
cancel context.CancelFunc
}
func (c *ChannelConsumer) ConsumeMessages(handleFunc messaging.MessageHandlerT) error {
c.logger.Info("Message consumer is ready")
for {
select {
case msg := <-c.ch:
if msg == nil { // nil message indicates the channel was closed
c.logger.Info("Consumer shutting down")
return nil
}
if err := handleFunc(c.ctx, msg); err != nil {
c.logger.Warn("Error processing message", zap.Error(err))
}
case <-c.ctx.Done():
c.logger.Info("Context done, shutting down")
return c.ctx.Err()
}
}
}
func (c *ChannelConsumer) Close() {
c.logger.Info("Shutting down...")
c.cancel()
if err := c.broker.Unsubscribe(c.event, c.ch); err != nil {
c.logger.Warn("Failed to unsubscribe", zap.Error(err))
}
}
func NewConsumer(logger mlogger.Logger, broker mb.Broker, event model.NotificationEvent) (*ChannelConsumer, error) {
ctx, cancel := context.WithCancel(context.Background())
ch, err := broker.Subscribe(event)
if err != nil {
logger.Warn("Failed to create channel consumer", zap.Error(err), zap.String("topic", event.ToString()))
cancel() // Ensure resources are released properly
return nil, err
}
return &ChannelConsumer{
logger: logger.Named("consumer").Named(event.ToString()),
broker: broker,
event: event,
ch: ch,
ctx: ctx,
cancel: cancel,
}, nil
}

View File

@@ -0,0 +1,67 @@
package messagingimp
import (
"context"
"errors"
"github.com/tech/sendico/pkg/messaging"
mb "github.com/tech/sendico/pkg/messaging/broker"
notifications "github.com/tech/sendico/pkg/messaging/notifications/processor"
mip "github.com/tech/sendico/pkg/messaging/producer"
"github.com/tech/sendico/pkg/mlogger"
"go.uber.org/zap"
)
type MessagingRouter struct {
logger mlogger.Logger
messaging mb.Broker
consumers []messaging.Consumer
producer messaging.Producer
}
func (mr *MessagingRouter) consumeMessages(c messaging.Consumer, processor notifications.EnvelopeProcessor) {
if err := c.ConsumeMessages(processor.Process); err != nil {
if !errors.Is(err, context.Canceled) {
mr.logger.Warn("Error consuming messages", zap.Error(err), zap.String("event", processor.GetSubject().ToString()))
} else {
mr.logger.Info("Finishing as context has been cancelled", zap.String("event", processor.GetSubject().ToString()))
}
}
}
func (mr *MessagingRouter) Consumer(processor notifications.EnvelopeProcessor) error {
c, err := NewConsumer(mr.logger, mr.messaging, processor.GetSubject())
if err != nil {
mr.logger.Warn("Failed to register message consumer", zap.Error(err), zap.String("event", processor.GetSubject().ToString()))
return err
}
mr.consumers = append(mr.consumers, c)
go mr.consumeMessages(c, processor)
return nil
}
func (mr *MessagingRouter) Finish() {
mr.logger.Info("Closing consumer channels")
for _, consumer := range mr.consumers {
consumer.Close()
}
}
func (mr *MessagingRouter) Producer() messaging.Producer {
return mr.producer
}
func NewMessagingRouterImp(logger mlogger.Logger, config *messaging.Config) (*MessagingRouter, error) {
l := logger.Named("messaging")
broker, err := messaging.CreateMessagingBroker(l, config)
if err != nil {
l.Error("Failed to create messaging broker", zap.Error(err), zap.String("broker", string(config.Driver)))
return nil, err
}
return &MessagingRouter{
logger: l,
messaging: broker,
producer: mip.NewProducer(logger, broker),
consumers: make([]messaging.Consumer, 0),
}, nil
}

View File

@@ -0,0 +1,16 @@
package routers
import (
"github.com/tech/sendico/pkg/api/routers/internal/messagingimp"
"github.com/tech/sendico/pkg/messaging"
"github.com/tech/sendico/pkg/mlogger"
)
type Messaging interface {
messaging.Register
Finish()
}
func NewMessagingRouter(logger mlogger.Logger, config *messaging.Config) (Messaging, error) {
return messagingimp.NewMessagingRouterImp(logger, config)
}