Kanject.Core.Api
ASP.NET Core building blocks that give every Kanject HTTP service the same baseline: a consistent Response<T> envelope, exception-to-error middleware, and x-tenant-id tenant resolution. Write endpoints as controllers on BaseController, or as minimal-API endpoint classes whose routes are source-generated. Free under the Community licence.
Install
dotnet add package Kanject.Core.Api Register
using Kanject.Core.Api.Extensions;var builder = WebApplication.CreateBuilder(args);builder.Services.AddControllers();builder.Services.AddCoreExceptionHandlerMiddleware(); // consistent error envelopebuilder.Services.AddTenantMiddleware(); // x-tenant-id resolutionvar app = builder.Build();app.UseRouting();app.UseCoreExceptionHandlerMiddleware();app.UseTenantMiddleware();app.MapControllers();app.Run(); Two middlewares, registered as services and then used in the pipeline: CoreExceptionHandler turns uncaught exceptions into a consistent JSON error envelope, and TenantMiddleware resolves the caller's tenant from the x-tenant-id header into the request context.
Controllers
Derive from BaseController and return through ApiResponse(...), which wraps your payload (and optional PayloadMetadata for pagination) in the shared Response<T> envelope. Errors come back in the same Response<string> shape, so every endpoint — success or failure — has one predictable body:
using Kanject.Core.Api.Abstractions.Models; // ApiResponseDataset<T>, PayloadMetadatausing Kanject.Core.Api.Controller;using Microsoft.AspNetCore.Mvc;[ApiController][Route("api/orders")]public sealed class OrdersController(OrderService orders) : BaseController{ // BaseController writes Kanject.Core.Api.Models.Response<T>. The Abstractions // package has a Response<T> of its own, so name the one you document in full. [HttpGet] [ProducesResponseType(typeof(Kanject.Core.Api.Models.Response<ApiResponseDataset<OrderDto>>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(Kanject.Core.Api.Models.Response<string>), StatusCodes.Status400BadRequest)] public async Task<IActionResult> ListAsync( [FromQuery] int pageSize = 20, [FromQuery] string? pageToken = null, CancellationToken cancellationToken = default) { // ListAsync returns (data, metadata): one page and its PayloadMetadata. var (items, metadata) = await orders.ListAsync(pageSize, pageToken, cancellationToken); // BaseController.ApiResponse wraps the payload in Response<T>. return ApiResponse(items, metadata, "Orders retrieved."); }} Kanject's NoSQL reads (the generated Find…Async finders, GetAllAsync and SqlQueryAsync) return (payload, metadata): the page and a PayloadMetadata that already holds the page size, item count and continuation token. A service that wraps one, like ListAsync here, passes the pair straight to ApiResponse. When a list gives you items and a token instead, build the metadata yourself with new PayloadMetadata(pageSize, items.Count, pageToken: nextToken). A non-empty token sets HasNextPage.
Minimal-API endpoints
The second option is a minimal API without the registration boilerplate. Put public static methods on a static class marked [ApiEndpoints], and the Kanject.Core.Api.Annotations source generator writes the MapGroup / MapGet / MapPost registration at compile time. Each method's XML <summary> and <remarks> become its OpenAPI summary and description. Kanject.Core.Api already brings in the attributes, but not the generator, so add it to the project that declares the classes:
dotnet add package Kanject.Core.Api.Annotations dotnet add package writes a plain reference. Add PrivateAssets="all" to it, so the generator stays in this project instead of flowing to the projects that reference it, and turn on XML documentation:
<PropertyGroup> <!-- the generator reads <summary> / <remarks> from your XML doc comments --> <GenerateDocumentationFile>true</GenerateDocumentationFile></PropertyGroup> using Kanject.Core.Api.Abstractions.Attributes; // [ApiEndpoints], [HttpGet], ...using Kanject.Core.Api.Abstractions.Endpoints; // ApiResponse(...) for minimal APIsusing Microsoft.AspNetCore.Authorization;namespace Contoso.Orders.Endpoints;/// <summary>Order management endpoints.</summary>[ApiEndpoints("Orders", "api/orders")]public static class OrderEndpoints{ /// <summary>Lists the caller's orders.</summary> [HttpGet] public static async Task<IResult> ListOrders( OrderService orders, int pageSize = 20, string? pageToken = null, CancellationToken cancellationToken = default) { var (items, metadata) = await orders.ListAsync(pageSize, pageToken, cancellationToken); // The same Response<T> envelope BaseController.ApiResponse writes. return items.ApiResponse(metadata, "Orders retrieved."); } /// <summary>Liveness probe for the orders group.</summary> [AllowAnonymous] [HttpGet("ping")] public static IResult PingOrders() => Results.Ok();} Then map every endpoint class with one call. The exception and tenant middleware are ordinary pipeline middleware, so they serve endpoint classes as they do controllers:
using Contoso.Orders.Endpoints;using Kanject.Core.Api.Extensions;var builder = WebApplication.CreateBuilder(args);builder.Services.AddAuthentication(/* your scheme */);builder.Services.AddAuthorization();builder.Services.AddCoreExceptionHandlerMiddleware();builder.Services.AddTenantMiddleware();builder.Services.AddScoped<OrderService>();var app = builder.Build();app.UseRouting();app.UseCoreExceptionHandlerMiddleware();app.UseAuthentication();app.UseAuthorization();app.UseTenantMiddleware(); // after authenticationapp.MapEndpoints(); // every [ApiEndpoints] class in the projectapp.Run(); [ApiEndpoints] takes the group name and route positionally, or as named properties:
EndpointGroupName— the OpenAPI tag (WithTags). Defaults to the class name.Route— the prefix passed toMapGroup. Defaults to the root. A verb attribute's template ([HttpGet("{id:guid}")]) is relative to it.RequireAuthorization—trueby default, so the group needs authentication and authorization registered. Set it tofalsefor a public group.AuthPolicy— a named policy for the whole group, applied whileRequireAuthorizationistrue.- Per method, ASP.NET Core's own
[AllowAnonymous]opts out of the group requirement, and[Authorize(Policy = "…")]or[Authorize(Roles = "…")]adds one.
Configure a group or one endpoint
For every class the generator also writes Map{ClassName}() and a {ClassName}EndpointOptions type with one property per endpoint method. Call that method to add conventions to the whole group (configureGroup) or to a single endpoint (configureEndpoints):
// Instead of MapEndpoints() for this group — never both, or its routes register twice.app.MapOrderEndpoints( configureGroup: group => group.RequireCors("frontend"), configureEndpoints: endpoints => { endpoints.PingOrders = endpoint => endpoint.ExcludeFromDescription(); }); - The class must be
static(KANAPI001) and its endpoint methodspublic static(KANAPI002). The generator skips anything else, and the analyzers say so at build time. - Each endpoint is named after its method and OpenAPI tools use that name as the operation ID, so keep method names unique across every endpoint class:
ListOrders, notList. - Return
IResultor a concrete type.IActionResultbelongs to controllers (KANAPI007). - The Kanject
[HttpGet]/[HttpPost]attributes share their names with MVC's. In a file that also importsMicrosoft.AspNetCore.Mvc, alias or fully qualify one of them. - Every build writes
<AssemblyName>.apischema.mdbeside the project: a catalogue of the routes, the authorization matrix and OpenAPI coverage. SetGenerateApiSchematofalseto skip it.
What ships with it
BaseController+ApiResponse(...)→ a consistentResponse<T>/ApiResponseDataset<T>envelope, withPayloadMetadatafor pagination[ApiEndpoints]and the[HttpGet]…[HttpPatch]verb attributes (Kanject.Core.Api.Abstractions), plusApiResponse(...)helpers that write the same envelope from a minimal APIKanject.Core.Api.Annotations— the source generator and analyzers that turn endpoint classes into route registrations, a per-groupMap{ClassName}()and an.apischema.mdroute catalogueAddCoreExceptionHandlerMiddleware/UseCoreExceptionHandlerMiddleware— uncaught exceptions become a uniform JSON error responseAddTenantMiddleware/UseTenantMiddleware—x-tenant-idresolved into the request-scoped tenant context- Distributed as a public NuGet Community package (free under the $250K threshold)