Skip to content

πŸ”₯ feat: unify internal and custom constraints into a single interfaceΒ #4395

@pageton

Description

@pageton

Problem

The current constraint system has two separate code paths for constraint matching:

  1. Internal constraints (int, bool, float, alpha, minLen, maxLen, len, betweenLen, min, max, range, datetime, guid, regex) β€” handled via a TypeConstraint bitmask + a large switch statement in Constraint.CheckConstraint() (path.go:795-932).

  2. Custom constraints β€” implement the CustomConstraint interface (Name() string, Execute(param string, args ...string) bool) but are matched by iterating c.customConstraints at the top of CheckConstraint() before falling through to the switch.

This creates several issues:

  • Redundant dispatch logic: getParamConstraintType() maps names to TypeConstraint constants, then CheckConstraint() maps those constants back to matching logic via switch β€” two lookups where one would suffice.
  • No analysis phase: Built-in constraints like minLen(3) or range(1,100) parse their integer args via strconv.Atoi() on every request, even though the data is static at route registration time.
  • Inconsistent treatment: Any built-in constraint could be expressed as a CustomConstraint, but the architecture forces them into separate paths.
  • Hard to extend: Adding a new built-in constraint requires changes in constants.go, getParamConstraintType(), CheckConstraint(), and the needOneData/needTwoData bitmasks.

Proposed Solution

Unify all constraints (internal + user-defined) under a single interface with an optional analysis phase:

type Constraint interface {
    // Name returns the constraint identifier used in route patterns (e.g. "int", "minLen", "regex").
    Name() string

    // Execute validates a request parameter value against the constraint.
    // precompiled is data produced by Analyse() at registration time (may be nil).
    Execute(param string, args []string, precompiled any) bool
}

Constraints that need precomputation (regex compilation, integer parsing, etc.) additionally implement:

type Analyseable interface {
    // Analyse preprocesses constraint data at route registration time.
    // Returns an opaque value passed to Execute() at match time.
    Analyse(args []string) (any, error)
}

Architecture

Registration time:
  list := merge(builtinConstraints, userCustomConstraints)
  for each constraint in route pattern:
    find matching Constraint from list (by Name)
    if Analyseable β†’ call Analyse(args), store precompiled data
    store {Constraint, args, precompiled} on the route

Request time:
  for each constraint on matched segment:
    call constraint.Execute(paramValue, args, precompiled)

Key changes

  1. Remove TypeConstraint bitmask and the noConstraint/intConstraint/... iota constants.
  2. Remove the large switch in CheckConstraint() β€” each built-in becomes a struct implementing Constraint.
  3. Remove customConstraints []CustomConstraint from the Constraint struct β€” replaced by a single unified lookup.
  4. Add Precompiled any field to store analysis results at registration time.
  5. Each built-in constraint (intConstraint, minLenConstraint, regexConstraint, etc.) becomes its own type:
    type intConstraint struct{}
    func (intConstraint) Name() string { return "int" }
    func (intConstraint) Execute(param string, _ []string, _ any) bool {
        _, err := strconv.Atoi(param)
        return err == nil
    }
    
    type minLenConstraint struct{}
    func (minLenConstraint) Name() string { return "minLen" }
    func (minLenConstraint) Analyse(args []string) (any, error) {
        return strconv.Atoi(args[0])
    }
    func (minLenConstraint) Execute(param string, _ []string, pre any) bool {
        limit, ok := pre.(int)
        if !ok { return false }
        return len(param) >= limit
    }

Benefits

  • Zero allocations at match time: strconv.Atoi / time.Parse layout parsing / regex compilation all happen once at registration.
  • Single dispatch: Replace TypeConstraint switch + custom constraint loop with one Execute() call.
  • Extensibility: Users can register constraints that implement Analyseable for their own precomputation.
  • Simpler code: Remove TypeConstraint, needOneData, needTwoData, getParamConstraintType(), and the ~130-line switch statement.

Migration / Compatibility

  • The public CustomConstraint interface is preserved (it satisfies the new Constraint interface β€” Name() and Execute() signatures are compatible).
  • The Analyse() method is optional β€” constraints that don't need precomputation simply don't implement Analyseable.
  • Route pattern syntax (:param<constraint(data)>) is unchanged.

Scope

This is a significant refactor of path.go. I plan to submit this as a draft PR for review and discussion before finalizing the implementation.

Related: #4387 (routing hot path perf β€” this issue extracts the constraint optimization that was split out of that PR).

Metadata

Metadata

Assignees

No one assigned

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions