celsius

package module
v0.0.0-...-810d26b Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jun 9, 2026 License: MIT Imports: 15 Imported by: 0

README

Celsius

A CEL-driven rule engine and request-override middleware for Go.

Define rules in YAML, evaluate them at request time with Google's Common Expression Language, attach a typed result to the request context, and let your handlers branch on it.

Go Reference

⚠️ Status — v0.x. API is stabilizing. Expect breaking changes until v1.0.

🔥 Battle-tested at the largest e-commerce platform in the Middle East.


What is it?

Celsius lets you push runtime decisions — A/B splits, feature flags, response mocks, canary routing, rate-shaping, tenant overrides — out of your handler code and into a hot-reloadable YAML file. Rules are expressed in CEL, so they are sandboxed, type-checked, and side-effect free.

A typical request flow:

HTTP request ──▶ middleware
                  │
                  │ 1. build inputs (headers, claims, anything)
                  │ 2. evaluate the named rule group
                  │ 3. attach matched Rule[T] to ctx (if any)
                  ▼
              your handler  ──▶  celsius.ResultFrom(ctx) → branch on Rule.Out

Use it for whatever fits a rule-engine shape:

  • A/B testing & gradual rollouts — bucket users by hash(uid) % 100 < 10.
  • Feature flags — turn behavior on by tenant, platform, environment.
  • Response overrides / mocking — return canned payloads when an upstream is down.
  • Routing — pick which backend to call based on request attributes.
  • Tenant overrides — let SaaS customers customize per-tenant behavior without redeploys.

Why CEL?

  • Safe: no I/O, no loops, no eval — expressions are pure functions.
  • Fast: compiled once at load time, evaluated as bytecode.
  • Familiar: C-like syntax (uid > 100 && platform == "ios").
  • Typed: compile errors surface on hot-reload, not at request time.

Install

go get github.com/amkarkhi/celsius

The core package has no web-framework dependencies. Pick the transport adapter you need:

go get github.com/amkarkhi/celsius/transport/nethttp   # net/http
go get github.com/amkarkhi/celsius/transport/fiber     # Fiber v2
go get github.com/amkarkhi/celsius/transport/gin       # Gin

Quick start

1. Define rules in YAML
# rules.yaml
rules:
  checkout_experiment:
    - script: 'hash(uid, 100) < 10'
      tag:    "variant_b"
      out:
        backend: "checkout-v2"
        banner:  "Try our new checkout!"
    - script: 'true'
      tag:    "control"
      out:
        backend: "checkout-v1"
        banner:  ""
variables: {}

Rules are evaluated top-down, first match wins. The last script: 'true' is the conventional default branch.

2. Describe your typed output
type CheckoutOut struct {
    Backend string `mapstructure:"backend"`
    Banner  string `mapstructure:"banner"`
}
3. Describe your inputs

Inputs implement the celsius.Input interface — they tell Celsius how to turn the incoming request into the variable map that CEL will see.

type CheckoutInput struct {
    Uid      int
    Platform string
}

func (i *CheckoutInput) Map() map[string]any {
    return map[string]any{"uid": i.Uid, "platform": i.Platform}
}

func (i *CheckoutInput) Parse(ctx celsius.Ctx) (map[string]any, error) {
    uid, _ := strconv.Atoi(ctx.Header("X-UID", "-1"))
    i.Uid = uid
    i.Platform = ctx.Header("X-Platform", "unknown")
    return i.Map(), nil
}
4. Build the engine
import (
    "github.com/amkarkhi/celsius"
    "github.com/google/cel-go/cel"
)

engine, err := celsius.New[CheckoutOut](celsius.Config{
    Source: celsius.FileSource("rules.yaml"),
    Env: celsius.DefaultEnv().With(
        celsius.Variable("uid",      cel.IntType),
        celsius.Variable("platform", cel.StringType),
    ),
    Input:  &CheckoutInput{},
    Logger: &log.Logger,
})
if err != nil { panic(err) }
5. Wire it as middleware
net/http
import celsiushttp "github.com/amkarkhi/celsius/transport/nethttp"

mux := http.NewServeMux()
mux.Handle("/checkout", celsiushttp.Middleware(engine, "checkout_experiment")(handler))
Gin
import celsiusgin "github.com/amkarkhi/celsius/transport/gin"

