Skip to main content

Client-side Refit clients

Every form-bearing entity — Dictionary, Document, Report, DataProcessor, and every server Handler — needs one small hand-written interface in Acme.ClientLib.Common/ApiClients/. It is the typed contract the Blazor WebAssembly client uses to call that entity's generated controller. It is almost always an empty interface with two attributes, and it is the one piece of client plumbing the generators cannot write for you.

Each entity-kind page shows its own client in one step (dictionary, document, report, data processor); this page explains the shared mechanism once.

What is generated and what is not

PieceWho writes itWhere
The interface declaration I{Name}ApiClientYouAcme.ClientLib.Common/ApiClients/
Its implementation (the HTTP calls)Refit's own source generatorAcme.ClientLib.Common/Generated.Net/InterfaceStubGeneratorV3/
DI registration, the bridge to the generic client interface, and the service wrapper Blazor pages injectKandra.Generators.ClientLibone generated Add{Config}ClientServices(baseAddress) method
The server controller the client callsKandra.Generators.ApiAcme.WebApi/Generated.Net/

The interface stays hand-written for a concrete reason: Refit's compile-time generator only produces an implementation for an interface it can see as source syntax in its own project. An interface emitted by another generator is invisible to it, and Refit 14 has no runtime fallback. So Kandra.Generators.ClientLib deliberately generates everything around your interface and refers to it by name.

The shape

// Acme.ClientLib.Common/ApiClients/IBranchesApiClient.cs
using Kandra.ClientLib.Common.ApiClients;
using Kandra.Forms.Paging;
using Acme.Forms.Dictionaries;
using Refit;

namespace Acme.ClientLib.Common.ApiClients;

[PathPrefix("/api/v1/dictionaries/Branches")]
public interface IBranchesApiClient : IDictionaryApiClient<BranchDto, PagedResult<BranchDto>, BranchQueryDto>
{
}

Three things carry all the meaning:

  1. The base interface — picks the kind and supplies every method.
  2. [PathPrefix] — the route of the entity's generated controller.
  3. The interface's nameI + the Dto's Name + ApiClient.

There is nothing to implement and nothing to register.

What each kind looks like

Entity kindBase interface (all in Kandra.ClientLib.Common.ApiClients)Route categoryMethods the base already supplies
Flat dictionaryIDictionaryApiClient<DTO, PagedResult<DTO>, QUERY_DTO>dictionariesAllAsync, QueryAsync, ReadAsync, InsertAsync, UpdateAsync, DeleteAsync, GetChangeStateAsync, GetHistoryAsync, NewAsync, LookupAsync
Hierarchical dictionaryIHierarchicalDictionaryApiClient<DTO, PagedResult<DTO>, QUERY_DTO>dictionariesthe CRUD set, plus NewAsync(parentId, isFolder), LookupAsync, MoveToAsync
DocumentIDocumentApiClient<DTO, PagedResult<DTO>, QUERY_DTO>documentsthe CRUD set, plus NewAsync, LookupAsync, SubmitAsync, UnsubmitAsync, PrintAsync, GetRegisterTransactionsAsync, GetAccountTransactionsAsync, GetLinksAsync
ReportIReportApiClient<TFilters, TResult>reportsNewAsync, RunAsync, ExportAsync
Data processorIDataProcessorApiClient<TInput, TResult>processorsNewAsync, ExecuteAsync
HandlerIHandlerApiClient<TIn, TOut>handlersHandleAsync

The shared CRUD set every dictionary and document inherits comes from IEntityApiClient<,,>. Reports and data processors do not — they inherit no CRUD and stand alone.

One example per kind

Real interfaces from Kandra's reference configuration, with KandraWms renamed to Acme:

// Hierarchical dictionary — closed over the abstract ROOT Dto, not the leaf
[PathPrefix("/api/v1/dictionaries/Counterparties")]
public interface ICounterpartiesApiClient
: IHierarchicalDictionaryApiClient<CounterpartyNodeDto, PagedResult<CounterpartyNodeDto>, CounterpartyQueryDto>
{
}

// Document
[PathPrefix("/api/v1/documents/GoodsReceipts")]
public interface IGoodsReceiptsApiClient
: IDocumentApiClient<GoodsReceiptDto, PagedResult<GoodsReceiptDto>, GoodsReceiptQueryDto>
{
}

// Report — filters Dto + result Dto, no paged wrapper
[PathPrefix("/api/v1/reports/RestsReport")]
public interface IRestsReportApiClient : IReportApiClient<ReportRestsDto, RestsReportResult>
{
}

// Data processor
[PathPrefix("/api/v1/processors/ImportNbuRates")]
public interface IImportNbuRatesApiClient : IDataProcessorApiClient<ImportNbuRatesDto, ImportNbuRatesResult>
{
}

// Handler — request + response Dto
[PathPrefix("/api/v1/handlers/PriceLookup")]
public interface IPriceLookupApiClient : IHandlerApiClient<PriceLookupRequestDto, PriceLookupResponseDto>
{
}

Every client in the reference configuration has an empty body. Only add a method if you have hand-written a custom controller endpoint beyond the generated contract, and then follow the design rules below.

Two kinds need no Refit client: a Constant (it has an engine-level admin page) and a local handler ([KandraLocalHandler], which runs entirely in the browser). See Handlers.

The naming contract

The generated registration refers to your interface by a name it computes, so the name is not free:

  • Interface name = I{Name}ApiClient, where {Name} is the Name in the Dto's [Kandra…Form(Name = "…")] (or [KandraHandler(Name = "…")]). A Dto named Name = "Branches" needs IBranchesApiClient. A dictionary called Branch whose Name is "Branches" is IBranchesApiClient, not IBranchApiClient.
  • [PathPrefix] = /api/v1/{category}/{Name}, using the same Name and the category from the table above. The generated server controller is routed api/v{version}/{category}/[controller], so a prefix that doesn't match points the client at a route that does not exist.
  • Namespace = Acme.ClientLib.Common.ApiClients (your project's root namespace plus .ApiClients) — the generated registration sits in that namespace and resolves your interface without a using.

Copy the Name from the Dto rather than retyping it; a typo in the prefix is not caught by the compiler.

How it gets registered

You never call AddProtectedApiClient yourself for an entity client. Kandra.Generators.ClientLib discovers every [Kandra*Form] Dto and every server handler and emits one method, Add{Config}ClientServices(this IServiceCollection, Uri baseAddress) (in the reference configuration, AddKandraWmsClientServices), which for each entity:

  1. registers your interface with the bearer-token, culture and time-zone HTTP handlers;
  2. bridges it to the generic interface it closes (for a dictionary, IDictionaryApiClient<…>);
  3. registers the service wrapper the generated pages actually inject (IDictionaryService<…>, IDocumentService<…>, and so on).

Your Configure{Config}ClientServices calls it once. The method exists only once the compilation contains at least one [Kandra*Form] Dto or handler — a freshly scaffolded configuration with no entities has nothing to call. Open the file under Acme.ClientLib.Common/Generated.Net/Kandra.Generators.ClientLib/ after your first entity to see the exact method name generated for your configuration, and make sure that call is present.

Design rules

The platform's own client interfaces follow four rules. These were re-verified against current source (Kandra.ClientLib.Common/ApiClients/), and two of them have a real exception you should know about before applying them by rote.

RuleStatus in source
A write/run endpoint's [Body] is required, never = null. A default of null lets a caller send an empty payload to insert/update/run/execute.Holds for every write, run and execute method. Exception: the three LookupAsync methods take [Body] QUERY_DTO? dto = null on purpose — a null body means "no filters, default paging".
Constrain generics to the real Dto marker interfaces, not bare class.Holds for IEntityApiClient (IEntityDto, IPagedResult<DTO>, IPagedQueryDto), IDictionaryApiClient (IDictionaryDto, IDictionaryQueryDto), the hierarchical one (+ IHierarchicalItemDto), IDocumentApiClient (IDocumentDto, IDocumentQueryDto), IReportApiClient (IReportFiltersDto, IReportResultDto) and IDataProcessorApiClient (IDataProcessorInputDto, IDataProcessorResultDto). Exception: IHandlerApiClient<TIn, TOut> is unconstrained.
Every client inherits IKandraProtectedApiClient or IKandraApiClient. The first means "bearer token attached", the second "no token" (only the login client today).Holds. AddProtectedApiClient<T> and AddApiClient<T> are each constrained to their marker, so registering with the wrong helper is a compile error. Every entity base above already inherits the protected marker, so your closed interface gets it for free.
List endpoints return IReadOnlyCollection<T>, not List<T> or an array.Holds (AllAsync and the platform's lookup/list calls; a few return IReadOnlyList<T>, equally read-only). Paged endpoints return PagedResult<T>.

If you do add a custom method: required [Body] for anything that writes, a marker-constrained Dto type, CancellationToken cancellationToken = default last, and a read-only collection for lists.

Verify

dotnet build Acme.slnx, then run the app and open the entity's list page — it should load, which exercises the route and the bearer-token handler. If the page loads but every call fails, suspect the [PathPrefix].

Gotchas worth knowing before you start

  • Skipping the interface breaks the build; it does not degrade silently. The generated Add{Config}ClientServices names I{Name}ApiClient directly, so a missing (or misnamed) interface fails Acme.ClientLib.Common with CS0246: The type or namespace name 'IBranchesApiClient' could not be found — pointing at the generated file, not yours. That error means "you forgot the client interface, or its name doesn't match the Dto's Name".
  • A wrong [PathPrefix] compiles. Only the string differs, so nothing catches it until the client calls a route that does not exist.
  • A hierarchical dictionary's client is closed over the root Dto (CounterpartyNodeDto), the same rule the selectors follow — not the leaf CounterpartyDto.
  • The base interface must match the Dto's kind. IDictionaryApiClient<TDocumentDto, …> does not compile, because the marker constraints (IDictionaryDto vs IDocumentDto) reject it.
  • Reports and data processors have no Lookup/CRUD. They only New/Run/Export and New/Execute — don't expect the dictionary surface on them.
  • Never edit the generated implementation under Generated.Net/; change the interface and rebuild.
  • Keep Acme.ClientLib.Common client-only. Like Acme.Forms, it must never reference Acme.Application, Acme.Domain or Acme.Persistence — the Blazor client can't load EF Core.

See also