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
| Piece | Who writes it | Where |
|---|---|---|
The interface declaration I{Name}ApiClient | You | Acme.ClientLib.Common/ApiClients/ |
| Its implementation (the HTTP calls) | Refit's own source generator | Acme.ClientLib.Common/Generated.Net/InterfaceStubGeneratorV3/ |
| DI registration, the bridge to the generic client interface, and the service wrapper Blazor pages inject | Kandra.Generators.ClientLib | one generated Add{Config}ClientServices(baseAddress) method |
| The server controller the client calls | Kandra.Generators.Api | Acme.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:
- The base interface — picks the kind and supplies every method.
[PathPrefix]— the route of the entity's generated controller.- The interface's name —
I+ the Dto'sName+ApiClient.
There is nothing to implement and nothing to register.
What each kind looks like
| Entity kind | Base interface (all in Kandra.ClientLib.Common.ApiClients) | Route category | Methods the base already supplies |
|---|---|---|---|
| Flat dictionary | IDictionaryApiClient<DTO, PagedResult<DTO>, QUERY_DTO> | dictionaries | AllAsync, QueryAsync, ReadAsync, InsertAsync, UpdateAsync, DeleteAsync, GetChangeStateAsync, GetHistoryAsync, NewAsync, LookupAsync |
| Hierarchical dictionary | IHierarchicalDictionaryApiClient<DTO, PagedResult<DTO>, QUERY_DTO> | dictionaries | the CRUD set, plus NewAsync(parentId, isFolder), LookupAsync, MoveToAsync |
| Document | IDocumentApiClient<DTO, PagedResult<DTO>, QUERY_DTO> | documents | the CRUD set, plus NewAsync, LookupAsync, SubmitAsync, UnsubmitAsync, PrintAsync, GetRegisterTransactionsAsync, GetAccountTransactionsAsync, GetLinksAsync |
| Report | IReportApiClient<TFilters, TResult> | reports | NewAsync, RunAsync, ExportAsync |
| Data processor | IDataProcessorApiClient<TInput, TResult> | processors | NewAsync, ExecuteAsync |
| Handler | IHandlerApiClient<TIn, TOut> | handlers | HandleAsync |
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 theNamein the Dto's[Kandra…Form(Name = "…")](or[KandraHandler(Name = "…")]). A Dto namedName = "Branches"needsIBranchesApiClient. A dictionary calledBranchwhoseNameis"Branches"isIBranchesApiClient, notIBranchApiClient. [PathPrefix]=/api/v1/{category}/{Name}, using the sameNameand the category from the table above. The generated server controller is routedapi/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 ausing.
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:
- registers your interface with the bearer-token, culture and time-zone HTTP handlers;
- bridges it to the generic interface it closes (for a dictionary,
IDictionaryApiClient<…>); - 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.
| Rule | Status 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}ClientServicesnamesI{Name}ApiClientdirectly, so a missing (or misnamed) interface failsAcme.ClientLib.CommonwithCS0246: 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'sName". - 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 leafCounterpartyDto. - The base interface must match the Dto's kind.
IDictionaryApiClient<TDocumentDto, …>does not compile, because the marker constraints (IDictionaryDtovsIDocumentDto) reject it. - Reports and data processors have no
Lookup/CRUD. They onlyNew/Run/ExportandNew/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.Commonclient-only. LikeAcme.Forms, it must never referenceAcme.Application,Acme.DomainorAcme.Persistence— the Blazor client can't load EF Core.
See also
- Creating a dictionary, Creating a document, Creating a report and Creating a data processor — the same interface in context.
- Handlers — server handlers need a client; local handlers don't.
- Overall architecture — why
ClientLib.CommonandFormsare the only projects the client sees. - Reference: Source generators