Skip to main content

Controller-first ASP.NET Core projection for Koan Entities with health, startup composition, and redacted runtime facts.

Total Downloads
10.9K
10,865
Published
Sep 13, 2026
1mo ago

Get Started

dotnet add package Sylin.Koan.Web --version 1.0.70
ProductVersions
.NET
net10.0

README

Sylin.Koan.Web

Turn Koan entities into controller-first ASP.NET Core APIs, with shared policy, health, and runtime facts.

  • Target framework: net10.0
  • License: Apache-2.0

What it adds

  • EntityController<TEntity, TKey> and the string-key EntityController<TEntity> convenience surface
  • attribute-routed ASP.NET Core MVC integration
  • health and optional OpenAPI wiring
  • static-file middleware only when the host supplies a real web-root provider
  • the same configured security headers on default/static files and API responses
  • redacted runtime facts at GET /.well-known/Koan/facts
  • response transformers for deliberate representation shaping
  • one ordered request-context contributor lifecycle for validated principal, tenant, and Entity read context

Install

dotnet add package Sylin.Koan.Web

Meaningful result

Define the business model and inherit one controller. AddKoan() discovers the capability; the base controller supplies the CRUD projection through the shared Entity operation boundary:

using Koan.Data.Core.Model;      // Entity<T>
using Koan.Web.Controllers;      // EntityController<T>
using Microsoft.AspNetCore.Mvc;  // Route

public sealed class Todo : Entity<Todo>
{
    public string Title { get; set; } = "";
    public bool Done { get; set; }
}

[Route("api/todos")]
public sealed class TodosController : EntityController<Todo>;

The result is the /api/todos entity API. Lifecycle, authorization, relationship budgets, and other Entity policies execute at the shared operation boundary rather than being copied into controller actions. Add custom actions only for business operations that are not entity CRUD.

Use transformers when a representation must differ from the stored model; see WEB-0035 below.

For localized content, keep publication authority in the canonical partition while filtering and sorting the requested language. An access realization adds that requirement to the ordinary query:

public sealed class ArticleAccess : EntityAccess<Article>
{
    public override IAccessFilter<Article> Constrain(IAccessFilter<Article> q, AccessAction action)
    {
        if (action != AccessAction.Read) return q;
        var editor = Principal.IsInRole("editor");
        return q.Where(article => editor || article.Published, partition: "");
    }
}

Here Article is the application's Entity type with a Published property. The same ID must exist in the default partition and satisfy the predicate before the selected content is counted or paged. Even an editor requires that counterpart; orphan content stays absent. null inherits the current partition, while "" explicitly selects the default. REST and MCP use the same declaration without extra registration. Mongo supports the native cross-partition query. Unsupported connectors or query combinations refuse it.

For request-derived business context, implement IWebContextContributor with ordinary scoped DI. Koan invokes it after authentication and before endpoints. The contributor validates standard HttpContext evidence once, then may contribute a principal, a capability scope, typed Entity predicates, or an existence-hiding rejection. For example, a share-link contributor can prove ?event= against a durable grant and call context.Where<Photo>(photo => photo.EventId == eventId); all downstream Entity, Vector, and Entity-backed Media reads inherit that predicate automatically. Query values are evidence, never authorization by themselves.

Inspection and configuration

Runtime facts use the same schema as startup, health, and koan://facts. Outside Development, enable Koan:Web:ExposeObservabilitySnapshot deliberately and protect GET /.well-known/Koan/facts as an operational surface. Static-file wiring stays dormant in API-only hosts that have no real web root.

Boundaries and failures

  • A constrained Create branch uses atomic insertion. An identity collision returns existence-hiding 404, including identities hidden by request predicates. Unsupported providers return 501 with web.mutation.insertUnsupported. Ordinary unconstrained upsert keeps its existing semantics.

  • Mutation constraints mean declared Create/Update predicates or stamps. A read-only realization does not constrain writes. Existing coarse authorization, including server grants, remains authoritative.

  • Constrained bulk requests containing a new or unavailable identity return 501 with web.mutation.bulkCreateUnsupported before persistence; submit creates individually. An update-only batch keeps ordinary bulk semantics. Atomic mixed bulk creation is not offered.

  • Dry-run remains a tentative validation preview; it cannot prove identity availability or reserve a key. Existing authorized-update races require a separate conditional-write guarantee.

  • This package projects capabilities into ASP.NET Core; it is not a standalone server and does not choose a data provider. Use Sylin.Koan.App for the shortest web entry bundle or compose lower-level packages deliberately.

  • EntityController<T> exposes direct Entity CRUD. It does not infer workflow endpoints, recursive graph traversal, authorization policy, or a public contract versioning strategy.

  • Relationship expansion preserves per-type visibility and finite budgets. Unsupported implicit scans return a corrective response instead of silently loading a whole source.

  • Runtime facts are redacted, not anonymous. Non-Development exposure remains an operator-owned security decision.

  • Authentication and richer authorization projections live in optional Web/Auth packages; referencing Web alone does not make an API secure.

  • Contributed predicates are request-lifetime read visibility. They do not authorize writes, secure raw storage/SQL, or travel into durable jobs; those boundaries must establish or re-resolve their own application authority.

Technical reference

Ordered response hooks

IEmitHook<T> runs in ascending Order. EmitDecision.With(payload) passes the replacement to subsequent hooks; Next() preserves the current payload. HookContext.ShortCircuit(...) stops the pipeline immediately and takes precedence over a returned replacement. This applies to both collection and model responses through the shared REST/MCP endpoint pipeline.

For a governed collection summary, use the selected rows as the projection source:

return EmitDecision.Project(articles, article => ArticleSummary.From(article));

The hook may load bounded personalization data first and capture it in this mapper. Koan verifies the exact selected source sequence, maps each row once, and checks identity and scope again before emitting any view. Project is terminal: later emit hooks cannot replace its sources or receive its DTOs. Ordinary IProjectionOf<TEntity, TView>.From remains application mapping code, not an authority claim.

Project supports flat collection and body-query responses, including shape=full. To keep map, dict, or relationship responses, a hook can return Next() when ctx.Options.Shape is map or dict, or ctx.Options.IncludeRelationships is true. Those options are normalized at the shared endpoint before hooks. Framework shapes are built after emit hooks, so hooks receive the selected entity rows rather than mutable response wrappers. Combining Project with those shapes refuses before mapping.

Counterpart reads reject arbitrary With payloads and successful custom short-circuits. Hook denial statuses remain usable, but arbitrary denial object bodies are removed. A failed mapper returns no partial view; its side effects, if any, are not undone. Model and mutation emit do not support Project. Mapper exceptions are logged server-side and return a safe 500 response with web.read.projectionFailed. Source-bound projection initially supports string, Guid, and the eight built-in integer key types. Other keys, including mutable byte arrays, refuse before mapping; ordinary With is unchanged.

Every invoked model hook's ShortCircuit result is honored. A pre-save, pre-delete or pre-patch stop prevents persistence, including dry runs and batch pre-save validation. An after-fetch stop precedes relationship expansion. Post-write stops control the response without undoing the committed mutation; the mutation is audited before response hooks run.

Conditional properties

Use the existing access declaration on a property when operators may see or write a field that ordinary callers cannot:

[Access(read: "is:admin", write: "is:admin")]
public List<string> ClaimedByUserIds { get; set; } = [];

The normal AddKoan() MVC pipeline applies the property gate to typed Entity, custom ObjectResult and JsonResult responses, including typed lists and dictionaries. Entity authorization still runs first. AgentGrants retain resource scope. MCP uses the same decisions and keeps its stronger McpIgnore exclusions. Storage and trusted server predicates remain unchanged.

Caller filters, sorts, delete predicates and patch paths cannot read a denied field. Full typed replacement, including a custom MVC body, refuses when an omitted field could erase a protected value. Use JSON Patch for an ordinary editable field. Partial JSON and JSON Merge Patch documents require permission to write every protected member, including when a document only changes a public field. Property remove and owner have no supported meaning and fail with a corrective explanation.

Known denied caller fields return 403; unknown or unsupported field intent returns 400. MVC's native invalid-model response returns 400 when complete typed input binding is refused. Unsupported typed serializer customization fails before buffered output. Static schemas remain a conditional superset. Explicit standard Newtonsoft casing and ordinary ShouldSerialize rules are preserved.

This protects retained typed member contracts, not secrets copied by custom code into an unannotated DTO, JToken or string. Such application projections must own their privacy. Direct execution of an IActionResult outside the MVC invocation pipeline is also outside the result-filter boundary.

For example, PATCH /works/id with Content-Type: application/json-patch+json and [{"op":"replace","path":"/name","value":"Updated"}] can edit an admitted field. The equivalent application/json or application/merge-patch+json document is admitted as a whole-value write and requires whole-value permission. Typed application restores a merged working copy rather than editing the stored row: identity and family-discriminator members, repeated members whose names differ only by case, and values that would create an abstract or interface member refuse correctively. Enum values bind by member name, dictionary keys keep their exact spelling, and a null merge-patch dictionary entry removes that entry.

Typed access sidecars and relationship responses preserve member policy through their known framework wrappers. Inherited property gates use the actual containing resource type. An explicitly Access-declared JSON-ignored property still restricts replacement; JSON ignoring remains intact on output.

Object/interface slots, legacy ISerializable contracts and nonsealed object contracts conservatively require guarded serialization, including a nonsealed root supplied by its actual runtime type. Custom formatters/resolvers/converters or suppressed buffering on those contracts refuse. Sealed, fully known unrestricted output contracts retain their original native formatter path. No application override bypasses this boundary.

Typed JSON collections

Standard typed POST bodies and Entity PUT replacements can bind additive collection arrays, including values inside dictionaries. A concrete IEnumerable<T> needs a public parameterless constructor and Add(T) when Json.NET has no native constructor. Seeded collections also need Clear() so supplied values replace defaults. Native constructors remain authoritative. No application converter is needed.

This shares collection construction with Data, not persistence permissions: nonpublic setters remain unwritable and storage discriminators are not introduced. Governed bodies retain complete field-write admission; PUT uses the same prepared serializer before normal endpoint authorization. Standard naming, converters and MVC settings are retained. Custom unrestricted resolvers and input formatters remain application-owned; arbitrary custom serialization and standalone JsonConvert calls are outside this guarantee.

Downloads by Version

Similar & Alternative Packages

Hand-picked matching NuGet libraries based on tags & ecosystem

Depended On By

Find packages that declare this package as a dependency. NuGet does not provide a reverse-dependency endpoint, so candidates are checked on demand.

Adoption Assessment

Assess this package against its supported frameworks, license, maintenance signals and dependency graph before making it a project standard.

Compatibility metadata lists 1 target framework; confirm the exact target used by your application.The published metadata declares 21 direct dependency entries across framework groups. Inspect the resolved dependency graph for your target framework.

Related Guides & Workflows

Learn how to evaluate, integrate, or audit this package in your .NET project.