r := gin.Default()
r.Use(celsiusgin.Middleware(engine, "checkout_experiment"))
r.GET("/checkout", func(c *gin.Context) {
    rule, ok := celsius.ResultFrom[CheckoutOut](c.Request.Context())
    if !ok {
        c.String(200, "no rule matched")
        return
    }
    c.String(200, "backend=%s", rule.Out.Backend)
})
Fiber
import celsiusfiber "github.com/amkarkhi/celsius/transport/fiber"

app := fiber.New()
app.Use(celsiusfiber.Middleware(engine, "checkout_experiment"))
app.Get("/checkout", func(c *fiber.Ctx) error {
    rule, ok := celsius.ResultFrom[CheckoutOut](c.UserContext())
    if !ok {
        return c.SendString("no rule matched")
    }
    return c.SendString("backend=" + rule.Out.Backend)
})
6. Evaluate directly (no middleware)

The engine is usable as a plain library outside of HTTP:

rule, err := engine.Match("checkout_experiment", map[string]any{
    "uid":      42,
    "platform": "ios",
})

Rule file reference

variables:                       # optional — `var.NAME` substitution in scripts
  internal_uids: "[1, 2, 3]"

rules:
  rule_group_name:               # the "key" passed to the middleware
    - script: "uid in var.internal_uids"
      tag:    "internal_user"    # free-form label, surfaced on Rule.Tag
      out:                       # decoded into your Rule[T].Out
        message: "hi, employee"
    - script: "true"
      tag:    "default"
      out:
        message: "hi, guest"
  • script — CEL expression that must evaluate to bool.
  • tag — opaque label your handler can read off rule.Tag.
  • out — arbitrary payload, decoded into your T via mapstructure.
  • var.NAME in any script is substituted with the matching entry from the top-level variables block. Cycles are detected and reported.

Built-in CEL helpers

Function Signature Example
hash(s, n) (string|int|uint, int) → int hash(uid, 100) < 10
md5(s) (string) → string md5(guid) == "..."
contains (string, string) → bool contains(platform, "ios")
rand() () → double rand() < 0.05
replace (string, string, string) → string replace(sid, "-", "")
to_str(v) (any) → string to_str(uid)

Extending the environment

Bring your own variables and functions:

engine, _ := celsius.New[Out](celsius.Config{
    Env: celsius.DefaultEnv().
        With(celsius.Variable("region", cel.StringType)).
        WithFunction("starts_with_test", startsWithTest()),
    // ...
})

Hot reload

Celsius watches the rule file via fsnotify and recompiles on change. Reads are guarded by an RWMutex, so in-flight requests during a reload see either the old set or the new set — never a half-applied state. A reload that fails to compile is logged and the previous good set stays active.

Why "Celsius"?

CEL — the Common Expression Language that every rule is written in — plus sieving, because that's literally what the engine does: it sieves an incoming stream of requests through an ordered set of predicates and routes each one to the first rule it falls through. The fact that the result also reads as the temperature scale is a happy coincidence.

Design notes

See DESIGN.md for architecture, lifecycle, threading model, and extension points.

Validating & testing rule files

Celsius ships a small CLI for validating rule files and running ad-hoc matches without standing up a service.

Important: the stock celsius binary only knows about celsius.DefaultEnv(). If your service registers extra variables or custom functions, that binary cannot validate your real configs — it'll reject every identifier it doesn't recognize.

Use it as-is for trivial configs, or build a 5-line wrapper in your own repo that hands clikit your real EnvBuilder (see Wrapping the CLI for your own env below). The wrapper inherits every variable and function you've registered, so validate, test, eval, and repl all behave exactly like production.

Install the stock binary with:

go install github.com/amkarkhi/celsius/cmd/celsius@latest

Or run from a checkout:

go run ./cmd/celsius <subcommand> ...
Validate (pipeline-friendly)
# Syntax check — fast, no variable declarations needed.
celsius validate rules.yaml

# Strict mode — also type-checks every script. Declare each free identifier
# as DynType so the validator doesn't reject it.
celsius validate --strict --var uid --var platform rules.yaml

Exit codes: 0 clean, 1 usage error, 2 issues found — drop the command straight into a CI job.

Test a single match
celsius test --group checkout_experiment \
  --input uid=42 --input platform=ios \
  rules.yaml

Output names the matched rule's tag and decoded out payload. Inputs are auto-typed: 42 → int, 1.5 → double, true/false → bool, anything else → string.

Eval a single expression

Quickly try out a CEL expression — including any custom function you've registered via WithFunction:

celsius eval --input uid=42 'hash(to_str(uid), 10)'
Wrapping the CLI for your own env

If your service registers extra variables or custom functions, drop a 5-line binary in your repo that hands clikit your real EnvBuilder:

// cmd/myapp-rules/main.go
package main

import (
    "os"

    "github.com/amkarkhi/celsius/clikit"
    "myapp/internal/celenv" // your Env() lives here
)

func main() {
    os.Exit(clikit.Run(clikit.Options{
        Name: "myapp-rules",
        Env:  celenv.Env(),
    }, os.Args[1:]))
}

Then myapp-rules validate --strict rules.yaml understands your custom identifiers, and anything that passes the CLI will run in production — same compilation paths, same env.

See docs/custom-env.md for the full walkthrough: structuring the env package, typing variables properly, unit-testing custom CEL functions with celsius.EvalExpr, gating CI on celsius.ValidateBytes, and anti-patterns to avoid. A runnable end-to-end example lives at examples/customenv.

Interactive REPL
celsius repl rules.yaml
> match greeting uid=200
matched tag=vip out=map[message:hello, big spender]
> match greeting uid=5
matched tag=default out=map[message:hello]
> validate
OK
> quit

Programmatic access is also available via celsius.ValidateBytes and celsius.MatchOnce — useful for unit tests, admin endpoints, or custom tooling.

Examples

Runnable examples live under examples/:

Example What it shows
examples/basic Minimal net/http server + a single rule group.
examples/gin Same flow wired through Gin.
examples/fiber Same flow wired through Fiber.
examples/customenv Custom CEL functions + variables, with a shared extensions package consumed by both the server and a thin CLI wrapper. Includes a *_test.go showing how to unit-test custom functions with celsius.EvalExpr and how to validate the production rules YAML against the production env in CI. This is the pattern you'll want to copy.
go run ./examples/basic     --rules examples/basic/rules.yaml
go run ./examples/gin       --rules examples/basic/rules.yaml
go run ./examples/fiber     --rules examples/basic/rules.yaml
go run ./examples/customenv/server --rules examples/customenv/rules.yaml
go run ./examples/customenv/cli    validate --strict examples/customenv/rules.yaml

Then:

curl -H 'X-UID: 200' http://localhost:8080/hello
curl -H 'X-UID: 42'  http://localhost:8080/hello   # customenv: hits is_internal()
curl -H 'X-UID: 5' -H 'X-Country: IR' http://localhost:8080/hello

Contributing

Issues and PRs welcome — see CONTRIBUTING.md. No CLA.

License

MIT © Amin Karkhi.

Documentation

Overview

Package celsius is a CEL-driven rule engine for runtime decisions.

Celsius compiles a YAML rule file at startup, hot-reloads it on change, and exposes a typed Engine whose Engine.Match method evaluates a named rule group against a map of inputs. Each rule's payload is decoded into a user-supplied generic parameter T.

Transport adapters (net/http, Gin, Fiber) live in separate sub-packages so the core has no web-framework dependencies.

Custom environments

Extend DefaultEnv with your own variables and functions via EnvBuilder.WithVariable / EnvBuilder.WithFunction. The same EnvBuilder should be passed to both New (in your server) and to clikit.Run (in a thin validation CLI) so the CLI accepts exactly the identifiers your production engine accepts. See docs/custom-env.md and the runnable example at examples/customenv.

Validation tooling

ValidateBytes / ValidateFile check a rules file against an EnvBuilder — drop them in a Go test, or use the shipped CLI at cmd/celsius. EvalExpr compiles and runs a single expression against an EnvBuilder and is the recommended unit-test entry point for custom CEL functions.

See https://github.com/amkarkhi/celsius for the complete README and design document.

Index

Constants

This section is empty.

Variables

View Source
var ErrClosed = errors.New("celsius: engine is closed")

ErrClosed is returned when an operation is performed on a closed engine.

View Source
var ErrNoMatch = errors.New("celsius: no rule matched")

ErrNoMatch is returned by Engine.Match when no rule in the group evaluated to true.

View Source
var ErrNoRuleGroup = errors.New("celsius: rule group not found")

ErrNoRuleGroup is returned by Engine.Match when the named rule group does not exist in the loaded configuration.

Functions

func EvalExpr

func EvalExpr(env *EnvBuilder, expr string, inputs map[string]any) (any, error)

EvalExpr compiles and evaluates a single CEL expression against env. Every key in inputs is auto-declared as cel.DynType if env doesn't already declare it, so callers don't need to mirror their production declarations in tests.

Intended for unit-testing custom functions registered on an EnvBuilder:

env := celsius.DefaultEnv().WithFunction("myhash", myHashFn())
got, err := celsius.EvalExpr(env, `myhash("abc") % 10`, nil)
require.NoError(t, err)
require.EqualValues(t, 7, got)

If env is nil, DefaultEnv is used.

func MatchOnce

func MatchOnce(data []byte, group string, inputs map[string]any, opts ValidateOptions) (tag string, out map[string]any, matched bool, err error)

MatchOnce parses bytes, builds a one-shot engine, and matches the named group against inputs. Variable names in inputs are auto-declared as cel.DynType. It is intended for ad-hoc testing (CLI, REPL); production code should use New + Engine.Match.

The matched rule's Out is returned as a map[string]any. tag is the rule's tag, matched is false when no rule matched.

func WithResult

func WithResult[T any](ctx context.Context, rule *Rule[T]) context.Context

WithResult stores the matched rule on ctx. Transport adapters call this after a successful Match; user code rarely needs to.

Types

type Config

type Config struct {
	// Source provides the raw rule-file bytes and notifies on change.
	// FileSource(path) is the default implementation.
	Source Source

	// Env declares the variables and functions visible to rule scripts.
	// Defaults to DefaultEnv() if nil.
	Env *EnvBuilder

	// Input parses incoming requests into the variable map. Required.
	Input Input

	// Logger receives operational logs (compile errors, reload events).
	// If nil, a no-op logger is used.
	Logger *zerolog.Logger

	// AllowMissingFile, when true, lets New succeed even if the rule file
	// does not yet exist. The engine starts with zero rules; the watcher
	// will pick the file up if it appears later.
	AllowMissingFile bool
}

Config is the input to New.

type Ctx

type Ctx interface {
	// Header returns the value of the named request header, or defaultVal
	// if the header is absent or empty.
	Header(key, defaultVal string) string

	// Value returns the value associated with key in the underlying
	// request context (e.g. a value placed there by upstream middleware),
	// or nil if no such value exists.
	Value(key any) any
}

Ctx is the abstraction the Input.Parse hook uses to read request data. Transport adapters provide concrete implementations.

func NewHTTPCtx

func NewHTTPCtx(r *http.Request) Ctx

NewHTTPCtx wraps an *http.Request as a celsius.Ctx.

type Engine

type Engine[T any] struct {
	// contains filtered or unexported fields
}

Engine is the compiled rule set. It is safe for concurrent use; reads are guarded by a sync.RWMutex and hot reloads swap the internal snapshot atomically.

func New

func New[T any](cfg Config) (*Engine[T], error)

New constructs an engine from the given config. It performs the initial load synchronously; any compile error in the rule file is returned.

If cfg.Source returns os.ErrNotExist and cfg.AllowMissingFile is true, the engine starts with an empty rule set and the watcher (if any) will pick up the file when it appears.

func (*Engine[T]) Close

func (e *Engine[T]) Close() error

Close stops the file watcher and releases resources. Subsequent calls to Match return ErrClosed.

func (*Engine[T]) Compile

func (e *Engine[T]) Compile(script string) (*cel.Ast, error)

Compile compiles an ad-hoc script against the engine's environment and returns the AST. Useful for validating user input before storing it.

func (*Engine[T]) Eval

func (e *Engine[T]) Eval(script string, inputs map[string]any) (any, error)

Eval compiles and evaluates an ad-hoc script against inputs. The result type is whatever the script returns.

func (*Engine[T]) Input

func (e *Engine[T]) Input() Input

Input returns the engine's configured input parser. Transport adapters call this; user code rarely needs it.

func (*Engine[T]) Match

func (e *Engine[T]) Match(group string, inputs map[string]any) (*Rule[T], error)

Match evaluates the named rule group against inputs. The first rule whose script returns true is returned. A non-matching rule whose script errors at evaluation time is logged and skipped.

When the rule file declares a top-level `compute:` block, each entry is evaluated once against `inputs` before the rule loop runs and its result is added to the input map under the entry's name. A compute script that errors is logged and treated as nil; rules can guard with `has()` or default checks.

type EnvBuilder

type EnvBuilder struct {
	// contains filtered or unexported fields
}

EnvBuilder accumulates CEL environment options. Use NewEnv for an empty environment or DefaultEnv to start with the built-in helper functions (hash, md5, contains, rand, replace, to_str).

func DefaultEnv

func DefaultEnv() *EnvBuilder

DefaultEnv returns an environment pre-populated with the built-in helper functions. It declares no variables — callers add their own.

func NewEnv

func NewEnv() *EnvBuilder

NewEnv returns an empty environment.

func (*EnvBuilder) With

func (e *EnvBuilder) With(opts ...EnvOpt) *EnvBuilder

With applies one or more declarations. It returns the receiver so that declarations can be chained.

func (*EnvBuilder) WithFunction

func (e *EnvBuilder) WithFunction(name string, opts ...cel.FunctionOpt) *EnvBuilder

WithFunction is a convenience equivalent to With(Function(name, opts...)).

func (*EnvBuilder) WithVariable

func (e *EnvBuilder) WithVariable(name string, t *cel.Type) *EnvBuilder

WithVariable is a convenience equivalent to With(Variable(name, t)).

type EnvOpt

type EnvOpt func(*EnvBuilder)

EnvOpt is a single declaration applied to an EnvBuilder.

func Function

func Function(name string, opts ...cel.FunctionOpt) EnvOpt

Function declares a CEL function with one or more overloads.

func Variable

func Variable(name string, t *cel.Type) EnvOpt

Variable declares a CEL variable.

type FunctionOpt

type FunctionOpt = cel.FunctionOpt

Type and FunctionOpt are re-exported from cel-go so users do not need to import cel-go directly for the common case of declaring variables.

type Input

type Input interface {
	Map() map[string]any
	Parse(ctx Ctx) (map[string]any, error)
}

Input parses a request into the variable map a CEL program will see.

Map should return the *current* state of the input — it is called by the engine after Parse to build the variable map. Parse is allowed (and expected) to mutate the receiver.

type Issue

type Issue struct {
	Group string
	Index int
	Tag   string
	Phase string
	Err   error
}

Issue describes a single problem found by ValidateBytes or ValidateFile.

Phase is one of:

  • "parse" — the YAML could not be decoded.
  • "expand" — a var.NAME expansion error (e.g. cycle).
  • "script" — the CEL expression failed to parse or type-check.

func ValidateBytes

func ValidateBytes(data []byte, opts ValidateOptions) ([]Issue, error)

ValidateBytes parses a Celsius YAML config and validates every rule's CEL script. It returns a slice of Issue; an empty slice means the config is valid. The second return value is a hard error (e.g. YAML decode failure) that prevented validation from running.

func ValidateFile

func ValidateFile(path string, opts ValidateOptions) ([]Issue, error)

ValidateFile reads path and runs ValidateBytes on its contents.

func (Issue) Error

func (i Issue) Error() string

type Rule

type Rule[T any] struct {
	Script  string         `yaml:"script"  mapstructure:"script"`
	Tag     string         `yaml:"tag"     mapstructure:"tag"`
	Out     T              `yaml:"out"     mapstructure:"out"`
	Replace map[string]any `yaml:"replace" mapstructure:"replace"`
	// contains filtered or unexported fields
}

Rule is a single rule. T is the user-defined payload type.

`Replace` declares per-field overlays for `Out`. Each key is a top-level field name in T (matched by its `mapstructure:` tag); each value is a CEL script evaluated at Match time against the same input map the rule script saw, plus any `compute:` results. The evaluated value replaces the static `Out.<key>` for the returned rule.

Two value shapes are accepted (kept for back-compat with rule files that already use the wrapper form):

