feat(echo server): Define backend api server using echo framework
API server (backend/cmd/api): - we load configuration - init logger - create echo server instance - we define the middleware pipeline --- recover --- request_id --- logger_format --- cors (ross-Origin Resource Sharing) --- security_headers --- gzip_compresssion - route_mapping - star_server - graceful_shutdown add-ons: - healthCheckHandler() - customHTTPErrorHandler() This commit establishes the foundation for all future development. Story: E1-002 - Backend Project Initialization (main echo server)
This commit is contained in:
parent
fe50efd3ab
commit
b99866db54
|
|
@ -1,7 +1,313 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import "fmt"
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/creativenoz/aurganize-v62/backend/internal/config"
|
||||||
|
"github.com/creativenoz/aurganize-v62/backend/pkg/logger"
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
"github.com/labstack/echo/v4/middleware"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
fmt.Println("Aurganize")
|
// =========================================================================
|
||||||
|
// Loading Configuration
|
||||||
|
// =========================================================================
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
// we are not using logger here, since we need config information to set the log level
|
||||||
|
fmt.Fprintf(os.Stderr, "Failed to load configurations : %v\n", err)
|
||||||
|
// hence when config load fails we exit application, cause have not point in continuing further
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
// =========================================================================
|
||||||
|
// Initializing Logger
|
||||||
|
// =========================================================================
|
||||||
|
logger.Init(cfg.Server.Environment)
|
||||||
|
log.Info().
|
||||||
|
Str("Version", "0.6.2").
|
||||||
|
Str("environment", cfg.Server.Environment).
|
||||||
|
Msg("Starting Aurganize v6.2 API server")
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Create Echo Instance
|
||||||
|
// =========================================================================
|
||||||
|
e := echo.New()
|
||||||
|
e.HideBanner = true
|
||||||
|
e.HidePort = true
|
||||||
|
|
||||||
|
e.HTTPErrorHandler = customHTTPErrorHandler // we are using a custom error handler
|
||||||
|
|
||||||
|
e.Server.ReadTimeout = cfg.Server.ReadTimeout
|
||||||
|
e.Server.WriteTimeout = cfg.Server.WriteTimeout
|
||||||
|
|
||||||
|
log.Info().Msg("Echo server instance created")
|
||||||
|
// =========================================================================
|
||||||
|
// Middleware Pipeline
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
// Setting safe recover middleware
|
||||||
|
e.Use(middleware.Recover())
|
||||||
|
// Middleware catches panic
|
||||||
|
// Returns 500 Internal Server Error
|
||||||
|
// Server keeps running
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Setting request ID middleware
|
||||||
|
e.Use(middleware.RequestID())
|
||||||
|
// Trace request through entire system
|
||||||
|
// Link frontend error to backend logs
|
||||||
|
// This adds a header : X-Request-ID: abc-123-def-456
|
||||||
|
// ------------------------------------------------------------------------
|
||||||
|
// Setting Logger format
|
||||||
|
e.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{
|
||||||
|
Format: `{"time":"${time_rfc3339}","method":"${method}","uri":"${uri}",` +
|
||||||
|
`"status":${status},"latency_ms":${latency_ms},"error":"${error}"}` + "\n",
|
||||||
|
Output: log.Logger,
|
||||||
|
}))
|
||||||
|
// We are setting a custom log format, which is consisten with our logger format
|
||||||
|
// {
|
||||||
|
// "time": "2025-11-26T10:30:45Z",
|
||||||
|
// "method": "POST",
|
||||||
|
// "uri": "/api/v1/login",
|
||||||
|
// "status": 200,
|
||||||
|
// "latency_ms": 45,
|
||||||
|
// "error": ""
|
||||||
|
// }
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Setting CORS (Cross-Origin Resource Sharing) middleware
|
||||||
|
e.Use(middleware.CORSWithConfig(middleware.CORSConfig{
|
||||||
|
AllowOrigins: []string{
|
||||||
|
"http://localhost:5173", // (Development) Svelte dev server : this is the port suggested to be used with front-end
|
||||||
|
"http://localhost:3000", // (Developement) Alternative dev port : this is an alternative port kept aside
|
||||||
|
"https://app.aurganize.com", // (Production) Production frontend : we can use this subdomain itself for front-end
|
||||||
|
},
|
||||||
|
AllowMethods: []string{
|
||||||
|
http.MethodGet,
|
||||||
|
http.MethodPost,
|
||||||
|
http.MethodPut,
|
||||||
|
http.MethodDelete,
|
||||||
|
http.MethodPatch,
|
||||||
|
http.MethodOptions,
|
||||||
|
},
|
||||||
|
AllowHeaders: []string{
|
||||||
|
"Origin",
|
||||||
|
"Content-Type",
|
||||||
|
"Accept",
|
||||||
|
"Authorization",
|
||||||
|
"X-Request-ID",
|
||||||
|
},
|
||||||
|
|
||||||
|
AllowCredentials: true, // Not sure about why are using this option
|
||||||
|
|
||||||
|
MaxAge: 3600, // 1 hour in seconds
|
||||||
|
}))
|
||||||
|
// Prevents malicious sites from calling your API
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
// Setting Security Headers middleware
|
||||||
|
e.Use(middleware.SecureWithConfig(middleware.SecureConfig{
|
||||||
|
XSSProtection: "1; mode=block",
|
||||||
|
ContentTypeNosniff: "nosniff",
|
||||||
|
XFrameOptions: "SAMEORIGIN",
|
||||||
|
HSTSMaxAge: 31536000,
|
||||||
|
HSTSExcludeSubdomains: false,
|
||||||
|
ContentSecurityPolicy: "default-src 'self'",
|
||||||
|
}))
|
||||||
|
// X-XSS-Protection:
|
||||||
|
// - Blocks cross-site scripting attacks
|
||||||
|
// - Browser detects XSS and blocks page
|
||||||
|
|
||||||
|
// X-Content-Type-Options: nosniff:
|
||||||
|
// - Prevents MIME-type sniffing
|
||||||
|
// - Browser trusts Content-Type header
|
||||||
|
// - Prevents executing scripts as HTML
|
||||||
|
|
||||||
|
// X-Frame-Options: SAMEORIGIN:
|
||||||
|
// - Prevents clickjacking
|
||||||
|
// - Page can't be embedded in iframe (except same origin)
|
||||||
|
// - Protects against UI redress attacks
|
||||||
|
|
||||||
|
// Strict-Transport-Security (HSTS):
|
||||||
|
// - Forces HTTPS for 1 year
|
||||||
|
// - Prevents downgrade attacks
|
||||||
|
// - Can't be disabled by user
|
||||||
|
|
||||||
|
// Content-Security-Policy:
|
||||||
|
// - Only load resources from same origin
|
||||||
|
// - Prevents loading malicious scripts
|
||||||
|
// - Additional layer of XSS protection
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// Setting Gzip compression middleware
|
||||||
|
e.Use(middleware.Gzip())
|
||||||
|
|
||||||
|
// TODO : Rate Limiting middleware (planning to use redis for custom rate limiter)
|
||||||
|
|
||||||
|
log.Info().Msg("Middleware configured")
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Middleware Pipeline
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
e.GET("/health", healthCheckHandler(cfg)) // (Public - health check)
|
||||||
|
api := e.Group("/api/v6.2")
|
||||||
|
|
||||||
|
api.GET("/ping", func(c echo.Context) error { // (Public - connectivity test)
|
||||||
|
return c.JSON(http.StatusOK, map[string]string{
|
||||||
|
"message": "pika pikaaa",
|
||||||
|
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
||||||
|
"version": "0.6.2",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
log.Info().Msg("Routes configured")
|
||||||
|
// =========================================================================
|
||||||
|
// Start Server in a new thread
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
serverAddr := fmt.Sprintf(":%s", cfg.Server.Port)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
log.Info().
|
||||||
|
Str("address", serverAddr).
|
||||||
|
Str("environment", cfg.Server.Environment).
|
||||||
|
Msg("Server starting")
|
||||||
|
|
||||||
|
if err := e.Start(serverAddr); err != nil && err != http.ErrServerClosed {
|
||||||
|
log.Fatal().
|
||||||
|
Err(err).
|
||||||
|
Msg("Failed to start server")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
// =========================================================================
|
||||||
|
// Shutdown Logic
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
quit := make(chan os.Signal, 1)
|
||||||
|
// Creates a channel named quit that can carry values of type os.Signal.
|
||||||
|
// The 1 means it’s a buffered channel with capacity 1 → it can hold one signal without blocking.
|
||||||
|
// This channel will be used to receive OS signals like Ctrl+C or kill.
|
||||||
|
|
||||||
|
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
|
||||||
|
// Whenever the process receives any of these signals, send them into the quit channel.”
|
||||||
|
// os.Interrupt → typically the signal sent when you press Ctrl+C in the terminal.
|
||||||
|
// syscall.SIGTERM → the “please terminate” signal, used by process managers / Docker / Kubernetes,
|
||||||
|
|
||||||
|
<-quit // Blocks until we get a signal in this channel
|
||||||
|
// This is a receive operation on the channel.
|
||||||
|
// The code blocks here and does nothing until:
|
||||||
|
// the OS sends os.Interrupt or SIGTERM,
|
||||||
|
// which signal.Notify pushes into quit.
|
||||||
|
// When a signal arrives, <-quit unblocks and the program continues.
|
||||||
|
|
||||||
|
log.Info().Msg("Shutting down server gracefully..")
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
// Creates a context with timeout of 10 seconds.
|
||||||
|
// This context is passed to e.Shutdown(ctx)
|
||||||
|
// Echo will have at most 10 seconds to shut down gracefully.
|
||||||
|
// After 10 seconds, the context is cancelled, and shutdown will be forced.
|
||||||
|
|
||||||
|
if err := e.Shutdown(ctx); err != nil {
|
||||||
|
log.Error().
|
||||||
|
Err(err).
|
||||||
|
Msg("Server forced to shutdown, graceful shutdown failed")
|
||||||
|
}
|
||||||
|
// Calls Echo’s Shutdown method with your timeout context.
|
||||||
|
// e.Shutdown(ctx):
|
||||||
|
// -- stops accepting new requests,
|
||||||
|
// -- waits for in-flight requests to finish,
|
||||||
|
// -- closes the server gracefully (within the timeout).
|
||||||
|
// If something goes wrong (e.g., it can’t shut down in time), err is non-nil:
|
||||||
|
// -- logs an error saying graceful shutdown failed and it had to force close.
|
||||||
|
|
||||||
|
log.Info().Msg("API Server exited")
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// HealthCheck Handler function
|
||||||
|
// This endpoint is to be used by:
|
||||||
|
// - Load balancers to determine if instance is healthy
|
||||||
|
// - Kubernetes for liveness and readiness probes
|
||||||
|
// - Monitoring systems for uptime checks
|
||||||
|
func healthCheckHandler(c *config.Config) echo.HandlerFunc {
|
||||||
|
return func(e echo.Context) error {
|
||||||
|
// TODO : we need to add health check for
|
||||||
|
// - Database connection
|
||||||
|
// - Redis connection if we are using it
|
||||||
|
// - NATS connection
|
||||||
|
|
||||||
|
// For now we are just returning OK, since most of the service dependecies are not implemented yet.
|
||||||
|
response := map[string]interface{}{
|
||||||
|
"status": "healthy",
|
||||||
|
"version": "0.6.2",
|
||||||
|
"environment": c.Server.Environment,
|
||||||
|
"timestamp": time.Now().UTC(),
|
||||||
|
"checks": map[string]string{
|
||||||
|
"server": "ok",
|
||||||
|
"database": "not setup",
|
||||||
|
"redis": "not setup",
|
||||||
|
"nats": "not setup",
|
||||||
|
},
|
||||||
|
"uptime": 999999999999, // logic yet to be implemented
|
||||||
|
}
|
||||||
|
return e.JSON(http.StatusOK, response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CustomHTTPErrorHandler
|
||||||
|
// Used to provide consistent error response
|
||||||
|
// Converts Echo erros to JSON format, that can be parsed at frontend
|
||||||
|
func customHTTPErrorHandler(err error, c echo.Context) {
|
||||||
|
|
||||||
|
// Setting default values
|
||||||
|
code := http.StatusInternalServerError
|
||||||
|
message := "internal server error"
|
||||||
|
|
||||||
|
// Checking if the error is and Echo error
|
||||||
|
// We do this through a type assertion err.(*echo.HTTPError) [ basetype.(type to assert to)]
|
||||||
|
if he, ok := err.(*echo.HTTPError); ok {
|
||||||
|
code = he.Code
|
||||||
|
// Then we again check with a type assertion
|
||||||
|
if msg, ok := he.Message.(string); ok {
|
||||||
|
message = msg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Debug().
|
||||||
|
Err(err).
|
||||||
|
Int("status", code).
|
||||||
|
Str("method", c.Request().Method).
|
||||||
|
Str("path", c.Request().URL.Path).
|
||||||
|
Msg("HTTP error")
|
||||||
|
|
||||||
|
if code >= 500 {
|
||||||
|
log.Error().
|
||||||
|
Err(err).
|
||||||
|
Int("status", code).
|
||||||
|
Str("method", c.Request().Method).
|
||||||
|
Str("path", c.Request().URL.Path).
|
||||||
|
Msg("HTTP error")
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the response is not already written
|
||||||
|
// Then we don't have to again log
|
||||||
|
if !c.Response().Committed {
|
||||||
|
c.JSON(code, map[string]interface{}{
|
||||||
|
"error": map[string]interface{}{
|
||||||
|
"code": code,
|
||||||
|
"message": message,
|
||||||
|
"timestamp": time.Now().UTC(),
|
||||||
|
"path": c.Request().URL.Path,
|
||||||
|
"request_id": c.Response().Header().Get(echo.HeaderXRequestID),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,12 @@ module github.com/creativenoz/aurganize-v62/backend
|
||||||
|
|
||||||
go 1.25.2
|
go 1.25.2
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
|
github.com/dgrijalva/jwt-go v3.2.0+incompatible // indirect
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.10 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.10 // indirect
|
||||||
github.com/go-playground/locales v0.14.1 // indirect
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSV
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM=
|
||||||
|
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0=
|
github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0=
|
||||||
|
|
@ -35,6 +37,8 @@ github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||||
|
github.com/labstack/echo v3.3.10+incompatible h1:pGRcYk231ExFAyoAjAfD85kQzRJCRI8bbnE7CX5OEgg=
|
||||||
|
github.com/labstack/echo v3.3.10+incompatible/go.mod h1:0INS7j/VjnFxD4E2wkz67b8cVwCLbBmJyDaka6Cmk1s=
|
||||||
github.com/labstack/echo/v4 v4.13.4 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcXEA=
|
github.com/labstack/echo/v4 v4.13.4 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcXEA=
|
||||||
github.com/labstack/echo/v4 v4.13.4/go.mod h1:g63b33BZ5vZzcIUF8AtRH40DrTlXnx4UMC8rBdndmjQ=
|
github.com/labstack/echo/v4 v4.13.4/go.mod h1:g63b33BZ5vZzcIUF8AtRH40DrTlXnx4UMC8rBdndmjQ=
|
||||||
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
|
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
|
||||||
|
|
@ -77,6 +81,7 @@ golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
||||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
||||||
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
|
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
|
||||||
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
|
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
|
||||||
|
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue