Compare commits

...
11 Commits
Author SHA1 Message Date
Hein a220338eea feat(spectypes): add CIString, LCString, and UCString types with tests
Tests / Unit Tests (push) Failing after 28s
Tests / Integration Tests (push) Failing after 30s
Build , Vet Test, and Lint / Build (push) Successful in 1m8s
Build , Vet Test, and Lint / Lint Code (push) Successful in 1m18s
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Successful in 1m35s
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Successful in 1m36s
2026-09-21 14:06:33 +02:00
Hein 20c67166d0 fix(handler): support implicit updates from request body 2026-09-21 11:18:10 +02:00
Hein 749dad4ed1 fix(quickproxy): ensure request body is preserved on fallback
Tests / Unit Tests (push) Failing after 26s
Tests / Integration Tests (push) Failing after 41s
Build , Vet Test, and Lint / Build (push) Successful in 4m26s
Build , Vet Test, and Lint / Lint Code (push) Successful in 4m58s
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Successful in 5m1s
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Successful in 5m3s
2026-09-21 09:21:58 +02:00
warkanum d6c5740f9c fix(handler): add operation type to hook context
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Failing after 1s
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Failing after 1s
Build , Vet Test, and Lint / Lint Code (push) Failing after 1s
Build , Vet Test, and Lint / Build (push) Failing after 1s
Tests / Unit Tests (push) Failing after 0s
Tests / Integration Tests (push) Failing after 10s
2026-09-20 16:12:44 +02:00
warkanum 817b781c88 fix(security): skip loading security rules if disabled 2026-09-20 15:52:25 +02:00
warkanum 87eaa9e18c fix(security): skip row security enforcement for specific operations
* Add ShouldSkipRowSecurity function to determine when to bypass row security
* Update ApplyRowSecurity to utilize operation context for enforcement
2026-09-20 15:51:02 +02:00
Hein 4f6878099b fix(quickproxy): reject Exclude entries outside their rule's URLPrefix
Tests / Unit Tests (push) Failing after 5s
Tests / Integration Tests (push) Failing after 23s
Build , Vet Test, and Lint / Build (push) Successful in 52s
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Successful in 55s
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Successful in 56s
Build , Vet Test, and Lint / Lint Code (push) Successful in 1m4s
An Exclude entry only ever matches requests that already fall under
its rule's URLPrefix, so one written without that prefix (e.g.
"/health" on a rule for "/api") silently never triggered. Validate
that each Exclude entry itself starts with the rule's URLPrefix,
failing NewService instead of accepting a no-op config.
2026-09-17 11:40:49 +02:00
Hein 0d8b136b91 feat(quickproxy): support per-rule Exclude path prefixes
Rule.Exclude lists path prefixes that should never be proxied by that
rule, even though they fall under its URLPrefix. A request matching an
Exclude prefix is treated as a non-match for that rule: matching
continues against other configured rules, falling back to the
caller-supplied handler if none apply. Lets a catch-all "/" rule proxy
everything except carved-out paths like "/health".
2026-09-17 11:38:02 +02:00
Hein 6de9be0ae7 chore(proxy): remove outdated proxy documentation 2026-09-17 11:09:14 +02:00
Hein 82e923b16e fix(quickproxy): use http.NotFoundHandler per golangci-lint gocritic 2026-09-17 11:08:12 +02:00
Hein 9a664593f0 feat(quickproxy): add reverse-proxy-with-static-fallback package
Adds pkg/server/quickproxy: longest-prefix rule matching over
net/http/httputil.ReverseProxy, falling back to a caller-supplied
handler when the upstream is unreachable or returns 404. Any other
upstream response streams through unchanged. All HTTP methods are
proxied, with a configurable global dial/response-header timeout
(quickproxy.WithTimeout, default 10s).