# Plain script string — preferred for new rules:
replace:
  flow: 'call_arbiter(uid).sub_id == "331" ? "hybrid_search_flow" : "es8_search_flow"'

# Wrapper form — equivalent, older convention:
replace:
  flow:
    type: script
    value: 'call_arbiter(uid).sub_id == "331" ? "hybrid_search_flow" : "es8_search_flow"'

A replace script that errors at Match time is logged at debug and the static `Out.<key>` is kept (graceful fallback).

func ResultFrom

func ResultFrom[T any](ctx context.Context) (*Rule[T], bool)

ResultFrom retrieves a rule previously stored by WithResult. The second return value is false when no rule was stored.

type Source

type Source interface {
	// Read returns the current rule bytes. Returning os.ErrNotExist signals
	// that the source is empty; the engine treats that as "no rules" if
	// Config.AllowMissingFile is true.
	Read() ([]byte, error)

	// Watch installs a callback invoked when the source changes. Calling
	// Watch more than once replaces the previous callback. A Source that
	// does not support change notifications may return nil without
	// installing a callback.
	Watch(onChange func()) error

	// Close releases any resources held by the source.
	Close() error
}

Source supplies the rule-file bytes and notifies the engine when they change. Implementations must be safe for concurrent reads.

func FileSource

func FileSource(path string) Source

FileSource reads rules from a file on disk and watches it via fsnotify.

type Type

type Type = cel.Type

Type and FunctionOpt are re-exported from cel-go so users do not need to import cel-go directly for the common case of declaring variables.

type ValidateOptions

type ValidateOptions struct {
	// Env is the CEL environment to use for compilation. If nil, DefaultEnv()
	// is used. The validator will additionally declare every name listed in
	// Variables as cel.DynType so unknown identifiers don't fail type-check.
	Env *EnvBuilder

	// Variables is an optional list of variable names to declare as
	// cel.DynType for the purpose of validation. Useful for pipeline
	// type-checking when the production code declares concrete types.
	Variables []string

	// SyntaxOnly, when true, only parses each script (no type-check). This
	// catches grammar errors but does not require variable declarations.
	// Use this mode for the simplest "is this YAML well-formed and is each
	// CEL expression syntactically valid" check.
	SyntaxOnly bool
}

ValidateOptions controls validator behavior.

Directories

Path Synopsis
Package clikit is the reusable implementation of the `celsius` CLI.
Package clikit is the reusable implementation of the `celsius` CLI.
cmd
celsius command
celsius is the default CLI for validating and exercising Celsius rule files.
celsius is the default CLI for validating and exercising Celsius rule files.
examples
basic command
Basic Celsius example: stdlib net/http + a single rule group.
Basic Celsius example: stdlib net/http + a single rule group.
customenv/cli command
Thin CLI wrapper that gives `celsius` validate/test/eval/repl visibility into this service's custom CEL functions and variables.
Thin CLI wrapper that gives `celsius` validate/test/eval/repl visibility into this service's custom CEL functions and variables.
customenv/extensions
Package extensions shows how a downstream service ships its own CEL variables and functions on top of Celsius' DefaultEnv.
Package extensions shows how a downstream service ships its own CEL variables and functions on top of Celsius' DefaultEnv.
customenv/server command
HTTP server example using a custom Celsius env (custom variables + custom CEL functions).
HTTP server example using a custom Celsius env (custom variables + custom CEL functions).
fiber command
Fiber Celsius example.
Fiber Celsius example.
gin command
Gin Celsius example.
Gin Celsius example.
internal
celfn
Package celfn contains the implementations of Celsius' default CEL helper functions.
Package celfn contains the implementations of Celsius' default CEL helper functions.
varexpand
Package varexpand resolves `var.NAME` references inside rule scripts.
Package varexpand resolves `var.NAME` references inside rule scripts.
transport
fiber
Package fiber provides a Fiber v2 middleware adapter for Celsius.
Package fiber provides a Fiber v2 middleware adapter for Celsius.
gin
Package gin provides a Gin middleware adapter for Celsius.
Package gin provides a Gin middleware adapter for Celsius.
nethttp
Package nethttp provides a net/http middleware adapter for Celsius.
Package nethttp provides a net/http middleware adapter for Celsius.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL