From 71ed0fd4aabba0dc872fcd39408c1ab8b991a6b2 Mon Sep 17 00:00:00 2001 From: Gellert Ilya Date: Thu, 14 May 2026 01:44:26 +0300 Subject: [PATCH] feat(grpc): add gRPC providers for client and server, test server provider --- internal/adapters/grpc/client.go | 69 +++++++++++ internal/adapters/grpc/server.go | 96 ++++++++++++++++ internal/adapters/grpc/server_test.go | 157 ++++++++++++++++++++++++++ 3 files changed, 322 insertions(+) create mode 100644 internal/adapters/grpc/server_test.go diff --git a/internal/adapters/grpc/client.go b/internal/adapters/grpc/client.go index 21e034e..7a08e04 100644 --- a/internal/adapters/grpc/client.go +++ b/internal/adapters/grpc/client.go @@ -1 +1,70 @@ package grpc + +import ( + "context" + "fmt" + "io" + + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/protobuf/types/known/emptypb" + + "github.com/ilindan-dev/dns-manager/internal/core/ports" + + pb "github.com/ilindan-dev/dns-manager/proto/dns/v1" +) + +var ( + // Compile check that Client implements ports.DNSManager. + _ ports.DNSManager = (*Client)(nil) + // Compile check that Client implements io.Closer . + _ io.Closer = (*Client)(nil) +) + +// Client is a thin wrapper around the generated pb.DNSManagerClient that manages +// the underlying connection. +type Client struct { + conn *grpc.ClientConn + api pb.DNSManagerClient +} + +// NewClient connects to target and returns a Client. +func NewClient(target string) (*Client, error) { + conn, err := grpc.NewClient(target, grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + return nil, fmt.Errorf("failed to connect to gRPC server at %s: %w", target, err) + } + + return &Client{ + conn: conn, + api: pb.NewDNSManagerClient(conn), + }, nil +} + +// Close closes the gRPC connection. +func (c *Client) Close() error { + return c.conn.Close() +} + +// AddDNSServer sends a request to add a DNS server. +func (c *Client) AddDNSServer(ctx context.Context, address string) error { + req := &pb.AddDNSServerRequest{Address: address} + _, err := c.api.AddDNSServer(ctx, req) + return err +} + +// RemoveDNSServer sends a request to delete the DNS server. +func (c *Client) RemoveDNSServer(ctx context.Context, address string) error { + req := &pb.RemoveDNSServerRequest{Address: address} + _, err := c.api.RemoveDNSServer(ctx, req) + return err +} + +// ListDNSServers requests a list of current DNS servers. +func (c *Client) ListDNSServers(ctx context.Context) ([]string, error) { + resp, err := c.api.ListDNSServers(ctx, &emptypb.Empty{}) + if err != nil { + return nil, err + } + return resp.Addresses, nil +} diff --git a/internal/adapters/grpc/server.go b/internal/adapters/grpc/server.go index 21e034e..f30e377 100644 --- a/internal/adapters/grpc/server.go +++ b/internal/adapters/grpc/server.go @@ -1 +1,97 @@ +// Package grpc provides a gRPC adapter for the DNS manager service. +// It implements the generated protobuf server (pb.DNSManagerServer), +// maps core domain errors to gRPC status codes, and supplies a small +// client wrapper used by tests and callers. package grpc + +import ( + "context" + "errors" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/emptypb" + + "github.com/ilindan-dev/dns-manager/internal/core/domain" + "github.com/ilindan-dev/dns-manager/internal/core/ports" + pb "github.com/ilindan-dev/dns-manager/proto/dns/v1" +) + +// Compile check that Server implements pb.DNSManagerServer. +var _ pb.DNSManagerServer = (*Server)(nil) + +// Server implements pb.DNSManagerServer and delegates requests to ports.DNSManager. +// It performs request validation and translates domain errors to gRPC status codes. +type Server struct { + pb.UnimplementedDNSManagerServer + svc ports.DNSManager +} + +// NewServer returns a Server that delegates business logic to the provided DNSManager. +func NewServer(svc ports.DNSManager) *Server { + return &Server{ + svc: svc, + } +} + +// AddDNSServer validates the request and calls svc.AddDNSServer. +// Returns InvalidArgument for empty address; domain errors are mapped to gRPC codes. +func (s *Server) AddDNSServer(ctx context.Context, req *pb.AddDNSServerRequest) (*emptypb.Empty, error) { + if req.Address == "" { + return nil, status.Error(codes.InvalidArgument, "address cannot be empty") + } + + err := s.svc.AddDNSServer(ctx, req.Address) + if err != nil { + return nil, mapError(err) + } + + return &emptypb.Empty{}, nil +} + +// RemoveDNSServer validates the request and calls svc.RemoveDNSServer. +// Returns InvalidArgument for empty address; domain errors are mapped to gRPC codes. +func (s *Server) RemoveDNSServer(ctx context.Context, req *pb.RemoveDNSServerRequest) (*emptypb.Empty, error) { + if req.Address == "" { + return nil, status.Error(codes.InvalidArgument, "address cannot be empty") + } + + err := s.svc.RemoveDNSServer(ctx, req.Address) + if err != nil { + return nil, mapError(err) + } + + return &emptypb.Empty{}, nil +} + +// ListDNSServers validates the request and calls svc.ListDNSServers. +// Returns domain errors are mapped to gRPC codes. +func (s *Server) ListDNSServers(ctx context.Context, _ *emptypb.Empty) (*pb.ListDNSServersResponse, error) { + servers, err := s.svc.ListDNSServers(ctx) + if err != nil { + return nil, mapError(err) + } + + return &pb.ListDNSServersResponse{ + Addresses: servers, + }, nil +} + +// mapError converts known domain errors into appropriate gRPC status codes, +// returning user-friendly messages for client responses. +func mapError(err error) error { + switch { + case errors.Is(err, domain.ErrInvalidIP): + return status.Errorf(codes.InvalidArgument, "validation failed: %v", err) + case errors.Is(err, domain.ErrAlreadyExists): + return status.Errorf(codes.AlreadyExists, "conflict: %v", err) + case errors.Is(err, domain.ErrNotFound): + return status.Errorf(codes.NotFound, "not found: %v", err) + case errors.Is(err, domain.ErrPermissionDenied): + return status.Errorf(codes.PermissionDenied, "system error: %v", err) + case errors.Is(err, domain.ErrIO): + return status.Error(codes.Internal, "internal server error while accessing resolver config") + default: + return status.Errorf(codes.Unknown, "unknown error: %v", err) + } +} diff --git a/internal/adapters/grpc/server_test.go b/internal/adapters/grpc/server_test.go new file mode 100644 index 0000000..f400b88 --- /dev/null +++ b/internal/adapters/grpc/server_test.go @@ -0,0 +1,157 @@ +package grpc + +import ( + "context" + "net" + "testing" + + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/status" + "google.golang.org/grpc/test/bufconn" + "google.golang.org/protobuf/types/known/emptypb" + + "github.com/ilindan-dev/dns-manager/internal/core/domain" + pb "github.com/ilindan-dev/dns-manager/proto/dns/v1" +) + +const bufSize = 1024 * 1024 + +// mockDNSManager implements the ports.DNSManager interface for deterministic tests. +type mockDNSManager struct { + addErr error + removeErr error + listRes []string + listErr error +} + +func (m *mockDNSManager) AddDNSServer(_ context.Context, _ string) error { return m.addErr } +func (m *mockDNSManager) RemoveDNSServer(_ context.Context, _ string) error { + return m.removeErr +} + +func (m *mockDNSManager) ListDNSServers(_ context.Context) ([]string, error) { + return m.listRes, m.listErr +} + +// setupTestServer starts a bufconn-backed gRPC server, registers NewServer(mockSvc), +// returns a pb.DNSManagerClient connected to it and a cleanup func (call via defer). +func setupTestServer(t *testing.T, mockSvc *mockDNSManager) (client pb.DNSManagerClient, closer func()) { + lis := bufconn.Listen(bufSize) + s := grpc.NewServer() + + pb.RegisterDNSManagerServer(s, NewServer(mockSvc)) + + go func() { + if err := s.Serve(lis); err != nil { + t.Logf("Server exited with error: %v", err) + } + }() + + dialer := func(context.Context, string) (net.Conn, error) { + return lis.Dial() + } + + conn, err := grpc.NewClient("passthrough://bufnet", + grpc.WithContextDialer(dialer), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + if err != nil { + t.Fatalf("Failed to dial bufnet: %v", err) + } + + client = pb.NewDNSManagerClient(conn) + + closer = func() { + err := lis.Close() + if err != nil { + t.Logf("Error closing listener: %v", err) + } + s.Stop() + } + + return client, closer +} + +// TestServer_AddDNSServer - covers success, validation (empty address) and conflict. +func TestServer_AddDNSServer(t *testing.T) { + ctx := context.Background() + + t.Run("Success", func(t *testing.T) { + mockSvc := &mockDNSManager{addErr: nil} + client, closer := setupTestServer(t, mockSvc) + defer closer() + + _, err := client.AddDNSServer(ctx, &pb.AddDNSServerRequest{Address: "8.8.8.8"}) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + }) + + t.Run("Empty Address", func(t *testing.T) { + client, closer := setupTestServer(t, &mockDNSManager{}) + defer closer() + + _, err := client.AddDNSServer(ctx, &pb.AddDNSServerRequest{Address: ""}) + st, ok := status.FromError(err) + if !ok || st.Code() != codes.InvalidArgument { + t.Fatalf("Expected InvalidArgument, got %v", err) + } + }) + + t.Run("Already Exists", func(t *testing.T) { + mockSvc := &mockDNSManager{addErr: domain.ErrAlreadyExists} + client, closer := setupTestServer(t, mockSvc) + defer closer() + + _, err := client.AddDNSServer(ctx, &pb.AddDNSServerRequest{Address: "8.8.8.8"}) + st, ok := status.FromError(err) + if !ok || st.Code() != codes.AlreadyExists { + t.Fatalf("Expected AlreadyExists, got code %v", st.Code()) + } + }) +} + +// TestServer_RemoveDNSServer - covers not-found mapping. +func TestServer_RemoveDNSServer(t *testing.T) { + ctx := context.Background() + + t.Run("Not Found", func(t *testing.T) { + mockSvc := &mockDNSManager{removeErr: domain.ErrNotFound} + client, closer := setupTestServer(t, mockSvc) + defer closer() + + _, err := client.RemoveDNSServer(ctx, &pb.RemoveDNSServerRequest{Address: "1.1.1.1"}) + st, ok := status.FromError(err) + if !ok || st.Code() != codes.NotFound { + t.Fatalf("Expected NotFound, got code %v", st.Code()) + } + }) +} + +// TestServer_ListDNSServers - verifies addresses are returned intact. +func TestServer_ListDNSServers(t *testing.T) { + ctx := context.Background() + + t.Run("Success", func(t *testing.T) { + expected := []string{"8.8.8.8", "1.1.1.1"} + mockSvc := &mockDNSManager{listRes: expected} + client, closer := setupTestServer(t, mockSvc) + defer closer() + + resp, err := client.ListDNSServers(ctx, &emptypb.Empty{}) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + if len(resp.Addresses) != len(expected) { + t.Fatalf("Expected %d addresses, got %d", len(expected), len(resp.Addresses)) + } + for i, addr := range expected { + if resp.Addresses[i] != addr { + t.Errorf("Expected address %s, got %s", addr, resp.Addresses[i]) + } + } + }) +}