JVM and .NET HTTP
Run Larkfield's pricing service in Spring Boot and ASP.NET Core, with every quote admitted by the kernel before the controller runs.
On this page
Many pricing systems run on the JVM or .NET. Each keeps its normal request pipeline here: a servlet filter in Spring Boot, middleware in ASP.NET Core, and Chio's sidecar beside both, deciding and signing before a quote is issued.
Starts from chio new pricing-jvm-dotnet --example pricing-jvm-dotnet. Both implementations serve POST /v1/quote and GET /v1/quotes on Larkfield's price table, computing prices with decimal arithmetic. The middleware architecture shows where the service and the sidecar meet.
A governed entry point for an existing service
The service keeps its controllers, validation, and storage. The adapter in its pipeline asks the sidecar whether the caller's grant covers the request, and only an admitted request reaches the controller.
1. Start the Spring Boot service and its sidecar
You need Java 17 or newer for Spring Boot, or the .NET 8 SDK for ASP.NET Core. The launcher resolves world.chio:chio-spring-boot from Maven Central, builds the service, starts it on port 8103 beside a sidecar on 9102, and issues a five-minute grant to Basalt's buying agent. You can also download the project.
chio new pricing-jvm-dotnet --example pricing-jvm-dotnet && cd pricing-jvm-dotnet
python3 run.py2. Quote and list
python3 run.py quote sku-118 10
python3 run.py list{
"status": 201,
"body": {
"id": 1, "sku": "sku-118", "percent": 10.0, "price": "21.60", "currency": "USD",
"receipt_id": "c3a9…5f12"
},
"headers": { "x-chio-receipt-id": "c3a9…5f12" }
}
{
"status": 200,
"body": { "quotes": [{ "id": 1, "sku": "sku-118", "percent": 10.0, "price": "21.60", "currency": "USD" }] },
"headers": {}
}You should see a 201 with USD 21.60 and the admission receipt's ID, then the recorded quote. After python3 run.py revoke the same quote returns 403 and records nothing.
Spring Boot: admission before the controller
Depend on the published starter
Read build.gradle.kts · complete 39 lines · complete file
plugins {
kotlin("jvm") version "2.3.0"
kotlin("plugin.spring") version "2.3.0"
id("org.springframework.boot") version "3.5.16"
}
group = "larkfield"
version = "0.1.0"
java {
sourceCompatibility = JavaVersion.VERSION_17
}
repositories {
mavenCentral()
}
dependencies {
implementation(platform("org.springframework.boot:spring-boot-dependencies:3.5.16"))
implementation("world.chio:chio-spring-boot:0.2.0")
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("org.jetbrains.kotlin:kotlin-reflect")
testImplementation(platform("org.springframework.boot:spring-boot-dependencies:3.5.16"))
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
}
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> {
compilerOptions {
freeCompilerArgs.add("-Xjsr305=strict")
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
}
}
tasks.withType<Test> {
useJUnitPlatform()
}Register the filter on the pricing routes
@Bean
fun chioFilter() = FilterRegistrationBean<ChioFilter>().apply {
filter = ChioFilter(ChioFilterConfig(
sidecarUrl = System.getenv("CHIO_SIDECAR_URL") ?: "http://127.0.0.1:9102"
))
addUrlPatterns("/v1/*")
order = Ordered.HIGHEST_PRECEDENCE + 1
}Read PricingApplication.kt · complete 34 lines · complete file
package larkfield.pricing
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
import org.springframework.boot.web.servlet.FilterRegistrationBean
import org.springframework.context.annotation.Bean
import org.springframework.core.Ordered
import world.chio.ChioFilter
import world.chio.ChioFilterConfig
@SpringBootApplication
class PricingApplication {
@Bean
fun bodyLimit() = FilterRegistrationBean<BodyLimit>().apply {
filter = BodyLimit()
addUrlPatterns("/v1/*")
order = Ordered.HIGHEST_PRECEDENCE
}
@Bean
fun chioFilter() = FilterRegistrationBean<ChioFilter>().apply {
filter = ChioFilter(ChioFilterConfig(
sidecarUrl = System.getenv("CHIO_SIDECAR_URL") ?: "http://127.0.0.1:9102"
))
addUrlPatterns("/v1/*")
order = Ordered.HIGHEST_PRECEDENCE + 1
}
}
fun main(args: Array<String>) {
runApplication<PricingApplication>(*args) {
setDefaultProperties(mapOf("server.address" to "127.0.0.1", "server.port" to "8103"))
}
}Read QuoteController.kt · complete 80 lines · complete file
package larkfield.pricing
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import jakarta.servlet.http.HttpServletResponse
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RestController
import java.math.BigDecimal
import java.math.RoundingMode
import java.nio.file.FileSystems
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.StandardCopyOption
import java.nio.file.attribute.PosixFilePermissions
class InvalidQuote(message: String) : IllegalArgumentException(message)
data class Quote(val id: Int, val sku: String, val percent: Double, val price: String, val currency: String = "USD")
// Larkfield list prices, as main holds them after the Quickstart.
val prices = mapOf("sku-118" to BigDecimal("24.00"), "sku-204" to BigDecimal("58.00"), "sku-301" to BigDecimal("140.00"))
class QuoteStore {
private val mapper = jacksonObjectMapper()
private val path = Path.of(".pricing/spring-quotes.json")
private var quotes: List<Quote>
init {
if (FileSystems.getDefault().supportedFileAttributeViews().contains("posix")) {
Files.createDirectories(path.parent, PosixFilePermissions.asFileAttribute(
PosixFilePermissions.fromString("rwx------")
))
} else { Files.createDirectories(path.parent) }
quotes = if (Files.exists(path)) mapper.readValue(Files.readString(path)) else emptyList()
}
@Synchronized fun list(): List<Quote> = quotes.toList()
@Synchronized fun issue(sku: String, percent: Double): Quote {
val list = prices[sku] ?: throw InvalidQuote("unknown sku $sku")
if (percent < 0 || percent > 100) throw InvalidQuote("percent must be between 0 and 100")
val factor = BigDecimal.ONE - BigDecimal.valueOf(percent).divide(BigDecimal(100))
val price = (list * factor).setScale(2, RoundingMode.HALF_UP).toPlainString()
val quote = Quote(quotes.size + 1, sku, percent, price)
val next = quotes + quote
val temporary = Files.createTempFile(path.parent, "spring-quotes-", ".tmp")
try {
Files.writeString(temporary, mapper.writerWithDefaultPrettyPrinter().writeValueAsString(next))
Files.move(temporary, path, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE)
} finally { Files.deleteIfExists(temporary) }
quotes = next
return quote
}
}
@RestController
class QuoteController {
private val store = QuoteStore()
@GetMapping("/healthz")
fun health() = mapOf("status" to "ready", "framework" to "spring")
@GetMapping("/v1/quotes")
fun list() = mapOf("quotes" to store.list())
@PostMapping("/v1/quote")
fun quote(@RequestBody payload: JsonNode, response: HttpServletResponse): ResponseEntity<Any> {
if (!payload.isObject || payload.size() != 2 || payload.get("sku")?.isTextual != true
|| payload.get("percent")?.isNumber != true) {
throw InvalidQuote("body must contain exactly sku and percent")
}
val quote = store.issue(payload.get("sku").asText(), payload.get("percent").asDouble())
return ResponseEntity.status(HttpStatus.CREATED).body(mapOf(
"id" to quote.id, "sku" to quote.sku, "percent" to quote.percent,
"price" to quote.price, "currency" to quote.currency,
"receipt_id" to response.getHeader("X-Chio-Receipt-Id")
))
}
@ExceptionHandler(InvalidQuote::class)
fun invalid(error: InvalidQuote) = ResponseEntity.badRequest().body(mapOf("error" to error.message))
}A body-limit filter runs first and bounds known-length and chunked bodies to 8192 bytes before ChioFilter caches them. The controller checks the request, prices it with BigDecimal rounded half up, and records the quote in .pricing/spring-quotes.json.
ASP.NET Core: the same contract in C#
Stop the Spring Boot service, then select .NET.
python3 run.py --framework dotnetbuilder.Services.AddChioProtection(options => {
options.MaxRequestBodyBytes = 8192;
options.SidecarUrl = Environment.GetEnvironmentVariable("CHIO_SIDECAR_URL")
?? "http://127.0.0.1:9102";
});
var app = builder.Build();
var store = new QuoteStore(".pricing/dotnet-quotes.json");
app.UseWhen(context => context.Request.Path.StartsWithSegments("/v1"), branch => {
branch.UseChioProtection();
});Reference the published package
Read Pricing.csproj · complete 10 lines · complete file
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Backbay.Chio.Middleware" Version="0.2.0" />
</ItemGroup>
</Project>The complete C# service
The middleware reads and hashes the complete body before admission, chunked requests included; the service sets MaxRequestBodyBytes and Kestrel's limit to 8192. The handler receives the rewound body after Chio admits it.
Read Program.cs · complete 76 lines · complete file
using Backbay.Chio;
using System.Text.Json;
var builder = WebApplication.CreateBuilder(args);
builder.WebHost.UseUrls("http://127.0.0.1:8104");
builder.WebHost.ConfigureKestrel(options => options.Limits.MaxRequestBodySize = 8192);
builder.Services.AddChioProtection(options => {
options.MaxRequestBodyBytes = 8192;
options.SidecarUrl = Environment.GetEnvironmentVariable("CHIO_SIDECAR_URL")
?? "http://127.0.0.1:9102";
});
var app = builder.Build();
var store = new QuoteStore(".pricing/dotnet-quotes.json");
app.UseWhen(context => context.Request.Path.StartsWithSegments("/v1"), branch => {
branch.UseChioProtection();
});
app.MapGet("/healthz", () => Results.Json(new { status = "ready", framework = "dotnet" }));
app.MapGet("/v1/quotes", () => Results.Json(new { quotes = store.List() }));
app.MapPost("/v1/quote", (JsonElement payload, HttpContext context) => {
if (payload.ValueKind != JsonValueKind.Object || payload.EnumerateObject().Count() != 2
|| !payload.TryGetProperty("sku", out var sku) || sku.ValueKind != JsonValueKind.String
|| !payload.TryGetProperty("percent", out var percent) || percent.ValueKind != JsonValueKind.Number) {
return Results.BadRequest(new { error = "body must contain exactly sku and percent" });
}
try {
var quote = store.Issue(sku.GetString()!, percent.GetDecimal());
return Results.Json(new {
id = quote.Id, sku = quote.Sku, percent = quote.Percent, price = quote.Price, currency = "USD",
receipt_id = context.Response.Headers["X-Chio-Receipt-Id"].ToString()
}, statusCode: StatusCodes.Status201Created);
} catch (ArgumentException error) {
return Results.BadRequest(new { error = error.Message });
}
});
app.Run();
record Quote(int Id, string Sku, decimal Percent, string Price);
sealed class QuoteStore {
// Larkfield list prices, as main holds them after the Quickstart.
static readonly Dictionary<string, decimal> Prices = new() {
["sku-118"] = 24.00m, ["sku-204"] = 58.00m, ["sku-301"] = 140.00m,
};
private readonly string path;
private readonly object gate = new();
private readonly JsonSerializerOptions json = new(JsonSerializerDefaults.Web) { WriteIndented = true };
private List<Quote> quotes;
public QuoteStore(string path) {
this.path = path;
var directory = Path.GetDirectoryName(path)!;
if (OperatingSystem.IsWindows()) Directory.CreateDirectory(directory);
else Directory.CreateDirectory(directory, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
quotes = File.Exists(path)
? JsonSerializer.Deserialize<List<Quote>>(File.ReadAllText(path), json)
?? throw new InvalidDataException("Quotes file must contain an array")
: new();
}
public Quote[] List() { lock (gate) return quotes.ToArray(); }
public Quote Issue(string sku, decimal percent) {
if (!Prices.TryGetValue(sku, out var list)) throw new ArgumentException($"unknown sku {sku}");
if (percent < 0 || percent > 100) throw new ArgumentException("percent must be between 0 and 100");
var price = Math.Round(list * (1 - percent / 100), 2, MidpointRounding.AwayFromZero).ToString("0.00");
lock (gate) {
var quote = new Quote(quotes.Count + 1, sku, percent, price);
var next = quotes.Append(quote).ToList();
var temporary = path + "." + Guid.NewGuid().ToString("N") + ".tmp";
try {
File.WriteAllText(temporary, JsonSerializer.Serialize(next, json));
File.Move(temporary, path, overwrite: true);
} finally { File.Delete(temporary); }
quotes = next;
return quote;
}
}
}{
"id": 1,
"sku": "sku-118",
"percent": 10,
"price": "21.60",
"currency": "USD",
"receipt_id": "5e20…b8c3"
}Know what the adapters govern
| Concern | Spring Boot | ASP.NET Core |
|---|---|---|
Interception | Servlet filter mapped to /v1/* | UseWhen on /v1, then Use |
Body boundary | 8192-byte reader before ChioFilter | 8192-byte middleware and Kestrel limits; complete body hash |
Receipt in the handler | Response X- header | Response X- header |
Health route | /healthz outside the filter mapping | /healthz outside the middleware branch |
The adapters ask the sidecar for a decision and verify the returned receipt; they never run the controller remotely. Authentication, validation, and persistence stay with the service.
Connect each response to its decision
The recorded quotes
The list in step 2 is the service's own record: one quote, issued under Basalt's grant.
A refused quote returns its receipt ID
{
"error": "chio_access_denied",
"message": "side-effect route requires a capability token",
"receipt_id": "0d6e…a417"
}An admitted quote returns the price
The 201 bodies above carry the price and the admission receipt's ID. A request for sku-999 with a valid grant is admitted and then rejected with 400; its receipt records the decision, not the controller's answer:
{
"verdict": { "verdict": "allow" },
"response_status": 200,
"metadata": { "chio_http_status_scope": "decision" }
}Read your own receipts
python3 receipts.py --decision deny0d6e…a417 DENY POST /v1/quote HTTP 403
receipts.py opens .pricing/receipts.db read-only; pass a receipt ID in place of --decision to print that complete signed record, and post it to /chio/verify as HTTP receipt verification shows.
Resolve the failure you received
| Situation | Spring Boot | ASP.NET Core | What to do |
|---|---|---|---|
No grant | 403 | 403 | Issue a grant; nothing was recorded. |
Grant, known SKU | 201 | 201 | Read the quote list and the receipt ID. |
Grant, sku-999 or percent outside 0 to 100 | 400 | 400 | Fix the request; admission allowed it and the controller refused it. |
Body over 8192 bytes | 413 | 413 | Send a smaller body; no receipt was created. |
Sidecar down | 502 | 502 | Restart the sidecar. /healthz stays 200 and recorded quotes survive. |
Keep the runtime and the authority separate
The JVM and .NET adapters reach the sidecar over HTTP, so interception stays in the managed runtime and Chio's Rust process owns evaluation and signing. Protect the sidecar's operator interface and include the request boundary in the deployment design. The JSON quote files are deliberately small, single-process stores; move the controller onto your own database and transaction model.
Check the whole contract in one run
With the launcher stopped, python3 run.py check refuses a quote with no grant, issues one with Basalt's grant, rejects sku-999 after admission, revokes the grant, refuses again, and confirms the refused requests recorded nothing. Add --framework dotnet for ASP.NET Core.
Require a grant to read quotes
Mark the read operation side-effecting in openapi.yaml, restart the launcher with the same .pricing state, and read with and without the grant.
paths:
/v1/quotes:
get:
operationId: listQuotes
x-chio-side-effects: true
responses:
"200": { description: Up to 100 issued quotes }
# Keep the existing /v1/quote post operation here.curl -sS -w ' HTTP %{http_code}\n' http://127.0.0.1:8103/v1/quotes
curl -sS http://127.0.0.1:8103/v1/quotes \
-H "X-Chio-Capability: $(jq -c . .pricing/capability.json)"{"error":"chio_access_denied","message":"side-effect route requires a capability token","receipt_id":"8f42…03bd"} HTTP 403
{"quotes":[{"id":1,"sku":"sku-118","percent":10.0,"price":"21.60","currency":"USD"}]}The read without a grant is refused, and the read with Basalt's grant returns the same quote. Only the matched operation changes; unmatched safe methods keep the method-based default.