TcgDex.CSharpSdk
0.2.1
dotnet add package TcgDex.CSharpSdk --version 0.2.1
NuGet\Install-Package TcgDex.CSharpSdk -Version 0.2.1
<PackageReference Include="TcgDex.CSharpSdk" Version="0.2.1" />
<PackageVersion Include="TcgDex.CSharpSdk" Version="0.2.1" />
<PackageReference Include="TcgDex.CSharpSdk" />
paket add TcgDex.CSharpSdk --version 0.2.1
#r "nuget: TcgDex.CSharpSdk, 0.2.1"
#:package TcgDex.CSharpSdk@0.2.1
#addin nuget:?package=TcgDex.CSharpSdk&version=0.2.1
#tool nuget:?package=TcgDex.CSharpSdk&version=0.2.1
TcgDex.CSharpSdk
A .NET SDK for the TCGdex Pokémon TCG API — strongly typed models, a fluent query builder over the full REST filter syntax, and first-class support for dependency injection, trimming and Native AOT.
Targets .NET 8, .NET 10 and netstandard2.0 — so it runs on modern .NET and on .NET Framework 4.6.1+. No API key required — TCGdex is a free, public, read-only API.
Unity is a supported target by construction rather than by test: the
netstandard2.0 assembly contains no runtime code generation, no
Expression.Compile() and no reflection-based serialization, and a Native AOT
publish exercises the one reflective path under full trimming. Nobody has yet
run it inside a Unity project, so see docs/unity.md for what
is verified, what is not, and the packaging and stripping caveats Unity adds.
The API surface is identical on every target, async included. One difference is
worth knowing before you rely on it: connection recycling, which keeps a
long-lived client from pinning stale DNS, uses SocketsHttpHandler on modern
.NET and ServicePoint.ConnectionLeaseTimeout on .NET Framework. Same
guarantee, different mechanism. Response cancellation mid-body is best-effort on
netstandard2.0, because the cancellable HttpContent read overloads do not
exist there.
Install
dotnet add package TcgDex.CSharpSdk
Quick start
With dependency injection, which wires the client through IHttpClientFactory:
builder.Services.AddTcgDex();
public sealed class CardLookup(ITcgDexClient tcgdex)
{
public async Task<string?> DescribeAsync(string id, CancellationToken ct)
{
Card? card = await tcgdex.Cards.GetAsync(id, ct);
return card is null ? null : $"{card.Name} ({card.Category}) — {card.Rarity}";
}
}
Without a container:
using HttpClient http = new();
TcgDexClient tcgdex = new(http, new TcgDexOptions());
Card? card = await tcgdex.Cards.GetAsync("swsh3-136", cancellationToken);
Console.WriteLine(card?.Name); // Furret
Querying
Predicates are written in C# and translated to the API's filter syntax:
CardQuery query = new CardQuery()
.Where(c => c.Name.Contains("Pikachu"))
.Where(c => c.Hp > 100)
.OrderByDescending(c => c.Name)
.Page(1, 50);
IReadOnlyList<CardBrief> cards = await tcgdex.Cards.ListAsync(query, cancellationToken);
which becomes:
cards?name=Pikachu&hp=gt:100&sort:field=name&sort:order=DESC&pagination:page=1&pagination:itemsPerPage=50
Supported translations, covering every operator the API has:
| C# | Query syntax |
|---|---|
c.Name == "Furret" |
name=eq:Furret |
c.Name != "Furret" |
name=neq:Furret |
c.Hp > 100 / >= / < / <= |
hp=gt:100 / gte: / lt: / lte: |
c.Name.Contains("pika") |
name=pika |
c.Name.StartsWith("fu") |
name=fu* |
c.Name.EndsWith("chu") |
name=*chu |
!c.Name.Contains("pika") |
name=not:pika |
c.Effect == null |
effect=null: |
c.Effect != null |
effect=notnull: |
a && b |
separate parameters |
c.Name == "a" \|\| c.Name == "b" |
name=eq:a\|b |
Two limits are inherited from the API rather than chosen here:
||works only within a single field. An OR across two fields has no encoding, so it throws rather than silently dropping half your predicate.- No total count exists. The API sends no count and no pagination headers, so page until you get back fewer items than you asked for.
Anything the API cannot express is rejected with a message naming the offending expression — never approximated into a filter that would quietly return the wrong cards.
Full card detail in one request
ListAsync returns briefs, so getting full detail for a result set costs one
call per card. When you need the detail, SearchDetailedAsync fetches it in a
single request over GraphQL:
IReadOnlyList<Card> cards = await tcgdex.Cards.SearchDetailedAsync(
new CardFilter { Name = "Furret" },
cancellationToken: ct);
// 12 fully populated cards — hp, types, attacks, weaknesses, set — in one hop.
// The REST equivalent is 13 round trips.
Three limits come with it, all imposed by the TCGdex GraphQL endpoint rather than by this SDK:
| REST | SearchDetailedAsync |
|
|---|---|---|
| Languages | all 18 | English only |
| Filters | all ten operators | equality only |
Pricing |
populated | never populated |
So reach for it when you want breadth cheaply, and stay on REST when you need a language, a range filter, or prices.
Caching
The API sends no-store, but it honours If-None-Match. Enabling the cache
means fresh reads skip the network entirely, and stale reads are revalidated —
an unchanged 22 KB set response costs a 304 and 0 bytes instead of a
re-download.
builder.Services.AddTcgDexWithCaching();
Errors are never cached, non-GET requests bypass it, and concurrent reads of
the same URL are collapsed into a single request. See
Caching.
What you can read
Card? card = await tcgdex.Cards.GetAsync("swsh3-136", ct);
Set? set = await tcgdex.Sets.GetAsync("swsh3", ct); // includes its cards
Serie? serie = await tcgdex.Series.GetAsync("swsh", ct); // includes its sets
Card lucky = await tcgdex.Random.CardAsync(ct);
// Distinct values, useful for building filters and pickers
IReadOnlyList<string> rarities = await tcgdex.Catalog.RaritiesAsync(ct);
IReadOnlyList<int> hpValues = await tcgdex.Catalog.HitPointsAsync(ct);
Catalog covers all thirteen enumeration endpoints: categories, rarities,
types, illustrators, stages, suffixes, variants, energy types, regulation marks,
trainer types, HP, retreat costs and dex ids.
Languages
18 are supported. Set one at registration:
builder.Services.AddTcgDex(options => options.Language = TcgDexLanguages.French);
An unsupported code fails at registration with a message listing the valid set, rather than surfacing later as a 404 that looks like a missing card.
Error handling
One rule, applied everywhere:
- A missing resource returns
null. Asking for a card that does not exist is a normal outcome, not an exception. - Everything else throws
TcgDexApiException— server errors, unsupported languages, timeouts and unparseable bodies alike, so you catch one type rather than four.
try
{
Card? card = await tcgdex.Cards.GetAsync(id, ct);
if (card is null) { /* no such card */ }
}
catch (TcgDexApiException ex)
{
logger.LogError(ex, "TCGdex failed with {Status}", ex.StatusCode);
}
Worth knowing: the API returns 404 for an unsupported language too, so the
status code alone cannot distinguish that from a missing card. The SDK
discriminates on the error body and exposes ex.IsLanguageError.
Trimming and Native AOT
The SDK is trim- and AOT-safe: serialization is source-generated, and the query
builder walks expression trees rather than calling Expression.Compile(), which
would emit IL at runtime.
This is verified, not asserted — CI publishes a Native AOT binary and runs it on
every push. See TcgDex.CSharpSdk.AotSmokeTest.
Cards are modelled as the API returns them
Fields are populated by category: Pokémon carry Hp, Types, Attacks and
Weaknesses; Trainers carry TrainerType and Effect; Energy cards carry
EnergyType. Anything category-specific is nullable because the API omits it
entirely rather than sending null.
Collections are never null — an absent array arrives as empty, so iterating
a Trainer's Attacks is safe.
A few shapes are irregular and the SDK smooths them over:
Attack.Damageis text, because the API sends60on one card and"50+"on another.Attack.BaseDamagegives you the numeric part.WeaknessOrResistance.Valueis text — values include"×2"and"-20".- TCGplayer prices are keyed by printing name, and the names vary per card, so
they are exposed as a dictionary:
card.Pricing?.Tcgplayer?["holofoil"].
Images
Image, Logo and Symbol are base URLs without a file extension. The
helpers build the right form for each:
string? art = card.GetImageUrl(ImageQuality.High, ImageFormat.Png);
string? logo = card.Set.GetLogoUrl();
string? sym = card.Set.GetSymbolUrl(ImageFormat.Webp);
They are separate methods because card artwork takes a quality segment and set assets do not:
https://assets.tcgdex.net/en/swsh/swsh3/136/high.png card 200
https://assets.tcgdex.net/en/swsh/swsh3/logo.png logo 200
https://assets.tcgdex.net/en/swsh/swsh3/logo/high.png logo 404
The three fields look alike on the model, so applying the card pattern to a logo is an easy mistake — and returns 404.
Each helper returns null rather than a broken URL when the asset is absent;
some cards genuinely have no artwork.
Logging and tracing
Structured logs via ILogger and OpenTelemetry spans via ActivitySource, both
free when nothing is listening. With DI it is automatic; everything is under the
TcgDex category.
builder.Services.AddOpenTelemetry()
.WithTracing(t => t.AddSource(TcgDexActivity.SourceName));
The SDK takes no dependency on any telemetry vendor — it writes to .NET's standard abstractions, so Serilog, Application Insights, Datadog, Sentry or anything else picks it up by configuring that tool in your application. See Logging and tracing.
Documentation
Full documentation and API reference
In this repository:
docs/api-info.md— the API reference this SDK is built against, verified field by field against live responses.docs/learnings.md— non-obvious behaviour discovered while building it.
Security
Found a vulnerability? Report it privately through
GitHub Security Advisories
rather than opening an issue — see SECURITY.md, which also sets
out what this library actually exposes and what is not in scope.
Contributing
dotnet build -warnaserror # zero warnings is enforced
dotnet test TcgDex.CSharpSdk.Tests # unit tests, offline
dotnet test TcgDex.CSharpSdk.IntegrationTests --filter "TestCategory=Integration"
Coverage is gated in CI and the same check runs locally. Run this before pushing — it is the gate most easily forgotten, because everything else is green by the time you get here:
dotnet test TcgDex.CSharpSdk.Tests/TcgDex.CSharpSdk.Tests.csproj --collect:"XPlat Code Coverage" --settings coverlet.runsettings --results-directory ./TestResults
pwsh ./scripts/Check-Coverage.ps1
--settings coverlet.runsettings is not optional. Without it the
source-generated files swamp the hand-written ones and the number means nothing
— 1,455 lines counted against the real 986.
Two further gates that are not part of a normal push:
- The public API is pinned. Adding, removing or changing anything public
fails
PublicApiTestsuntil the diff is accepted intoTcgDex.CSharpSdk.Tests/PublicApi.approved.cs. That commit is the record of what consumers are promised. - Mutation testing and fuzzing run periodically rather than per push. See
docs/measuring.mdfor both, including how to run the fuzzer locally under WSL.
Unit tests run against recorded API responses in
TcgDex.CSharpSdk.Tests/Fixtures, so they need no network. Integration tests
hit the live API and run weekly in CI rather than per push, so a TCGdex outage
does not redden a pull request.
License
MIT — see LICENSE.txt.
This is an unofficial, community-maintained SDK. It is not affiliated with TCGdex, Nintendo, Creatures Inc., GAME FREAK inc. or The Pokémon Company. Pokémon and all related names are trademarks of their respective owners.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net5.0 was computed. net5.0-windows was computed. net6.0 was computed. net6.0-android was computed. net6.0-ios was computed. net6.0-maccatalyst was computed. net6.0-macos was computed. net6.0-tvos was computed. net6.0-windows was computed. net7.0 was computed. net7.0-android was computed. net7.0-ios was computed. net7.0-maccatalyst was computed. net7.0-macos was computed. net7.0-tvos was computed. net7.0-windows was computed. net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 was computed. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
| .NET Core | netcoreapp2.0 was computed. netcoreapp2.1 was computed. netcoreapp2.2 was computed. netcoreapp3.0 was computed. netcoreapp3.1 was computed. |
| .NET Standard | netstandard2.0 is compatible. netstandard2.1 was computed. |
| .NET Framework | net461 was computed. net462 was computed. net463 was computed. net47 was computed. net471 was computed. net472 was computed. net48 was computed. net481 was computed. |
| MonoAndroid | monoandroid was computed. |
| MonoMac | monomac was computed. |
| MonoTouch | monotouch was computed. |
| Tizen | tizen40 was computed. tizen60 was computed. |
| Xamarin.iOS | xamarinios was computed. |
| Xamarin.Mac | xamarinmac was computed. |
| Xamarin.TVOS | xamarintvos was computed. |
| Xamarin.WatchOS | xamarinwatchos was computed. |
-
.NETStandard 2.0
- Microsoft.Bcl.AsyncInterfaces (>= 8.0.0)
- Microsoft.Bcl.TimeProvider (>= 8.0.1)
- Microsoft.Extensions.Http (>= 8.0.1)
- Microsoft.Extensions.Logging.Abstractions (>= 8.0.3)
- Microsoft.Extensions.Options (>= 8.0.2)
- System.Diagnostics.DiagnosticSource (>= 8.0.1)
- System.Net.Http.Json (>= 8.0.1)
- System.Text.Json (>= 8.0.6)
-
net10.0
- Microsoft.Extensions.Http (>= 10.0.10)
- Microsoft.Extensions.Logging.Abstractions (>= 10.0.10)
- Microsoft.Extensions.Options (>= 10.0.10)
-
net8.0
- Microsoft.Extensions.Http (>= 8.0.1)
- Microsoft.Extensions.Logging.Abstractions (>= 8.0.3)
- Microsoft.Extensions.Options (>= 8.0.2)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.