Table of Contents

Calling an external API from a component or seam

A seam that reads Users.api, an OnFetchData that fronts a partner service, a Save that pushes to a provisioning API — all of them need an HttpClient. Getting one with new HttpClient() works and is the wrong answer: the call then produces no api-call log event, carries no X-Request-ID (so it cannot be correlated with the operation-complete of the request that triggered it), identifies no caller in the callee's access log, and — if the client is held in a static field, which is how it usually ends up — never refreshes DNS.

This page is the contract for getting one that does all of that. It is the companion to Extending Core Components (when and why to use a seam at all) and the Extension Seam Reference (the seam signatures themselves).

7.0, prerelease. The types below live in MediaiBox.Cms.FrontEnd.Model.Mvc, the package a customisation project already references. Nothing here needs MediaiBox.Core.Middleware, and nothing here needs the customisation to run code at startup — see Why there is no startup hook.


The platform HTTP client

IMibHttpClientFactory hands out an HttpClient that already carries the platform's outbound handler chain:

Handler What it adds
MibDefaultHeadersHandler User-Agent: {application}/{version}, plus every registered header contributor — which is how X-Request-ID reaches the callee. A value the caller set always wins.
MibLoggingApiCallRequestsHandler One structured Information event per call, on success and on failure, scoped FunctionName=api-call, HttpClient=<name>, RequestId=<inbound TraceIdentifier>. Body capture is opt-in via the SerilogLogIncludeBody config knob (off by default).
public interface IMibHttpClientFactory
{
    HttpClient CreateClient();                              // the platform default client
    HttpClient CreateClient(string name);                   // name = api-call nickname + pooling key
    HttpClient CreateClient(MibHttpClientOptions options);  // name + BaseAddress + Timeout
}

public sealed class MibHttpClientOptions
{
    public string Name { get; init; }
    public Uri BaseAddress { get; init; }
    public TimeSpan? Timeout { get; init; }
}

The name needs no registration. CreateClient("UsersApi") works even though nothing anywhere called AddHttpClient("UsersApi"): the handler chain is built for whatever name is asked for, the first time it is asked for. That is what makes this usable from a customisation, which has no startup hook of its own.

A name is a service label, not a per-call value. Each distinct name keeps its own pooled handler chain, so "UsersApi" is right and $"UsersApi-{userId}" leaks handlers. The name is what lets Kibana split by callee — HttpClient=UsersApi instead of every outbound call in the process being attributed to the default client.

BaseAddress and Timeout are applied to the instance you get back, not to the named client's options, so two call sites can share a name and still use different addresses. Configuring them on the named options would let whichever caller ran first silently decide both for everyone else.

Three ways to obtain the factory, in order of preference:

// 1. Constructor injection — the loader resolves the greediest constructor it can satisfy.
public MyComponent(IMibHttpClientFactory http) => _http = http;

// 2. IServiceAwareComponent — for a component the loader does not construct directly.
protected IMibHttpClientFactory Http => Services.GetMibHttpClientFactory();

// 3. From a seam, via the accessor every component receives in Initialize.
var http = _httpContextAccessor.GetMibHttpClientFactory().CreateClient("UsersApi");

Trap — do not build the client in a constructor. Constructor injection is fine, but resolving through Services is not: the loader assigns that property after construction. Create the client lazily on first use.

Trap — creating an HttpClient per request is correct here. The client is a cheap wrapper; the expensive HttpMessageHandler behind it is pooled per name and recycled on HandlerLifetime. The old "always reuse one HttpClient" advice predates the factory, and it is exactly what freezes DNS in a static field.


Reaching the container from a component the loader does not construct

The component loader resolves constructor dependencies for the type it instantiates. A customisation layer that builds its own inner component through a fixed constructor signature — the Activator.CreateInstance(type, apiClient) shape — leaves no slot for a second argument, so nothing can be handed in during construction.

IServiceAwareComponent is the injection route that survives that: the object is constructed however the layer above wants, and receives the container afterwards.

namespace MediaiBox.Cms.FrontEnd.Model.Mvc.DependencyInjection;

public interface IServiceAwareComponent
{
    IMibDependencyResolver Services { get; set; }
}

The loader assigns Services right after construction and before Initialize runs, alongside the Context and Configuration properties it already fills in (MediaiBox.Cms.FrontEnd.Workflow.Mvc/IComponentLoader.cs).

public sealed class MyComponent : ListComponent, IServiceAwareComponent
{
    public IMibDependencyResolver Services { get; set; }

    private IMibHttpClientFactory _http;
    private IMibHttpClientFactory Http => _http ??= Services.GetMibHttpClientFactory();
}

IMibDependencyResolver carries GetService / GetRequiredService, with typed extension sugar (Services.GetRequiredService<T>(), Services.GetMibHttpClientFactory()).

Opt-in and additive. A component that does not declare the interface is constructed and initialised exactly as before — the loader tests the type rather than reflecting on a property name, so the contract is never mandatory.

Never null after the loader runs. Loaded with no request scope (a background load, a test that passes no accessor), Services is a resolver whose GetService returns null and whose GetRequiredService throws a message naming the cause — rather than the property itself being null and surfacing as an unexplained NullReferenceException.

Prefer a constructor where you can. A constructor parameter is visible in the signature; a property assigned by the framework is not. Use this interface when the constructor is not yours to choose.


MibHttpClients — the no-request entry point

var http = MibHttpClients.Create(new MibHttpClientOptions
{
    Name = "UsersApi", BaseAddress = new Uri(url), Timeout = TimeSpan.FromMilliseconds(ms)
});

Supported use: code with no request scope to resolve from — agents, workers, hosted services. There is no container to reach there, and this is the only entry point.

Note the degradation: the api-call event is still emitted, but X-Request-ID comes out empty, because there is no inbound request to correlate with (the headers handler skips a header with an empty value on purpose).

Inside a request, use the container instead. All three routes above declare the dependency where a reader can see it and keep the component testable; a static accessor does neither.

Correctness is not the objection — during a request this path does propagate the right X-Request-ID, because the header handler reads the ambient HttpContext, which flows on AsyncLocal. The objection is the hidden dependency.

Trap — not available during host startup, and the failure is sticky. It is wired after the application's service provider is built, which is later than component types are first instantiated: the asset-bundling pass constructs every registered component to collect its scripts and styles. A static field initializer calling this therefore throws — and because the CLR caches a failed static constructor for the life of the process, that component keeps failing afterwards, even once the factory is available. The bundling pass logs the first failure at Verbose and drops the component's assets silently, so the symptom surfaces far from the cause.

Create the client lazily on first use. Never in a static initializer.


Why there is no startup hook

A customisation ships as a DLL overlay onto the MibServer3 image, whose entrypoint is MediaiBox.Cms.FrontEnd.Server.dll. The MibHost builder a customisation's own Program.cs calls lives in MediaiBox.Cms.FrontEnd.Server.Development and starts the server in development mode — it is a local development host, and in production that Main never runs.

So a design that asks the customisation to register a typed HttpClient at startup cannot work in a deployed environment. Everything on this page is reachable at call time instead, which is the only thing a DLL overlay can rely on.

IMibDependencyRegistrar still exists for hosts that do own startup, and its AddHttpClient* overloads get the handler chain like any other name. AddRawHttpClient* opts a client out of it entirely — no api-call, no X-Request-ID, no configured timeout — for the rare client that must not touch the ambient request, such as a health probe.


Verifying it works

Exercise the component and look for the three correlated lines under one RequestId:

FunctionName=operation-complete  RequestId=0HN9…  url=/api/v2/list/data      statusCode=200
FunctionName=api-call            RequestId=0HN9…  HttpClient=MibHttpClient   url=/MibApi/…
FunctionName=api-call            RequestId=0HN9…  HttpClient=UsersApi        url=/devices/search

The third line is the one that does not exist when the client is built by hand.