service backend
This commit is contained in:
36
api/pkg/api/http/methods.go
Normal file
36
api/pkg/api/http/methods.go
Normal 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)
|
||||
}
|
||||
}
|
||||
205
api/pkg/api/http/response/response.go
Normal file
205
api/pkg/api/http/response/response.go
Normal 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)
|
||||
}
|
||||
}
|
||||
19
api/pkg/api/http/response/result.go
Normal file
19
api/pkg/api/http/response/result.go
Normal 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})
|
||||
}
|
||||
8
api/pkg/api/http/status.go
Normal file
8
api/pkg/api/http/status.go
Normal file
@@ -0,0 +1,8 @@
|
||||
package api
|
||||
|
||||
const (
|
||||
MSSuccess string = "success"
|
||||
MSProcessed string = "processed"
|
||||
MSError string = "error"
|
||||
MSRequest string = "request"
|
||||
)
|
||||
61
api/pkg/api/routers/grpc.go
Normal file
61
api/pkg/api/routers/grpc.go
Normal 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)
|
||||
}
|
||||
149
api/pkg/api/routers/gsresponse/response.go
Normal file
149
api/pkg/api/routers/gsresponse/response.go
Normal 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)
|
||||
}
|
||||
}
|
||||
75
api/pkg/api/routers/gsresponse/response_test.go
Normal file
75
api/pkg/api/routers/gsresponse/response_test.go
Normal 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())
|
||||
}
|
||||
17
api/pkg/api/routers/health.go
Normal file
17
api/pkg/api/routers/health.go
Normal 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
|
||||
}
|
||||
10
api/pkg/api/routers/health/status.go
Normal file
10
api/pkg/api/routers/health/status.go
Normal file
@@ -0,0 +1,10 @@
|
||||
package health
|
||||
|
||||
type ServiceStatus string
|
||||
|
||||
const (
|
||||
SSCreated ServiceStatus = "created"
|
||||
SSStarting ServiceStatus = "starting"
|
||||
SSRunning ServiceStatus = "ok"
|
||||
SSTerminating ServiceStatus = "deactivating"
|
||||
)
|
||||
18
api/pkg/api/routers/internal/grpcimp/config.go
Normal file
18
api/pkg/api/routers/internal/grpcimp/config.go
Normal 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"`
|
||||
}
|
||||
103
api/pkg/api/routers/internal/grpcimp/metrics.go
Normal file
103
api/pkg/api/routers/internal/grpcimp/metrics.go
Normal 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]
|
||||
}
|
||||
14
api/pkg/api/routers/internal/grpcimp/options.go
Normal file
14
api/pkg/api/routers/internal/grpcimp/options.go
Normal 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
|
||||
}
|
||||
293
api/pkg/api/routers/internal/grpcimp/router.go
Normal file
293
api/pkg/api/routers/internal/grpcimp/router.go
Normal 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
|
||||
}
|
||||
150
api/pkg/api/routers/internal/grpcimp/router_test.go
Normal file
150
api/pkg/api/routers/internal/grpcimp/router_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
45
api/pkg/api/routers/internal/healthimp/health.go
Normal file
45
api/pkg/api/routers/internal/healthimp/health.go
Normal 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
|
||||
}
|
||||
38
api/pkg/api/routers/internal/healthimp/status.go
Normal file
38
api/pkg/api/routers/internal/healthimp/status.go
Normal 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
|
||||
}
|
||||
66
api/pkg/api/routers/internal/messagingimp/consumer.go
Normal file
66
api/pkg/api/routers/internal/messagingimp/consumer.go
Normal 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
|
||||
}
|
||||
67
api/pkg/api/routers/internal/messagingimp/messsaging.go
Normal file
67
api/pkg/api/routers/internal/messagingimp/messsaging.go
Normal 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
|
||||
}
|
||||
16
api/pkg/api/routers/messaging.go
Normal file
16
api/pkg/api/routers/messaging.go
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user