// Package bash provides utilities for executing bash commands and processing their output. // It includes functionality for process management, system monitoring, and output formatting. package bash import ( "bytes" "errors" "fmt" "os/exec" "regexp" "strconv" "strings" ) const ( // ansi is a regular expression pattern for matching ANSI escape sequences. // These sequences are used for terminal color formatting and control. // The pattern matches: // - CSI (Control Sequence Introducer) sequences starting with ESC[ // - OSC (Operating System Command) sequences // - Other common terminal control sequences. ansi = "[\u001B\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[a-zA-Z\\d]*)*)?\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PRZcf-ntqry=><~]))" //nolint:lll // commandBash specifies the default command to use for bash shell execution. commandBash = "bash" // zeroValue is the default string value returned when numeric operations fail. zeroValue = "0.0" // uptimeZeroValue is the default string value returned when uptime cannot be determined. uptimeZeroValue = "00:00:00" ) // ansiRegexp is a precompiled regular expression created from the ansi pattern. // This is used for efficient stripping of ANSI escape sequences from strings. var ansiRegexp = regexp.MustCompile(ansi) // Common error definitions used throughout the package. var ( // ErrInvalidCommand indicates that command is invalid. ErrInvalidCommand = errors.New("invalid command") // ErrEmptyPID indicates that a process lookup returned an empty process ID. ErrEmptyPID = errors.New("empty process id") // ErrNotRunning indicates that the requested process is not currently running. ErrNotRunning = errors.New("process is not running") // ErrBashExecuteFailed indicates that a bash command execution failed. ErrBashExecuteFailed = errors.New("bash execute failed") ) // Strip removes all ANSI escape sequences and trailing newline characters from a string. // This is useful for cleaning up colored terminal output or formatted text. // // Parameters: // - str: The input string potentially containing ANSI codes // // Returns: // - A cleaned string with all ANSI sequences and trailing newlines removed // // Example: // // cleaned := Strip("\033[32mHello\033[0m\n") // Returns "Hello". func Strip(str string) string { return strings.TrimRight(ansiRegexp.ReplaceAllString(str, ""), "\n") } // Execute runs a system command and captures its output streams. // It provides a convenient wrapper around exec.Command with integrated error handling. // // Parameters: // - name: The name/path of the command to execute (e.g. "ls", "/bin/bash") // - args: Variadic arguments to pass to the command (e.g. "-l", "-a") // // Returns: // - string: The combined stdout output of the command // - error: Returns ErrBashExecuteFailed if stderr contains output, // or the original exec error if the command failed to run. // Returns nil if execution was successful with empty stderr. // // Behavior: // - Captures both stdout and stderr streams separately // - Considers any stderr output as an error condition // - Preserves the command's exit status error if present // - Trims no output - returned strings may contain trailing newlines // // Example: // // output, err := Execute("ls", "-l", "/tmp") // if err != nil { // // Handle error (either from stderr or command failure) // } // fmt.Println(output) // // Notes: // - For bash commands, consider using commandBash constant as name // - Command output is not stripped of ANSI codes (use Strip() separately) // - Not suitable for interactive commands requiring stdin. func Execute(name string, args ...string) (string, error) { var stdout, stder bytes.Buffer cmd := exec.Command(name, args...) cmd.Stdout = &stdout cmd.Stderr = &stder err := cmd.Run() // Return stderr if present, even if command technically succeeded. if stder.String() != "" { return stdout.String(), fmt.Errorf("%w: %s", ErrBashExecuteFailed, stder.String()) } return stdout.String(), err } // GetLargeFileList finds large files with specific extension in given path // Parameters: // - path: directory to search // - ext: file extension to match (e.g. ".log") // - params: optional count parameter (default 20) // // Returns list of files or error if command fails. func GetLargeFileList(path, mask string, params ...int) (string, error) { count := "20" if len(params) > 0 { count = strconv.Itoa(params[0]) } args := []string{ "-c", "ls " + path + " -hSRs | egrep '" + mask + "' | head -" + count, } return Execute(commandBash, args...) } // PidofByProcess retrieves the process ID (PID) of a running process by its name. // It uses the system's 'pidof' command to find the PID of the specified process. // // Parameters: // - process: Name of the process to look up (e.g., "nginx", "java"). // Should match the exact executable name. // // Returns: // - string: The PID of the process as a string if found. // - error: May return: // - Original error from command execution if pidof fails // - ErrEmptyPID if process is not running or pidof returns empty // - Other system errors if command cannot be executed // // Behavior: // - Executes 'pidof <process>' command internally // - Automatically trims trailing newline from output // - Returns first PID if multiple instances are running (pidof behavior) // - Does not validate if the process is actually running beyond PID existence // // Example: // // pid, err := PidofByProcess("nginx") // if err != nil { // if errors.Is(err, ErrEmptyPID) { // fmt.Println("Nginx is not running") // } else { // log.Fatalf("Error checking nginx: %v", err) // } // } // fmt.Printf("Nginx PID: %s\n", pid) // // Notes: // - Requires pidof command to be available in system PATH // - For more advanced process lookups, see PidofByProcessAndParam // - Returned PID string may need conversion to int for numeric operations // - On systems with multiple process instances, consider using pgrep instead. func PidofByProcess(process string) (string, error) { out, err := Execute("pidof", process) if err != nil { return "", err } if l := strings.Split(out, "\n"); len(l) > 0 { pid := l[0] if pid == "" { return "", ErrEmptyPID } return pid, nil } return "", ErrNotRunning } // PidofByProcessAndParam finds a process ID by process name and matching parameter. // It executes a command pipeline: pgrep -af <process> | grep <param> to locate // the specific process instance containing the given parameter. // // Parameters: // - process: The name of the process to search for (e.g. "java", "nginx") // - param: The parameter string to match in the process command line // (e.g. "--config=myapp.conf", "servername") // // Returns: // - string: The PID of the matching process // - error: ErrEmptyPID if process is found but PID is empty, // ErrNotRunning if no matching process is found, // or other errors from command execution // // Example: // // pid, err := PidofByProcessAndParam("java", "-Dapp.name=myapp") // if err != nil { // // handle error // }. func PidofByProcessAndParam(process, param string) (string, error) { if process == "" || param == "" || string(process[0]) == "-" { return "", ErrInvalidCommand } if string(param[0]) == "-" { param = "\\" + param } cmd := fmt.Sprintf("pgrep -af %q | grep -v %q | grep %q | grep -o -e %q", process, " bash ", param, `^[0-9]*`) out, err := Execute(commandBash, "-c", cmd) if err != nil { return "", err } if l := strings.Split(out, "\n"); len(l) > 0 { pid := l[0] if pid == "" { return "", ErrEmptyPID } return pid, nil } return "", ErrNotRunning } // GetUptimeByPID retrieves the elapsed time since a process started using its PID. // It executes the 'ps' command to get the process's running duration in format [[DD-]HH:]MM:SS. // // Parameters: // - pid: The process ID as a string (e.g., "12345"). Must be a valid running process ID. // // Returns: // - string: The process uptime in format: // - "MM:SS" for processes running <1 hour // - "HH:MM:SS" for processes running <1 day // - "DD-HH:MM:SS" for processes running multiple days // - uptimeZeroValue ("00:00:00") if the process is not found // - error: Returns: // - Original error if 'ps' command execution fails // - nil if successful (even if process not found) // // Behavior: // - Uses 'ps -o etime= -p PID' command to get process duration // - Automatically trims whitespace and newlines from output // - Returns zero value (not error) if process doesn't exist // - Output format matches system 'ps' command behavior // // Example: // // uptime, err := GetUptimeByPID("12345") // if err != nil { // log.Printf("Failed to check uptime: %v", err) // } // fmt.Printf("Process running for: %s", uptime) // e.g. "01:23:45" // // Notes: // - Requires 'ps' command to be available in system PATH // - Unlike other functions, returns zero value rather than error for missing process // - For empty/zero uptime, check against uptimeZeroValue constant // - Uptime resolution is seconds (no milliseconds). func GetUptimeByPID(pid string) (string, error) { uptime, err := Execute("ps", "-o", "etime=", "-p", pid) if err != nil { return uptimeZeroValue, err } return strings.Trim(uptime, " \n"), nil } // CPUPercentByPID retrieves the CPU usage percentage for a specific process. // The percentage represents the process's total CPU utilization since its start. // // Parameters: // - pid: The process ID as a string (e.g., "1234"). Must be a valid running process. // // Returns: // - string: CPU usage percentage with "%" suffix (e.g., "25.5%") // Returns zeroValue + "%" ("0.0%") if: // - Process is not found // - Process is using 0% CPU // - Command fails // - error: Error from command execution if ps command fails, // nil if successful (even if process shows 0% usage) // // Behavior: // - Uses 'ps S -p PID -o pcpu=' command to get CPU percentage // - The 'S' option includes child processes in calculation // - Automatically trims whitespace and appends "%" symbol // - Returns string formatted to one decimal place // // Example: // // cpu, err := CPUPercentByPID("1234") // if err != nil { // log.Printf("CPU check failed: %v", err) // } // fmt.Printf("CPU Usage: %s", cpu) // e.g. "75.3%" // // Notes: // - CPU percentage is relative to a single core (may exceed 100% on multicore systems) // - Requires 'ps' command to be available in system PATH // - For containerized processes, results may differ from host metrics // - Values are snapshots, not averages over time // - Consider using multiple samples for monitoring trending usage. func CPUPercentByPID(pid string) (string, error) { cpu, err := Execute("ps", "S", "-p", pid, "-o", "pcpu=") if err != nil { return zeroValue + "%", err } return strings.Trim(cpu, " \n") + "%", nil } // MemPercentByPID retrieves the memory usage percentage for a specific process. // The percentage represents the process's resident memory relative to total system memory. // // Parameters: // - pid: The process ID as a string (e.g., "5678"). Must be a valid running process. // // Returns: // - string: Memory usage percentage with "%" suffix (e.g., "4.2%") // Returns zeroValue + "%" ("0.0%") if: // - Process is not found // - Process uses 0% memory // - Command execution fails // - error: Error from command execution if ps command fails, // nil if successful (even if process shows 0% usage) // // Behavior: // - Uses 'ps S -p PID -o pmem=' command to get memory percentage // - The 'S' option includes child processes in calculation // - Automatically trims whitespace and appends "%" symbol // - Returns string formatted to one decimal place // // Example: // // mem, err := MemPercentByPID("5678") // if err != nil { // log.Printf("Memory check failed: %v", err) // } // fmt.Printf("Memory Usage: %s", mem) // e.g. "2.8%" // // Notes: // - Percentage is relative to total physical memory (RAM) // - Does not include shared memory or swap usage // - Requires 'ps' command to be available in system PATH // - Values represent current snapshot, not averages over time. func MemPercentByPID(pid string) (string, error) { mem, err := Execute("ps", "S", "-p", pid, "-o", "pmem=") if err != nil { return zeroValue + "%", err } return strings.Trim(mem, " \n") + "%", nil } // MemUsedByPID calculates the total resident memory usage of a process and its children in megabytes. // It sums the RSS (Resident Set Size) memory of all process threads and converts to MB. // // Parameters: // - pid: The process ID as a string (e.g., "1234"). Must be a valid running process. // // Returns: // - string: Memory usage formatted with " MB" suffix (e.g., "24.5 MB") // Returns zeroValue + " MB" ("0.0 MB") if: // - Process is not found // - Process uses no resident memory // - Command execution fails // - error: Error from command execution if the bash command fails, // nil if successful (even if memory usage is 0) // // Implementation Details: // - Uses bash command pipeline: // 1. `ps -ylp PID` lists all threads with memory info // 2. `awk` sums the RSS (column 8) and converts to MB (/1024) // - Automatically trims whitespace/newlines from output // - Adds " MB" suffix to clarify units // // Example: // // memUsage, err := MemUsedByPID("1234") // if err != nil { // log.Printf("Memory check failed: %v", err) // } // fmt.Printf("Memory used: %s", memUsage) // e.g. "45.2 MB" // // Notes: // - Measures physical RAM usage (RSS), not virtual memory // - Includes memory used by all process threads // - Values are in binary megabytes (MiB, 1024-based) // - Requires GNU ps and awk utilities. func MemUsedByPID(pid string) (string, error) { args := []string{ "-c", "ps -ylp " + pid + " | awk '{x += $8} END {print \"\" x/1024;}'", } mem, err := Execute(commandBash, args...) if err != nil { return zeroValue + " MB", err } return strings.Trim(mem, " \n") + " MB", nil } // MemUsed retrieves the total used system memory in megabytes (MB). // It calculates the actively used memory excluding buffers/cache. // // Returns: // - string: Total used memory formatted with " MB" suffix (e.g., "2048 MB") // Returns zeroValue + " MB" ("0.0 MB") if: // - Command execution fails // - Unable to parse memory information // - error: Error from command execution if the bash command fails, // nil if successful // // Implementation Details: // - Uses bash command pipeline: // 1. `free` command to get memory statistics // 2. `awk` extracts the used memory value (column 3 from second line) // 3. Converts from kilobytes to megabytes (/1024) // 4. Formats as integer (%.0f) to remove decimal places // - Automatically trims whitespace/newlines from output // - Adds " MB" suffix to clarify units // // Example: // // usedMem, err := MemUsed() // if err != nil { // log.Printf("Failed to get system memory: %v", err) // } // fmt.Printf("System memory used: %s", usedMem) // e.g. "3752 MB" // // Notes: // - Measures actual used memory excluding buffers/cache // - Values are in binary megabytes (MiB, 1024-based) // - Requires GNU free and awk utilities // - Represents system-wide memory usage, not per-process // - Consider using /proc/meminfo for more detailed breakdown. func MemUsed() (string, error) { args := []string{ "-c", "free | awk 'NR==2 { printf(\"%.0f\", $3/1024); }'", } mem, err := Execute(commandBash, args...) if err != nil { return zeroValue + " MB", err } return strings.Trim(mem, " \n") + " MB", nil } // MemAvail retrieves the total available system memory in megabytes (MB). // This represents the physical RAM available for new processes, excluding buffers/cache. // // Returns: // - string: Total available memory formatted with " MB" suffix (e.g., "8192 MB") // Returns zeroValue + " MB" ("0.0 MB") if: // - Command execution fails // - Unable to parse memory information // - error: Error from command execution if the bash command fails, // nil if successful // // Implementation Details: // - Uses bash command pipeline: // 1. `free` command to get memory statistics // 2. `awk` extracts the total memory value (column 2 from second line) // 3. Converts from kilobytes to megabytes (/1024) // 4. Formats as integer (%.0f) for clean output // - Automatically trims whitespace/newlines from output // - Adds " MB" suffix to clarify units // // Example: // // availMem, err := MemAvail() // if err != nil { // log.Printf("Failed to get available memory: %v", err) // } // fmt.Printf("Available system memory: %s", availMem) // e.g. "16384 MB" // // Notes: // - Measures physical RAM, not including swap space // - Values are in binary megabytes (MiB, 1024-based) // - Requires GNU free and awk utilities // - Represents system-wide available memory // - For accurate container memory limits, check cgroup settings. func MemAvail() (string, error) { args := []string{ "-c", "free | awk 'NR==2 { printf(\"%.0f\", $2/1024); }'", } mem, err := Execute(commandBash, args...) if err != nil { return zeroValue + " MB", err } return strings.Trim(mem, " \n") + " MB", nil }
package extract // String safely dereferences a string pointer, returning the string value or empty string if nil. // This provides a nil-safe way to access string pointers and ensures you always get a valid string: // - If pointer is non-nil: returns the underlying string value // - If pointer is nil: returns an empty string ("") // // Useful when working with optional string fields in structs or API responses. func String(pointer *string) string { if pointer != nil { return *pointer } return "" } // IsEmptyString checks if a string pointer is nil or points to an empty string. // Returns true if either: // - The pointer is nil // - The dereferenced string is empty ("") // // Useful for safely checking optional string fields that may be nil. func IsEmptyString(pointer *string) bool { return pointer == nil || *pointer == "" } // Int64 safely dereferences an int64 pointer, returning the value or 0 if nil. // // This helper function provides nil-safety when working with optional numeric fields: // - If pointer is non-nil: returns the underlying int64 value // - If pointer is nil: returns 0 (zero value for int64) // // Useful when working with optional int64 fields in structs or API responses. func Int64(pointer *int64) int64 { if pointer != nil { return *pointer } return 0 }
package files import ( "fmt" "io" "os" "path/filepath" "strings" "syscall" "time" ) // OwnerWritePerm provides 0755 permission. const OwnerWritePerm = os.FileMode(0o755) // FileExists checks if a file exists and is not a directory before we // try using it to prevent further errors. func FileExists(filename string) bool { info, err := os.Stat(filename) if os.IsNotExist(err) { return false } return !info.IsDir() } // FileCopy copies src file to destination path. func FileCopy(src string, destination string, perms ...os.FileMode) error { perm := os.ModePerm if len(perms) != 0 { perm = perms[0] } input, err := os.ReadFile(src) if err != nil { return err } return os.WriteFile(destination, input, perm) } // ReadStringFile reads file as string. func ReadStringFile(path string, name string) (string, error) { b, err := os.ReadFile(path + "/" + name) if err != nil { return "", err } return string(b), nil } // ReadBinFile reads file as slice of bytes. func ReadBinFile(path string, name string) ([]byte, error) { b, err := os.ReadFile(path + "/" + name) if err != nil { return []byte{}, err } return b, nil } // WriteFileString writes string content to text file. func WriteFileString(path string, name string, value string) error { fo, err := os.Create(path + "/" + name) if err != nil { return err } if _, err := fo.WriteString(value); err != nil { return err } return nil } // CreateAndOpenFile creates file and open it foe recording. func CreateAndOpenFile(path string, fileName string, perms ...os.FileMode) (io.Writer, error) { perm := OwnerWritePerm if len(perms) != 0 { perm = perms[0] } filePath := path if filePath != "" { // Not dependent on the transmitted "/". filePath = strings.TrimRight(filePath, "/") + "/" if err := MkdirAll(path); err != nil { return nil, err } } filePath += fileName f, err := os.OpenFile(filePath, os.O_WRONLY|os.O_CREATE|os.O_APPEND, perm) if err != nil { return nil, err } return f, nil } // StatTimes gets file stats info. func StatTimes(name string) (atime, mtime, ctime time.Time, err error) { info, err := os.Stat(name) if err != nil { return } mtime = info.ModTime() stat := info.Sys().(*syscall.Stat_t) //nolint atime = time.Unix(stat.Atim.Sec, stat.Atim.Nsec) ctime = time.Unix(stat.Ctim.Sec, stat.Ctim.Nsec) return } // MkdirAll creates a directory named path, // along with any necessary parents, and returns nil, // or else returns an error. // The permission bits perm (before umask) are used for all // directories that MkdirAll creates. func MkdirAll(path string, perm ...os.FileMode) error { _, err := os.Stat(path) if os.IsNotExist(err) { p := os.ModePerm if len(perm) != 0 { p = perm[0] } return os.MkdirAll(path, p) } return err } // GetDirNamesInFolder returns slice with directory names in path. func GetDirNamesInFolder(path string) ([]string, error) { items, err := os.ReadDir(path) if err != nil { return make([]string, 0), fmt.Errorf("scan dirrectory: %w", err) } names := make([]string, 0, len(items)) for _, item := range items { if item.IsDir() { names = append(names, item.Name()) } } return names, nil } // GetFileNamesInFolder returns slice with file names in path. func GetFileNamesInFolder(path string) ([]string, error) { items, err := os.ReadDir(path) if err != nil { return make([]string, 0), fmt.Errorf("scan dirrectory: %w", err) } names := make([]string, 0, len(items)) for _, item := range items { if !item.IsDir() { names = append(names, item.Name()) } } return names, nil } // GetAbsPath returns an absolute path based on the input path and a default path. // It handles three cases for the input path: // 1. If the path is already absolute (starts with "/"), home-relative (starts with "~/"), // or relative to current directory (starts with "./"), it returns the path as-is. // 2. If the path is empty, it uses the defaultPath instead. // 3. For all other cases, it treats the path as relative to the executable's directory. // // Parameters: // - path: The input path to process (can be empty, absolute, or relative) // - defaultPath: The default path to use if input path is empty // // Returns: // - string: The resulting absolute path // - error: Any error that occurred while getting the executable's directory // // Example usage: // // absPath, err := GetAbsPath("config.json", "/etc/default/config.json") // // Returns "/path/to/executable/config.json" if no error func GetAbsPath(path, defaultPath string) (string, error) { // Use default path if input path is empty if path == "" { path = defaultPath } // Check if path is already in absolute, home-relative, or current-directory-relative form if path != "" && (string(path[0]) == "/" || strings.HasPrefix(path, "~/") || strings.HasPrefix(path, "./")) { return path, nil } // Get the absolute path of the directory containing the executable home, err := filepath.Abs(filepath.Dir(os.Args[0])) if err != nil { return "", err } // Combine executable directory with the relative path path = home + "/" + path return path, nil } // ClearDir removes all contents of the specified directory while preserving // the directory itself. It traverses the directory recursively, deleting all // files, subdirectories, and symbolic links. // // Parameters: // - dir - string path to the directory to be cleared // // Returns: // - error - nil on success, or any error encountered during the operation // // Behavior details: // - Preserves the original directory (only removes its contents) // - Handles nested directory structures recursively // - Follows symbolic links when deleting (removes link targets) // - Stops and returns on the first error encountered // - Returns nil if directory doesn't exist (consistent with os.RemoveAll) // // Example usage: // // err := ClearDir("/tmp/workdir") // if err != nil { // log.Fatal("Failed to clear directory:", err) // } // // Warning: // - This is a destructive operation - deleted files cannot be recovered // - The function will remove ALL contents without confirmation // - Ensure proper permissions exist for all files/subdirectories. func ClearDir(dir string) error { entries, err := os.ReadDir(dir) if err != nil { return err } for _, entry := range entries { if err := os.RemoveAll(filepath.Join(dir, entry.Name())); err != nil { return err } } return nil }
// Package httpclient provides a configurable HTTP client with sensible defaults // and helper methods for making HTTP requests. package httpclient import ( "context" "errors" "fmt" "io" "net" "net/http" ) var ( // ErrWrongStatusCode is returned when the server responds with an unexpected HTTP status code. ErrWrongStatusCode = errors.New("wrong status code") // ErrEmptyResponse is returned when the server response is nil and error is nil. ErrEmptyResponse = errors.New("empty response") ) // Client wraps http.Client to provide additional functionality and configuration. // It embeds the standard http.Client to expose all its methods while adding custom behavior. type Client struct { http.Client } // New creates and returns a new Client instance configured with the given settings. // The configuration includes timeouts, transport settings, and dialer parameters. func New(cfg *Config) *Client { cfg.SetDefaults() return &Client{ http.Client{ Timeout: cfg.Timeout, Transport: &http.Transport{ DialContext: (&net.Dialer{ Timeout: cfg.Dialer.Timeout, Deadline: cfg.Dialer.Deadline, FallbackDelay: cfg.Dialer.FallbackDelay, KeepAlive: cfg.Dialer.KeepAlive, }).DialContext, TLSHandshakeTimeout: cfg.TLSHandshakeTimeout, }, }, } } // SendRequest executes an HTTP request with the given method, URI, and optional body. // It handles the full request lifecycle including context cancellation, error handling, // and response processing. // // Parameters: // - ctx: Context for cancellation and timeout control // - method: HTTP method (GET, POST, etc.) // - uri: Target URL for the request // - body: Request body content (can be nil) // // Returns: // - Response body as byte slice if successful // - Error if request fails, response status is not 200 OK, or body read fails. func (c *Client) SendRequest(ctx context.Context, method, uri string, body io.Reader) ([]byte, error) { req, err := http.NewRequestWithContext(ctx, method, uri, body) if err != nil { return nil, err } res, err := c.Do(req) if err != nil { return nil, err } if res == nil { return nil, ErrEmptyResponse } defer res.Body.Close() if res.StatusCode != http.StatusOK { return nil, fmt.Errorf("%w: %d %s", ErrWrongStatusCode, res.StatusCode, res.Status) } resBody, err := io.ReadAll(res.Body) if err != nil { return nil, err } return resBody, nil }
package httpclient import "time" const ( // DefaultTimeout is the default timeout for general network operations. DefaultTimeout = 10 * time.Second // DefaultTLSHandshakeTimeout is the default timeout for TLS handshake operations. DefaultTLSHandshakeTimeout = 5 * time.Second // DefaultDialerTimeout is the default timeout for establishing new connections. DefaultDialerTimeout = 5 * time.Second ) // Config represents configuration settings for network connections. // It includes timeouts and dialer-specific parameters that can be // unmarshalled from either JSON or YAML formats. type Config struct { // Timeout specifies the maximum duration for the entire connection // process. Zero means no timeout. Timeout time.Duration `json:"timeout" yaml:"timeout"` // TLSHandshakeTimeout specifies the maximum duration to wait for // a TLS handshake to complete. Zero means no timeout. TLSHandshakeTimeout time.Duration `json:"tls_handshake_timeout" yaml:"tls_handshake_timeout"` // Dialer contains configuration specific to the connection dialer. Dialer struct { // Timeout is the maximum duration for dialing a connection. // This includes name resolution if required. Timeout time.Duration `json:"timeout" yaml:"timeout"` // Deadline specifies an absolute time point after which // dial operations will fail. // Zero means no deadline. Deadline time.Time `json:"deadline" yaml:"deadline"` // FallbackDelay specifies the length of time to wait before // spawning a fallback connection, when dual-stack IPv4/IPv6 // is enabled. FallbackDelay time.Duration `json:"fallback_delay" yaml:"fallback_delay"` // KeepAlive specifies the keep-alive period for network // connections. If zero, keep-alives are not enabled. KeepAlive time.Duration `json:"keep_alive" yaml:"keep_alive"` } `json:"dialer" yaml:"dialer"` } // SetDefaults initializes the configuration with default values for any unset fields. // If Timeout, TLSHandshakeTimeout, or Dialer.Timeout are zero (unset), they will be // populated with their respective default values. // // This ensures the configuration is always valid and prevents zero-values from causing // unexpected behavior in network operations. func (cfg *Config) SetDefaults() { // Set default general operation timeout if not specified. if cfg.Timeout == 0 { cfg.Timeout = DefaultTimeout } // Set default TLS handshake timeout if not specified. if cfg.TLSHandshakeTimeout == 0 { cfg.TLSHandshakeTimeout = DefaultTLSHandshakeTimeout } // Set default dialer connection timeout if not specified. if cfg.Dialer.Timeout == 0 { cfg.Dialer.Timeout = DefaultDialerTimeout } }
package problemdetails import ( "encoding/json" "errors" "github.com/labstack/echo/v4" "github.com/outdead/golibs/httpserver/validator" ) type Binder struct { logger Logger } // NewBinder creates and returns new Binder instance. func NewBinder(l Logger) *Binder { return &Binder{ logger: l, } } // Bind parses echo Context to data structure. Returns en error if data is invalid. func (b *Binder) Bind(c echo.Context, req interface{}) error { if err := c.Bind(req); err != nil { var t *json.UnmarshalTypeError if ok := errors.As(err, &t); ok { return validator.NewValidationError(t.Field, err.Error()) } // TODO: There is no way to get field names of int, time.Time, uuid.CartUUID if got incorrect data. return validator.NewValidationError("", err.Error()) } return nil } // BindAndValidate parses echo Context to data structure and validate received // data. Returns en error if data is invalid. func (b *Binder) BindAndValidate(c echo.Context, req interface{}) error { if err := b.Bind(c, req); err != nil { return err } return c.Validate(req) }
package problemdetails import ( "errors" "fmt" "net/http" "github.com/labstack/echo/v4" "github.com/outdead/golibs/httpserver/validator" ) // Error types for RFC-7807 Problem Details format. // See: https://datatracker.ietf.org/doc/html/rfc7807 const ( TypeValidation = "validation-error" TypeUnauthorized = "unauthorized" TypeForbidden = "forbidden" TypeNotFound = "data-not-found" TypeInternalServerError = "internal-server-error" TypeHTTP = "http-error" ) // Titles for RFC-7807 Problem Details format. const ( TitleValidation = "Your request parameters didn't validate." TitleUnauthorized = "Your request has not been applied." TitleForbidden = "Your request has been forbidden." TitleNotFound = "Not Found" TitleInternalServerError = "Internal Server Error" ) // Statuses for RFC-7807 Problem Details format. const ( StatusValidation = http.StatusBadRequest // 400 StatusUnauthorized = http.StatusUnauthorized // 401 StatusForbidden = http.StatusForbidden // 403 StatusNotFound = http.StatusNotFound // 404 StatusBusinessError = http.StatusUnprocessableEntity // 422 StatusInternalServerError = http.StatusInternalServerError // 500 ) // Error contains error in RFC-7807 Problem Details format. type Error struct { Type string `json:"type"` Title string `json:"title"` Status int `json:"status"` Detail string `json:"detail,omitempty"` // InvalidParams contains the explanation of errors in RFC-7807 format. InvalidParams validator.ValidationErrors `json:"invalid-params,omitempty"` //nolint } // Error represents an error condition, with the nil value representing no error. func (e *Error) Error() string { msg := fmt.Sprintf("%d %s: %s", e.Status, e.Type, e.Title) if len(e.InvalidParams) != 0 { msg += " - " + e.InvalidParams.Error() } return msg } func NewError(err error) *Error { body := NewInternalServerError() if ok := errors.As(err, &body); !ok { switch e := err.(type) { //nolint:errorlint, varnamelen // errors.As is too annoying in this case. case *validator.ValidationError: body = NewValidationError(*e) if e.Name == "" { body.Detail = e.Reason } case validator.ValidationErrors: body = NewValidationError(e...) case *echo.HTTPError: body = NewHTTPError(e) default: if errors.Is(err, validator.ErrNotFound) { body = NewNotFoundError(err) } } } return body } func NewValidationError(err ...validator.ValidationError) *Error { return &Error{ Type: TypeValidation, Title: TitleValidation, Status: StatusValidation, Detail: "", InvalidParams: err, } } func NewUnauthorizedError(err ...validator.ValidationError) *Error { return &Error{ Type: TypeUnauthorized, Title: TitleUnauthorized, Status: StatusUnauthorized, Detail: "", InvalidParams: err, } } func NewForbiddenError(err ...validator.ValidationError) *Error { return &Error{ Type: TypeForbidden, Title: TitleForbidden, Status: StatusForbidden, Detail: "", InvalidParams: err, } } func NewNotFoundError(err error) *Error { return &Error{ Type: TypeNotFound, Title: TitleNotFound, Status: StatusNotFound, Detail: err.Error(), } } func NewInternalServerError() *Error { return &Error{ Type: TypeInternalServerError, Title: TitleInternalServerError, Status: StatusInternalServerError, Detail: TitleInternalServerError, } } func NewHTTPError(err *echo.HTTPError) *Error { detail, _ := err.Message.(string) return &Error{ Type: TypeHTTP, Title: http.StatusText(err.Code), Status: err.Code, Detail: detail, } }
package problemdetails import ( "fmt" "net/http" "github.com/labstack/echo/v4" ) // Responder wraps on echo Context to be used on echo HTTP handlers to // construct an HTTP response. type Responder struct { logger Logger } // NewResponder creates and returns pointer to Responder. func NewResponder(l Logger) *Responder { return &Responder{l} } // ServeResult sends a JSON response with the result data. func (r *Responder) ServeResult(c echo.Context, i interface{}) error { return c.JSON(http.StatusOK, i) } // ServeError sends a JSON error response with status code. func (r *Responder) ServeError(c echo.Context, err error, logPrefix ...string) error { if err == nil { return nil } body := NewError(err) if body.Status == StatusInternalServerError { if len(logPrefix) != 0 { err = fmt.Errorf("%s: %w", logPrefix[0], err) } r.logger.Error(err.Error()) } return c.JSON(body.Status, body) }
package httpserver import ( "context" "errors" "fmt" "io" "net/http" "sync" "time" "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" "github.com/outdead/golibs/httpserver/problemdetails" "github.com/outdead/golibs/httpserver/validator" ) // ShutdownTimeOut is time to terminate queries when quit signal given. const ShutdownTimeOut = 10 * time.Second // ErrLockedServer returned on repeated call Close() the HTTP server. var ErrLockedServer = errors.New("http server is locked") // Logger describes Error and Info functions. type Logger interface { Infof(format string, args ...interface{}) Fatalf(format string, args ...interface{}) Debug(args ...interface{}) Error(args ...interface{}) Writer() io.Writer } // Binder represents Bind and BindAndValidate functions. type Binder interface { Bind(c echo.Context, req interface{}) error BindAndValidate(c echo.Context, req interface{}) error } // Responder represents ServeResult and ServeError functions. type Responder interface { ServeResult(c echo.Context, i interface{}) error ServeError(c echo.Context, err error, logPrefix ...string) error } // Option infects params to Server. type Option func(server *Server) // WithBinder injects Binder to Server. func WithBinder(binder Binder) Option { return func(s *Server) { s.Binder = binder } } // WithResponder injects Responder to Server. func WithResponder(responser Responder) Option { return func(s *Server) { s.Responder = responser } } func WithRecover(rec bool) Option { return func(s *Server) { s.recover = rec } } // A Server defines parameters for running an HTTP server. type Server struct { Binder Responder Echo *echo.Echo logger Logger errors chan error recover bool quit chan bool wg sync.WaitGroup } // NewServer allocates and returns a new Server. func NewServer(log Logger, errs chan error, options ...Option) *Server { s := Server{ logger: log, errors: errs, quit: make(chan bool), } for _, option := range options { option(&s) } if s.Binder == nil { s.Binder = problemdetails.NewBinder(s.logger) } if s.Responder == nil { s.Responder = problemdetails.NewResponder(s.logger) } s.Echo = s.newEcho() return &s } // Serve initializes HTTP Server and runs it on received port. func (s *Server) Serve(port string) { go func() { if err := s.Echo.Start(":" + port); err != nil && !errors.Is(err, http.ErrServerClosed) { // Report error if server is not closed by Echo#Shutdown. s.ReportError(fmt.Errorf("start http server: %w", err)) } }() s.logger.Infof("http server started on port %s", port) s.quit = make(chan bool) s.wg.Add(1) go func() { defer s.wg.Done() <-s.quit s.logger.Debug("stopping http server...") ctx, cancel := context.WithTimeout(context.Background(), ShutdownTimeOut) defer cancel() if s.Echo != nil { if err := s.Echo.Shutdown(ctx); err != nil { s.ReportError(fmt.Errorf("shutdown http server: %w", err)) } } }() } // ReportError publishes error to the errors channel. // if you do not read errors from the errors channel then after the channel // buffer overflows the application exits with a fatal level and the // os.Exit(1) exit code. func (s *Server) ReportError(err error) { if err != nil { select { case s.errors <- err: default: s.logger.Fatalf("http server error channel is locked: %s", err) } } } // Close stops HTTP Server. func (s *Server) Close() error { if s.quit == nil { return ErrLockedServer } select { case s.quit <- true: s.wg.Wait() s.logger.Debug("stop http server success") return nil default: return ErrLockedServer } } func (s *Server) newEcho() *echo.Echo { e := echo.New() e.Use(middleware.CORS()) if s.recover { e.Use(middleware.Recover()) } e.Validator = validator.New() e.Logger.SetOutput(s.logger.Writer()) e.HideBanner = true e.HidePort = true e.HTTPErrorHandler = s.httpErrorHandler return e } // httpErrorHandler customizes error response. // @source: https://github.com/labstack/echo/issues/325 func (s *Server) httpErrorHandler(err error, c echo.Context) { if err = s.ServeError(c, err); err != nil { s.ReportError(fmt.Errorf("error handle: %w", err)) } }
package validator import "errors" var ( // ErrNotFound is returned when data not found by identifier. ErrNotFound = errors.New("not found") // ErrDuplicateKey is returned when got duplicate key error from database. ErrDuplicateKey = errors.New("duplicate key") // ErrRequiredFieldMissed is returned when required param is empty in the request. ErrRequiredFieldMissed = errors.New("is required") ) // Delimiters for text representation of errors. const ( ErrorSeparator = "; " FieldSeparator = " : " ) // ValidationError contains field name and error message. type ValidationError struct { Name string `json:"name"` Reason string `json:"reason"` } // NewValidationError creates and returns pointer to ValidationError. func NewValidationError(field, msg string) *ValidationError { return &ValidationError{Name: field, Reason: msg} } // Error represents an error condition, with the nil value representing no error. func (ve *ValidationError) Error() string { return ve.Name + FieldSeparator + ve.Reason } // ValidationErrors is an array of ValidationError's for use in custom error // messages post validation. type ValidationErrors []ValidationError // Error represents an error condition, with the nil value representing no error. func (ves ValidationErrors) Error() string { var message string for i, ve := range ves { message += ve.Error() if i+1 != len(ves) { message += ErrorSeparator } } return message }
package validator import ( "errors" "fmt" "github.com/go-playground/validator/v10" ) // Validator is a wrapper for external validation package. Allows extending validation rules. type Validator struct { v *validator.Validate } // New returns a new instance of Validator with sane defaults. func New() *Validator { return &Validator{ v: validator.New(), } } // Validate implement Validator interface. func (v *Validator) Validate(i interface{}) error { err := v.v.Struct(i) if err == nil { return nil } var vErrs validator.ValidationErrors if !errors.As(err, &vErrs) { return err } fields := make(ValidationErrors, 0, len(vErrs)) for _, vErr := range vErrs { msg := fmt.Sprintf("invalid on '%s' rule", vErr.Tag()) valErr := NewValidationError(vErr.StructNamespace(), msg) fields = append(fields, *valErr) } return fields }
// Package jobticker provides a managed ticker for periodic job execution. // It wraps time.Ticker with start/stop controls and error handling. package jobticker import ( "sync" "time" ) // Logger describes the minimal logging interface required by the Ticker. // Implementations should provide Debug and Error logging capabilities. type Logger interface { Debug(args ...interface{}) Error(args ...interface{}) } // HandlerFunc defines the signature for functions that will be executed // on each tick interval. Returning an error will log the failure. type HandlerFunc func() error // Ticker manages a recurring background job with start/stop capabilities. // It ensures safe concurrent operation and proper resource cleanup. type Ticker struct { name string interval time.Duration handler HandlerFunc logger Logger metrics Metrics wg sync.WaitGroup stopTimeout time.Duration mu sync.Mutex quit chan bool started bool } // New creates a configured but unstarted Ticker instance. // Useful when you need to delay starting the ticker. // // Parameters: // // name - identifier for logging. // handler - function to execute on each interval. // interval - time between executions. // l - logger implementation. // // Returns: // // *Ticker - started ticker instance (call Close when done). func New(name string, handler HandlerFunc, interval time.Duration, l Logger, options ...Option) *Ticker { ticker := &Ticker{ name: name, interval: interval, handler: handler, logger: l, } for _, option := range options { option(ticker) } return ticker } // Start creates and immediately starts a new Ticker instance. // This is the preferred entry point for most use cases. // // Parameters: // // name - identifier for logging. // handler - function to execute on each interval. // interval - time between executions. // l - logger implementation. // // Returns: // // *Ticker - started ticker instance (call Close when done). func Start(name string, handler HandlerFunc, interval time.Duration, l Logger, options ...Option) *Ticker { ti := New(name, handler, interval, l, options...) ti.Start() return ti } // Start begins the ticker's execution loop in a new goroutine. // Safe to call multiple times (will log and ignore subsequent calls). func (t *Ticker) Start() { t.mu.Lock() defer t.mu.Unlock() if t.started { t.logger.Debug(t.name + ": already been started") return } t.quit = make(chan bool, 1) t.started = true t.wg.Add(1) go t.run() } // Stop initiates a graceful shutdown of the ticker. It: // 1. Sends a quit signal to the running goroutine // 2. Waits for the current handler to complete // 3. Implements timeout protection for stuck handlers // // Safe to call multiple times - will return immediately if: // - Ticker isn't running (!started) // - Quit channel is already full (shutdown in progress) // // Logs debug messages for all edge cases (already stopped, etc.). func (t *Ticker) Stop() { t.mu.Lock() defer t.mu.Unlock() if t.quit == nil || !t.started { t.logger.Debug(t.name + ": is not running") return } select { case t.quit <- true: if t.stopTimeout == 0 { t.wg.Wait() // waiting for goroutines return } done := make(chan struct{}) go func() { t.wg.Wait() // waiting for goroutines close(done) }() select { case <-done: case <-time.After(t.stopTimeout): t.logger.Error(t.name + ": forced shutdown due to timeout") } default: t.logger.Debug(t.name + ": close already been called") } } // IsRunning safely checks the ticker's current state. // Returns: // // true - if ticker is actively running // false - if stopped or never started // // Thread-safe atomic read - safe to call from any goroutine. func (t *Ticker) IsRunning() bool { t.mu.Lock() defer t.mu.Unlock() return t.started } // run is the main ticker event loop running in a goroutine. // Handles: // - Interval tick execution // - Graceful shutdown signals // - Resource cleanup on exit // // Note: Started by Start(), stopped by Stop(). // Uses defer for guaranteed state cleanup. func (t *Ticker) run() { defer func() { t.started = false t.wg.Done() }() ticker := time.NewTicker(t.interval) defer ticker.Stop() for { select { case now := <-ticker.C: t.executeHandler(now) case <-t.quit: t.logger.Debug(t.name + ": quit...") return } } } // executeHandler safely runs the user-provided handler function. // Provides: // - Panic recovery // - Error logging // - Performance metrics collection // // Parameters: // // startedAt - timestamp when handler execution began. func (t *Ticker) executeHandler(startedAt time.Time) { defer func() { if r := recover(); r != nil { t.logger.Error(t.name+": handler panic:", r) } }() err := t.handler() if err != nil { t.logger.Error(t.name+":", err) } if t.metrics != nil { t.metrics.Observe(t.name, startedAt, time.Since(startedAt), err) } }
package jobticker import "time" type Option func(t *Ticker) func WithStopTimeout(timeout time.Duration) Option { return func(t *Ticker) { t.stopTimeout = timeout } } func WithMetrics(metrics Metrics) Option { return func(t *Ticker) { t.metrics = metrics } }
package logger import ( "fmt" "io" "time" "github.com/bwmarrin/discordgo" "github.com/outdead/discordbotrus" "github.com/outdead/golibs/files" "github.com/sirupsen/logrus" ) // Hook includes logrus.Hook interface and describes Close method. type Hook interface { logrus.Hook Close() error } // Logger wraps logrus.Logger with additional configuration and methods. type Logger struct { *logrus.Logger config Config discordHook Hook discordSession *discordgo.Session } // New creates and returns a new Logger instance with default JSON formatter. // The returned logger has no output set by default (uses stderr). func New() *Logger { logger := &Logger{ Logger: logrus.New(), } logger.Formatter = new(logrus.JSONFormatter) return logger } // AddOutput adds additional output writer to the logger. // This allows writing logs to multiple destinations simultaneously. // The new writer will be used in addition to any existing outputs. func (log *Logger) AddOutput(w io.Writer) { log.Out = io.MultiWriter(log.Out, w) } // SetConfig applies a new configuration to the Logger and configures all required outputs. // It handles the following configuration aspects: // - Core logger settings (log level) // - File output configuration (if specified) // - Discord hook setup (if configured) // // Parameters: // - cfg: Pointer to Config struct containing all logger settings. Must not be nil. // - options: Optional variadic list of Option functions to modify logger behavior. // // Returns: // - error: Returns ErrInvalidConfig if cfg is nil or contains invalid settings. // Returns file creation errors if file logging is configured. // Returns Discord hook initialization errors if Discord logging is configured. // // Usage: // // err := logger.SetConfig(&Config{ // Level: "info", // File: FileConfig{Path: "/var/log", Layout: "2006-01-02.log"}, // }) // if err != nil { // // handle error // } // // Notes: // - This function is not concurrent-safe and should not be called while the logger is in use. // - Replaces all previous configuration when called. // - File outputs are created immediately if specified in config. // - Discord hooks are initialized immediately if configured. func (log *Logger) SetConfig(cfg *Config, options ...Option) error { if cfg == nil { return ErrInvalidConfig } log.config = *cfg for _, option := range options { option(log) } if cfg.Level != "" { logrusLevel, err := logrus.ParseLevel(cfg.Level) if err != nil { return fmt.Errorf("%w: %w", ErrInvalidConfig, err) } log.Level = logrusLevel } if log.config.File.Layout != "" { file, err := files.CreateAndOpenFile(log.config.File.Path, time.Now().Format(log.config.File.Layout)) if err != nil { return fmt.Errorf("create logger file hook: %w", err) } log.AddOutput(file) } if cfg.Discord.ChannelID != "" { var err error if cfg.Discord.Token != "" { log.discordHook, err = discordbotrus.New(&cfg.Discord) } else { log.discordHook, err = discordbotrus.New(&cfg.Discord, discordbotrus.WithSession(log.discordSession)) } if err != nil { return fmt.Errorf("create logrus discord hook error: %w", err) } log.AddHook(log.discordHook) } return nil } // Writer returns the current writer used by the logger. // This can be used to redirect the logger's output or integrate with other systems. func (log *Logger) Writer() io.Writer { return log.Logger.Writer() } // Close implements the io.Closer interface for the Logger. // Currently, it doesn't perform any cleanup but is provided for future compatibility. func (log *Logger) Close() error { if log.discordHook != nil { return log.discordHook.Close() } return nil }
package logger import ( "github.com/bwmarrin/discordgo" ) type Option func(log *Logger) func WithDiscordSession(session *discordgo.Session) Option { return func(log *Logger) { log.discordSession = session } }
package random import ( crand "crypto/rand" "math/big" mrand "math/rand" ) func IntWeak(from, to int) int { return from + mrand.Intn(to-from+1) //nolint:gosec // I'm ok with unsecure here. } func IntStrong(from, to int) int { num, _ := crand.Int(crand.Reader, big.NewInt(int64(to-from+1))) return from + int(num.Int64()) }
package times import ( "fmt" "time" ) // FmtDuration formats a time.Duration into a human-readable string with optional seconds. // The duration is rounded to the nearest minute (or second) before formatting. // // The function provides two formatting modes: // - Default: "[-]XXhXXm" format (hours and minutes) // - With seconds: "[-]XXhXXmXXs" format (when secs[0] is true) // // Parameters: // - duration: The time duration to format (can be negative) // - secs: Optional boolean flag to include seconds in output // // Returns: // - Formatted duration string with leading zero padding and optional sign func FmtDuration(duration time.Duration, withSeconds ...bool) string { sign := "" showSeconds := len(withSeconds) > 0 && withSeconds[0] if showSeconds { duration = duration.Round(time.Second) } else { duration = duration.Round(time.Minute) } if negative := duration < 0; negative { duration = -duration sign = "-" } hours := duration / time.Hour duration -= hours * time.Hour //nolint:durationcheck // Intentional duration math - safe conversion minutes := duration / time.Minute if !showSeconds { return fmt.Sprintf("%s%02dh%02dm", sign, hours, minutes) } duration -= minutes * time.Minute //nolint:durationcheck // Intentional duration math - safe conversion seconds := duration / time.Second return fmt.Sprintf("%s%02dh%02dm%02ds", sign, hours, minutes, seconds) }
package times import ( "errors" "fmt" "time" ) var ErrInvalidTimeFormat = errors.New("invalid time format") // ParseOnlyTime parses a time string into hours, minutes, and seconds components. // It supports multiple time formats with flexible parsing: // - "HH:MM:SS" (full time format) // - "HH:MM" (hours and minutes, seconds default to 0) // - "HH" (only hours, minutes and seconds default to 0) // // The function attempts each format in order and returns the first successful match. // All returned time components are unsigned integers. // // Parameters: // // strtime - string containing the time to parse (e.g., "14:30:00", "09:45", "23") // // Returns: // // hour - parsed hour (0-23) // minute - parsed minute (0-59) // second - parsed second (0-59) // err - error if parsing fails, wrapping ErrInvalidTimeFormat with the invalid input // // Example usage: // // h, m, s, err := ParseOnlyTime("08:30:15") // returns 8, 30, 15, nil // h, m, s, err := ParseOnlyTime("12:45") // returns 12, 45, 0, nil // h, m, s, err := ParseOnlyTime("invalid") // returns 0, 0, 0, error. func ParseOnlyTime(strtime string) (hour, minute, second uint, err error) { formats := []string{ "15:04:05", // Full format (HH:MM:SS) "15:04", // Hours and minutes (HH:MM) "15", // Only hours (HH) } for _, layout := range formats { if t, err := time.Parse(layout, strtime); err == nil { // #nosec G115 - Conversion is safe because time.Time methods // always return valid ranges (Hour: 0-23, Minute/Second: 0-59) return uint(t.Hour()), uint(t.Minute()), uint(t.Second()), nil } } return 0, 0, 0, fmt.Errorf("%w: %q", ErrInvalidTimeFormat, strtime) } // ParseOnlyTimeSafe tries to parse time string, returns zero values (0,0,0) on failure. // Silent version that never fails - ideal for non-critical time parsing. func ParseOnlyTimeSafe(strtime string) (hour, minute, second uint) { hour, minute, second, _ = ParseOnlyTime(strtime) return }
// Package times provides custom time handling utilities. package times import ( "errors" "fmt" "time" ) // DateTimeLayout defines the standard format for date-time strings // Uses Go's reference time format: Mon Jan 2 15:04:05 MST 2006. const DateTimeLayout = "2006-01-02 15:04:05" // ErrUndefinedDateTime is returned when an unsupported type is encountered. var ErrUndefinedDateTime = errors.New("undefined datetime") // SQLTime is a custom type that wraps time.Time to provide custom scanning behavior. type SQLTime time.Time // Scan implements the sql.Scanner interface for SQLTime // It converts various input types into a SQLTime value. func (t *SQLTime) Scan(v interface{}) error { if v == nil { return nil } switch value := v.(type) { case time.Time: // Direct assignment if input is time.Time *t = SQLTime(value) case string: // Parse string using the defined layout vt, err := time.Parse(DateTimeLayout, value) if err != nil { return err } *t = SQLTime(vt) case []byte: // Handle byte slices (common in database drivers) vt, err := time.Parse(DateTimeLayout, string(value)) if err != nil { return err } *t = SQLTime(vt) default: // Return error for unsupported types return fmt.Errorf("%w: %v", ErrUndefinedDateTime, value) } return nil }
// Package ttlcounter provides a thread-safe TTL counter. // All methods are safe for concurrent use by multiple goroutines. // Could be useful for scenarios like rate limiting, tracking recent // activity, or any case where you need to count events but only care // about recent ones. package ttlcounter import ( "sync" "time" ) const ( // DefaultTTL is the default time-to-live in seconds for counter items. DefaultTTL = 1 * time.Second // DefaultVacuumInterval is the default interval for automatic cleanup of expired items. DefaultVacuumInterval = 1 * time.Second ) // Item represents a counter item with a value and last access timestamp. type Item struct { value int // The current counter value access int64 // Unix timestamp of last access } func (item *Item) Expired(ttl time.Duration) bool { return item.access+ttl.Nanoseconds() <= time.Now().UnixNano() } // Counter is a TTL-based counter that automatically expires old entries. type Counter struct { mu sync.Mutex items map[string]*Item ttl time.Duration stop chan struct{} stopped sync.Once } // New creates a new Counter with the specified TTL (in seconds) // and starts a background goroutine to periodically clean up expired items // If ttl <= 0, DefaultTTL will be used. func New(ttl time.Duration) *Counter { if ttl <= 0 { ttl = DefaultTTL } counter := &Counter{ items: make(map[string]*Item), ttl: ttl, stop: make(chan struct{}), } // Start a background goroutine to clean up expired items every second go counter.vacuumLoop() return counter } // Len returns the current number of items in the counter. func (c *Counter) Len() int { c.mu.Lock() defer c.mu.Unlock() return len(c.items) } // Keys returns a slice of all existing keys in the counter // The order of keys is not guaranteed. func (c *Counter) Keys() []string { c.mu.Lock() defer c.mu.Unlock() keys := make([]string, 0, len(c.items)) for key := range c.items { keys = append(keys, key) } return keys } // Inc increments the counter for the specified key // If the key doesn't exist, it creates a new counter starting at 1 // Updates the last access time to current time. func (c *Counter) Inc(key string) { c.mu.Lock() defer c.mu.Unlock() now := time.Now() item, ok := c.items[key] if !ok { item = &Item{} c.items[key] = item } item.value++ item.access = now.UnixNano() } // Get returns the current value for the specified key // Returns 0 if the key doesn't exist // Note: This doesn't update the last access time. func (c *Counter) Get(key string) int { c.mu.Lock() defer c.mu.Unlock() var value int if item, ok := c.items[key]; ok { if item.Expired(c.ttl) { return 0 } value = item.value } return value } // Touch returns the current value for the specified key and resets last access time. // Returns 0 if the key doesn't exist. func (c *Counter) Touch(key string) int { c.mu.Lock() defer c.mu.Unlock() now := time.Now() var value int if item, ok := c.items[key]; ok { if item.Expired(c.ttl) { return 0 } value = item.value item.access = now.UnixNano() } return value } // Del removes the specified key from the counter. // If the key doesn't exist, nothing happens. func (c *Counter) Del(key string) { c.mu.Lock() defer c.mu.Unlock() delete(c.items, key) } // Expire returns how many seconds until the key expires // Returns a negative number if the key is already expired // Returns 0 if the key doesn't exist. func (c *Counter) Expire(key string) time.Duration { c.mu.Lock() defer c.mu.Unlock() now := time.Now().UnixNano() if item, ok := c.items[key]; ok { remaining := item.access + c.ttl.Nanoseconds() - now if remaining > 0 { return time.Duration(remaining) } return 0 } return 0 } // Vacuum cleans up expired items based on the provided current time // Called automatically by the background goroutine. func (c *Counter) Vacuum() { c.mu.Lock() defer c.mu.Unlock() for key, item := range c.items { if item.Expired(c.ttl) { delete(c.items, key) } } } // TTL returns the configured time-to-live in seconds. func (c *Counter) TTL() time.Duration { return c.ttl } // SetTTL updates the time-to-live for counter items // If ttl <= 0, DefaultTTL will be used. func (c *Counter) SetTTL(ttl time.Duration) { c.mu.Lock() defer c.mu.Unlock() if ttl <= 0 { ttl = DefaultTTL } c.ttl = ttl } // Close stops the background cleanup goroutine // Safe to call multiple times. func (c *Counter) Close() { c.stopped.Do(func() { close(c.stop) }) } // vacuumLoop runs in a goroutine and periodically calls Vacuum // until the counter is closed. func (c *Counter) vacuumLoop() { ticker := time.NewTicker(DefaultVacuumInterval) defer ticker.Stop() for { select { case <-ticker.C: c.Vacuum() case <-c.stop: return } } }