Skip to content
Open
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
82 changes: 82 additions & 0 deletions cmd/server/main.go
Original file line number Diff line number Diff line change
@@ -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")
}
3 changes: 3 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module github.com/challenge2016

go 1.21
282 changes: 282 additions & 0 deletions internal/api/handler.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading