diff --git a/main.go b/main.go new file mode 100644 index 0000000..dfb327e --- /dev/null +++ b/main.go @@ -0,0 +1,215 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "log" + "net/http" + "os" + "strconv" + "strings" + "sync" + "sync/atomic" + + "github.com/prometheus/client_golang/prometheus/promhttp" + "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp" + "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp" + "go.opentelemetry.io/otel/propagation" + sdkmetric "go.opentelemetry.io/otel/sdk/metric" + "go.opentelemetry.io/otel/sdk/resource" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + semconv "go.opentelemetry.io/otel/semconv/v1.24.0" +) + +// --------------------------------------------------------------------------- +// Domain model +// --------------------------------------------------------------------------- + +type Item struct { + ID int `json:"id"` + Name string `json:"name"` + Description string `json:"description"` +} + +type ItemRequest struct { + Name string `json:"name"` + Description string `json:"description"` +} + +// --------------------------------------------------------------------------- +// In-memory store +// --------------------------------------------------------------------------- + +var ( + store sync.Map + counter atomic.Int64 +) + +func nextID() int { + return int(counter.Add(1)) +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +func itemIDFromPath(path string) (int, bool) { + parts := strings.Split(strings.TrimSuffix(path, "/"), "/") + if len(parts) < 3 { + return 0, false + } + id, err := strconv.Atoi(parts[len(parts)-1]) + return id, err == nil +} + +// --------------------------------------------------------------------------- +// Handlers +// --------------------------------------------------------------------------- + +func healthHandler(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, map[string]string{"status": "UP"}) +} + +func itemsHandler(w http.ResponseWriter, r *http.Request) { + // Route: /api/items or /api/items/{id} + hasSub := strings.Count(strings.TrimSuffix(r.URL.Path, "/"), "/") >= 3 + + if hasSub { + id, ok := itemIDFromPath(r.URL.Path) + if !ok { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"}) + return + } + switch r.Method { + case http.MethodGet: + v, exists := store.Load(id) + if !exists { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"}) + return + } + writeJSON(w, http.StatusOK, v) + case http.MethodPut: + var req ItemRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + existingRaw, exists := store.Load(id) + if !exists { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"}) + return + } + existing := existingRaw.(Item) + if req.Name == "" { + req.Name = existing.Name + } + if req.Description == "" { + req.Description = existing.Description + } + updated := Item{ID: id, Name: req.Name, Description: req.Description} + store.Store(id, updated) + writeJSON(w, http.StatusOK, updated) + case http.MethodDelete: + store.Delete(id) + writeJSON(w, http.StatusOK, map[string]int{"deleted": id}) + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } + return + } + + // /api/items — list or create + switch r.Method { + case http.MethodGet: + var items []Item + store.Range(func(_, v any) bool { + items = append(items, v.(Item)) + return true + }) + if items == nil { + items = []Item{} + } + writeJSON(w, http.StatusOK, items) + case http.MethodPost: + var req ItemRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + if req.Name == "" { + req.Name = "unnamed" + } + item := Item{ID: nextID(), Name: req.Name, Description: req.Description} + store.Store(item.ID, item) + writeJSON(w, http.StatusCreated, item) + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } +} + +// --------------------------------------------------------------------------- +// OpenTelemetry setup +// --------------------------------------------------------------------------- + +// setupOTel initialises the OTLP trace and metric pipelines from OTEL_* env +// vars (injected by score.yaml / Humanitec). Returns a no-op shutdown func +// when OTEL_EXPORTER_OTLP_ENDPOINT is not set (local dev). +func setupOTel(ctx context.Context) func() { + if os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT") == "" { + return func() {} + } + svcName := os.Getenv("OTEL_SERVICE_NAME") + if svcName == "" { + svcName = "unknown" + } + res, _ := resource.New(ctx, + resource.WithAttributes(semconv.ServiceName(svcName)), + resource.WithFromEnv(), + ) + traceExp, _ := otlptracehttp.New(ctx) + tp := sdktrace.NewTracerProvider(sdktrace.WithBatcher(traceExp), sdktrace.WithResource(res)) + otel.SetTracerProvider(tp) + otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator( + propagation.TraceContext{}, propagation.Baggage{}, + )) + metricExp, _ := otlpmetrichttp.New(ctx) + mp := sdkmetric.NewMeterProvider( + sdkmetric.WithReader(sdkmetric.NewPeriodicReader(metricExp)), + sdkmetric.WithResource(res), + ) + otel.SetMeterProvider(mp) + return func() { + _ = tp.Shutdown(context.Background()) + _ = mp.Shutdown(context.Background()) + } +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +func main() { + ctx := context.Background() + shutdownOTel := setupOTel(ctx) + defer shutdownOTel() + + mux := http.NewServeMux() + + mux.HandleFunc("/health", healthHandler) + mux.Handle("/metrics", promhttp.Handler()) + mux.HandleFunc("/api/items/", itemsHandler) + mux.HandleFunc("/api/items", itemsHandler) + + addr := ":8080" + fmt.Printf("sonar-test-go2 listening on %s\n", addr) + log.Fatal(http.ListenAndServe(addr, otelhttp.NewHandler(mux, ""))) +}