API Clients¶
Cargonerds exposes a single HTTP surface (see REST API and OData, Filtering & Facets). Three different kinds of client consume that surface, and they are deliberately different in nature:
| Client | Lives in | How it is built | Primary use |
|---|---|---|---|
| C# dynamic proxies | src/Cargonerds.HttpApi.Client (+ Hub/Pricing client modules) |
ABP generates typed proxies at runtime from the *.Application.Contracts interfaces |
.NET consumers (other services, tests, tooling) calling the API as if it were local |
| TypeScript service modules | frontend/realtime/src/services/*.ts |
Hand-written fetch wrappers — not code-generated |
The Next.js realtime portal |
| Bruno collection | etc/bruno/S.P.A.R.K |
A checked-in REST client collection (HTTP/manual) | Local/manual API exploration and smoke testing |
Two layers of HttpApi
Cargonerds.HttpApi hosts the controllers and is what the server runs.
Cargonerds.HttpApi.Client produces the client proxies for .NET consumers. They are
separate projects with separate module classes — don't confuse them.
C# client — ABP dynamic proxies¶
ABP can turn any application-service contract (an IApplicationService interface in a
*.Application.Contracts project) into a transparent HTTP client. A consumer injects the
interface (e.g. IProfileAppService) and calls a normal C# method; ABP serializes the call into
an HTTP request against the configured remote service, deserializes the response, and hands you back
the DTO. This is ABP's
Dynamic C# API client proxy
feature, and it pairs one-to-one with the server-side
Auto API Controllers.
The client module¶
src/Cargonerds.HttpApi.Client/CargonerdsHttpApiClientModule.cs registers the proxies for the
Cargonerds.Application.Contracts assembly against the "Default" remote service:
[DependsOn(
typeof(PricingHttpApiClientModule),
typeof(HubHttpApiClientModule),
typeof(CargonerdsApplicationContractsModule),
typeof(AbpPermissionManagementHttpApiClientModule),
typeof(AbpFeatureManagementHttpApiClientModule),
typeof(AbpIdentityHttpApiClientModule),
typeof(AbpAccountAdminHttpApiClientModule),
typeof(AbpAccountPublicHttpApiClientModule),
typeof(SaasHostHttpApiClientModule),
typeof(AbpAuditLoggingHttpApiClientModule),
typeof(AbpOpenIddictProHttpApiClientModule),
typeof(TextTemplateManagementHttpApiClientModule),
typeof(LanguageManagementHttpApiClientModule),
typeof(FileManagementHttpApiClientModule),
typeof(AbpGdprHttpApiClientModule),
typeof(CmsKitProHttpApiClientModule),
typeof(ChatHttpApiClientModule),
typeof(LeptonXThemeManagementHttpApiClientModule),
typeof(AbpSettingManagementHttpApiClientModule)
)]
public class CargonerdsHttpApiClientModule : AbpModule
{
public const string RemoteServiceName = "Default";
public override void ConfigureServices(ServiceConfigurationContext context)
{
context.Services.AddHttpClientProxies(
typeof(CargonerdsApplicationContractsModule).Assembly,
RemoteServiceName);
Configure<AbpVirtualFileSystemOptions>(options =>
{
options.FileSets.AddEmbedded<CargonerdsHttpApiClientModule>();
});
}
}
The [DependsOn(...)] list is the catalogue of every HTTP API the client can talk to. Each
referenced *.HttpApiClientModule brings the proxies for its own contracts, so a single reference to
CargonerdsHttpApiClientModule gives a consumer typed access to the whole platform: the Cargonerds
app services plus the two in-repo modules (Hub, Pricing) plus the Volo modules (Identity,
Account admin/public, SaaS Host, Permission/Feature/Setting management, OpenIddict Pro, Audit
Logging, Language/Text-Template/File management, GDPR, CmsKit Pro, Chat, LeptonX theme management).
The project's role is declared in Cargonerds.HttpApi.Client.abppkg as "lib.http-api-client".
How the proxies are registered (and why they're "dynamic")¶
AddHttpClientProxies(assembly, remoteServiceName) scans the assembly for application-service
interfaces and registers an interceptor-backed implementation for each one. The implementation is
synthesized at runtime (via Castle DynamicProxy) — there is no generated C# proxy file in
this repository.
Dynamic vs. static proxies
ABP supports two proxy styles: dynamic (built at runtime, the default) and static (generated
.cs files via abp generate-proxy). This codebase uses the dynamic style. A search of
Cargonerds.HttpApi.Client, Hub.HttpApi.Client, and Pricing.HttpApi.Client finds no
*generate-proxy.json files and no generated proxy folders. The
<EmbeddedResource Include="**\*generate-proxy.json" /> line in the .csproj is inert
ABP-template boilerplate — it currently matches nothing. (The only generate-proxy/proxy README
artifacts in the tree live under abpSrc/**/angular/..., which is Volo's bundled module source
for reference, not this app's client.)
Per-module proxy registration¶
Each module client module follows the same pattern but targets its own remote-service name, so calls are routed to the right base address / route prefix:
// modules/hub/src/Hub.HttpApi.Client/HubHttpApiClientModule.cs
context.Services.AddHttpClientProxies(
typeof(HubApplicationContractsModule).Assembly,
HubRemoteServiceConsts.RemoteServiceName); // "Hub"
// modules/pricing/src/Pricing.HttpApi.Client/PricingHttpApiClientModule.cs
[DependsOn(typeof(HubHttpApiClientModule), typeof(PricingApplicationContractsModule), typeof(AbpHttpClientModule))]
// ...
context.Services.AddHttpClientProxies(
typeof(PricingApplicationContractsModule).Assembly,
PricingRemoteServiceConsts.RemoteServiceName); // "Pricing"
The remote-service names map to the route prefixes used by the auto-API controllers:
| Contracts assembly | Remote service name | Route prefix |
|---|---|---|
Cargonerds.Application.Contracts |
Default |
/api/app/... |
Hub.Application.Contracts |
Hub (ModuleName = "hub") |
/api/hub/... |
Pricing.Application.Contracts |
Pricing (ModuleName = "pricing") |
/api/pricing/... |
Pricing depends on Hub
PricingHttpApiClientModule [DependsOn] HubHttpApiClientModule, so referencing the Pricing
client transitively pulls in the Hub proxies (and its OData client below).
The Hub OData client (a hand-rolled HttpClient)¶
OData endpoints are not application services, so they are not covered by AddHttpClientProxies.
For .NET consumers that want the Hub OData route, HubHttpApiClientModule additionally registers a
named keyed HttpClient pointed at the odata route, with a DelegatingHandler that injects
the bearer token:
context.Services.AddHttpClient(
HubConsts.ODataClient, // nameof(ODataClient)
client =>
{
client.BaseAddress = new Uri(
$"{context.Configuration["RemoteServices:Default:BaseUrl"]}/{HubConsts.ODataRoute}"); // ".../odata"
})
.AddHttpMessageHandler<ODataAuthHandler>()
.AddAsKeyed();
// modules/hub/src/Hub.HttpApi.Client/ODataAuthHandler.cs
[ExposeServices(IncludeSelf = true)]
public class ODataAuthHandler(IAbpAccessTokenProvider tokenProvider) : DelegatingHandler, IScopedDependency
{
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
var tokenResult = await tokenProvider.GetTokenAsync();
if (!string.IsNullOrEmpty(tokenResult))
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tokenResult);
return await base.SendAsync(request, cancellationToken);
}
}
IAbpAccessTokenProvider is the same token source the dynamic proxies use, so the OData client and
the proxies authenticate identically. Relevant constants
(modules/hub/src/Hub.Domain.Shared/HubConsts.cs):
public const string ODataRoute = "odata";
public const string ODataClient = nameof(ODataClient); // "ODataClient"
public const string ODataPrefer = "Prefer";
public const string ODataAnnotations = "odata.include-annotations=\"*\"";
Putting it together — the console test app¶
test/Cargonerds.HttpApi.Client.ConsoleTestApp is a complete, working .NET consumer and the
reference example for wiring proxies into a host. Its module depends on AbpAutofacModule,
CargonerdsHttpApiClientModule, and AbpHttpClientIdentityModelModule (which acquires OIDC
tokens for every outgoing proxy call), and it bolts a Polly transient-error retry policy onto every
proxy client:
// CargonerdsConsoleApiClientModule.cs
[DependsOn(typeof(AbpAutofacModule), typeof(CargonerdsHttpApiClientModule), typeof(AbpHttpClientIdentityModelModule))]
public class CargonerdsConsoleApiClientModule : AbpModule
{
public override void PreConfigureServices(ServiceConfigurationContext context)
{
PreConfigure<AbpHttpClientBuilderOptions>(options =>
{
options.ProxyClientBuildActions.Add((remoteServiceName, clientBuilder) =>
clientBuilder.AddTransientHttpErrorPolicy(policyBuilder =>
policyBuilder.WaitAndRetryAsync(3, i => TimeSpan.FromSeconds(Math.Pow(2, i)))));
});
}
}
Consuming a proxy is then just dependency injection — inject the contract interface and call it:
// ClientDemoService.cs
public class ClientDemoService(
IProfileAppService profileAppService,
IIdentityUserAppService identityUserAppService) : ITransientDependency
{
public async Task RunAsync()
{
var profile = await profileAppService.GetAsync(); // GET /api/account/my-profile
var users = await identityUserAppService.GetListAsync(new GetIdentityUsersInput());
// ...
}
}
Configuration is supplied through standard ABP config keys
(ConsoleTestApp/appsettings.json):
{
"RemoteServices": {
"Default": { "BaseUrl": "https://localhost:44354/" }
},
"IdentityClients": {
"Default": {
"GrantType": "password",
"ClientId": "Cargonerds_App",
"UserName": "admin",
"UserPassword": "1q2w3E*",
"Authority": "https://localhost:44345/",
"Scope": "Cargonerds"
}
}
}
RemoteServices:Default:BaseUrlis where the proxies (and the keyed OData client) send requests. You can add per-remote-service entries (e.g. aHubkey) to override individual modules; absent an override they all fall back toDefault.IdentityClients:DefaultdrivesAbpHttpClientIdentityModelModule. The console app uses the resource-owner-password grant (GrantType: password) — convenient for a server-to-server tool but not how interactive clients authenticate. The browser portal and Bruno use the authorization-code + PKCE flow instead (see API Authentication).
Test credentials are committed
ConsoleTestApp/appsettings.json contains a literal admin username/password and uses the local
dev hosts (44354 API / 44345 auth). These are local-dev defaults; do not point this file at a
real environment with those values.
End-to-end call flow¶
flowchart LR
subgraph Consumer[".NET consumer (e.g. ConsoleTestApp)"]
I["inject IProfileAppService"]
P["dynamic proxy (Castle DynamicProxy)"]
T["AbpHttpClientIdentityModelModule\n(token acquisition)"]
R["Polly retry handler"]
end
I --> P
P --> T
T --> R
R -->|"HTTP + Bearer token"| API["Cargonerds.HttpApi.Host\n/api/app · /api/hub · /api/pricing"]
API -->|"JSON DTO"| P
AUTH["OpenIddict auth server"] -. "token" .-> T
TypeScript client — the realtime frontend¶
The realtime Next.js app (frontend/realtime) does not use a generated client. There is no
@abp/ng.* Angular proxy and no NSwag/OpenAPI codegen step. Instead it has ~25 hand-written
service factories under frontend/realtime/src/services/, all sharing one small typed fetch
helper.
No codegen = types can drift
Every request/response shape lives in hand-maintained mirrors under
frontend/realtime/src/types/api*.ts (apiShipment.ts, apiQuotation.ts, apiOrder.ts, …).
There is nothing keeping them in lockstep with the backend DTOs. In particular, ABP serializes
enums as integers by default, so the TS enum values must match the numeric backend values.
The fetch wrapper (useClient)¶
frontend/realtime/src/hooks/useClient.ts returns { fetch, rawFetch }. It resolves the API base
URL from runtime config, attaches the OIDC access token, sets headers, and normalizes responses:
export const useClient = () =>
{
const { apiUrl } = useConfig();
const auth = useAuth();
const buildUrl = (url: string) => (/^https?:\/\//i.test(url) ? url : `${apiUrl}${url}`);
const buildHeaders = (options: RequestInit = {}) =>
{
const headers = new Headers(options.headers);
headers.set('Authorization', `Bearer ${auth.user?.access_token}`);
headers.set('Accept-Language', 'en-US');
if (!(options.body instanceof FormData) && !headers.has('Content-Type'))
headers.set('Content-Type', 'application/json');
return headers;
};
const wrapper = async <T>(url: string, options: RequestInit = {}) =>
{
const response = await fetch(buildUrl(url), { ...options, signal: options.signal, headers: buildHeaders(options) });
// 403 → parse ApiError; JSON (and not 204) → parse data; else leave both undefined
return { data, error, response };
};
const rawFetch = /* returns the raw Response — used for SSE/streaming */;
return { fetch: wrapper, rawFetch };
};
Key behaviours:
- Base URL: relative URLs are prefixed with
useConfig().apiUrl; absolute URLs pass through. - Auth:
Authorization: Bearer <access_token>fromreact-oidc-context'suseAuth(). - Content type:
application/jsonunless the body isFormData. - Response handling:
204returns no data;403is parsed into a typedApiError; other JSON responses are parsed intodata;rawFetchbypasses parsing for SSE/streaming.
Accept-Language is hard-coded to en-US
useClient always sends Accept-Language: en-US for normal API calls, so most payloads come
back in English regardless of the user's chosen language. Only the application-configuration
request (localized labels/settings) uses the user's actual culture. See
Localization.
Service factories and useServices¶
Each service is a plain factory service(client) => ({ ...methods }). useServices
(frontend/realtime/src/hooks/useServices.ts) builds the shared client once and wires every factory
to it, exposing a single entry point for components:
export const useServices = () =>
{
const client = useClient();
const { mapTilerKey, authUrl } = useConfig();
return {
shipmentService: shipmentService(client),
orderService: orderService(client),
quotationService: quotationService(client),
// ...~25 services total
};
};
A service mixes the REST auto-API endpoints with OData query endpoints — both go through the
same client.fetch:
// frontend/realtime/src/services/shipmentService.ts
import buildQuery from 'odata-query';
export const shipmentService = (client: { fetch: Fetch }) =>
{
// Legacy REST endpoint (kept for facets/filters)
const getShipments = async (params: Record<string, string>, opts?: { signal?: AbortSignal }) =>
{
const queryParams = new URLSearchParams(params);
const { data } = await client.fetch<ApiShipmentResult>(`/api/app/shipment?${queryParams}`, { signal: opts?.signal });
if (!data) throw new Error('Failed to fetch shipments');
return data;
};
const getShipmentsOData = async (params /* filter/expand/select/orderBy/top/skip/count */, opts) =>
{
// builds /odata/Shipment?<query> with buildQuery from 'odata-query'
// sends `Prefer: odata.include-annotations="*"` when opts.withFacets
};
// ...
};
OData from the frontend¶
List/query screens build OData query strings with the odata-query package's buildQuery
($filter, $expand, $select, $orderby, $top, $skip, $count, $apply) and POST/GET them
against /odata/<EntitySet>. Facets are an opt-in: sending Prefer: odata.include-annotations="*"
makes the backend attach a custom @cn.facets annotation to the response. Full details and the
entity-set list are on OData, Filtering & Facets.
Authentication¶
Browser auth uses the OpenID Connect authorization-code + PKCE flow against the ABP
OpenIddict auth server, managed by react-oidc-context. The useClient hook reads
auth.user?.access_token for every call. The default client id is realtime, and the requested
scope includes the Cargonerds API resource plus offline_access for refresh tokens. See
API Authentication.
Bruno collection (etc/bruno)¶
etc/bruno/S.P.A.R.K is a Bruno collection checked into the repo for
manual API exploration and smoke testing. Bruno stores everything as plain .bru text files (one per
request), so the collection is diff-friendly and version-controlled.
Structure¶
etc/bruno/S.P.A.R.K
├── bruno.json # collection manifest
├── collection.bru # collection-level OAuth2 config (shared by all requests)
├── README.txt # how to log in (PKCE auth-code)
├── environments/
│ ├── local.bru # 44354 / 44345, client "bruno"
│ ├── Main.bru # api.main.cargonerds.dev
│ └── Stage.bru # api.stage.cargonerds.dev
└── ABP/ # requests grouped by domain
├── Shipments/ Quotations/ Purchase Orders/ Organization/
├── Tracking/ CalculatedData/ Dashboard/ Insights/ Notifications/
├── CodeEntities/ OData Meta/ IdentityUser/ OrganizationUnit/ Booking/
└── ABP Application Configuration (protected).bru, Ping API Root (public).bru, ...
Collection-level auth¶
collection.bru configures OAuth2 once for the whole collection; individual requests just set
auth: inherit:
auth {
mode: oauth2
}
auth:oauth2 {
grant_type: authorization_code
authorization_url: {{AUTH_AUTHORITY}}/connect/authorize?disableExternalSources=1
access_token_url: {{AUTH_AUTHORITY}}/connect/token
client_id: {{CLIENT_ID}}
scope: {{SCOPES}}
token_placement: header
token_header_prefix: Bearer
auto_fetch_token: true
}
Environments parameterize the endpoints. For example environments/local.bru:
vars {
BASE_URL: https://localhost:44354
AUTH_AUTHORITY: https://localhost:44345
CLIENT_ID: bruno
REDIRECT_URI: http://localhost:5173/callback
SCOPES: openid profile email roles address Cargonerds
}
Main.bru and Stage.bru swap in api.main.cargonerds.dev / api.stage.cargonerds.dev (and the
matching auth.* hosts). The collection authenticates against the same
OpenIddict auth server the apps use, with the
Cargonerds API scope — but with a dedicated bruno client id and the
http://localhost:5173/callback redirect (both must be registered on the auth-server client).
Example requests¶
The collection exercises both REST and OData. An OData list request, for instance, shows the
Prefer facets header and an $apply group-by alternative:
# ABP/Shipments/OData Shipment (List).bru
get {
url: {{BASE_URL}}/odata/Shipment?$top=5
auth: inherit
}
params:query {
$top: 5
~$apply: groupby((TransportMode/Id),aggregate($count as Count)) # ~ = disabled by default
~$filter: ServiceLevel/Id eq 2ac14ebe-0126-46fb-9301-08dc1ed48f75
}
headers {
Prefer: odata.include-annotations="*"
}
Other useful entries: Ping API Root (public).bru (GET {{BASE_URL}}/ — an unauthenticated health
ping) and ABP Application Configuration (protected).bru
(GET {{BASE_URL}}/api/abp/application-configuration — the same config payload the SPA loads).
Logging in with Bruno
Pick an environment (top-right), open Collection → Auth → Get Access Token, complete the
interactive login, then Use Token. All requests inherit the token. See
etc/bruno/S.P.A.R.K/README.txt.
PKCE flag mismatch between README and collection.bru
README.txt instructs you to enable PKCE / S256, but the committed collection.bru has
pkce: false. If the bruno auth-server client is configured to require PKCE, you must turn
PKCE on in the Bruno auth tab (or update collection.bru) before token acquisition will succeed.
Gotchas¶
- No generated client anywhere. The C# side uses ABP dynamic (runtime) proxies (no
generate-proxy.json, no committed.csproxies); the TS side is hand-written. Neither is driven by Swagger/OpenAPI codegen. Treatfrontend/realtime/src/types/api*.tsas a source of truth that can silently drift from the backend. - Enums are integers. Because the backend serializes enums as integers by default, both the hand-written TS types and any direct HTTP caller must send/expect numeric enum values (see REST API).
- OData isn't proxied.
AddHttpClientProxiesonly covers application services. For OData from .NET, use the keyedHubConsts.ODataClientHttpClient; from the browser, hit/odata/...directly withodata-query. - One base URL by default. All remote services fall back to
RemoteServices:Default:BaseUrlunless you add a per-service entry (RemoteServices:Hub:BaseUrl, etc.). - Console app uses password grant. That flow is for server-to-server tooling only; interactive clients (browser, Bruno) use authorization-code + PKCE. Don't copy the password-grant config into a user-facing client.
- Verbose server errors. The host returns stack traces and exception details to clients, so proxy
and
fetcherrors carry server detail in dev — convenient locally, a hardening item for production (see the REST API gotchas).
Related pages¶
- REST API — the auto-API surface the C# proxies and TS services consume.
- OData, Filtering & Facets — the
/odataquery layer and the@cn.facetsannotation. - API Authentication — OIDC flows, scopes, and token handling.
- Realtime Frontend — the Next.js app that owns the TS client.
- Localization — why
Accept-Languagematters for responses. - Architecture Overview — where
HttpApi.Clientsits in the layered solution.