Skip to content

About

.NET client implementation for RaceResult WebAPI

Resources

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

RaceResultClient.NET

A strongly-typed, async .NET client library for the RaceResult Web API. It wraps the entire HTTP surface — authentication, event management, participants, timing, results, and more — behind a clean, fluent C# API.


Requirements

  • .NET 10.0 or later
  • A running RaceResult Web server (on-premise or cloud)

Project Structure

RaceResultClient.NET/
├── Apiclient.cs              # Core HTTP client (auth, request building, error handling)
├── EventApiClient.cs         # Per-event facade; entry point for all event-scoped endpoints
├── EventEndpoints.cs         # Core event-scoped endpoint groups (participants, timing, etc.)
├── EventEndpointsDesign.cs   # Additional groups (archives, certificates, lists, labels, …)
├── PublicAndGeneral.cs       # Server-level endpoints (login, event lifecycle, user rights)
├── Models.cs                 # Core request/response record types, enums and RaceResult's codes
├── DesignModels.cs           # Design-object models (certificate, label, list, kiosk, …)
├── Supporting.cs             # QueryParams builder, ApiException, Identifier, JSON config
└── RaceResultClient.csproj

This client mirrors the official Go libraries go-webapi and go-model: endpoint paths, query parameters and model fields track those repositories. Enums serialize numerically (as in Go's encoding/json), except the certificate page-size/format enums which serialize as strings.

Property names follow C# conventions (Id, Url, Rssi), but every property whose Go JSON name differs carries a [JsonPropertyName] attribute (ID, URL, RSSI, …), so the bytes on the wire are byte-for-byte identical to what go-webapi sends and expects.


Getting Started

1. Create a client

using RaceResultClient;

var client = new ApiClient(server: "my-raceresult-server.com", useHttps: true);

// Or with a handler of your own (one that checks where it may connect, a proxy, a test double);
// it is disposed with the client
var checkedClient = new ApiClient("my-raceresult-server.com", true, "webapi/1.0", new SocketsHttpHandler());

2. Authenticate

Three authentication methods are supported:

// Username + password
await client.Public.LoginAsync(new LoginOptions { User = "admin", Password = "secret" });

// API key
await client.Public.LoginAsync(new LoginOptions { ApiKey = "your-api-key" });

// RaceResult user token
await client.Public.LoginAsync(new LoginOptions { RrUserToken = "token" });

After login, the session token is stored internally and sent with every subsequent request as a Bearer token. A session obtained elsewhere can be used instead of logging in, and the request timeout — 30 seconds, as in go-webapi — can be changed before the first request:

client.SessionId = sessionIdFromElsewhere;   // go-webapi's SetSessionID
client.Timeout = TimeSpan.FromMinutes(2);    // go-webapi's SetTimeout

3. Get an event client

// By known event ID
var ev = client.ForEvent("my-event-id");

// Or create a new event and get a client for it
var ev = await client.Public.CreateEventAsync("My Race 2025", new DateTime(2025, 6, 1));

// Or pick from the event list
var events = await client.Public.GetEventListAsync(year: 2025);
var nextEvents = await client.Public.GetNextEventListAsync();
var pastEvents = await client.Public.GetPastEventListAsync();
var ev = client.ForEvent(events[0].Id);

// A RACE RESULT server at the venue: no login, its own event list (public/eventlist
// would ask it for a token it cannot make); event endpoints then work as usual
using var local = new ApiClient("192.168.1.20", useHttps: false);
var localEvents = await local.Local.GetEventListAsync();
var localEv = local.ForEvent(localEvents[0].Id);

4. Call endpoints

All endpoint groups are accessed as properties on EventApiClient:

// Count registered participants
int count = await ev.Data.CountAsync();

// Get all contests
Contest[] contests = await ev.Contests.GetAsync();

// Create a new participant
var result = await ev.Participants.NewAsync(bib: 42, contest: 1);

// Submit a timing passing
await ev.Times.AddAsync(new[] {
    new Passing {
        Transponder = "ABC123",
        UtcTime = DateTime.UtcNow
    }
});

5. Dispose

ApiClient owns an HttpClient and implements IDisposable:

using var client = new ApiClient("my-server.com");
// ...

Endpoint Reference

Server-level (client.Public, client.General, client.Local)

Endpoint Description
Public.LoginAsync Authenticate and store session
Public.LogoutAsync Terminate the current session
Public.GetEventListAsync List events for a given year
Public.CreateEventAsync Create a new event
Public.DeleteEventAsync Permanently delete an event
Public.GetUserInfoAsync Get info about the authenticated user
Public.TokenFromSessionAsync Exchange the session for an OAuth2 token
Public.GetUserRightsAsync List user access rights for an event
Public.SaveUserRightsAsync Grant/update user rights
Public.DeleteUserRightsAsync Revoke user rights
General.GetFontsAsync List supported font names
General.GetAppVersionAsync Get server application version
General.TranslateAsync Translate field names or expressions
General.GetLangItemAsync Get a single translation string
Local.GetEventListAsync All events on a RACE RESULT server at the venue (no login)

Event-scoped (ev.*)

All groups below are properties on EventApiClient.

Group Description
AgeGroups CRUD, generate and reassign age group definitions
Archives Archive lookup, create/write/import the event archive
Backup Start/stop and monitor the backup process
BibRanges CRUD for bib number ranges
Certificates CRUD for certificate designs; render PDF/JPG/thumbnail
CertificateSets CRUD for certificate sets; render and count
Chat Read messages, register users, post messages
ChipFile Read, save and clear the transponder chip file
Contests CRUD for contest/category definitions
CustomFields CRUD for custom participant fields
Data Query and transform participant data with filtering, sorting, pagination
Dependencies Inspect the field dependency tree and circular references
EmailTemplates CRUD for email/SMS templates; preview and send
EntryFees CRUD for entry fee tiers
Exporters CRUD and start/stop for data exporters
File File access, activation, version, modjobid, ownership/rights
Forwarding Manage and monitor live data forwarding
GroupTimes Read and save group start times / finish time limits
History Read, export and delete participant field history
Information Frequent first names and gender lookup
Kiosks CRUD for kiosk configurations
Labels CRUD for label designs; render PDF
Lists CRUD for list designs; render PDF/HTML/XML/JSON/CSV/XLSX/…
OverwriteValues Save/delete/count result overwrite values
Participants Full participant lifecycle: create, read, update, delete, import (incl. SES), bib management
Pictures Manage participant/event pictures and thumbnails
Rankings CRUD for ranking definitions
RawData Read, filter, export, copy/swap and delete raw timing data
RawDataRules CRUD for raw data routing rules
Registrations Process submissions; CRUD for registration forms
Results CRUD for result column definitions
Settings Read, write and delete event settings by name
SimpleApi CRUD for Simple API endpoint configurations
Splits CRUD for split/checkpoint definitions
Statistics CRUD for statistic definitions; render and compute pivots
Synchronization Check-out/check-in status (online server)
TeamScores CRUD for team scoring definitions
TimingPoints CRUD for timing point definitions
TimingPointRules CRUD for timing point decoder routing rules
Times Submit passings, read/delete/copy/swap times, single-start and random times
UserDefinedFields Read and overwrite user-defined formula fields
Vouchers CRUD for discount vouchers and validation
WebHooks CRUD for webhook configurations

Key Concepts

Identifier — Bib vs. PID

Many endpoints accept either a bib number, an internal participant ID (PID), or a generic internal ID. The Identifier struct encodes this choice:

// Address by bib number
await ev.Times.GetAsync(Identifier.Bib(101));

// Address by internal participant ID (PID)
await ev.Participants.GetFieldsAsync(Identifier.Pid(5432), new[] { "Firstname", "Lastname" });

// Address by internal ID
await ev.History.GetAsync(Identifier.Id(99));

RaceResult's codes

Where a model field or a parameter is a choice among RaceResult's own numbers, the library takes an enum, not an int, so a caller keeps no RaceResult numbers of its own. Identifiers stay int — a PID, a bib, a contest, a result or a split Id is a number, not a choice. The values are RaceResult's go-model constants or what RaceResult's own splits page writes (checked against a splits definition set up by hand and exported):

Enum Field Values
SplitType Split.SplitType Split 0, Internal 2, Leg 9
SplitTimeMode Split.TimeMode (what TimeMin/TimeMax are) RaceTime 0, TimeOfDay -1, FromPreviousSplit -2, MinPerKm -3, MinPerMile -4, MinPer100m -5, Kmh -6, Mph -7, MetresPerSecond -8
SplitDistanceFrom Split.DistanceFrom Start 0, PreviousSplit -1, or SplitDistanceFromExtensions.FromSplit(id)
TypeOfSport Split.TypeOfSport None 0, Swimming 4, Skiing 5, Other 10, Cycling 11, Running 100, TransitionFirstRead 250, TransitionLastRead 251
ContestTimingMode Contest.TimingMode GunTime 0, ChipTime 1
ParticipantStatus Participant.Status Regular 0, OutOfCompetition 1, Disqualified 2, DidNotFinish 3, DidNotStart 4, NotPresent 5
CustomFieldType CustomField.FieldType Text 0 … Transponder 10
VoucherType Voucher.Type Amount 0, Percent 1, FirstReg 2, PrevReg 3
WebHookType WebHook.Type ParticipantNew 0 … ModJobIdSettings 4
TriggerType TriggerSettings.TriggerType NewTime 0 … NewRawDataV2 10
StatisticAggregation Statistics.Aggregation, Statistics.ComputeAsync, Data.TransformationAsync Count 1, Minimum 2, Maximum 3, Mean 4, Sum 5
CertificateAlignment / CertificateVAlignment CertificateElement.Alignment / .VAlignment Left 1, Center 2, Right 3 / Top 0, Middle 1, Bottom 2

The design enums (PageSize, PageFormat, CertificateElementType, LabelAlignment, ListShowAt, ListPageBreak, EmailTemplateType, CertificateSetType, …) cover the same ground for the design objects.

Two fields RaceResult stores as text keep named string constants instead, since a value outside the set is still valid text:

Class Field Values
SplitDistanceUnits Split.DistanceUnit m, km, miles
SplitSpeed Split.SpeedOrPace (the number as text) None 0 … MetresPerSecond 6
// Lap 2: 5 km from the previous split, between 5 s and 1 min after it, cycling
var lap = new Split
{
    Contest = 1, Name = "Lap2", TimingPoint = "FINISH",
    TypeOfSport = TypeOfSport.Cycling,
    Distance = 5, DistanceUnit = SplitDistanceUnits.Kilometers,
    DistanceFrom = SplitDistanceFrom.PreviousSplit,
    TimeMin = 5, TimeMax = 60, TimeMode = SplitTimeMode.FromPreviousSplit,
};
await ev.Splits.SaveAsync([lap]);

// Measured from another split instead
var fromSplit = lap with { DistanceFrom = SplitDistanceFromExtensions.FromSplit(13) };
int? origin = fromSplit.DistanceFrom.SplitId();   // 13

Every enum is backed by its RaceResult number and serializes as that number, so a value RaceResult adds later still round-trips: Split.TimeMode also carries RaceResult's own encoding of "From Split …" (a value of its own, outside SplitTimeMode), and reading and saving a split keeps it unchanged.

Models go-webapi has no endpoint for

go-model carries a few types that no go-webapi endpoint returns; this library ports them too, so a caller that meets them elsewhere (a web hook's body, a trigger subscription, a participant's payment JSON) does not have to write them itself:

Type Where it comes from
WebHookMessage the body a web hook posts to its WebHook.Url
TriggerSettings, TriggerType a push-trigger subscription
OpData, OpDataPayment the parsed form of Participant.Opjson
RegistrationRequestResponse, RegistrationRequestResponseRecord, InvoicePaymentTerms the answer to a RegistrationRequest (Registrations.ProcessAsync returns the raw JsonElement; deserialize it into these)
RawDataReduced the short form of a raw-data entry, without the Passing
TableValue a row of the event file's generic lookup table
ParticipantAndDummyCount a participant count split into real and dummy records
ArchiveEvent, ArchiveContest an archive's events and their contests

UserRight.Has("participants.edit") reads a rights map the way go-model's UserRights.Has does, "*" as a group or a detail included.

QueryParams — Fluent query string builder

QueryParams is a fluent, type-safe builder used internally and exposed for raw calls:

var q = new QueryParams()
    .Add("filter", "contest=1")
    .Add("limitFrom", 0)
    .Add("limitTo", 50)
    .AddArray("fields", new[] { "Firstname", "Lastname", "Bib" });

ApiException — Error handling

Any non-200 HTTP response throws an ApiException carrying the status code and server error message:

try
{
    await ev.Participants.DeleteAsync(filter: "bib=999");
}
catch (ApiException ex)
{
    Console.WriteLine($"API error {ex.StatusCode}: {ex.Message}");
}

You can also inject a custom exception factory if your application uses its own exception hierarchy:

client.ErrorFactory = (message, statusCode) => new MyAppException(message, statusCode);

Raw access

If you need to call an endpoint not yet surfaced by the library:

byte[] raw = await ev.GetRawAsync("some/undocumented/endpoint");

Common Usage Examples

Import participants from a file

byte[] fileBytes = File.ReadAllBytes("participants.xlsx");

ImportResult result = await ev.Participants.ImportAsync(
    file: fileBytes,
    addParticipants: true,
    updateParticipants: true
);

Console.WriteLine($"Added: {result.Added}, Updated: {result.Updated}");

Query participant data with field projection

var rows = await ev.Data.ListAsync(
    fields: new[] { "Bib", "Firstname", "Lastname", "Contest" },
    filter: "status=0",
    sort: new[] { "Lastname" },
    limitFrom: 0,
    limitTo: 100
);

foreach (var row in rows)
    Console.WriteLine($"{row[0]} — {row[1]} {row[2]}");

Submit a timing passing

var responses = await ev.Times.AddAsync(new[]
{
    new Passing
    {
        Transponder = "A1B2C3",
        UtcTime = DateTime.UtcNow,
        DeviceName = "Finish Line Reader"
    }
});

foreach (var r in responses)
    Console.WriteLine($"Bib {r.ResultId}: {r.Time} at {r.TimingPoint}");

Batch-update participant fields

await ev.Participants.SaveFieldsAsync(
    id: Identifier.Bib(42),
    values: new Dictionary<string, object?>
    {
        ["Status"] = 1,
        ["Comment"] = "DNS"
    }
);

Read and write event settings

var settings = await ev.Settings.GetAsync(ct, "EventName", "EventDate");

await ev.Settings.SaveAsync("EventName", "My Updated Race Name");

Architecture Notes

  • ApiClient owns the HttpClient and session state. It is the root object and should be disposed when done.
  • EventApiClient is a lightweight façade created via client.ForEvent(id). It holds only an event ID string and a reference to ApiClient — no state of its own. Endpoint group objects (e.g. ev.Participants) are instantiated on each property access (no caching overhead, no shared state).
  • All models use C# record types with init-only properties, making them immutable and copy-friendly.
  • Serialization uses System.Text.Json throughout, with case-insensitive deserialization. Enums serialize as their numbers, as Go's encoding/json does; the certificate page-size/format enums are the exception and serialize as strings, matching go-model's own marshallers.
  • CancellationToken is a last optional parameter on every async method, defaulting to default.

License

This project is licensed under the MIT License.

About

.NET client implementation for RaceResult WebAPI

Resources

Stars

1 star

Watchers

0 watching

Forks

Contributors

Languages