Skip to content
Merged
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
76 changes: 68 additions & 8 deletions contract/request/param.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
package request

import "net/http"
import (
"fmt"
"net/http"
"strconv"
)

// Param retrieves a path parameter value by name from the HTTP request.
// This uses Go's built-in PathValue method which extracts values from
// URL path patterns like "/users/{id}" where {id} is the parameter name.
// Param retrieves a path parameter value by name from the
// HTTP request. This uses Go's built-in PathValue method
// which extracts values from URL path patterns like
// "/users/{id}" where {id} is the parameter name.
//
// Parameters:
// - r: The HTTP request containing the path parameters
Expand All @@ -15,10 +20,11 @@ func Param(r *http.Request, name string) string {
return r.PathValue(name)
}

// ParamOr retrieves a path parameter value by name, returning a default
// value if the parameter doesn't exist or is empty. This is useful for
// providing fallback values when path parameters are optional or when
// you want to handle missing parameters gracefully.
// ParamOr retrieves a path parameter value by name,
// returning a default value if the parameter doesn't exist
// or is empty. This is useful for providing fallback values
// when path parameters are optional or when you want to
// handle missing parameters gracefully.
//
// Parameters:
// - r: The HTTP request containing the path parameters
Expand All @@ -33,3 +39,57 @@ func ParamOr(r *http.Request, name string, fallback string) string {

return fallback
}

// ParamInt retrieves a path parameter by name and parses
// it as an integer. This prevents injection via malformed
// numeric path parameters by validating that the value is
// a well-formed integer.
//
// Parameters:
// - r: The HTTP request containing the path parameters
// - k: The name of the path parameter to parse
//
// Returns the parsed integer value and any parsing error.
// Returns an error if the parameter is empty or is not
// a valid integer string.
func ParamInt(r *http.Request, k string) (int, error) {
raw := Param(r, k)

if raw == "" {
return 0, fmt.Errorf("path parameter %q is empty", k)
}

value, err := strconv.Atoi(raw)

if err != nil {
return 0, fmt.Errorf(
"path parameter %q is not a valid integer: %w",
k, err,
)
}

return value, nil
}

// ParamIntOr retrieves a path parameter by name and parses
// it as an integer, returning the provided fallback value
// if the parameter is empty or cannot be parsed. This is
// useful when a numeric path parameter is optional or when
// a sensible default exists.
//
// Parameters:
// - r: The HTTP request containing the path parameters
// - k: The name of the path parameter to parse
// - d: The fallback value to return on failure
//
// Returns the parsed integer if valid, otherwise the
// fallback value.
func ParamIntOr(r *http.Request, k string, d int) int {
value, err := ParamInt(r, k)

if err != nil {
return d
}

return value
}
86 changes: 75 additions & 11 deletions contract/request/query.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
package request

import "net/http"
import (
"fmt"
"net/http"
"strconv"
)

// Query retrieves a query parameter value by name from the HTTP request URL.
// This extracts values from the URL query string like "?name=value&other=test"
// where the parameter name matches the provided key.
// Query retrieves a query parameter value by name from the
// HTTP request URL. This extracts values from the URL query
// string like "?name=value&other=test" where the parameter
// name matches the provided key.
//
// Parameters:
// - r: The HTTP request containing the URL with query parameters
Expand All @@ -15,9 +20,10 @@ func Query(r *http.Request, name string) string {
return r.URL.Query().Get(name)
}

// HasQuery checks if a query parameter exists in the HTTP request URL,
// regardless of its value. This is useful for distinguishing between
// a parameter that doesn't exist and one that exists but has an empty value.
// HasQuery checks if a query parameter exists in the HTTP
// request URL, regardless of its value. This is useful for
// distinguishing between a parameter that doesn't exist and
// one that exists but has an empty value.
//
// Parameters:
// - r: The HTTP request containing the URL with query parameters
Expand All @@ -28,10 +34,12 @@ func HasQuery(r *http.Request, name string) bool {
return r.URL.Query().Has(name)
}

// QueryOr retrieves a query parameter value by name, returning a default
// value if the parameter doesn't exist. Note that if the parameter exists
// but has an empty value, the empty value is returned, not the default.
// This is useful for providing fallback values for optional parameters.
// QueryOr retrieves a query parameter value by name,
// returning a default value if the parameter doesn't exist.
// Note that if the parameter exists but has an empty value,
// the empty value is returned, not the default. This is
// useful for providing fallback values for optional
// parameters.
//
// Parameters:
// - r: The HTTP request containing the URL with query parameters
Expand All @@ -46,3 +54,59 @@ func QueryOr(r *http.Request, name string, fallback string) string {

return fallback
}

// QueryInt retrieves a query parameter by name and parses
// it as an integer. This prevents injection via malformed
// numeric query parameters by validating that the value is
// a well-formed integer.
//
// Parameters:
// - r: The HTTP request containing the URL with query
// parameters
// - k: The name of the query parameter to parse
//
// Returns the parsed integer value and any parsing error.
// Returns an error if the parameter is missing or is not
// a valid integer string.
func QueryInt(r *http.Request, k string) (int, error) {
raw := Query(r, k)

if raw == "" {
return 0, fmt.Errorf("query parameter %q is empty", k)
}

value, err := strconv.Atoi(raw)

if err != nil {
return 0, fmt.Errorf(
"query parameter %q is not a valid integer: %w",
k, err,
)
}

return value, nil
}

// QueryIntOr retrieves a query parameter by name and parses
// it as an integer, returning the provided fallback value
// if the parameter is missing or cannot be parsed. This is
// useful when a numeric query parameter is optional or when
// a sensible default exists (e.g., pagination page numbers).
//
// Parameters:
// - r: The HTTP request containing the URL with query
// parameters
// - k: The name of the query parameter to parse
// - d: The fallback value to return on failure
//
// Returns the parsed integer if valid, otherwise the
// fallback value.
func QueryIntOr(r *http.Request, k string, d int) int {
value, err := QueryInt(r, k)

if err != nil {
return d
}

return value
}
21 changes: 14 additions & 7 deletions contract/response/static.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,21 @@ import (
"text/template"
)

// Raw writes raw byte data to the response writer with the specified status code.
// It does not set any Content-Type header, allowing the caller full control over
// the response format. This is the most basic response function that other
// response functions build upon.
// Raw writes raw byte data to the response writer with
// the specified status code. If no Content-Type header has
// been explicitly set on the response writer, it defaults
// to application/octet-stream to ensure a safe default
// content type is always present.
//
// Parameters:
// - w: The HTTP response writer
// - status: The HTTP status code to set
// - data: The raw byte data to write to the response
func Raw(w http.ResponseWriter, status int, data []byte) error {
if w.Header().Get("Content-Type") == "" {
w.Header().Set("Content-Type", "application/octet-stream")
}

w.WriteHeader(status)

_, err := w.Write(data)
Expand Down Expand Up @@ -88,9 +93,11 @@ func StringTemplate(w http.ResponseWriter, status int, tmpl template.Template, d
return tmpl.Execute(w, data)
}

// HTML writes HTML content to the response writer with the appropriate
// Content-Type header for HTML (text/html). This is used for serving
// static HTML content or HTML strings that have been pre-generated.
// HTML writes HTML content to the response writer with the
// appropriate Content-Type header for HTML content
// (text/html; charset=utf-8). The charset is explicitly
// set to prevent XSS attacks via charset sniffing in
// older browsers that may otherwise guess the encoding.
//
// Parameters:
// - w: The HTTP response writer
Expand Down
17 changes: 13 additions & 4 deletions contract/response/stream.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,20 @@ import (
// implement the http.Flusher interface, which is required for streaming responses.
var ErrNonFlushableWriter = errors.New("non-flushable response writer")

// Stream sends data from a channel to an HTTP client using streaming.
// It flushes data as it becomes available and returns when the channel
// is closed or the request context is canceled. Returns
// [ErrNonFlushableWriter] if the writer does not support flushing.
// Stream sends data from a channel to an HTTP client using
// streaming. It flushes data as it becomes available and
// returns when the channel is closed or the request context
// is canceled. Returns [ErrNonFlushableWriter] if the writer
// does not support flushing.
//
// If no Content-Type header has been explicitly set on the
// response writer, it defaults to application/octet-stream
// to ensure a safe default content type is always present.
func Stream(w http.ResponseWriter, r *http.Request, c <-chan []byte) error {
if w.Header().Get("Content-Type") == "" {
w.Header().Set("Content-Type", "application/octet-stream")
}

w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")

Expand Down
14 changes: 7 additions & 7 deletions framework/cache/memory.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package cache

import (
"context"
"fmt"
"sync"
"time"

Expand All @@ -28,14 +27,15 @@ func NewMemory(expiration time.Duration, cleanup time.Duration) *Memory {
}
}

// Get retrieves the value for the given key from the in-memory store.
// Returns contract.ErrCacheKeyNotFound wrapped with the key name
// when the key does not exist or has expired.
// Get retrieves the value for the given key from the in-memory
// store. Returns contract.ErrCacheKeyNotFound when the key does
// not exist or has expired. The key is intentionally omitted from
// the error to prevent cache key enumeration attacks.
func (memory *Memory) Get(_ context.Context, key string) (any, error) {
val, found := memory.store.Get(key)

if !found {
return nil, fmt.Errorf("%w: %s", contract.ErrCacheKeyNotFound, key)
return nil, contract.ErrCacheKeyNotFound
}

return val, nil
Expand Down Expand Up @@ -98,7 +98,7 @@ func (memory *Memory) Increment(ctx context.Context, key string, by int64) (int6
defer memory.mux.Unlock()

if found, _ := memory.Has(ctx, key); !found {
return 0, fmt.Errorf("%w: %s", contract.ErrCacheKeyNotFound, key)
return 0, contract.ErrCacheKeyNotFound
}

return memory.store.IncrementInt64(key, by)
Expand All @@ -112,7 +112,7 @@ func (memory *Memory) Decrement(ctx context.Context, key string, by int64) (int6
defer memory.mux.Unlock()

if found, _ := memory.Has(ctx, key); !found {
return 0, fmt.Errorf("%w: %s", contract.ErrCacheKeyNotFound, key)
return 0, contract.ErrCacheKeyNotFound
}

return memory.store.DecrementInt64(key, by)
Expand Down
11 changes: 6 additions & 5 deletions framework/cache/redis.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"time"

"github.com/redis/go-redis/v9"
Expand Down Expand Up @@ -32,13 +31,15 @@ func NewRedisFrom(client *redis.Client) *RedisClient {
return (*RedisClient)(client)
}

// Get retrieves a value by key. Returns contract.ErrCacheKeyNotFound
// wrapped with the key name when the key does not exist.
// Get retrieves a value by key. Returns
// contract.ErrCacheKeyNotFound when the key does not exist.
// The key is intentionally omitted from the error to prevent
// cache key enumeration attacks.
func (client *RedisClient) Get(ctx context.Context, key string) (any, error) {
value, err := (*redis.Client)(client).Get(ctx, key).Result()

if errors.Is(err, redis.Nil) {
return nil, fmt.Errorf("%w: %s", contract.ErrCacheKeyNotFound, key)
return nil, contract.ErrCacheKeyNotFound
}

if err != nil {
Expand Down Expand Up @@ -76,7 +77,7 @@ func (client *RedisClient) Pull(ctx context.Context, key string) (value any, err
encoded, err := (*redis.Client)(client).GetDel(ctx, key).Result()

if errors.Is(err, redis.Nil) {
return nil, fmt.Errorf("%w: %s", contract.ErrCacheKeyNotFound, key)
return nil, contract.ErrCacheKeyNotFound
}

if err != nil {
Expand Down
Loading
Loading