From ca5474ad0cb7fa99db3e169d6f56e0a98ffcfd3a Mon Sep 17 00:00:00 2001 From: cletus Date: Sat, 7 Feb 2026 23:20:19 +0530 Subject: [PATCH] task completed --- cmd/server/main.go | 82 ++++++++ go.mod | 3 + internal/api/handler.go | 282 ++++++++++++++++++++++++++++ internal/distributor/distributor.go | 277 +++++++++++++++++++++++++++ internal/geography/geography.go | 193 +++++++++++++++++++ 5 files changed, 837 insertions(+) create mode 100644 cmd/server/main.go create mode 100644 go.mod create mode 100644 internal/api/handler.go create mode 100644 internal/distributor/distributor.go create mode 100644 internal/geography/geography.go diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 000000000..ef6c48734 --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,82 @@ +package main + +import ( + "context" + "flag" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/challenge2016/internal/api" + "github.com/challenge2016/internal/distributor" + "github.com/challenge2016/internal/geography" +) + +func main() { + port := flag.String("port", "8081", "HTTP server port") + csvPath := flag.String("csv", "cities.csv", "Path to cities CSV file") + flag.Parse() + + // Load geographic data + log.Println("Loading geographic data...") + geoTree := geography.NewGeoTree() + if err := geoTree.LoadFromCSV(*csvPath); err != nil { + log.Fatalf("Failed to load CSV: %v", err) + } + + countries, provinces, cities := geoTree.Stats() + log.Printf("Loaded %d countries, %d provinces, %d cities", countries, provinces, cities) + + // Create distributor manager + dm := distributor.NewDistributorManager(geoTree) + + // Create and start server + server := api.NewServer(geoTree, dm) + + httpServer := &http.Server{ + Addr: ":" + *port, + Handler: server.Handler(), + ReadTimeout: 15 * time.Second, + WriteTimeout: 15 * time.Second, + IdleTimeout: 60 * time.Second, + } + + // Graceful shutdown + done := make(chan bool) + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + + go func() { + <-quit + log.Println("Server is shutting down...") + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + if err := httpServer.Shutdown(ctx); err != nil { + log.Fatalf("Could not gracefully shutdown: %v", err) + } + close(done) + }() + + log.Printf("Starting HTTP server on port %s", *port) + log.Println("Endpoints:") + log.Println(" GET /api/health") + log.Println(" GET /api/stats") + log.Println(" GET /api/distributors") + log.Println(" POST /api/distributors") + log.Println(" GET /api/distributor/{name}") + log.Println(" POST /api/distributor/{name}/permission") + log.Println(" GET /api/permission/{distributor}/{region}") + log.Println(" GET /api/region/{code}") + + if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("Could not start server: %v", err) + } + + <-done + log.Println("Server stopped") +} diff --git a/go.mod b/go.mod new file mode 100644 index 000000000..fae7a53a6 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/challenge2016 + +go 1.21 diff --git a/internal/api/handler.go b/internal/api/handler.go new file mode 100644 index 000000000..fadd08a1f --- /dev/null +++ b/internal/api/handler.go @@ -0,0 +1,282 @@ +package api + +import ( + "encoding/json" + "log" + "net/http" + "strings" + "time" + + "github.com/challenge2016/internal/distributor" + "github.com/challenge2016/internal/geography" +) + +// HTTP API server +type Server struct { + geoTree *geography.GeoTree + dm *distributor.DistributorManager + mux *http.ServeMux +} + +func NewServer(geoTree *geography.GeoTree, dm *distributor.DistributorManager) *Server { + s := &Server{ + geoTree: geoTree, + dm: dm, + mux: http.NewServeMux(), + } + s.setupRoutes() + return s +} + +// Configures the HTTP routes +func (s *Server) setupRoutes() { + s.mux.HandleFunc("/api/health", s.withLogging(s.healthHandler)) + s.mux.HandleFunc("/api/stats", s.withLogging(s.statsHandler)) + s.mux.HandleFunc("/api/distributors", s.withLogging(s.distributorsHandler)) + s.mux.HandleFunc("/api/distributor/", s.withLogging(s.distributorHandler)) + s.mux.HandleFunc("/api/permission/", s.withLogging(s.permissionHandler)) + s.mux.HandleFunc("/api/region/", s.withLogging(s.regionHandler)) +} + +// Handler +func (s *Server) Handler() http.Handler { + return s.mux +} + +// wraps a handler with logging middleware +func (s *Server) withLogging(handler http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + handler(w, r) + log.Printf("%s %s %v", r.Method, r.URL.Path, time.Since(start)) + } +} + +// returns server health status +func (s *Server) healthHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) +} + +// returns statistics about the geographic data +func (s *Server) statsHandler(w http.ResponseWriter, r *http.Request) { + countries, provinces, cities := s.geoTree.Stats() + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "countries": countries, + "provinces": provinces, + "cities": cities, + "distributors": len(s.dm.ListDistributors()), + }) +} + +// DistributorRequest represents a request to create a distributor +type DistributorRequest struct { + Name string `json:"name"` + Parent string `json:"parent,omitempty"` +} + +// PermissionRequest represents a request to add a permission +type PermissionRequest struct { + Type string `json:"type"` // "include" or "exclude" + Region string `json:"region"` +} + +// distributorsHandler handles requests related to distributor listing and creation +func (s *Server) distributorsHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + switch r.Method { + case http.MethodGet: + distributors := s.dm.ListDistributors() + json.NewEncoder(w).Encode(map[string]interface{}{ + "distributors": distributors, + }) + + case http.MethodPost: + var req DistributorRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, `{"error": "invalid request body"}`, http.StatusBadRequest) + return + } + + if req.Name == "" { + http.Error(w, `{"error": "name is required"}`, http.StatusBadRequest) + return + } + + _, err := s.dm.CreateDistributor(req.Name, req.Parent) + if err != nil { + http.Error(w, `{"error": "`+err.Error()+`"}`, http.StatusBadRequest) + return + } + + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]string{ + "status": "created", + "name": req.Name, + }) + + default: + http.Error(w, `{"error": "method not allowed"}`, http.StatusMethodNotAllowed) + } +} + +// distributorHandler handles requests for a specific distributor +func (s *Server) distributorHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/api/distributor/"), "/") + if len(parts) == 0 || parts[0] == "" { + http.Error(w, `{"error": "distributor name required"}`, http.StatusBadRequest) + return + } + distName := parts[0] + + switch r.Method { + case http.MethodGet: + info, err := s.dm.GetDistributorInfo(distName) + if err != nil { + http.Error(w, `{"error": "`+err.Error()+`"}`, http.StatusNotFound) + return + } + json.NewEncoder(w).Encode(info) + + case http.MethodPost: + + if len(parts) < 2 || parts[1] != "permission" { + http.Error(w, `{"error": "invalid endpoint"}`, http.StatusBadRequest) + return + } + + var req PermissionRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, `{"error": "invalid request body"}`, http.StatusBadRequest) + return + } + + var permType distributor.PermissionType + switch strings.ToLower(req.Type) { + case "include": + permType = distributor.Include + case "exclude": + permType = distributor.Exclude + default: + http.Error(w, `{"error": "type must be 'include' or 'exclude'"}`, http.StatusBadRequest) + return + } + + err := s.dm.AddPermission(distName, permType, req.Region) + if err != nil { + http.Error(w, `{"error": "`+err.Error()+`"}`, http.StatusBadRequest) + return + } + + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]string{ + "status": "permission added", + "type": req.Type, + "region": req.Region, + }) + + default: + http.Error(w, `{"error": "method not allowed"}`, http.StatusMethodNotAllowed) + } +} + +// PermissionCheckResponse represents the response for a permission check +type PermissionCheckResponse struct { + Distributor string `json:"distributor"` + Region string `json:"region"` + Result string `json:"result"` // "YES", "NO", or "NOT_DEFINED" +} + +// permissionHandler checks if a distributor has permission for a region +// Path: /api/permission/{distributor}/{region} +func (s *Server) permissionHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + if r.Method != http.MethodGet { + http.Error(w, `{"error": "method not allowed"}`, http.StatusMethodNotAllowed) + return + } + + // Extract distributor and region from path + path := strings.TrimPrefix(r.URL.Path, "/api/permission/") + parts := strings.SplitN(path, "/", 2) + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + http.Error(w, `{"error": "path must be /api/permission/{distributor}/{region}"}`, http.StatusBadRequest) + return + } + + distName := parts[0] + regionCode := parts[1] + + result, err := s.dm.CheckPermission(distName, regionCode) + if err != nil { + http.Error(w, `{"error": "`+err.Error()+`"}`, http.StatusBadRequest) + return + } + + var resultStr string + switch result { + case distributor.Allowed: + resultStr = "YES" + case distributor.Denied: + resultStr = "NO" + default: + resultStr = "NOT_DEFINED" + } + + json.NewEncoder(w).Encode(PermissionCheckResponse{ + Distributor: distName, + Region: regionCode, + Result: resultStr, + }) +} + +// regionHandler returns information about a region +func (s *Server) regionHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + if r.Method != http.MethodGet { + http.Error(w, `{"error": "method not allowed"}`, http.StatusMethodNotAllowed) + return + } + + regionCode := strings.TrimPrefix(r.URL.Path, "/api/region/") + if regionCode == "" { + http.Error(w, `{"error": "region code required"}`, http.StatusBadRequest) + return + } + + region, exists := s.geoTree.GetRegion(regionCode) + if !exists { + http.Error(w, `{"error": "region not found"}`, http.StatusNotFound) + return + } + + response := map[string]interface{}{ + "code": region.Code, + "full_code": region.FullCode, + "name": region.Name, + "level": region.Level, + } + + if region.Parent != nil { + response["parent"] = region.Parent.FullCode + } + + childCount := len(region.Children) + response["child_count"] = childCount + + if childCount <= 100 { + children := make([]string, 0, childCount) + for code := range region.Children { + children = append(children, code) + } + response["children"] = children + } + + json.NewEncoder(w).Encode(response) +} diff --git a/internal/distributor/distributor.go b/internal/distributor/distributor.go new file mode 100644 index 000000000..66caf524b --- /dev/null +++ b/internal/distributor/distributor.go @@ -0,0 +1,277 @@ +package distributor + +import ( + "fmt" + "sync" + + "github.com/challenge2016/internal/geography" +) + +// PermissionType represents the type of permission (include or exclude) +type PermissionType int + +const ( + Include PermissionType = iota + Exclude +) + +// Distributor represents a film distributor with their permissions +type Distributor struct { + Name string + Parent *Distributor + Includes map[string]*geography.Region + Excludes map[string]*geography.Region + Children []*Distributor + mu sync.RWMutex + geoTree *geography.GeoTree +} + +// DistributorManager manages all distributors +type DistributorManager struct { + distributors map[string]*Distributor + geoTree *geography.GeoTree + mu sync.RWMutex +} + +// NewDistributorManager creates a new distributor manager +func NewDistributorManager(geoTree *geography.GeoTree) *DistributorManager { + return &DistributorManager{ + distributors: make(map[string]*Distributor), + geoTree: geoTree, + } +} + +// CreateDistributor creates a new distributor, optionally with a parent +func (dm *DistributorManager) CreateDistributor(name string, parentName string) (*Distributor, error) { + dm.mu.Lock() + defer dm.mu.Unlock() + + if _, exists := dm.distributors[name]; exists { + return nil, fmt.Errorf("distributor %s already exists", name) + } + + var parent *Distributor + if parentName != "" { + var exists bool + parent, exists = dm.distributors[parentName] + if !exists { + return nil, fmt.Errorf("parent distributor %s not found", parentName) + } + } + + dist := &Distributor{ + Name: name, + Parent: parent, + Includes: make(map[string]*geography.Region), + Excludes: make(map[string]*geography.Region), + Children: make([]*Distributor, 0), + geoTree: dm.geoTree, + } + + if parent != nil { + parent.mu.Lock() + parent.Children = append(parent.Children, dist) + parent.mu.Unlock() + } + + dm.distributors[name] = dist + return dist, nil +} + +// GetDistributor retrieves a distributor by name +func (dm *DistributorManager) GetDistributor(name string) (*Distributor, bool) { + dm.mu.RLock() + defer dm.mu.RUnlock() + dist, exists := dm.distributors[name] + return dist, exists +} + +// AddPermission adds a permission to a distributor +func (dm *DistributorManager) AddPermission(distName string, permType PermissionType, regionCode string) error { + dm.mu.RLock() + dist, exists := dm.distributors[distName] + dm.mu.RUnlock() + + if !exists { + return fmt.Errorf("distributor %s not found", distName) + } + + region, err := dm.geoTree.ParseRegionCode(regionCode) + if err != nil { + return err + } + + if dist.Parent != nil { + if permType == Include { + if !dm.parentHasAccess(dist.Parent, region) { + return fmt.Errorf("parent distributor %s does not have access to region %s", + dist.Parent.Name, regionCode) + } + } + } + + dist.mu.Lock() + defer dist.mu.Unlock() + + switch permType { + case Include: + dist.Includes[region.FullCode] = region + case Exclude: + dist.Excludes[region.FullCode] = region + } + + return nil +} + +// parentHasAccess checks if the parent distributor has access to the given region +func (dm *DistributorManager) parentHasAccess(parent *Distributor, region *geography.Region) bool { + return dm.checkPermission(parent, region) == Allowed +} + +// PermissionResult represents the result of a permission check +type PermissionResult int + +const ( + Allowed PermissionResult = iota + Denied + NotDefined +) + +// CheckPermission checks if a distributor has permission to distribute in a region +func (dm *DistributorManager) CheckPermission(distName, regionCode string) (PermissionResult, error) { + dm.mu.RLock() + dist, exists := dm.distributors[distName] + dm.mu.RUnlock() + + if !exists { + return NotDefined, fmt.Errorf("distributor %s not found", distName) + } + + region, err := dm.geoTree.ParseRegionCode(regionCode) + if err != nil { + return NotDefined, err + } + + return dm.checkPermission(dist, region), nil +} + +// checkPermission performs the actual permission check +func (dm *DistributorManager) checkPermission(dist *Distributor, region *geography.Region) PermissionResult { + // First check parent's permission if exists + if dist.Parent != nil { + parentResult := dm.checkPermission(dist.Parent, region) + if parentResult == Denied || parentResult == NotDefined { + return parentResult + } + } + + dist.mu.RLock() + defer dist.mu.RUnlock() + + if dm.isExcluded(dist, region) { + return Denied + } + + if dm.isIncluded(dist, region) { + return Allowed + } + + if len(dist.Includes) == 0 { + return NotDefined + } + + return NotDefined +} + +// isExcluded checks if a region is excluded +func (dm *DistributorManager) isExcluded(dist *Distributor, region *geography.Region) bool { + if _, exists := dist.Excludes[region.FullCode]; exists { + return true + } + + current := region.Parent + for current != nil { + if _, exists := dist.Excludes[current.FullCode]; exists { + return true + } + current = current.Parent + } + + return false +} + +// isIncluded checks if a region is included (directly or via ancestor) +func (dm *DistributorManager) isIncluded(dist *Distributor, region *geography.Region) bool { + if _, exists := dist.Includes[region.FullCode]; exists { + return true + } + + current := region.Parent + for current != nil { + if _, exists := dist.Includes[current.FullCode]; exists { + return true + } + current = current.Parent + } + + return false +} + +// GetDistributorInfo returns information about a distributor +func (dm *DistributorManager) GetDistributorInfo(name string) (*DistributorInfo, error) { + dm.mu.RLock() + dist, exists := dm.distributors[name] + dm.mu.RUnlock() + + if !exists { + return nil, fmt.Errorf("distributor %s not found", name) + } + + dist.mu.RLock() + defer dist.mu.RUnlock() + + info := &DistributorInfo{ + Name: dist.Name, + Includes: make([]string, 0, len(dist.Includes)), + Excludes: make([]string, 0, len(dist.Excludes)), + } + + if dist.Parent != nil { + info.Parent = dist.Parent.Name + } + + for code := range dist.Includes { + info.Includes = append(info.Includes, code) + } + + for code := range dist.Excludes { + info.Excludes = append(info.Excludes, code) + } + + for _, child := range dist.Children { + info.Children = append(info.Children, child.Name) + } + + return info, nil +} + +// DistributorInfo contains information about a distributor +type DistributorInfo struct { + Name string `json:"name"` + Parent string `json:"parent,omitempty"` + Includes []string `json:"includes"` + Excludes []string `json:"excludes"` + Children []string `json:"children,omitempty"` +} + +// ListDistributors returns a list of all distributor names +func (dm *DistributorManager) ListDistributors() []string { + dm.mu.RLock() + defer dm.mu.RUnlock() + + names := make([]string, 0, len(dm.distributors)) + for name := range dm.distributors { + names = append(names, name) + } + return names +} diff --git a/internal/geography/geography.go b/internal/geography/geography.go new file mode 100644 index 000000000..f5358da5d --- /dev/null +++ b/internal/geography/geography.go @@ -0,0 +1,193 @@ +package geography + +import ( + "bufio" + "encoding/csv" + "fmt" + "io" + "os" + "strings" + "sync" +) + +// Region represents a geographic region at any level (country, province, city) +type Region struct { + Code string + Name string + Level int // 0 = country, 1 = province, 2 = city + Parent *Region + Children map[string]*Region + FullCode string // e.g., "CHICAGO-IL-US" + mu sync.RWMutex +} + + +type GeoTree struct { + Countries map[string]*Region + AllRegions map[string]*Region + mu sync.RWMutex +} + + +func NewGeoTree() *GeoTree { + return &GeoTree{ + Countries: make(map[string]*Region), + AllRegions: make(map[string]*Region), + } +} + +// LoadFromCSV +func (gt *GeoTree) LoadFromCSV(filename string) error { + file, err := os.Open(filename) + if err != nil { + return fmt.Errorf("failed to open file: %w", err) + } + defer file.Close() + + reader := csv.NewReader(bufio.NewReader(file)) + + if _, err := reader.Read(); err != nil { + return fmt.Errorf("failed to read header: %w", err) + } + + for { + record, err := reader.Read() + if err == io.EOF { + break + } + if err != nil { + return fmt.Errorf("failed to read record: %w", err) + } + + if len(record) < 6 { + continue + } + + cityCode := strings.TrimSpace(record[0]) + provinceCode := strings.TrimSpace(record[1]) + countryCode := strings.TrimSpace(record[2]) + cityName := strings.TrimSpace(record[3]) + provinceName := strings.TrimSpace(record[4]) + countryName := strings.TrimSpace(record[5]) + + gt.addLocation(countryCode, countryName, provinceCode, provinceName, cityCode, cityName) + } + + return nil +} + +// addLocation adds a city with its province and country to the tree +func (gt *GeoTree) addLocation(countryCode, countryName, provinceCode, provinceName, cityCode, cityName string) { + gt.mu.Lock() + defer gt.mu.Unlock() + + // Get or create country + country, exists := gt.Countries[countryCode] + if !exists { + country = &Region{ + Code: countryCode, + Name: countryName, + Level: 0, + Children: make(map[string]*Region), + FullCode: countryCode, + } + gt.Countries[countryCode] = country + gt.AllRegions[countryCode] = country + } + + // Get or create province + provinceFullCode := provinceCode + "-" + countryCode + province, exists := country.Children[provinceCode] + if !exists { + province = &Region{ + Code: provinceCode, + Name: provinceName, + Level: 1, + Parent: country, + Children: make(map[string]*Region), + FullCode: provinceFullCode, + } + country.Children[provinceCode] = province + gt.AllRegions[provinceFullCode] = province + } + + // Create city + cityFullCode := cityCode + "-" + provinceCode + "-" + countryCode + if _, exists := province.Children[cityCode]; !exists { + city := &Region{ + Code: cityCode, + Name: cityName, + Level: 2, + Parent: province, + Children: make(map[string]*Region), + FullCode: cityFullCode, + } + province.Children[cityCode] = city + gt.AllRegions[cityFullCode] = city + } +} + +// GetRegion retrieves a region by its full code +func (gt *GeoTree) GetRegion(fullCode string) (*Region, bool) { + gt.mu.RLock() + defer gt.mu.RUnlock() + + region, exists := gt.AllRegions[fullCode] + return region, exists +} + +// IsAncestor checks if potentialAncestor is an ancestor of region +func (gt *GeoTree) IsAncestor(region, potentialAncestor *Region) bool { + current := region + for current != nil { + if current == potentialAncestor { + return true + } + current = current.Parent + } + return false +} + +// GetAllDescendants returns all descendant full codes of a region +func (gt *GeoTree) GetAllDescendants(region *Region) []string { + var descendants []string + gt.collectDescendants(region, &descendants) + return descendants +} + +func (gt *GeoTree) collectDescendants(region *Region, descendants *[]string) { + region.mu.RLock() + defer region.mu.RUnlock() + + for _, child := range region.Children { + *descendants = append(*descendants, child.FullCode) + gt.collectDescendants(child, descendants) + } +} + +// ParseRegionCode parses a region code and returns the region +func (gt *GeoTree) ParseRegionCode(code string) (*Region, error) { + code = strings.ToUpper(strings.TrimSpace(code)) + + region, exists := gt.GetRegion(code) + if !exists { + return nil, fmt.Errorf("region not found: %s", code) + } + + return region, nil +} + +// Stats returns statistics about the geographic tree +func (gt *GeoTree) Stats() (countries, provinces, cities int) { + gt.mu.RLock() + defer gt.mu.RUnlock() + + countries = len(gt.Countries) + for _, country := range gt.Countries { + provinces += len(country.Children) + for _, province := range country.Children { + cities += len(province.Children) + } + } + return +}