The Server Endpoint

The Rule Repository's browser script never talks to your database or to Code Effects. Every time it needs something (a page of a grid, the options of a drop-down list, a record, a save, a test run, an AI step), it builds a request as a JSON string and hands it to a function on your page. That function posts the string to one endpoint in your application, and the endpoint passes it to the library and returns the answer unchanged.

You need this endpoint because it is the only door between your users and their rules. It is where your application proves who the user is, decides what they may do, and gives RR the database to work with. The library does the rest.

What the Endpoint Does

For each request, in this order:

  1. Requires your application's sign-in (no anonymous access).
  2. Accepts only application/json and refuses oversized bodies.
  3. Reads the body as a string.
  4. Builds a Configuration for the signed-in user: who they are and what they may do.
  5. Calls Context.GetResponse(config, db, json) and returns the string it returns, with content type application/json.
  6. Catches the library's two exception types, logs them, and returns the JSON they carry.

Context is a static class: there is no repository object to create, configure or dispose of. Each call is independent and safe to make from many requests at once. Everything a call needs arrives in its arguments: the configuration, the database provider and the request.

ASP.NET Core

A complete endpoint, with the user's configuration built by a small service of your own (see The Configuration for RepositoryConfigurationFactory):

using CodeEffects.Rule.Connection;
using CodeEffects.Rule.Repository;

public static class RuleRepositoryEndpoint
{
	// RR refuses a request longer than 3 MB itself; the body limit here is a little higher.
	// Raise it if you allow AI attachments (see AI Assistant and AI Rule Editor).
	private const long MaxRequestBytes = 4 * 1024 * 1024;

	public static void MapRuleRepository(this WebApplication app, string path = "/api/rules")
	{
		app.MapPost(path, async (HttpContext http, IDbProvider db, RepositoryConfigurationFactory configs,
			ILogger<RepositoryConfigurationFactory> log) =>
		{
			// JSON only: a cross-site form can post text/plain without the browser asking first
			if (http.Request.ContentType == null ||
				!http.Request.ContentType.StartsWith("application/json", StringComparison.OrdinalIgnoreCase))
				return Results.StatusCode(StatusCodes.Status415UnsupportedMediaType);

			if (http.Request.ContentLength > MaxRequestBytes)
				return Results.StatusCode(StatusCodes.Status413PayloadTooLarge);

			var size = http.Features.Get<Microsoft.AspNetCore.Http.Features.IHttpMaxRequestBodySizeFeature>();
			if (size != null && !size.IsReadOnly) size.MaxRequestBodySize = MaxRequestBytes;

			string json;
			using (var reader = new StreamReader(http.Request.Body))
				json = await reader.ReadToEndAsync();

			var config = configs.Create(http.User);

			try
			{
				return Results.Content(Context.GetResponse(config, db, json), "application/json");
			}
			catch (RepositoryPermissionException ex)
			{
				// A refusal, not a failure: the user tried something their permissions do not allow
				log.LogWarning("Rule Repository refused {Action} for {User}: {Message}",
					ex.Action, config.CurrentUserId, ex.Message);
				return Results.Content(ex.ResponseJson, "application/json");
			}
			catch (RepositoryException ex)
			{
				// Your log gets everything; the browser gets a general message and the control number
				log.LogError(ex, "Rule Repository request failed: {ControlNumber}", ex.ControlNumber);
				return Results.Content(ex.ResponseJson, "application/json");
			}
		})
		.RequireAuthorization("RuleRepositoryUsers");
	}
}

In Program.cs:

builder.Services.AddSingleton<IDbProvider>(_ =>
	new CodeEffects.Rule.Connection.SqlServer.DbProvider(builder.Configuration.GetConnectionString("Rules")));
builder.Services.AddSingleton<RepositoryConfigurationFactory>();
builder.Services.AddAuthorization(o =>
	o.AddPolicy("RuleRepositoryUsers", p => p.RequireAuthenticatedUser().RequireRole("RuleManager", "RuleAuthor", "RuleReviewer")));

var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapRuleRepository();

In an MVC controller

The same steps fit an action method:

using CodeEffects.Rule.Connection;
using CodeEffects.Rule.Repository;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;

[Authorize(Policy = "RuleRepositoryUsers")]
[ApiController]
[Route("api/rules")]
public class RulesController : ControllerBase
{
	private readonly IDbProvider db;
	private readonly RepositoryConfigurationFactory configs;
	private readonly ILogger<RulesController> log;

	public RulesController(IDbProvider db, RepositoryConfigurationFactory configs, ILogger<RulesController> log)
	{
		this.db = db;
		this.configs = configs;
		this.log = log;
	}

	[HttpPost]
	[Consumes("application/json")]
	[RequestSizeLimit(4 * 1024 * 1024)]
	public async Task<IActionResult> Post()
	{
		string json;
		using (var reader = new StreamReader(this.Request.Body))
			json = await reader.ReadToEndAsync();

		var config = this.configs.Create(this.User);

		try
		{
			return this.Content(Context.GetResponse(config, this.db, json), "application/json");
		}
		catch (RepositoryPermissionException ex)
		{
			this.log.LogWarning("Rule Repository refused {Action}: {Message}", ex.Action, ex.Message);
			return this.Content(ex.ResponseJson, "application/json");
		}
		catch (RepositoryException ex)
		{
			this.log.LogError(ex, "Rule Repository request failed: {ControlNumber}", ex.ControlNumber);
			return this.Content(ex.ResponseJson, "application/json");
		}
	}
}

Read the body yourself as a string, as above, rather than binding it to a model: the library expects the JSON exactly as the browser sent it.

ASP.NET WebForms and MVC on .NET Framework

Repository is a .NET Standard 2.0 library, so an application on .NET Framework 4.7.2 or later hosts RR the same way. A generic handler (RuleRepository.ashx) is the simplest endpoint:

using System.IO;
using System.Web;
using CodeEffects.Rule.Repository;

public class RuleRepositoryHandler : IHttpHandler
{
	public bool IsReusable => true;

	public void ProcessRequest(HttpContext context)
	{
		var request = context.Request;
		var response = context.Response;

		if (!context.User.Identity.IsAuthenticated) { response.StatusCode = 401; return; }
		if (request.HttpMethod != "POST") { response.StatusCode = 405; return; }
		if (request.ContentType == null || !request.ContentType.StartsWith("application/json")) { response.StatusCode = 415; return; }
		if (request.ContentLength > 4 * 1024 * 1024) { response.StatusCode = 413; return; }

		string json;
		using (var reader = new StreamReader(request.InputStream))
			json = reader.ReadToEnd();

		var config = new RepositoryConfigurationFactory().Create((System.Security.Claims.ClaimsPrincipal)context.User);
		response.ContentType = "application/json";

		try
		{
			response.Write(Context.GetResponse(config, Global.RulesDb, json));
		}
		catch (RepositoryPermissionException ex)
		{
			// log the refusal with your own logging
			response.Write(ex.ResponseJson);
		}
		catch (RepositoryException ex)
		{
			// log ex with your own logging
			response.Write(ex.ResponseJson);
		}
	}
}

Protect the handler's path with your authentication (<authorization> in web.config, or your sign-in module) as you would any page, and set maxRequestLength in web.config to allow a few megabytes. The page that shows RR can be a WebForms page, an MVC view or a static HTML file: see The Client.

What the Library Does on Its Side

  • Anti-forgery token. RR's first request of a page (Init) returns a token bound to CurrentUserId; the browser script sends it with every later request, and the library refuses a request without a valid one. A page on another site therefore cannot post to your endpoint as your user, as long as your endpoint does not allow cross-origin requests with credentials. You never handle the token yourself. If your own server code builds repository requests (an administration tool, an integration test), get a token with Context.GetRequestToken(config, db).
  • Request limits. Requests longer than 3 MB, more than 50 filters, more than 1,000 drop-down options and more than eight test runs at once are refused. Change them with Configuration.Limits.
  • Permissions. Every request is checked against the user's permission set before any work is done.
  • Errors. Failures are classified and given a control number. The browser sees only a general message and the number; the exception you log has the full detail, database errors included.

Errors and What to Return

Exception Meaning What to do
RepositoryPermissionException (CE10219) The user's permissions do not allow the request. Log it as a warning (denials are worth auditing) and return ResponseJson, a warning the user sees, which also carries their current permissions so the page updates itself.
RepositoryException The request failed: a database error, an invalid request, an incomplete permission set (CE10218). Log the exception and return ResponseJson, a general message with the control number.
Anything else Should not happen; the library wraps its failures. Let your application's error handling deal with it. The browser reports a failed call.

Always return status 200 with the JSON for the two library exceptions: the browser script reads the warning or error from the JSON and shows it in RR's own way. A non-200 status makes it report a failed call instead. See Error Codes.

Several Servers

The endpoint keeps no state between requests. Everything lives in the database, including the key that signs the anti-forgery tokens, so any number of web servers behind a load balancer can answer RR's requests without sticky sessions.

Ask on Stack Overflow - Yes, it's still active
Ask in GitHub Discussions

p102

l097 --

l102

p101

×