Skip to content

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:BaseUrl is where the proxies (and the keyed OData client) send requests. You can add per-remote-service entries (e.g. a Hub key) to override individual modules; absent an override they all fall back to Default.
  • IdentityClients:Default drives AbpHttpClientIdentityModelModule. 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> from react-oidc-context's useAuth().
  • Content type: application/json unless the body is FormData.
  • Response handling: 204 returns no data; 403 is parsed into a typed ApiError; other JSON responses are parsed into data; rawFetch bypasses 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 .cs proxies); the TS side is hand-written. Neither is driven by Swagger/OpenAPI codegen. Treat frontend/realtime/src/types/api*.ts as 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. AddHttpClientProxies only covers application services. For OData from .NET, use the keyed HubConsts.ODataClient HttpClient; from the browser, hit /odata/... directly with odata-query.
  • One base URL by default. All remote services fall back to RemoteServices:Default:BaseUrl unless 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 fetch errors carry server detail in dev — convenient locally, a hardening item for production (see the REST API gotchas).