Skip to content

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:

  1. ABP auto-API controllers — the bulk of the REST surface (/api/app/…, /api/hub/…, /api/pricing/…), generated from application services.
  2. A handful of hand-written MVC controllers — Swagger redirects, the white-labeled Swagger CSS, and the home redirect.
  3. A large OData v8 controller layer in modules/hub/src/Hub.HttpApi (~60 ODataController subclasses) mounted under the odata route 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:

src/Cargonerds.HttpApi.Host/Program.cs
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:

CargonerdsHttpApiHostModule.cs
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/…
modules/hub/src/Hub.Application.Contracts/HubRemoteServiceConsts.cs
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
src/Cargonerds.HttpApi/Controllers/CargonerdsController.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.

modules/hub/src/Hub.Application.Contracts/Dtos/Excel/ExportFilterDto.cs
/// <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:

CargonerdsHttpApiHostModule.cs
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:

src/Cargonerds.HttpApi.Host/CargonerdsAppConfigContributor.cs
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:

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).

CargonerdsHttpApiHostModule.cs — ConfigureSwagger
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":

options.AddSecurityRequirement(document => new OpenApiSecurityRequirement
{
    [new OpenApiSecuritySchemeReference("oidc", document)] = [],
});

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:

CargonerdsHttpApiHostModule.cs — ConfigureAuthentication
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:

CargonerdsHttpApiHostModule.cs
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:

CargonerdsHttpApiHostModule.cs
Configure<AbpExceptionHandlingOptions>(options =>
{
    options.SendStackTraceToClients = true;
    options.SendExceptionsDetailsToClients = true;
    options.SendExceptionDataToClientTypes = [typeof(Exception)];
});

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 v1 group and assembly-version metadata on the application-configuration endpoint.
  • One host, no module hosts. There is exactly one host (Cargonerds.HttpApi.Host); there is no Hub.HttpApi.Host or Pricing.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 Guid parameter is a route segment. A method with two Guid params renders both as query parameters instead.
  • API-key query-string credentials are Development-only by design; in production only the X-Api-Key / Api-Key headers work.
  • FluentValidation only runs if a validator is registered for the exact argument type — there is no automatic discovery per DTO.