mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2026-09-21 15:42:01 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a220338eea | ||
|
|
20c67166d0 | ||
|
|
749dad4ed1 | ||
|
|
d6c5740f9c | ||
|
|
817b781c88 | ||
|
|
87eaa9e18c |
+113
-102
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -5,8 +5,10 @@
|
||||
package quickproxy
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
@@ -191,6 +193,15 @@ func (s *Service) Handler(fallback http.Handler) http.Handler {
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -201,6 +212,22 @@ func (s *Service) Handler(fallback http.Handler) http.Handler {
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -130,6 +131,75 @@ func TestHandler_UnreachableUpstreamFallsBack(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user