REST API¶
Cargonerds exposes a REST/HTTP API that is, for the most part, not hand-written. Application services are turned into HTTP endpoints automatically at startup using ABP's Auto API Controllers convention. The same controllers also drive the generated dynamic C# client proxies and the Swagger/OpenAPI document.
The entire HTTP surface is served by a single host, Cargonerds.HttpApi.Host. The in-repo
modules (Hub, Pricing) ship only *.HttpApi projects (controllers + a module class); they do not
have their own hosts and are composed into the one host via [DependsOn].
Three API styles in one pipeline
The host layers three different things into the same ASP.NET Core pipeline:
- ABP auto-API controllers — the bulk of the REST surface (
/api/app/…,/api/hub/…,/api/pricing/…), generated from application services. - A handful of hand-written MVC controllers — Swagger redirects, the white-labeled Swagger CSS, and the home redirect.
- A large OData v8 controller layer in
modules/hub/src/Hub.HttpApi(~60ODataControllersubclasses) mounted under theodataroute prefix. This is the Hub read/query backbone and is documented on its own page — see OData, Filtering & Facets.
Host¶
The host is an ASP.NET Core application (Microsoft.NET.Sdk.Web, net10.0).
| Concern | Location |
|---|---|
| Entry point | src/Cargonerds.HttpApi.Host/Program.cs |
| ABP module / pipeline | src/Cargonerds.HttpApi.Host/CargonerdsHttpApiHostModule.cs |
| Contracts-level HttpApi module | src/Cargonerds.HttpApi/CargonerdsHttpApiModule.cs |
Program.cs builds a WebApplication, wires up Aspire service defaults and a Redis client, and
bootstraps the ABP module. Kestrel is given a 2-minute keep-alive and header timeout:
var builder = WebApplication.CreateBuilder(args);
builder.AddServiceDefaults(); // Aspire (OpenTelemetry, health, service discovery)
builder.AddRedisClient("redis");
builder
.Host.AddAppSettingsSecretsJson()
.UseAutofac()
.UseSerilog(/* … OpenTelemetry + Application Insights sinks … */);
builder.WebHost.ConfigureKestrel(options =>
{
options.Limits.KeepAliveTimeout = TimeSpan.FromMinutes(2);
options.Limits.RequestHeadersTimeout = TimeSpan.FromMinutes(2);
});
await builder.AddApplicationAsync<CargonerdsHttpApiHostModule>();
CargonerdsHttpApiModule is the catalog of mounted Volo/ABP HTTP APIs (its [DependsOn(...)] list
includes PermissionManagement, SettingManagement, Identity, Account, AuditLogging, OpenIddict Pro,
LanguageManagement, FileManagement, Saas, Gdpr, CmsKit Pro, Chat, FeatureManagement) plus the two
in-repo module HTTP APIs (HubHttpApiModule, PricingHttpApiModule).
The host pipeline (OnApplicationInitialization) runs, in order: request localization → routing →
static assets → security headers → CORS → authentication → multi-tenancy → unit of work → dynamic
claims → a per-user filter-context middleware → authorization → response compression → Swagger →
auditing → Serilog enrichers → endpoints. See
Hosting & Aspire for the full startup story.
Auto-generated controllers¶
Application services are exposed as controllers explicitly for three assemblies in
CargonerdsHttpApiHostModule.ConfigureConventionalControllers:
Configure<AbpAspNetCoreMvcOptions>(options =>
{
options.ConventionalControllers.Create(typeof(CargonerdsApplicationModule).Assembly);
options.ConventionalControllers.Create(typeof(HubApplicationModule).Assembly);
options.ConventionalControllers.Create(typeof(PricingApplicationModule).Assembly);
});
Any public application service implementing IApplicationService (or inheriting
ApplicationService) in those assemblies becomes a REST endpoint. ABP derives the route, HTTP
verb and status codes from method naming conventions.
Route prefixes per module¶
The route prefix is derived from each module's remote-service settings, not hard-coded:
| Source module | Constant | Route prefix |
|---|---|---|
Cargonerds.Application |
(ABP default) | /api/app/… |
Hub.Application |
HubRemoteServiceConsts.ModuleName = "hub" |
/api/hub/… |
Pricing.Application |
PricingRemoteServiceConsts.ModuleName = "pricing" |
/api/pricing/… |
public class HubRemoteServiceConsts
{
public const string RemoteServiceName = "Hub";
public const string ModuleName = "hub";
}
On top of these, the standard ABP module endpoints are mounted (/api/identity/…, /api/account/…,
/api/saas/…, /api/permission-management/…, /api/setting-management/…, …), plus the OData
controllers under /odata/… (HubConsts.ODataRoute = "odata").
Verb prefix → HTTP method¶
ABP infers the HTTP verb from the method-name prefix. Methods can override this with an explicit
[HttpGet] / [HttpPost] / [HttpPut] / [HttpDelete] attribute.
| Method name prefix | HTTP verb | Example method | Example route |
|---|---|---|---|
Get… |
GET |
GetListAsync / GetAsync(Guid id) |
GET /api/app/shipment · GET /api/app/shipment/{id} |
Create…, Add…, Insert…, Post… |
POST |
CreateAsync(dto) |
POST /api/app/shipment |
Update…, Put… |
PUT |
UpdateAsync(Guid id, dto) |
PUT /api/app/shipment/{id} |
Delete…, Remove… |
DELETE |
DeleteAsync(Guid id) |
DELETE /api/app/shipment/{id} |
| anything else | POST |
UpsertWidgetAsync(...) |
POST /api/app/… |
Guid parameters become route segments
The first Guid parameter of an action is promoted to a path segment rather than a query
parameter. So UpsertWidgetAsync(Guid reportId, …) maps to
POST /api/app/poc-report/upsert-widget/{reportId}, whereas a method that takes two simple
Guid parameters (e.g. DeleteWidgetAsync(Guid reportId, Guid widgetId)) renders both as
query parameters: DELETE /api/app/poc-report/widget?reportId=…&widgetId=….
Explicit attribute overrides are used in a few places, e.g. two [HttpGet] methods in
Hub.Application/Services/AddressService.cs and per-method [Authorize(...)] on
ExcelExportAppService.
Hand-written controllers¶
The few hand-written MVC controllers inherit from a per-module localization-resource base built on
AbpControllerBase:
| Base controller | File |
|---|---|
Cargonerds.Controllers.CargonerdsController |
src/Cargonerds.HttpApi/Controllers/CargonerdsController.cs |
HubController |
modules/hub/src/Hub.HttpApi/HubController.cs |
PricingController |
modules/pricing/src/Pricing.HttpApi/PricingController.cs |
public abstract class CargonerdsController : AbpControllerBase
{
protected CargonerdsController()
{
LocalizationResource = typeof(CargonerdsResource);
}
}
The remaining hand-written controllers in the host are infrastructure: HomeController redirects
/ to /swagger, SwaggerController works around a duplicated swagger/swagger path segment, and
SwaggerUiController serves the white-labeled Swagger CSS.
Enum serialization (integers by default)¶
This is the single most important wire-format gotcha for clients.
Enums serialize as integers, not strings
Nothing in the HttpApi/host layer registers a global JsonStringEnumConverter, so ABP /
System.Text.Json serialize enums as integer values. Clients must send and expect numeric
enum values everywhere unless a specific property opts out.
The default is confirmed by the deliberate exceptions: individual DTO properties opt into string enums with a per-property converter, which would be unnecessary if strings were the default.
/// <summary>
/// Supported values: "Daily", "Weekly", "Monthly".
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public ExcelExportScheduleOptions? Frequency { get; init; }
The only global System.Text.Json tweak in the host is cycle handling, not enum handling:
Configure<Microsoft.AspNetCore.Mvc.JsonOptions>(options =>
{
options.JsonSerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles;
});
Versioning¶
There is no URL- or header-based API versioning in this solution. Swagger publishes a single
document group, v1 (/swagger/v1/swagger.json), and routes are not version-prefixed
(/api/app/…, not /api/v1/app/…).
What does exist is build/version metadata surfaced through ABP's application-configuration
endpoint. CargonerdsAppConfigContributor (an IApplicationConfigurationContributor) adds the
running assembly version, the build date (parsed from AssemblyInformationalVersionAttribute), and
the deployment environment:
var version = Assembly.GetEntryAssembly()?.GetName().Version?.ToString();
var buildDate = GetBuildDate(Assembly.GetEntryAssembly());
context.ApplicationConfiguration.SetProperty("AppVersion", version);
context.ApplicationConfiguration.SetProperty("Environment", sparkEnvironment.Name);
context.ApplicationConfiguration.SetProperty("BuildDate", buildDate?.ToString("yyyy-MM-dd HH:mm:ss"));
context.ApplicationConfiguration.SetProperty("WhiteLabeling", whitelabel);
Front-end clients read these from GET /api/abp/application-configuration under
extraProperties (AppVersion, BuildDate, Environment). The assembly version itself is set
centrally in common.props (see the recent chore: bump version to … commits).
API hosts and ports¶
The development hosts and their default URLs:
| Host | Project | Default URL | Role |
|---|---|---|---|
| API host | src/Cargonerds.HttpApi.Host |
https://localhost:44354 |
REST + OData + Swagger |
| Auth server | src/Cargonerds.AuthServer |
https://localhost:44345 |
OpenIddict + Account UI |
These come from src/Cargonerds.HttpApi.Host/appsettings.json:
"App": {
"SelfUrl": "https://localhost:44354",
"MVCPublicUrl": "https://localhost:44332",
"CorsOrigins": "https://*.cargonerds.com,…,https://localhost:44381,http://localhost:4200",
"HealthCheckUrl": "/health-status"
},
"AuthServer": {
"Authority": "https://localhost:44345",
"MetaAddress": "https://localhost:44345",
"SwaggerClientId": "api"
}
Aspire orchestrates the real URLs
Under .NET Aspire the concrete URLs/ports are injected at runtime (see the appsettings.aspire.*
files and src/Cargonerds.AppHost); the values above are the static local-dev defaults. The
/api/abp/application-configuration endpoint also doubles as the Aspire readiness probe.
The CORS allow-list is built from App:CorsOrigins (comma-separated, trailing slashes stripped)
merged with App:AllowedCorsOrigins[]; wildcard subdomains are supported, and a literal * switches
the policy to AllowAnyOrigin.
Swagger / OpenAPI¶
Swagger UI is enabled with OIDC integration via ABP's AddAbpSwaggerGenWithOidc. The document title
is built from the white-label CompanyName / AppName, and Swagger authenticates against the auth
server using the api client and the Cargonerds scope (authorization-code flow).
context.Services.AddAbpSwaggerGenWithOidc(
configuration["AuthServer:Authority"]!,
["Cargonerds"], // scope
[AbpSwaggerOidcFlows.AuthorizationCode],
configuration["AuthServer:MetaAddress"],
options =>
{
options.SwaggerDoc("v1", new OpenApiInfo
{
Title = $"{design.CompanyName} - {design.AppName} - API",
Version = "v1",
});
options.DocInclusionPredicate((_, _) => true);
options.CustomSchemaIds(type => type.FullName); // full type names avoid schema-id clashes
options.DocumentFilter<BreakCircularReferencesDocumentFilter>();
// … oidc security-requirement fix (see warning below) …
}
);
At runtime:
| Endpoint | Purpose |
|---|---|
GET /swagger |
Swagger UI (white-labeled CSS injected from /swagger-ui/swagger-ui.css) |
GET /swagger/v1/swagger.json |
OpenAPI document |
The Swagger UI client id comes from AuthServer:SwaggerClientId ("api"), so the api
OpenIddict client must allow the authorization-code flow and the Cargonerds scope. See
API Clients for the seeded client list.
ABP 10.1.1 Swagger OIDC bug is worked around here
AddAbpSwaggerGenWithOidc registers the security definition as "oidc" but emits a security
requirement referencing a non-existent "oauth2" scheme. Without the manual fix in
ConfigureSwagger, Swagger UI never attaches the Authorization header and every endpoint
returns 401 with no lock icons. The module re-adds a correct requirement pointing at "oidc":
Heavy schemas are flattened
BreakCircularReferencesDocumentFilter exists specifically because large object graphs (e.g.
ShipmentDto) blow up Swagger UI. It DFS-breaks circular $ref chains. XML doc comments flow
into Swagger because the relevant projects set <GenerateDocumentationFile>True</GenerateDocumentationFile>.
How a request flows¶
flowchart TD
Client["Frontend / Swagger / API-key caller"]
subgraph Host["Cargonerds.HttpApi.Host"]
Auth["Authentication<br/>JWT bearer + api-key scheme"]
DynClaims["Dynamic claims + UnitOfWork"]
Authz["Authorization"]
AutoApi["Auto-API controllers<br/>/api/app · /api/hub · /api/pricing"]
OData["OData controllers<br/>/odata/* (EnableQuery)"]
end
AuthServer["Cargonerds.AuthServer<br/>OpenIddict (scope: Cargonerds)"]
Hub[("Hub DB")]
Default[("Default DB")]
Client -->|"HTTPS + Bearer / X-Api-Key"| Auth
Auth -.->|"validates JWT against"| AuthServer
Auth --> DynClaims --> Authz
Authz --> AutoApi
Authz --> OData
AutoApi --> Hub
AutoApi --> Default
OData --> Hub
Authentication¶
Endpoints are protected by JWT bearer tokens (issued by the OpenIddict auth server) and, additionally, by a custom API-key scheme layered into the same pipeline.
The default scheme is JWT bearer, registered via AddAbpJwtBearer with Audience = "Cargonerds",
and ABP dynamic claims are enabled:
context.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddAbpJwtBearer(options =>
{
options.Authority = configuration["AuthServer:Authority"];
options.RequireHttpsMetadata = configuration.GetValue<bool>("AuthServer:RequireHttpsMetadata");
options.Audience = "Cargonerds";
});
A second scheme (api-key) accepts caller-supplied keys via the X-Api-Key and Api-Key
headers (query-string keys apiKey / api_key are accepted only in Development). An API key
can never exceed its owner's permissions. The full mechanism — resolution, principal pinning,
owner-capped permissions, and management — is documented in
API Authentication.
Validation¶
ABP does not run validation interceptors on conventional (auto-API) controllers, so the host adds a global MVC action filter that bridges FluentValidation into ABP's validation pipeline:
Configure<Microsoft.AspNetCore.Mvc.MvcOptions>(options =>
{
options.Filters.Add<FluentValidationActionFilter>();
});
FluentValidationActionFilter reflectively resolves an IValidator<T> for each action argument and
throws AbpValidationException on failure. A validator only fires if it is registered in DI for the
exact argument type.
Health checks¶
The host registers health checks (including a 1-row database probe in CargonerdsDatabaseCheck) and
a health-checks UI:
| Endpoint | Source |
|---|---|
/health-status (App:HealthCheckUrl) |
health-check JSON |
/api/abp/application-configuration |
Aspire readiness probe |
Error responses¶
Verbose exception detail is returned to clients
The host turns on full error detail, which is convenient for an internal/dev API but is a production hardening item:
Gotchas¶
- Enums are integers. Send numeric enum values everywhere except the handful of properties
decorated with
[JsonConverter(typeof(JsonStringEnumConverter))]. - No URL/header API versioning. Only a single Swagger
v1group and assembly-version metadata on the application-configuration endpoint. - One host, no module hosts. There is exactly one host (
Cargonerds.HttpApi.Host); there is noHub.HttpApi.HostorPricing.HttpApi.Host. - OData is a separate engine. The
/odata/*controllers use[EnableQuery]and a different query pipeline from the auto-API controllers — see OData, Filtering & Facets. - First
Guidparameter is a route segment. A method with twoGuidparams renders both as query parameters instead. - API-key query-string credentials are Development-only by design; in production only the
X-Api-Key/Api-Keyheaders work. - FluentValidation only runs if a validator is registered for the exact argument type — there is no automatic discovery per DTO.
Related pages¶
- API Authentication — JWT bearer + the custom API-key scheme
- API Clients — the seeded OpenIddict clients (including the
apiSwagger client) - OData, Filtering & Facets — the
/odata/*query layer - Architecture Overview — where the API host sits in the solution
- ABP Patterns — framework conventions used across the codebase
- Hosting & Aspire — startup, pipeline order, service defaults