Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions internal/adapters/grpc/client.go
Original file line number Diff line number Diff line change
@@ -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
}
96 changes: 96 additions & 0 deletions internal/adapters/grpc/server.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
157 changes: 157 additions & 0 deletions internal/adapters/grpc/server_test.go
Original file line number Diff line number Diff line change
@@ -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])
}
}
})
}
Loading