Tutorial: Host rules behind a web API
Build an ASP.NET Core minimal API around the checkout policy. It evaluates orders posted as JSON, validates rule drafts for an editor, swaps in new rules without a restart (refusing any that don't load), and publishes the vocabulary a rule editor needs.
You will learn to:
- register one engine and a swappable rule set as singletons
- evaluate JSON request bodies as dictionary facts
- validate drafts, and hot-swap rules safely
- accept camelCase JSON from clients, and refuse ambiguous bodies
Step 1: Create the project
dotnet new web -n RulesService
cd RulesService
dotnet add package RuleWright
Copy checkout-policy.json from the checkout tutorial (download) into the project folder. dotnet run starts in that folder, so the service finds it there.
Step 2: A holder for the live rules
The service must be able to replace its rules while requests are running. This class holds the current LoadedRuleSet, and only swaps in a document that loaded:
using RuleWright.Execution;
/// <summary>
/// The current rule set, swappable while requests are being evaluated. Readers take whatever
/// reference is current; a reload validates and loads the new document before swapping, so a bad
/// file never replaces good rules.
/// </summary>
public sealed class RuleSetHolder
{
private readonly RuleWrightEngine _engine;
private LoadedRuleSet _current;
public RuleSetHolder(RuleWrightEngine engine, string json)
{
_engine = engine;
_current = engine.LoadRuleSet(json);
}
public LoadedRuleSet Current => Volatile.Read(ref _current);
public void Reload(string json)
{
LoadedRuleSet next = _engine.LoadRuleSet(json); // throws on a bad document: nothing swapped
Volatile.Write(ref _current, next);
}
}
Step 3: Write the API
Replace Program.cs with the following, and end it with app.Run();. In a dotnet new web project the ASP.NET Core namespaces are imported automatically.
using System.Text.Json;
using RuleWright.Core;
using RuleWright.Execution;
using RuleWright.Extensions.Functions;
using RuleWright.Json.SystemText;
using RuleWright.Serialization;
var builder = WebApplication.CreateBuilder(args);
// One engine for the process: thread-safe, and it caches every compiled rule.
builder.Services.AddSingleton(new RuleWrightBuilder()
.UseJsonReader(new SystemTextJsonReader())
.RegisterBuiltInFunctions()
.Build());
// The current policy, loaded at startup and swappable at run time.
builder.Services.AddSingleton(provider => new RuleSetHolder(
provider.GetRequiredService<RuleWrightEngine>(),
File.ReadAllText("checkout-policy.json")));
var app = builder.Build();
// Evaluate a fact posted as JSON. Clients usually send camelCase ("order": { "total": … }),
// so keys match without regard to case, the way a typed fact's members would.
app.MapPost("/evaluate", (JsonElement fact, RuleWrightEngine engine, RuleSetHolder rules) =>
{
Dictionary<string, object?> facts;
try
{
facts = SystemTextJsonFacts.ToDictionary(fact, StringComparer.OrdinalIgnoreCase);
}
catch (ArgumentException ex)
{
// Not an object, or "Total" and "total" in one object: ambiguous, so refuse it.
return Results.Problem(ex.Message, statusCode: 400);
}
RuleEvaluationResult result = engine.Evaluate(rules.Current, facts);
return Results.Ok(new
{
fired = result.FiredRules.Select(r => r.RuleId),
outputs = result.Outputs,
});
});
// Check a rule document without loading it: what an editor calls on every keystroke.
app.MapPost("/rules/validate", async (HttpRequest request, RuleWrightEngine engine) =>
{
string json = await new StreamReader(request.Body).ReadToEndAsync();
RuleSetValidationResult validation = engine.Validate(json);
return Results.Ok(new
{
validation.IsValid,
errors = validation.Errors.Select(e => new { e.Path, e.Message }),
});
});
// Replace the live policy. A document that does not load is refused, and the old one stays.
app.MapPut("/rules", async (HttpRequest request, RuleSetHolder rules) =>
{
string json = await new StreamReader(request.Body).ReadToEndAsync();
try
{
rules.Reload(json);
return Results.NoContent();
}
catch (RuleWrightException ex)
{
return Results.Problem(ex.Message, statusCode: 422);
}
});
// The vocabulary an editor may offer, including this engine's custom functions.
app.MapGet("/vocabulary", (RuleWrightEngine engine) => Results.Ok(new
{
conditionOperators = RuleSchemaCatalog.ConditionOperators.Select(o => o.JsonName),
expressionOperators = RuleSchemaCatalog.ExpressionOperators.Select(o => o.JsonName),
actionTypes = RuleSchemaCatalog.ActionTypes.Select(a => a.Name),
functions = engine.FunctionCatalog.Select(f => new { f.Name, f.Description }),
}));
Why it's written this way
- One engine, as a singleton. The engine caches every compiled rule. An engine per request would recompile the policy on every request.
- The holder, not the rule set, is the singleton. Endpoints read
rules.Currentper request, so aPUT /rulestakes effect on the next request, and a request already running finishes with the rules it started with. - JSON facts, no C# model. The body binds to a
JsonElementand becomes a dictionary fact, so the rules, not the service, own the shape of an order. - Keys match without regard to case. Clients send camelCase (
"order": { "total": 240 }) and the rules readOrder.Total.StringComparer.OrdinalIgnoreCasemakes them meet, as they would on a typed fact. A body with"Total"and"total"in one object is ambiguous, so it gets a400instead of a guess. - Refuse, don't break.
Reloadloads the new document before swapping. A document that fails to parse, validate or bind throws, the endpoint answers422, and the old rules keep serving. /rules/validatenever throws.Validatereturns errors with JSON pointers, which an editor can underline as the author types.
Step 4: Call it
Run the service with dotnet run, then call it. From C#:
using System.Net.Http.Json;
using System.Text;
using var http = new HttpClient { BaseAddress = new Uri(baseAddress) };
// Evaluate an order. PostAsJsonAsync writes camelCase ("order": { "total": 240 }),
// which the service matches against the rules' Order.Total.
var order = new
{
Customer = new { Name = "Aroha", Age = 34, IsVip = true, LoyaltyYears = 6 },
Order = new { Total = 240, Lines = new[] { new { Category = "alcohol" } } },
};
HttpResponseMessage evaluated = await http.PostAsJsonAsync("/evaluate", order);
string firstAnswer = await evaluated.Content.ReadAsStringAsync();
Console.WriteLine($"POST /evaluate -> {(int)evaluated.StatusCode}");
Console.WriteLine(firstAnswer);
// A body with "Total" and "total" in one object is ambiguous.
string clashing = """{ "Order": { "Total": 240, "total": 24 } }""";
HttpResponseMessage ambiguous = await http.PostAsync("/evaluate", new StringContent(clashing, Encoding.UTF8, "application/json"));
Console.WriteLine($"POST /evaluate -> {(int)ambiguous.StatusCode} (Total and total)");
// Validate a draft with a typo in it.
string draft = """{ "id": "d", "condition": { "field": "Order.Total", "operator": "GreaterThen", "value": 10 } }""";
HttpResponseMessage checkedDraft = await http.PostAsync("/rules/validate", new StringContent(draft, Encoding.UTF8, "application/json"));
Console.WriteLine($"POST /rules/validate -> {(int)checkedDraft.StatusCode}");
Console.WriteLine(await checkedDraft.Content.ReadAsStringAsync());
// Try to publish it: refused, and the live policy is untouched.
HttpResponseMessage published = await http.PutAsync("/rules", new StringContent(draft, Encoding.UTF8, "application/json"));
Console.WriteLine($"PUT /rules -> {(int)published.StatusCode}");
// Publish a new policy: from now on, every order gets 20% off.
string newPolicy = """
{ "id": "sale", "condition": { "field": "Order.Total", "operator": "GreaterThan", "value": 0 },
"actions": [ { "type": "setOutput", "target": "DiscountPercent", "value": 20 } ] }
""";
HttpResponseMessage replaced = await http.PutAsync("/rules", new StringContent(newPolicy, Encoding.UTF8, "application/json"));
Console.WriteLine($"PUT /rules -> {(int)replaced.StatusCode}");
HttpResponseMessage again = await http.PostAsJsonAsync("/evaluate", order);
string secondAnswer = await again.Content.ReadAsStringAsync();
Console.WriteLine($"POST /evaluate -> {(int)again.StatusCode}");
Console.WriteLine(secondAnswer);
Output
POST /evaluate -> 200
{"fired":["member-discount","big-basket","age-check","shipping","loyalty-points"],"outputs":{"DiscountPercent":15,"Notes":["member discount","big basket","ID required on delivery"],"IdRequired":true,"Shipping":0,"Points":24,"Greeting":"Thanks for your order, Aroha!"}}
POST /evaluate -> 400 (Total and total)
POST /rules/validate -> 200
{"isValid":false,"errors":[{"path":"/condition/operator","message":"Unknown operator 'GreaterThen'. Expected one of: Equals, NotEquals, GreaterThan, GreaterThanOrEqual, LessThan, LessThanOrEqual, Contains, StartsWith, EndsWith, MatchesRegex, In, NotIn, IsNull, IsNotNull, custom, Any, All, None."}]}
PUT /rules -> 422
PUT /rules -> 204
POST /evaluate -> 200
{"fired":["sale"],"outputs":{"DiscountPercent":20}}
PostAsJsonAsync sent the order in camelCase and every rule matched. The ambiguous body is refused, the draft with a typo is reported and then refused, and the new policy replaces the old one for the next evaluation.
Warning
Without the comparer, camelCase fails silently. SystemTextJsonFacts.ToDictionary(fact) keeps keys exact, so Order.Total wouldn't find "order": { "total": 240 }. Every rule would read null, only else branches would fire, and the response would look like a real answer. Always pass a comparer for JSON you don't control.
Or from a terminal:
curl -X POST http://localhost:5000/evaluate -H "Content-Type: application/json" \
-d '{"Customer":{"Name":"Aroha","Age":34,"IsVip":true},"Order":{"Total":240,"Lines":[]}}'
curl -X PUT http://localhost:5000/rules -H "Content-Type: application/json" --data-binary @checkout-policy.json
Going to production
- Protect
PUT /rules. Replacing the rules changes business decisions. Put the endpoint behind authorization, and log who changed what. - Persist what you publish. The holder lives in memory. Save accepted documents (to a database, a blob or git) and load the latest at startup.
- Several instances. Each instance holds its own rules. Publish to shared storage and have each instance reload, rather than calling
PUTon one of them. - Warm up at startup. Evaluate a representative fact once after loading, so the first customer doesn't pay for compilation.
- Bound what you accept. See Accept rules you didn't write for regex timeouts, document size and fact projections.
Next steps
- Test your rule documents before they reach
PUT /rules. - Discover the vocabulary has the full catalog
/vocabularydraws on.