GoCore-side wiring (config field, webserver2/proxy.go, server.go
route ordering) is tracked separately in that repo.
2026-09-17 11:07:53 +02:00
8 changed files with 1442 additions and 121 deletions
+113 -102
View File
@@ -306,15 +306,16 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
txErr := h.db.RunInTransaction(ctx, func(tx common.Database) error {
hookCtx := &HookContext{
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
Model: model,
Options: options,
ID: id,
Writer: w,
Tx: tx,
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
Model: model,
Operation: "read",
Options: options,
ID: id,
Writer: w,
Tx: tx,
}
if err := h.hooks.ExecuteBeforeOp(BeforeRead, hookCtx); err != nil {
statusCode, errCode, errMsg = http.StatusInternalServerError, "hook_error", "BeforeRead hook failed"
@@ -722,15 +723,16 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
var nestedResult *common.ProcessResult
err := h.db.RunInTransaction(ctx, func(tx common.Database) error {
hookCtx := &HookContext{
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
Model: model,
Options: options,
Data: v,
Writer: w,
Tx: tx,
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
Model: model,
Operation: "create",
Options: options,
Data: v,
Writer: w,
Tx: tx,
}
if err := h.hooks.ExecuteBeforeOp(BeforeCreate, hookCtx); err != nil {
return fmt.Errorf("BeforeCreate hook failed: %w", err)
@@ -769,15 +771,16 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
var responseData interface{} = v
err := h.db.RunInTransaction(ctx, func(tx common.Database) error {
hookCtx := &HookContext{
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
Model: model,
Options: options,
Data: v,
Writer: w,
Tx: tx,
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
Model: model,
Operation: "create",
Options: options,
Data: v,
Writer: w,
Tx: tx,
}
if err := h.hooks.ExecuteBeforeOp(BeforeCreate, hookCtx); err != nil {
return fmt.Errorf("BeforeCreate hook failed: %w", err)
@@ -851,15 +854,16 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
for _, item := range v {
hookCtx := &HookContext{
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
Model: model,
Options: options,
Data: item,
Writer: w,
Tx: tx,
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
Model: model,
Operation: "create",
Options: options,
Data: item,
Writer: w,
Tx: tx,
}
if err := h.hooks.ExecuteBeforeOp(BeforeCreate, hookCtx); err != nil {
return fmt.Errorf("BeforeCreate hook failed: %w", err)
@@ -898,15 +902,16 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
err := h.db.RunInTransaction(ctx, func(tx common.Database) error {
for _, item := range v {
hookCtx := &HookContext{
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
Model: model,
Options: options,
Data: item,
Writer: w,
Tx: tx,
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
Model: model,
Operation: "create",
Options: options,
Data: item,
Writer: w,
Tx: tx,
}
if err := h.hooks.ExecuteBeforeOp(BeforeCreate, hookCtx); err != nil {
return fmt.Errorf("BeforeCreate hook failed: %w", err)
@@ -982,15 +987,16 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
for _, item := range v {
if itemMap, ok := item.(map[string]interface{}); ok {
hookCtx := &HookContext{
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
Model: model,
Options: options,
Data: itemMap,
Writer: w,
Tx: tx,
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
Model: model,
Operation: "create",
Options: options,
Data: itemMap,
Writer: w,
Tx: tx,
}
if err := h.hooks.ExecuteBeforeOp(BeforeCreate, hookCtx); err != nil {
return fmt.Errorf("BeforeCreate hook failed: %w", err)
@@ -1035,15 +1041,16 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
}
hookCtx := &HookContext{
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
Model: model,
Options: options,
Data: itemMap,
Writer: w,
Tx: tx,
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
Model: model,
Operation: "create",
Options: options,
Data: itemMap,
Writer: w,
Tx: tx,
}
if err := h.hooks.ExecuteBeforeOp(BeforeCreate, hookCtx); err != nil {
return fmt.Errorf("BeforeCreate hook failed: %w", err)
@@ -1166,16 +1173,17 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, url
// they must run before the existence-check select so that select is
// also subject to RLS on this connection/transaction.
hookCtx := &HookContext{
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
Model: model,
Options: options,
ID: urlID,
Data: updates,
Writer: w,
Tx: tx,
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
Model: model,
Operation: "update",
Options: options,
ID: urlID,
Data: updates,
Writer: w,
Tx: tx,
}
if err := h.hooks.ExecuteBeforeOp(BeforeUpdate, hookCtx); err != nil {
@@ -1387,16 +1395,17 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, url
// Execute BeforeUpdate hooks inside transaction
hookCtx := &HookContext{
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
Model: model,
Options: options,
ID: itemIDStr,
Data: item,
Writer: w,
Tx: tx,
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
Model: model,
Operation: "update",
Options: options,
ID: itemIDStr,
Data: item,
Writer: w,
Tx: tx,
}
if err := h.hooks.ExecuteBeforeOp(BeforeUpdate, hookCtx); err != nil {
@@ -1543,16 +1552,17 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, url
// Execute BeforeUpdate hooks inside transaction
hookCtx := &HookContext{
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
Model: model,
Options: options,
ID: itemIDStr,
Data: itemMap,
Writer: w,
Tx: tx,
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
Model: model,
Operation: "update",
Options: options,
ID: itemIDStr,
Data: itemMap,
Writer: w,
Tx: tx,
}
if err := h.hooks.ExecuteBeforeOp(BeforeUpdate, hookCtx); err != nil {
@@ -1648,15 +1658,16 @@ func (h *Handler) handleDelete(ctx context.Context, w common.ResponseWriter, id
// Execute BeforeDelete hooks (covers model-rule checks before any deletion)
hookCtx := &HookContext{
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
Model: model,
ID: id,
Data: data,
Writer: w,
Tx: h.db,
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
Model: model,
Operation: "delete",
ID: id,
Data: data,
Writer: w,
Tx: h.db,
}
if err := h.hooks.ExecuteBeforeOp(BeforeDelete, hookCtx); err != nil {
logger.Error("BeforeDelete hook failed: %v", err)
+10
View File
@@ -25,12 +25,18 @@ func RegisterSecurityHooks(handler *Handler, securityList *security.SecurityList
// Hook 1: BeforeRead - Load security rules
handler.Hooks().Register(BeforeRead, func(hookCtx *HookContext) error {
secCtx := newSecurityContext(hookCtx)
if security.IsModelSecurityDisabled(secCtx) {
return nil
}
return security.LoadSecurityRules(secCtx, securityList)
})
// Hook 2: BeforeScan - Apply row-level security filters
handler.Hooks().Register(BeforeScan, func(hookCtx *HookContext) error {
secCtx := newSecurityContext(hookCtx)
if security.ShouldSkipRowSecurity(secCtx, hookCtx.Operation) {
return nil
}
return security.ApplyRowSecurity(secCtx, securityList)
})
@@ -97,6 +103,10 @@ func (s *securityContext) GetEntity() string {
return s.ctx.Entity
}
func (s *securityContext) GetOperation() string {
return s.ctx.Operation
}
func (s *securityContext) GetModel() interface{} {
return s.ctx.Model
}
+63 -2
View File
@@ -233,8 +233,18 @@ func (h *Handler) Handle(w common.ResponseWriter, r common.Request, params map[s
return
}
validId, _ := strconv.ParseInt(id, 10, 64)
if validId > 0 {
h.handleUpdate(ctx, w, id, nil, data, options)
updateID := id
isUpdate := validId > 0
if !isUpdate {
// No valid /:id in the URL - check whether the body itself carries
// a valid primary key value and treat this as an update if so.
if pkID, ok := h.extractPrimaryKeyFromBody(model, data); ok && pkID != "0" {
updateID = pkID
isUpdate = true
}
}
if isUpdate {
h.handleUpdate(ctx, w, updateID, nil, data, options)
} else {
h.handleCreate(ctx, w, data, options)
}
@@ -271,6 +281,49 @@ func (h *Handler) Handle(w common.ResponseWriter, r common.Request, params map[s
}
}
// extractPrimaryKeyFromBody looks for a valid primary key value inside a
// decoded (single-record) POST body, keyed by the model's primary key column
// or its JSON equivalent. It returns the string form of that value and true
// if one was found and is non-empty/non-zero; otherwise ("", false).
func (h *Handler) extractPrimaryKeyFromBody(model interface{}, data interface{}) (string, bool) {
dataMap, ok := data.(map[string]interface{})
if !ok {
// Batch payloads (slices) aren't eligible for this implicit-update detection.
return "", false
}
pkCol := reflection.GetPrimaryKeyName(model)
if pkCol == "" {
return "", false
}
val, exists := dataMap[pkCol]
if !exists {
modelType := reflection.GetPointerElement(reflect.TypeOf(model))
for jsonKey, col := range reflection.BuildJSONToDBColumnMap(modelType) {
if col == pkCol {
val, exists = dataMap[jsonKey]
break
}
}
}
if !exists || val == nil || reflection.IsEmptyValue(val) {
return "", false
}
switch v := val.(type) {
case float64:
if v <= 0 {
return "", false
}
return strconv.FormatInt(int64(v), 10), true
case string:
return v, true
default:
return fmt.Sprintf("%v", v), true
}
}
// HandleGet processes GET requests for metadata
func (h *Handler) HandleGet(w common.ResponseWriter, r common.Request, params map[string]string) {
// Capture panics and return error response
@@ -379,6 +432,7 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
Entity: entity,
TableName: tableName,
Model: model,
Operation: "read",
Options: options,
ID: id,
Writer: w,
@@ -1236,6 +1290,7 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
Entity: entity,
TableName: tableName,
Model: model,
Operation: "create",
Options: options,
Data: data,
Writer: w,
@@ -1335,6 +1390,7 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
Entity: entity,
TableName: tableName,
Model: model,
Operation: "create",
Options: options,
Data: modelValue,
Writer: w,
@@ -1489,6 +1545,7 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, id
TableName: tableName,
Tx: tx,
Model: model,
Operation: "update",
Options: options,
ID: id,
Data: dataMap,
@@ -1686,6 +1743,7 @@ func (h *Handler) handleDelete(ctx context.Context, w common.ResponseWriter, id
Entity: entity,
TableName: tableName,
Model: model,
Operation: "delete",
ID: itemID,
Writer: w,
Tx: tx,
@@ -1760,6 +1818,7 @@ func (h *Handler) handleDelete(ctx context.Context, w common.ResponseWriter, id
Entity: entity,
TableName: tableName,
Model: model,
Operation: "delete",
ID: itemIDStr,
Writer: w,
Tx: tx,
@@ -1818,6 +1877,7 @@ func (h *Handler) handleDelete(ctx context.Context, w common.ResponseWriter, id
Entity: entity,
TableName: tableName,
Model: model,
Operation: "delete",
ID: itemIDStr,
Writer: w,
Tx: tx,
@@ -1902,6 +1962,7 @@ func (h *Handler) handleDelete(ctx context.Context, w common.ResponseWriter, id
Entity: entity,
TableName: tableName,
Model: model,
Operation: "delete",
ID: id,
Writer: w,
Tx: h.db,
+59 -17
View File
@@ -232,9 +232,37 @@ func LoadSecurityRules(secCtx SecurityContext, securityList *SecurityList) error
// ApplyRowSecurity is a public wrapper for applyRowSecurity that accepts a SecurityContext
// This allows other packages to apply row-level security using the generic interface
func ApplyRowSecurity(secCtx SecurityContext, securityList *SecurityList) error {
// Spec adapters that expose the dispatched operation can enforce the same
// model-rule bypass even when ApplyRowSecurity is called directly.
if operationCtx, ok := secCtx.(interface{ GetOperation() string }); ok &&
ShouldSkipRowSecurity(secCtx, operationCtx.GetOperation()) {
return nil
}
return applyRowSecurity(secCtx, securityList)
}
// ShouldSkipRowSecurity reports whether row-security enforcement should be
// skipped for the operation. It uses the same model-rule resolution as
// CheckModelAuthAllowed so the model registry remains the single source of
// truth for security behavior.
func ShouldSkipRowSecurity(secCtx SecurityContext, operation string) bool {
rules, ok := resolveModelRules(secCtx)
if !ok {
return false
}
return rules.SecurityDisabled || (operation == "read" && rules.CanPublicRead)
}
// IsModelSecurityDisabled reports whether all model-level security processing
// is disabled for the model. This is distinct from ShouldSkipRowSecurity:
// CanPublicRead skips row filtering for reads but must still allow other read
// security, such as column masking, to be loaded.
func IsModelSecurityDisabled(secCtx SecurityContext) bool {
rules, ok := resolveModelRules(secCtx)
return ok && rules.SecurityDisabled
}
// ApplyColumnSecurity is a public wrapper for applyColumnSecurity that accepts a SecurityContext
// This allows other packages to apply column-level security using the generic interface
func ApplyColumnSecurity(secCtx SecurityContext, securityList *SecurityList) error {
@@ -303,25 +331,14 @@ func checkModelDeleteAllowed(secCtx SecurityContext) error {
// 7. Guest (UserID == 0) → return "authentication required".
// 8. Authenticated user → allow (operation-specific checks remain in BeforeUpdate/BeforeDelete).
func CheckModelAuthAllowed(secCtx SecurityContext, operation string) error {
rules, ok := GetModelRulesFromContext(secCtx.GetContext())
rules, ok := resolveModelRules(secCtx)
if !ok {
schema := secCtx.GetSchema()
entity := secCtx.GetEntity()
var err error
if schema != "" {
rules, err = modelregistry.GetModelRulesByName(fmt.Sprintf("%s.%s", schema, entity))
}
if err != nil || schema == "" {
rules, err = modelregistry.GetModelRulesByName(entity)
}
if err != nil {
// Model not registered - fall through to auth check
userID, _ := secCtx.GetUserID()
if userID == 0 {
return fmt.Errorf("authentication required")
}
return nil
// Model not registered - fall through to auth check
userID, _ := secCtx.GetUserID()
if userID == 0 {
return fmt.Errorf("authentication required")
}
return nil
}
if rules.SecurityDisabled {
@@ -347,6 +364,31 @@ func CheckModelAuthAllowed(secCtx SecurityContext, operation string) error {
return nil
}
// resolveModelRules returns model rules from the request context first, then
// falls back to the schema-qualified and unqualified registry names.
func resolveModelRules(secCtx SecurityContext) (modelregistry.ModelRules, bool) {
if rules, ok := GetModelRulesFromContext(secCtx.GetContext()); ok {
return rules, true
}
schema := secCtx.GetSchema()
entity := secCtx.GetEntity()
var err error
if schema != "" {
var rules modelregistry.ModelRules
rules, err = modelregistry.GetModelRulesByName(fmt.Sprintf("%s.%s", schema, entity))
if err == nil {
return rules, true
}
}
rules, err := modelregistry.GetModelRulesByName(entity)
if err != nil {
return modelregistry.ModelRules{}, false
}
return rules, true
}
// CheckModelUpdateAllowed is the public wrapper for checkModelUpdateAllowed.
func CheckModelUpdateAllowed(secCtx SecurityContext) error {
return checkModelUpdateAllowed(secCtx)
+249
View File
@@ -0,0 +1,249 @@
// Package quickproxy provides a small reverse-proxy layer that tries a set
// of configured upstream targets first, and falls back to a caller-supplied
// http.Handler (typically static file serving) when the upstream is
// unreachable or returns 404.
package quickproxy
import (
"bytes"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/http/httputil"
"net/url"
"sort"
"strings"
"time"
)
// Rule maps a URL path prefix to an upstream target.
// A Rule with URLPrefix "/" acts as a catch-all passthrough.
type Rule struct {
// URLPrefix is the URL path prefix this rule matches. Must start with "/".
URLPrefix string
// Target is the upstream base URL, e.g. "http://localhost:3000".
// The incoming request path and query are forwarded unchanged; only the
// scheme and host are rewritten to Target's.
Target string
// Exclude is a list of URL path prefixes that this rule should not
// proxy, even though they fall under URLPrefix. Each entry is a full
// path from root and must itself start with URLPrefix (e.g. rule
// URLPrefix "/api" excluding a subpath must use "/api/health", not
// "/health"). A request matching an Exclude prefix is treated as if
// this rule didn't match at all: matching continues against any other
// configured rule, falling back if none match. This is typically used
// to carve out paths (e.g. "/health") from a catch-all "/" rule so
// they're served by the fallback handler instead of being proxied.
Exclude []string
}
// DefaultTimeout is the dial and response-header timeout applied to
// upstream requests when no WithTimeout option is given. It does not limit
// response body streaming.
const DefaultTimeout = 10 * time.Second
// Option configures a Service.
type Option func(*options)
type options struct {
timeout time.Duration
}
// WithTimeout sets the dial and response-header timeout used when
// connecting to upstream targets. It does not limit response body
// streaming, so it won't interrupt long-lived downloads or SSE/WebSocket
// connections once established.
func WithTimeout(d time.Duration) Option {
return func(o *options) { o.timeout = d }
}
// compiledRule pairs a Rule with its ready-to-use reverse proxy.
type compiledRule struct {
prefix string
excludes []string
proxy *httputil.ReverseProxy
}
// excluded reports whether path falls under one of the rule's Exclude prefixes.
func (r *compiledRule) excluded(path string) bool {
for _, ex := range r.excludes {
if strings.HasPrefix(path, ex) {
return true
}
}
return false
}
// Service holds a compiled set of proxy rules and performs longest-prefix
// matching against them. A Service is safe for concurrent use once
// returned from NewService; Handler must be called once per Service to
// wire up the fallback handler before the returned http.Handler is served.
type Service struct {
rules []compiledRule // sorted by descending prefix length
}
// errUpstreamNotFound is a sentinel error returned from ModifyResponse to
// make ReverseProxy invoke ErrorHandler (our fallback path) instead of
// writing the upstream's 404 to the client. Nothing has been written to
// the ResponseWriter yet when this happens.
var errUpstreamNotFound = errors.New("quickproxy: upstream returned 404")
// NewService compiles the given rules into a Service. Rules are matched by
// longest URLPrefix, so a catch-all "/" rule can coexist with more specific
// rules such as "/api".
func NewService(rules []Rule, opts ...Option) (*Service, error) {
if len(rules) == 0 {
return nil, fmt.Errorf("quickproxy: no rules configured")
}
cfg := options{timeout: DefaultTimeout}
for _, opt := range opts {
opt(&cfg)
}
seen := make(map[string]bool, len(rules))
compiled := make([]compiledRule, 0, len(rules))
for _, r := range rules {
if !strings.HasPrefix(r.URLPrefix, "/") {
return nil, fmt.Errorf("quickproxy: rule prefix %q must start with /", r.URLPrefix)
}
if seen[r.URLPrefix] {
return nil, fmt.Errorf("quickproxy: duplicate rule prefix %q", r.URLPrefix)
}
seen[r.URLPrefix] = true
target, err := url.Parse(r.Target)
if err != nil || target.Scheme == "" || target.Host == "" {
return nil, fmt.Errorf("quickproxy: invalid target %q for prefix %q", r.Target, r.URLPrefix)
}
for _, ex := range r.Exclude {
if !strings.HasPrefix(ex, "/") {
return nil, fmt.Errorf("quickproxy: exclude prefix %q for rule %q must start with /", ex, r.URLPrefix)
}
if !strings.HasPrefix(ex, r.URLPrefix) {
return nil, fmt.Errorf("quickproxy: exclude prefix %q for rule %q must itself start with the rule's URLPrefix", ex, r.URLPrefix)
}
}
compiled = append(compiled, compiledRule{
prefix: r.URLPrefix,
excludes: r.Exclude,
proxy: newReverseProxy(target, cfg.timeout),
})
}
// Longest prefix first, so the first match in Handler is always the
// most specific one.
sort.Slice(compiled, func(i, j int) bool {
return len(compiled[i].prefix) > len(compiled[j].prefix)
})
return &Service{rules: compiled}, nil
}
func newReverseProxy(target *url.URL, timeout time.Duration) *httputil.ReverseProxy {
transport := &http.Transport{
DialContext: (&net.Dialer{
Timeout: timeout,
}).DialContext,
ResponseHeaderTimeout: timeout,
}
return &httputil.ReverseProxy{
Transport: transport,
Director: func(req *http.Request) {
originalHost := req.Host
req.URL.Scheme = target.Scheme
req.URL.Host = target.Host
req.Host = target.Host
if originalHost != "" {
req.Header.Set("X-Forwarded-Host", originalHost)
}
},
ModifyResponse: func(resp *http.Response) error {
if resp.StatusCode == http.StatusNotFound {
return errUpstreamNotFound
}
return nil
},
}
}
// Handler returns an http.Handler that tries the configured proxy rules
// first (longest-prefix match), and calls fallback when no rule matches,
// the upstream is unreachable, or the upstream returns 404. Any other
// upstream response (2xx, other 4xx, 5xx) is streamed through to the
// client unchanged.
//
// Handler wires up ErrorHandler on the Service's compiled rules, so it
// should be called once per Service, before the returned http.Handler
// starts serving requests.
func (s *Service) Handler(fallback http.Handler) http.Handler {
if fallback == nil {
fallback = http.NotFoundHandler()
}
for i := range s.rules {
s.rules[i].proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, _ error) {
// ReverseProxy consumes and closes r.Body while attempting the
// upstream request, even when that attempt fails (per the
// http.RoundTripper contract). Restore a fresh copy from
// r.GetBody, set below, before handing the request to fallback.
if r.GetBody != nil {
if body, err := r.GetBody(); err == nil {
r.Body = body
}
}
fallback.ServeHTTP(w, r)
}
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
rule := s.match(r.URL.Path)
if rule == nil {
fallback.ServeHTTP(w, r)
return
}
// Buffer the body so it can be replayed to fallback if the upstream
// attempt fails; see ErrorHandler above.
if r.Body != nil && r.Body != http.NoBody {
bodyBytes, err := io.ReadAll(r.Body)
r.Body.Close()
if err != nil {
http.Error(w, "failed to read request body", http.StatusInternalServerError)
return
}
r.Body = io.NopCloser(bytes.NewReader(bodyBytes))
r.GetBody = func() (io.ReadCloser, error) {
return io.NopCloser(bytes.NewReader(bodyBytes)), nil
}
}
rule.proxy.ServeHTTP(w, r)
})
}
// match returns the longest-prefix rule matching path, or nil if none match.
// A rule whose Exclude covers path is skipped, and matching continues
// against the next-longest-prefix rule.
func (s *Service) match(path string) *compiledRule {
for i := range s.rules {
if !strings.HasPrefix(path, s.rules[i].prefix) {
continue
}
if s.rules[i].excluded(path) {
continue
}
return &s.rules[i]
}
return nil
}
+392
View File
@@ -0,0 +1,392 @@
package quickproxy
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestNewService_Validation(t *testing.T) {
tests := []struct {
name string
rules []Rule
wantErr bool
}{
{"no rules", nil, true},
{"empty rules", []Rule{}, true},
{"bad prefix", []Rule{{URLPrefix: "api", Target: "http://localhost:1"}}, true},
{"bad target", []Rule{{URLPrefix: "/api", Target: "not-a-url"}}, true},
{"missing host", []Rule{{URLPrefix: "/api", Target: "http://"}}, true},
{"duplicate prefix", []Rule{
{URLPrefix: "/api", Target: "http://localhost:1"},
{URLPrefix: "/api", Target: "http://localhost:2"},
}, true},
{"bad exclude prefix", []Rule{
{URLPrefix: "/", Target: "http://localhost:1", Exclude: []string{"health"}},
}, true},
{"exclude outside rule's URLPrefix", []Rule{
{URLPrefix: "/api", Target: "http://localhost:1", Exclude: []string{"/health"}},
}, true},
{"valid", []Rule{{URLPrefix: "/api", Target: "http://localhost:1"}}, false},
{"valid with exclude", []Rule{
{URLPrefix: "/", Target: "http://localhost:1", Exclude: []string{"/health"}},
}, false},
{"valid with nested exclude", []Rule{
{URLPrefix: "/api", Target: "http://localhost:1", Exclude: []string{"/api/health"}},
}, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := NewService(tt.rules)
if (err != nil) != tt.wantErr {
t.Fatalf("NewService() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
func fallbackHandler(body string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(body))
})
}
func TestHandler_ProxiesSuccessResponse(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("upstream:" + r.URL.Path))
}))
defer upstream.Close()
svc, err := NewService([]Rule{{URLPrefix: "/api", Target: upstream.URL}})
if err != nil {
t.Fatalf("NewService: %v", err)
}
handler := svc.Handler(fallbackHandler("fallback"))
req := httptest.NewRequest(http.MethodGet, "/api/widgets", nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rr.Code)
}
if got := rr.Body.String(); got != "upstream:/api/widgets" {
t.Fatalf("body = %q", got)
}
}
func TestHandler_404FallsBack(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte("upstream not found"))
}))
defer upstream.Close()
svc, err := NewService([]Rule{{URLPrefix: "/", Target: upstream.URL}})
if err != nil {
t.Fatalf("NewService: %v", err)
}
handler := svc.Handler(fallbackHandler("fallback-content"))
req := httptest.NewRequest(http.MethodGet, "/missing.html", nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rr.Code)
}
if got := rr.Body.String(); got != "fallback-content" {
t.Fatalf("body = %q, want fallback-content", got)
}
}
func TestHandler_UnreachableUpstreamFallsBack(t *testing.T) {
// A closed listener address: nothing is listening, so dialing fails.
unreachable := "http://127.0.0.1:1"
svc, err := NewService([]Rule{{URLPrefix: "/", Target: unreachable}}, WithTimeout(500*time.Millisecond))
if err != nil {
t.Fatalf("NewService: %v", err)
}
handler := svc.Handler(fallbackHandler("fallback-content"))
req := httptest.NewRequest(http.MethodGet, "/anything", nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rr.Code)
}
if got := rr.Body.String(); got != "fallback-content" {
t.Fatalf("body = %q, want fallback-content", got)
}
}
func TestHandler_UnreachableUpstreamFallsBackWithBody(t *testing.T) {
// A closed listener address: nothing is listening, so dialing fails and
// ReverseProxy invokes ErrorHandler. The fallback handler must still see
// the original request body, even though ReverseProxy consumed and
// closed it while attempting (and failing) the upstream request.
unreachable := "http://127.0.0.1:1"
svc, err := NewService([]Rule{{URLPrefix: "/", Target: unreachable}}, WithTimeout(500*time.Millisecond))
if err != nil {
t.Fatalf("NewService: %v", err)
}
echoBody := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
t.Fatalf("fallback reading body: %v", err)
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write(body)
})
handler := svc.Handler(echoBody)
req := httptest.NewRequest(http.MethodPost, "/submit", strings.NewReader("payload=1"))
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rr.Code)
}
if got := rr.Body.String(); got != "payload=1" {
t.Fatalf("body = %q, want payload=1", got)
}
}
func TestHandler_404FallsBackWithBody(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer upstream.Close()
svc, err := NewService([]Rule{{URLPrefix: "/", Target: upstream.URL}})
if err != nil {
t.Fatalf("NewService: %v", err)
}
echoBody := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
t.Fatalf("fallback reading body: %v", err)
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write(body)
})
handler := svc.Handler(echoBody)
req := httptest.NewRequest(http.MethodPut, "/missing", strings.NewReader("payload=2"))
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rr.Code)
}
if got := rr.Body.String(); got != "payload=2" {
t.Fatalf("body = %q, want payload=2", got)
}
}
func TestHandler_NonNotFoundErrorsPassThrough(t *testing.T) {
codes := []int{http.StatusOK, http.StatusForbidden, http.StatusBadRequest, http.StatusInternalServerError}
for _, code := range codes {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(code)
_, _ = w.Write([]byte("upstream response"))
}))
svc, err := NewService([]Rule{{URLPrefix: "/", Target: upstream.URL}})
if err != nil {
upstream.Close()
t.Fatalf("NewService: %v", err)
}
handler := svc.Handler(fallbackHandler("fallback-content"))
req := httptest.NewRequest(http.MethodGet, "/x", nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != code {
t.Errorf("status for upstream code %d = %d, want %d", code, rr.Code, code)
}
if got := rr.Body.String(); got != "upstream response" {
t.Errorf("body for upstream code %d = %q, want passthrough", code, got)
}
upstream.Close()
}
}
func TestHandler_LongestPrefixMatch(t *testing.T) {
specific := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("specific"))
}))
defer specific.Close()
general := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("general"))
}))
defer general.Close()
svc, err := NewService([]Rule{
{URLPrefix: "/", Target: general.URL},
{URLPrefix: "/api/v1", Target: specific.URL},
})
if err != nil {
t.Fatalf("NewService: %v", err)
}
handler := svc.Handler(fallbackHandler("fallback"))
for path, want := range map[string]string{
"/api/v1/thing": "specific",
"/api/other": "general",
"/anything": "general",
} {
req := httptest.NewRequest(http.MethodGet, path, nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if got := rr.Body.String(); got != want {
t.Errorf("path %s: body = %q, want %q", path, got, want)
}
}
}
func TestHandler_ExcludeFallsBackToFallback(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("upstream:" + r.URL.Path))
}))
defer upstream.Close()
svc, err := NewService([]Rule{
{URLPrefix: "/", Target: upstream.URL, Exclude: []string{"/health"}},
})
if err != nil {
t.Fatalf("NewService: %v", err)
}
handler := svc.Handler(fallbackHandler("fallback-content"))
req := httptest.NewRequest(http.MethodGet, "/health", nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if got := rr.Body.String(); got != "fallback-content" {
t.Fatalf("body = %q, want fallback-content", got)
}
req = httptest.NewRequest(http.MethodGet, "/health/live", nil)
rr = httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if got := rr.Body.String(); got != "fallback-content" {
t.Fatalf("body = %q, want fallback-content", got)
}
req = httptest.NewRequest(http.MethodGet, "/other", nil)
rr = httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if got := rr.Body.String(); got != "upstream:/other" {
t.Fatalf("body = %q, want upstream:/other", got)
}
}
func TestHandler_ExcludeFallsThroughToNextRule(t *testing.T) {
specific := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("specific"))
}))
defer specific.Close()
general := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("general"))
}))
defer general.Close()
svc, err := NewService([]Rule{
{URLPrefix: "/api", Target: general.URL},
{URLPrefix: "/api/v1", Target: specific.URL, Exclude: []string{"/api/v1/health"}},
})
if err != nil {
t.Fatalf("NewService: %v", err)
}
handler := svc.Handler(fallbackHandler("fallback"))
for path, want := range map[string]string{
"/api/v1/thing": "specific",
"/api/v1/health": "general",
} {
req := httptest.NewRequest(http.MethodGet, path, nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if got := rr.Body.String(); got != want {
t.Errorf("path %s: body = %q, want %q", path, got, want)
}
}
}
func TestHandler_NoMatchFallsBack(t *testing.T) {
svc, err := NewService([]Rule{{URLPrefix: "/api", Target: "http://127.0.0.1:1"}})
if err != nil {
t.Fatalf("NewService: %v", err)
}
handler := svc.Handler(fallbackHandler("fallback-content"))
req := httptest.NewRequest(http.MethodGet, "/other", nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rr.Code)
}
if got := rr.Body.String(); got != "fallback-content" {
t.Fatalf("body = %q, want fallback-content", got)
}
}
func TestHandler_AllMethodsProxied(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(r.Method + ":" + string(body)))
}))
defer upstream.Close()
svc, err := NewService([]Rule{{URLPrefix: "/api", Target: upstream.URL}})
if err != nil {
t.Fatalf("NewService: %v", err)
}
handler := svc.Handler(fallbackHandler("fallback"))
methods := []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete}
for _, method := range methods {
req := httptest.NewRequest(method, "/api/widgets", nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
want := method + ":"
if got := rr.Body.String(); got != want {
t.Errorf("method %s: body = %q, want %q", method, got, want)
}
}
}
+177
View File
@@ -0,0 +1,177 @@
package spectypes
import (
"database/sql/driver"
"encoding/json"
"fmt"
"strings"
)
// CIString is a string that stores, scans, and returns its value exactly as
// given (no case normalization), but compares case-insensitively via Equal
// and EqualString. Use it as a bun model field type for columns (e.g.
// citext, or codes matched case-insensitively) where you want Go-side
// case-insensitive comparisons without forcing the stored/returned value to
// a particular case.
type CIString string
// Value implements driver.Valuer. The value is passed through unchanged.
func (s CIString) Value() (driver.Value, error) {
return string(s), nil
}
// Scan implements sql.Scanner. The value is stored unchanged.
func (s *CIString) Scan(value any) error {
switch v := value.(type) {
case string:
*s = CIString(v)
case []byte:
*s = CIString(v)
case nil:
*s = ""
default:
return fmt.Errorf("cannot scan %T into CIString", value)
}
return nil
}
// String implements fmt.Stringer.
func (s CIString) String() string { return string(s) }
// Equal reports whether s and other are equal, ignoring case.
func (s CIString) Equal(other CIString) bool {
return strings.EqualFold(string(s), string(other))
}
// EqualString reports whether s equals other, ignoring case.
func (s CIString) EqualString(other string) bool {
return strings.EqualFold(string(s), other)
}
// Compare returns -1, 0, or +1 if s is less than, equal to, or greater than
// other, ignoring case. Useful with slices.SortFunc or similar.
func (s CIString) Compare(other CIString) int {
return strings.Compare(strings.ToLower(string(s)), strings.ToLower(string(other)))
}
// Less reports whether s sorts before other, ignoring case. Suitable for
// sort.Slice or slices.SortFunc comparisons.
func (s CIString) Less(other CIString) bool {
return s.Compare(other) < 0
}
// LCString is a string that always stores, scans, and returns as lowercase.
// Use it as a bun model field type for columns that must be normalized to
// lowercase (e.g. codes, slugs, emails) rather than merely compared
// case-insensitively; see CIString if the original case must be preserved.
type LCString string
// Value implements driver.Valuer, always lowercase.
func (s LCString) Value() (driver.Value, error) {
return strings.ToLower(string(s)), nil
}
// Scan implements sql.Scanner, always lowercase.
func (s *LCString) Scan(value any) error {
switch v := value.(type) {
case string:
*s = LCString(strings.ToLower(v))
case []byte:
*s = LCString(strings.ToLower(string(v)))
case nil:
*s = ""
default:
return fmt.Errorf("cannot scan %T into LCString", value)
}
return nil
}
// String implements fmt.Stringer, always lowercase.
func (s LCString) String() string { return strings.ToLower(string(s)) }
// Equal reports whether s and other are equal (case-insensitively, since
// both normalize to lowercase).
func (s LCString) Equal(other LCString) bool {
return s.String() == other.String()
}
// EqualString reports whether s equals other, ignoring case.
func (s LCString) EqualString(other string) bool {
return s.String() == strings.ToLower(other)
}
// MarshalJSON implements json.Marshaler, always lowercase. Needed because
// encoding/json marshals a bare string-kind type as-is and does not call
// Value/String, so a value constructed directly (not scanned from the DB)
// would otherwise serialize with its original case.
func (s LCString) MarshalJSON() ([]byte, error) {
return json.Marshal(strings.ToLower(string(s)))
}
// UnmarshalJSON implements json.Unmarshaler, always lowercase.
func (s *LCString) UnmarshalJSON(b []byte) error {
var str string
if err := json.Unmarshal(b, &str); err != nil {
return err
}
*s = LCString(strings.ToLower(str))
return nil
}
// UCString is a string that always stores, scans, and returns as uppercase.
// Use it as a bun model field type for columns that must be normalized to
// uppercase (e.g. table prefix codes) rather than merely compared
// case-insensitively; see CIString if the original case must be preserved.
type UCString string
// Value implements driver.Valuer, always uppercase.
func (s UCString) Value() (driver.Value, error) {
return strings.ToUpper(string(s)), nil
}
// Scan implements sql.Scanner, always uppercase.
func (s *UCString) Scan(value any) error {
switch v := value.(type) {
case string:
*s = UCString(strings.ToUpper(v))
case []byte:
*s = UCString(strings.ToUpper(string(v)))
case nil:
*s = ""
default:
return fmt.Errorf("cannot scan %T into UCString", value)
}
return nil
}
// String implements fmt.Stringer, always uppercase.
func (s UCString) String() string { return strings.ToUpper(string(s)) }
// Equal reports whether s and other are equal (case-insensitively, since
// both normalize to uppercase).
func (s UCString) Equal(other UCString) bool {
return s.String() == other.String()
}
// EqualString reports whether s equals other, ignoring case.
func (s UCString) EqualString(other string) bool {
return s.String() == strings.ToUpper(other)
}
// MarshalJSON implements json.Marshaler, always uppercase. Needed because
// encoding/json marshals a bare string-kind type as-is and does not call
// Value/String, so a value constructed directly (not scanned from the DB)
// would otherwise serialize with its original case.
func (s UCString) MarshalJSON() ([]byte, error) {
return json.Marshal(strings.ToUpper(string(s)))
}
// UnmarshalJSON implements json.Unmarshaler, always uppercase.
func (s *UCString) UnmarshalJSON(b []byte) error {
var str string
if err := json.Unmarshal(b, &str); err != nil {
return err
}
*s = UCString(strings.ToUpper(str))
return nil
}
+379
View File
@@ -0,0 +1,379 @@
package spectypes
import (
"encoding/json"
"sort"
"testing"
)
func TestCIString_Scan(t *testing.T) {
tests := []struct {
name string
input interface{}
expected CIString
}{
{name: "plain string", input: "MixedCase", expected: "MixedCase"},
{name: "bytes as string", input: []byte("FromBytes"), expected: "FromBytes"},
{name: "nil value", input: nil, expected: ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var s CIString
if err := s.Scan(tt.input); err != nil {
t.Fatalf("Scan failed: %v", err)
}
if s != tt.expected {
t.Errorf("expected %q, got %q", tt.expected, s)
}
})
}
}
func TestCIString_Scan_InvalidType(t *testing.T) {
var s CIString
if err := s.Scan(123); err == nil {
t.Fatal("expected error scanning int into CIString, got nil")
}
}
func TestCIString_Value(t *testing.T) {
s := CIString("MixedCase")
v, err := s.Value()
if err != nil {
t.Fatalf("Value failed: %v", err)
}
if v != "MixedCase" {
t.Errorf("expected %q, got %q (case must be preserved)", "MixedCase", v)
}
}
func TestCIString_String(t *testing.T) {
s := CIString("MixedCase")
if s.String() != "MixedCase" {
t.Errorf("expected %q, got %q", "MixedCase", s.String())
}
}
func TestCIString_Equal(t *testing.T) {
tests := []struct {
name string
a, b CIString
expected bool
}{
{name: "same case", a: "ABC", b: "ABC", expected: true},
{name: "different case", a: "ABC", b: "abc", expected: true},
{name: "mixed case", a: "AbC", b: "aBc", expected: true},
{name: "not equal", a: "ABC", b: "XYZ", expected: false},
{name: "both empty", a: "", b: "", expected: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.a.Equal(tt.b); got != tt.expected {
t.Errorf("Equal(%q, %q) = %v, want %v", tt.a, tt.b, got, tt.expected)
}
})
}
}
func TestCIString_EqualString(t *testing.T) {
s := CIString("ABC")
if !s.EqualString("abc") {
t.Error("expected EqualString to match case-insensitively")
}
if s.EqualString("xyz") {
t.Error("expected EqualString to not match different strings")
}
}
func TestCIString_Compare(t *testing.T) {
tests := []struct {
name string
a, b CIString
expected int
}{
{name: "equal same case", a: "abc", b: "abc", expected: 0},
{name: "equal different case", a: "ABC", b: "abc", expected: 0},
{name: "less", a: "abc", b: "xyz", expected: -1},
{name: "less different case", a: "ABC", b: "xyz", expected: -1},
{name: "greater", a: "xyz", b: "abc", expected: 1},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.a.Compare(tt.b); got != tt.expected {
t.Errorf("Compare(%q, %q) = %v, want %v", tt.a, tt.b, got, tt.expected)
}
})
}
}
func TestCIString_Less(t *testing.T) {
if !CIString("abc").Less("xyz") {
t.Error("expected abc < xyz")
}
if CIString("xyz").Less("abc") {
t.Error("expected xyz not < abc")
}
if CIString("ABC").Less("abc") {
t.Error("expected ABC not < abc (equal ignoring case)")
}
}
func TestCIString_Sort(t *testing.T) {
vals := []CIString{"banana", "Apple", "cherry", "apple"}
sort.Slice(vals, func(i, j int) bool { return vals[i].Less(vals[j]) })
// After a case-insensitive sort, "Apple"/"apple" must be adjacent and first,
// followed by banana then cherry.
if !vals[0].EqualString("apple") || !vals[1].EqualString("apple") {
t.Errorf("expected the two apple variants first, got %v", vals)
}
if !vals[2].EqualString("banana") {
t.Errorf("expected banana third, got %v", vals)
}
if !vals[3].EqualString("cherry") {
t.Errorf("expected cherry fourth, got %v", vals)
}
}
func TestLCString_Scan(t *testing.T) {
tests := []struct {
name string
input interface{}
expected LCString
}{
{name: "mixed case string", input: "MixedCase", expected: "mixedcase"},
{name: "bytes mixed case", input: []byte("FromBytes"), expected: "frombytes"},
{name: "nil value", input: nil, expected: ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var s LCString
if err := s.Scan(tt.input); err != nil {
t.Fatalf("Scan failed: %v", err)
}
if s != tt.expected {
t.Errorf("expected %q, got %q", tt.expected, s)
}
})
}
}
func TestLCString_Scan_InvalidType(t *testing.T) {
var s LCString
if err := s.Scan(123); err == nil {
t.Fatal("expected error scanning int into LCString, got nil")
}
}
func TestLCString_Value(t *testing.T) {
s := LCString("MixedCase")
v, err := s.Value()
if err != nil {
t.Fatalf("Value failed: %v", err)
}
if v != "mixedcase" {
t.Errorf("expected %q, got %q", "mixedcase", v)
}
}
func TestLCString_String(t *testing.T) {
s := LCString("MixedCase")
if s.String() != "mixedcase" {
t.Errorf("expected %q, got %q", "mixedcase", s.String())
}
}
func TestLCString_Equal(t *testing.T) {
if !LCString("ABC").Equal(LCString("abc")) {
t.Error("expected ABC and abc to be equal")
}
if LCString("ABC").Equal(LCString("xyz")) {
t.Error("expected ABC and xyz to not be equal")
}
}
func TestLCString_EqualString(t *testing.T) {
if !LCString("ABC").EqualString("abc") {
t.Error("expected EqualString to match case-insensitively")
}
}
func TestUCString_Scan(t *testing.T) {
tests := []struct {
name string
input interface{}
expected UCString
}{
{name: "mixed case string", input: "MixedCase", expected: "MIXEDCASE"},
{name: "bytes mixed case", input: []byte("FromBytes"), expected: "FROMBYTES"},
{name: "nil value", input: nil, expected: ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var s UCString
if err := s.Scan(tt.input); err != nil {
t.Fatalf("Scan failed: %v", err)
}
if s != tt.expected {
t.Errorf("expected %q, got %q", tt.expected, s)
}
})
}
}
func TestUCString_Scan_InvalidType(t *testing.T) {
var s UCString
if err := s.Scan(123); err == nil {
t.Fatal("expected error scanning int into UCString, got nil")
}
}
func TestUCString_Value(t *testing.T) {
s := UCString("MixedCase")
v, err := s.Value()
if err != nil {
t.Fatalf("Value failed: %v", err)
}
if v != "MIXEDCASE" {
t.Errorf("expected %q, got %q", "MIXEDCASE", v)
}
}
func TestUCString_String(t *testing.T) {
s := UCString("MixedCase")
if s.String() != "MIXEDCASE" {
t.Errorf("expected %q, got %q", "MIXEDCASE", s.String())
}
}
func TestUCString_Equal(t *testing.T) {
if !UCString("ABC").Equal(UCString("abc")) {
t.Error("expected ABC and abc to be equal")
}
if UCString("ABC").Equal(UCString("xyz")) {
t.Error("expected ABC and xyz to not be equal")
}
}
func TestUCString_EqualString(t *testing.T) {
if !UCString("ABC").EqualString("abc") {
t.Error("expected EqualString to match case-insensitively")
}
}
// TestLCString_MarshalJSON_NotFromDB verifies a value constructed directly
// in Go (never passed through Scan) still normalizes on JSON marshal.
func TestLCString_MarshalJSON_NotFromDB(t *testing.T) {
s := LCString("MixedCase")
b, err := json.Marshal(s)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
if string(b) != `"mixedcase"` {
t.Errorf("expected %s, got %s", `"mixedcase"`, b)
}
}
func TestLCString_UnmarshalJSON(t *testing.T) {
var s LCString
if err := json.Unmarshal([]byte(`"MixedCase"`), &s); err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}
if s != "mixedcase" {
t.Errorf("expected %q, got %q", "mixedcase", s)
}
}
func TestLCString_JSON_StructField(t *testing.T) {
type wrapper struct {
Code LCString `json:"code"`
}
in := wrapper{Code: "MixedCase"}
b, err := json.Marshal(in)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
if string(b) != `{"code":"mixedcase"}` {
t.Errorf("expected %s, got %s", `{"code":"mixedcase"}`, b)
}
var out wrapper
if err := json.Unmarshal([]byte(`{"code":"AnotherMixedCase"}`), &out); err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}
if out.Code != "anothermixedcase" {
t.Errorf("expected %q, got %q", "anothermixedcase", out.Code)
}
}
// TestUCString_MarshalJSON_NotFromDB verifies a value constructed directly
// in Go (never passed through Scan) still normalizes on JSON marshal.
func TestUCString_MarshalJSON_NotFromDB(t *testing.T) {
s := UCString("MixedCase")
b, err := json.Marshal(s)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
if string(b) != `"MIXEDCASE"` {
t.Errorf("expected %s, got %s", `"MIXEDCASE"`, b)
}
}
func TestUCString_UnmarshalJSON(t *testing.T) {
var s UCString
if err := json.Unmarshal([]byte(`"MixedCase"`), &s); err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}
if s != "MIXEDCASE" {
t.Errorf("expected %q, got %q", "MIXEDCASE", s)
}
}
func TestUCString_JSON_StructField(t *testing.T) {
type wrapper struct {
Code UCString `json:"code"`
}
in := wrapper{Code: "MixedCase"}
b, err := json.Marshal(in)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
if string(b) != `{"code":"MIXEDCASE"}` {
t.Errorf("expected %s, got %s", `{"code":"MIXEDCASE"}`, b)
}
var out wrapper
if err := json.Unmarshal([]byte(`{"code":"AnotherMixedCase"}`), &out); err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}
if out.Code != "ANOTHERMIXEDCASE" {
t.Errorf("expected %q, got %q", "ANOTHERMIXEDCASE", out.Code)
}
}
// TestCIString_JSON_PreservesCase confirms CIString needs no custom JSON
// methods: it should never normalize case, only its DB Value/Scan and the
// Equal/EqualString comparisons apply case-insensitivity.
func TestCIString_JSON_PreservesCase(t *testing.T) {
s := CIString("MixedCase")
b, err := json.Marshal(s)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
if string(b) != `"MixedCase"` {
t.Errorf("expected %s, got %s", `"MixedCase"`, b)
}
var out CIString
if err := json.Unmarshal([]byte(`"AnotherMixedCase"`), &out); err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}
if out != "AnotherMixedCase" {
t.Errorf("expected case to be preserved, got %q", out)
}
}