Tutoriel API de films en C# pour .NET 8
Ce tutoriel construit une application console .NET 8 qui prend un titre de film, le cherche sur amodb, charge le meilleur résultat avec ses notes et affiche une fiche courte. Elle n'utilise que HttpClient et System.Text.Json, lit la clé dans AMODB_KEY, réessaie sur 429 et met les réponses en cache. La mise en place prend environ 15 minutes.
Ce que vous construisez
Vous construisez MovieLookup, une application console qui prend un titre sur la ligne de commande, le cherche sur amodb, charge le meilleur résultat avec ses notes et affiche une courte fiche avec un lien d'affiche. Il vous faut le SDK .NET 8, une clé amodb et environ 15 minutes. Le programme n'utilise que HttpClient et System.Text.Json, donc aucun paquet NuGet n'est à installer.
Étape 1 : obtenir une clé d'API
Choisissez une offre sur les tarifs. Les offres ne diffèrent que par le débit, à 20, 40 ou 120 requêtes par minute. Créez une clé dans le tableau de bord de votre compte : une chaîne de 32 caractères hexadécimaux en minuscules. Exportez-la et testez-la avec un appel :
Un 200 signifie que la clé fonctionne. Un 401 signifie que la clé est fausse ou absente. Dans PowerShell, définissez la variable avec $env:AMODB_. La page authentification présente le paramètre de requête api_key comme alternative à l'en-tête.
export AMODB_KEY=YOUR_API_KEY
curl -s -o /dev/null -w "%{http_code}\n" \
-H "Authorization: Bearer $AMODB_KEY" \
"https://api.amodb.dev/3/movie/550"Étape 2 : créer le projet
Le modèle .NET 8 active les usings implicites et les types référence non nullables. Cela importe déjà System, System.IO, System.Linq et System.Net.Http. Vous ajoutez les autres lignes using dans le fichier.
dotnet new console -n MovieLookup -f net8.0
cd MovieLookupÉtape 3 : envoyer la première requête
Créez un seul HttpClient avec une adresse de base, un délai d'expiration de 10 secondes et l'en-tête Bearer. Terminez l'adresse de base par une barre oblique et écrivez les chemins relatifs sans barre oblique initiale. Une barre oblique initiale remplacerait la partie /3 du chemin de base.
using System.Net.Http.Headers;
using var http = new HttpClient
{
BaseAddress = new Uri("https://api.amodb.dev/3/"),
Timeout = TimeSpan.FromSeconds(10),
};
http.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", Environment.GetEnvironmentVariable("AMODB_KEY"));
var response = await http.GetAsync("search/movie?query=fight%20club&language=en-US");
Console.WriteLine((int)response.StatusCode);Étape 4 : analyser le JSON dans des records
La réponse utilise des noms en snake case comme release_date. Définissez JsonNamingPolicy.SnakeCaseLower, nouveau dans .NET 8, et écrivez des membres en PascalCase. Les champs que vous ne déclarez pas sont ignorés.
L'objet ratings apparaît parce que l'appel de détails ajoute append_. Chacune de ses cinq sources est un objet avec value et votes, ou null quand la note manque. votes peut aussi valoir null : la vraie réponse pour Fight Club n'a pas de nombre de votes pour Kinopoisk, Metacritic ni Rotten Tomatoes. La page films liste tous les autres champs.
record SearchResponse(List<SearchHit> Results);
record SearchHit(int Id, string Title, string? ReleaseDate);
record Movie(int Id, string Title, string? Tagline, string? ReleaseDate, int? Runtime,
string? PosterPath, double VoteAverage, RatingSet? Ratings);
record RatingSet(Score? Imdb, Score? Kinopoisk, Score? Metacritic, Score? RottenTomatoes, Score? Tmdb);
record Score(double Value, int? Votes);Étape 5 : gérer les erreurs et les limites de débit
Enveloppez l'appel dans une boucle qui réagit au code de statut.
| Statut | Signification | Ce que fait l'application |
|---|---|---|
| 401 | Clé absente, fausse ou suspendue | Afficher un message et s'arrêter, une nouvelle tentative ne peut pas aider. |
| 402 | La période de l'offre est terminée | Afficher un message et s'arrêter. Renouveler sur les tarifs. |
| 404 | Aucune fiche pour cet identifiant | Le signaler et quitter. |
| 429 | Au-dessus de vos requêtes par minute | Attendre Retry-After secondes, plafonnées à 30, et réessayer au plus deux fois. |
| 500 ou 504 | Échec côté serveur | Signaler le statut et relancer la commande plus tard. |
Les défaillances réseau apparaissent sous forme de HttpRequestException, et un délai dépassé sous forme de TaskCanceledException. Interceptez les deux au niveau supérieur. Chaque réponse avec clé porte aussi X-RateLimit-Remaining, si bien qu'un long traitement peut ralentir avant d'atteindre un 429. Voir limites de débit et erreurs pour les listes complètes.
for (var attempt = 0; ; attempt++)
{
using var response = await http.GetAsync(path);
if (response.StatusCode == HttpStatusCode.TooManyRequests && attempt < 2)
{
var seconds = Math.Min(response.Headers.RetryAfter?.Delta?.TotalSeconds ?? 5, 30);
await Task.Delay(TimeSpan.FromSeconds(seconds));
continue;
}
if (response.StatusCode == HttpStatusCode.Unauthorized)
throw new AmodbException("The API key was rejected (401). Check AMODB_KEY.");
// ...handle other statuses, then read the body
}Étape 6 : mettre les réponses en cache sur disque
La vérification de la limite de débit passe avant le cache de périphérie : une réponse servie depuis la périphérie compte donc quand même dans vos requêtes par minute. Un cache dans votre propre programme est le seul moyen d'économiser des requêtes. La périphérie garde les détails 6 heures et les résultats de recherche 10 minutes, et l'application utilise les mêmes durées. Un fichier dans le dossier temporaire est frais quand sa date d'écriture est dans la durée de vie.
class FileCache
{
readonly string dir = Path.Combine(Path.GetTempPath(), "amodb-cache");
string PathFor(string key) =>
Path.Combine(dir, Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(key))) + ".json");
public string? Get(string key, TimeSpan ttl)
{
var file = PathFor(key);
return File.Exists(file) && DateTime.UtcNow - File.GetLastWriteTimeUtc(file) < ttl
? File.ReadAllText(file)
: null;
}
}Étape 7 : assembler le tout
Voici le Program.cs complet. Il ajoute la méthode Set au cache et la classe AmodbClient qui combine le cache, la boucle de nouvelle tentative et les options JSON.
using System.Globalization;
using System.Net;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture; // same number format on every machine
var key = Environment.GetEnvironmentVariable("AMODB_KEY");
if (string.IsNullOrWhiteSpace(key))
{
Console.Error.WriteLine("Set AMODB_KEY first.");
return 1;
}
var title = string.Join(' ', args).Trim();
if (title.Length == 0)
{
Console.Error.WriteLine("Usage: dotnet run -- \"movie title\"");
return 1;
}
using var http = new HttpClient
{
BaseAddress = new Uri("https://api.amodb.dev/3/"),
Timeout = TimeSpan.FromSeconds(10),
};
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", key);
var client = new AmodbClient(http, new FileCache());
try
{
var search = await client.GetAsync<SearchResponse>(
$"search/movie?query={Uri.EscapeDataString(title)}&language=en-US", TimeSpan.FromMinutes(10));
var hit = search.Results.FirstOrDefault();
if (hit is null)
{
Console.WriteLine($"No movie found for \"{title}\".");
return 0;
}
var movie = await client.GetAsync<Movie>(
$"movie/{hit.Id}?language=en-US&append_to_response=ratings", TimeSpan.FromHours(6));
Print(movie);
return 0;
}
catch (AmodbException ex)
{
Console.Error.WriteLine(ex.Message);
return 1;
}
catch (HttpRequestException ex)
{
Console.Error.WriteLine($"Network error: {ex.Message}");
return 1;
}
catch (TaskCanceledException)
{
Console.Error.WriteLine("The request timed out after 10 seconds.");
return 1;
}
static void Print(Movie m)
{
var year = m.ReleaseDate is { Length: >= 4 } d ? d[..4] : "n/a";
Console.WriteLine($"{m.Title} ({year}), {m.Runtime?.ToString() ?? "?"} min");
if (!string.IsNullOrEmpty(m.Tagline)) Console.WriteLine(m.Tagline);
Console.WriteLine($"IMDb: {Describe(m.Ratings?.Imdb)}");
Console.WriteLine($"TMDB: {Describe(m.Ratings?.Tmdb)}");
if (m.PosterPath is not null)
Console.WriteLine($"Poster: https://img.amodb.dev/t/p/w342{m.PosterPath}");
}
static string Describe(Score? s) =>
s is null ? "not known" : $"{s.Value:0.0}" + (s.Votes is { } v ? $" ({v:N0} votes)" : "");
record SearchResponse(List<SearchHit> Results);
record SearchHit(int Id, string Title, string? ReleaseDate);
record Movie(int Id, string Title, string? Tagline, string? ReleaseDate, int? Runtime,
string? PosterPath, double VoteAverage, RatingSet? Ratings);
record RatingSet(Score? Imdb, Score? Kinopoisk, Score? Metacritic, Score? RottenTomatoes, Score? Tmdb);
record Score(double Value, int? Votes);
class AmodbException : Exception
{
public AmodbException(string message) : base(message) { }
}
class FileCache
{
readonly string dir = Path.Combine(Path.GetTempPath(), "amodb-cache");
string PathFor(string key) =>
Path.Combine(dir, Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(key))) + ".json");
public string? Get(string key, TimeSpan ttl)
{
var file = PathFor(key);
return File.Exists(file) && DateTime.UtcNow - File.GetLastWriteTimeUtc(file) < ttl
? File.ReadAllText(file)
: null;
}
public void Set(string key, string json)
{
Directory.CreateDirectory(dir);
File.WriteAllText(PathFor(key), json);
}
}
class AmodbClient(HttpClient http, FileCache cache)
{
static readonly JsonSerializerOptions Options = new()
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
};
public async Task<T> GetAsync<T>(string path, TimeSpan ttl)
{
var cached = cache.Get(path, ttl);
if (cached is not null) return JsonSerializer.Deserialize<T>(cached, Options)!;
for (var attempt = 0; ; attempt++)
{
using var response = await http.GetAsync(path);
if (response.StatusCode == HttpStatusCode.TooManyRequests && attempt < 2)
{
var seconds = Math.Min(response.Headers.RetryAfter?.Delta?.TotalSeconds ?? 5, 30);
await Task.Delay(TimeSpan.FromSeconds(seconds));
continue;
}
if (response.StatusCode == HttpStatusCode.Unauthorized)
throw new AmodbException("The API key was rejected (401). Check AMODB_KEY.");
if (!response.IsSuccessStatusCode)
throw new AmodbException($"amodb returned status {(int)response.StatusCode}.");
var body = await response.Content.ReadAsStringAsync();
cache.Set(path, body);
return JsonSerializer.Deserialize<T>(body, Options)!;
}
}
}Lancez-le avec un titre. Voici la sortie pour le premier résultat de recherche, le film 550. Les nombres de votes augmentent avec le temps : les vôtres seront un peu différents.
La ligne TMDB affiche 8,4 parce que le code affiche une décimale du 8.437 de ratings.tmdb.value. L'hôte d'affiches img.amodb.dev n'exige aucune clé : vous pouvez donc passer l'URL de l'affiche directement à un navigateur ou à un contrôle d'image. Voir images pour la liste des tailles.
dotnet run -- "fight club"
# Fight Club (1999), 139 min
# Mischief. Mayhem. Soap.
# IMDb: 8.8 (2,669,989 votes)
# TMDB: 8.4 (33,039 votes)
# Poster: https://img.amodb.dev/t/p/w342/jSziioSwPVrOy9Yow3XhWIBDjq1.jpgPour aller plus loin
- Cherchez avec une année ou une pagination dans recherche, et filtrez les listes avec découverte.
- Affichez toutes les notes et leurs règles pour null avec notes.
- Déplacez le code de la console dans un service ASP.NET Core avec
AddHttpClientetIMemoryCache. - Comparez la même tâche en Java ou en Go.