Client
Xapi.Client is an HttpClient-based client for any LRS that speaks xAPI 1.0.3 or 2.0.0 (IEEE 9274.1.1). It
covers the Statement, State, Activity Profile, Agent Profile, Agents, Activities and About resources. It
targets netstandard2.0 and net10.0, uses no reflection, and the net10.0 build can be published with Native
AOT.
Code that sends statements takes an XapiClient. Tests can point it at the embedded LRS (see
samples/FakeLrsInTests),
or replace it with a test double: the service methods are virtual, and a class derived from XapiClient that
calls its protected parameterless constructor sends nothing and overrides only the methods the test needs.
Methods it does not override throw InvalidOperationException. There is no IXapiClient interface;
ADR-0008 explains why.
Creating a client
new XapiClient(httpClient, options) takes the HttpClient to send with and an XapiClientOptions
(getting-started.md has a complete example):
| Option | Default | Meaning |
|---|---|---|
Endpoint |
required | The base address of the LRS, for example https://lrs.example.com/xapi/. A missing trailing slash is added. |
Version |
null |
The xAPI version to speak. null negotiates it with the LRS (see below). |
Credentials |
null |
XapiCredentials.Basic(key, secret), XapiCredentials.Bearer(...), or null to send no Authorization header. |
ValidateBeforeSend |
true |
Validate statements before sending them, and throw XapiValidationException instead of sending an invalid one. |
The options are copied when the client is created. The client keeps no per-request state, so one instance can
be shared by all threads. It does not dispose the HttpClient; timeouts, proxies and handlers are configured
there.
Versions and negotiation
Every request carries the X-Experience-API-Version header, and the version also decides which validation
rules apply and how JSON is read and written.
When Version is null, the client reads the About resource before its first request and picks 2.0.0 if the
LRS lists a 2.0 version, otherwise 1.0.3 if it lists a 1.0 version. If it lists neither, the call throws
NotSupportedException. The result is kept for the life of the client (with dependency injection, for the
life of the service provider); a failed negotiation is tried again on the next call. GetVersionAsync returns
the version in use.
To skip negotiation, set the version:
var client = new XapiClient(http, new XapiClientOptions
{
Endpoint = new Uri("https://lrs.example.com/xapi/"),
Credentials = XapiCredentials.Basic(key, secret),
Version = XapiVersion.V1_0_3,
});
versions.md describes what changes between the two versions.
Credentials
XapiCredentials.Basic sends HTTP Basic credentials. XapiCredentials.Bearer takes a function that returns a
token; it is called before every request, so it should cache the token and refresh it when needed:
var client = new XapiClient(http, new XapiClientOptions
{
Endpoint = new Uri("https://lrs.example.com/xapi/"),
Credentials = XapiCredentials.Bearer(tokens.GetAccessTokenAsync),
});
For any other scheme, leave Credentials empty and add the header in a DelegatingHandler on the
HttpClient.
Dependency injection and retries
On net10.0, AddXapiClient registers XapiClient as the typed client of a named HttpClient
(XapiClientServiceCollectionExtensions.HttpClientName, "Xapi.Client") from IHttpClientFactory. The
client is transient, as typed clients are; the negotiated version is shared by every instance.
The client does not retry. To retry transient failures, add a resilience handler from
Microsoft.Extensions.Http.Resilience to the HttpClient that AddXapiClient returns:
builder.Services
.AddXapiClient(options =>
{
options.Endpoint = new Uri("https://lrs.example.com/xapi/");
options.Credentials = XapiCredentials.Basic(key, secret);
})
.HttpClient.AddStandardResilienceHandler(options => options.Retry.DisableForUnsafeHttpMethods());
DisableForUnsafeHttpMethods keeps retries to GET and other safe methods. A POST of a statement without an id
is not idempotent: if the response is lost, a retry stores the statement a second time. Statements that
already have an id are sent with PUT (or in a POST batch with their ids), and the LRS keeps the first copy, so
those are safe to repeat. For delivery that survives long outages and restarts, use the
outbox instead of long retry chains.
Statements
var ids = await client.SaveStatementsAsync([statement, statement with { Verb = Verbs.Passed }]);
var stored = await client.GetStatementAsync(ids[0]);
var voidingId = await client.VoidStatementAsync(ids[1], learner);
var voided = await client.GetVoidedStatementAsync(ids[1]);
SaveStatementAsyncsends PUT withstatementIdwhen the statement has anId, and POST otherwise. It returns the id and does not change the statement.SaveStatementsAsyncsends one POST; the LRS stores all statements or none. An empty list returns at once.GetStatementAsyncreturnsnullwhen the LRS answers 404. Voided statements are only returned byGetVoidedStatementAsync. Both take aStatementFormat(Exact,IdsorCanonical).VoidStatementAsyncstores a statement with thevoidedverb and a StatementRef to the voided one, and returns the voiding statement's id.
Queries
var query = new StatementQuery
{
Agent = Agent.FromMbox("learner@example.com"),
Verb = Verbs.Completed.Id,
Since = DateTimeOffset.UtcNow.AddDays(-7),
};
await foreach (var statement in client.QueryAllStatementsAsync(query))
{
Console.WriteLine($"{statement.Id} {statement.Timestamp:u}");
}
StatementQuery has a property for every filter of the standard: Agent, Verb, Activity,
Registration, RelatedActivities, RelatedAgents, Since, Until, Limit, Format, Ascending and
Attachments. Properties left at their defaults are not sent.
QueryAllStatementsAsync follows the more links until the last page, one page request at a time.
QueryStatementsAsync returns one page as a StatementResult (Statements, More, ConsistentThrough and
Attachments); pass More to GetMoreStatementsAsync for the next page. Only the path and query of More
are used, and they are sent to the host of Endpoint, so credentials never go to another host.
Documents and ETags
State, Activity Profile and Agent Profile documents are addressed by a key:
| Resource | Key |
|---|---|
| State | new StateKey(activityId, agent, stateId, registration) (registration is optional) |
| Activity Profile | new ActivityProfileKey(activityId, profileId) |
| Agent Profile | new AgentProfileKey(agent, profileId) |
GetDocumentAsync returns an XapiDocument (Content, ContentType, ETag, LastModified) or null when
there is none. PutDocumentAsync replaces a document. Under xAPI 2.0 an LRS answers 409 to a PUT without a
precondition when the document exists, so send DocumentPrecondition.IfNoneMatchAny to create and
DocumentPrecondition.IfMatch(etag) to replace the version you read. When the document changed in between,
the LRS answers 412 and the client throws XapiPreconditionFailedException:
var key = new StateKey(course, learner, "bookmark");
var current = await client.GetDocumentAsync(key);
var precondition = current?.ETag is { } etag
? DocumentPrecondition.IfMatch(etag)
: DocumentPrecondition.IfNoneMatchAny;
try
{
await client.PutDocumentAsync(key, new XapiDocument(Encoding.UTF8.GetBytes("module-3/page-1"), "text/plain"), precondition);
}
catch (XapiPreconditionFailedException)
{
// Another writer changed the document after it was read: read it again and decide
}
MergeDocumentAsync sends a POST with a JSON object; the LRS replaces each top-level property of the stored
JSON object with the one sent and creates the document if it does not exist:
using var changes = JsonDocument.Parse("""{ "lastPage": 12, "theme": "dark" }""");
var key = new StateKey(course, learner, "preferences");
await client.MergeDocumentAsync(key, changes.RootElement);
var preferences = await client.GetDocumentAsync(key);
var theme = preferences?.ToJson().GetProperty("theme").GetString();
DeleteDocumentAsync deletes one document (optionally with IfMatch), and DeleteStatesAsync deletes all
State documents of an Activity and Agent, or of one registration. GetStateIdsAsync,
GetActivityProfileIdsAsync and GetAgentProfileIdsAsync list document ids, optionally only those changed
after since.
Attachments
Attachment data is sent with the statement as multipart/mixed. AttachmentPayload.Create computes the
SHA-256 of the bytes, and ToAttachment builds the Attachment object that refers to it. Asking for
Attachments = true in a query returns the data in StatementResult.Attachments, matched to the statements
by SHA-2:
var certificate = AttachmentPayload.Create(await File.ReadAllBytesAsync("certificate.pdf"), "application/pdf");
var statement = new Statement(learner, Verbs.Completed, new Activity(course))
{
Attachments =
[
certificate.ToAttachment(
new Iri("https://example.com/attachment-usages/certificate"),
LanguageMap.Create("en-US", "Certificate")),
],
};
await client.SaveStatementAsync(statement, [certificate]);
var page = await client.QueryStatementsAsync(new StatementQuery { Activity = course, Attachments = true });
foreach (var payload in page.Attachments)
{
Console.WriteLine($"{payload.Sha2} {payload.ContentType} {payload.Content.Length} bytes");
}
With ValidateBeforeSend, every Attachment without a fileUrl needs a payload with the same SHA-2 (error
code attachment.payload.missing), and every payload needs an Attachment (attachment.payload.unreferenced,
whose path is the payload's index in the list, such as attachments[0], since payloads are not part of the
JSON). Both codes are constants on XapiErrorCodes.
Attachments that only point to a fileUrl need no payload. The client integration tests send and receive
attachments against lrsql, and the LRS tests against Xapi.Lrs.
StatementSigner signs a statement and returns it with its signature attachment and the JWS to send with it;
see signatures.md.
Agents, Activities and About
GetPersonAsync(agent) returns the Person object the LRS combines from everything it knows about an Agent,
GetActivityAsync(activityId) the LRS's definition of an Activity (null on 404), and GetAboutAsync the
About resource.
Errors
| Exception | When |
|---|---|
XapiValidationException |
A statement is invalid for the xAPI version in use and ValidateBeforeSend is on. Nothing was sent. Errors lists each problem with a JSON path and an error code. Also thrown, as the inner exception of XapiHttpException, when a response body is not valid xAPI. |
XapiConflictException |
409: the LRS has a different statement with the same id, or a document PUT had no precondition and the document exists. |
XapiPreconditionFailedException |
412: an If-Match or If-None-Match precondition failed. |
XapiHttpException |
Any other error status, or a successful response the client cannot read. StatusCode, ResponseBody, RetryAfter and IsTransient (408, 429, and 5xx except 501 and 505) describe it. The conflict and precondition exceptions derive from it. |
NotSupportedException |
Version negotiation found no version both sides support. |
HttpRequestException, TaskCanceledException |
No response: a network error, or the HttpClient timeout. |
try
{
await client.SaveStatementAsync(statement);
}
catch (XapiValidationException exception)
{
// Nothing was sent: the statement breaks a rule of the xAPI version in use
foreach (var error in exception.Errors)
{
Console.WriteLine($"{error.Path}: {error.Message} ({error.Code})");
}
}
catch (XapiConflictException)
{
// The LRS already has a different statement with this id
}
catch (XapiHttpException exception) when (exception.IsTransient)
{
// 408, 429 or 5xx: try again later, not before exception.RetryAfter if the LRS sent one
}
catch (XapiHttpException exception)
{
// Any other error status; the LRS usually explains it in the body
Console.WriteLine($"{(int)exception.StatusCode}: {exception.ResponseBody}");
}
catch (HttpRequestException)
{
// No response: DNS, connection or TLS failure
}
Error messages name the method and path of the request but not its query string, because agent parameters can hold personal data.
Metrics
The client counts the statements it delivers on the Xapi.Client meter. It uses System.Diagnostics.Metrics,
which on netstandard2.0 comes from the System.Diagnostics.DiagnosticSource package. To export the count with
the OpenTelemetry .NET SDK, add .WithMetrics(m => m.AddMeter(XapiClientDiagnostics.MeterName)) on
AddOpenTelemetry(). The outbox reports on the same meter; outbox.md lists its
instruments.
| Instrument | Type | Unit | Tags |
|---|---|---|---|
xapi.client.statements.sent |
Counter | {statement} |
xapi.version (2.0.0 or 1.0.3) |
It counts the statements of SaveStatementAsync and SaveStatementsAsync calls the LRS accepted. Failed calls
are not counted; HttpClient's own http.client.request.duration (meter System.Net.Http) measures every
request with its status. A client created with AddXapiClient gets its meter from the host's IMeterFactory; one
created with new XapiClient(...) reports on a meter shared by the process. The names are constants of
XapiClientDiagnostics.