Tutorial API film in C# per .NET 8

Questo tutorial costruisce un'app console .NET 8 che prende il titolo di un film, lo cerca su amodb, carica il risultato migliore con le sue valutazioni e stampa una scheda breve. Usa solo HttpClient e System.Text.Json, legge la chiave da AMODB_KEY, ritenta su 429 e mette in cache le risposte in file. La configurazione richiede circa 15 minuti.

.cs C# Aggiornato il Vedi in Markdown

Cosa costruisci

Costruisci MovieLookup, un'app console che prende un titolo dalla riga di comando, lo cerca su amodb, carica il risultato migliore con le sue valutazioni e stampa una breve scheda con il link della locandina. Ti servono l'SDK .NET 8, una chiave amodb e circa 15 minuti. Il programma usa solo HttpClient e System.Text.Json, quindi non c'è alcun pacchetto NuGet da installare.

Passaggio 1: ottieni una chiave API

Scegli un piano nella pagina dei prezzi. I piani differiscono solo per la frequenza, a 20, 40 o 120 richieste al minuto. Crea una chiave nella dashboard del tuo account: una stringa di 32 caratteri esadecimali minuscoli. Esportala e provala con una chiamata:

Un 200 significa che la chiave funziona. Un 401 significa che la chiave è sbagliata o mancante. In PowerShell imposta la variabile con $env:AMODB_KEY = "YOUR_API_KEY". La pagina autenticazione descrive il parametro di query api_key come alternativa all'header.

Shell
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"

Passaggio 2: crea il progetto

Il template di .NET 8 attiva gli using impliciti e i tipi riferimento non nullable. Questo importa già System, System.IO, System.Linq e System.Net.Http. Aggiungi tu le altre righe using nel file.

Shell
dotnet new console -n MovieLookup -f net8.0
cd MovieLookup

Passaggio 3: invia la prima richiesta

Crea un solo HttpClient con un indirizzo di base, un timeout di 10 secondi e l'header Bearer. Termina l'indirizzo di base con una barra e scrivi i percorsi relativi senza barra iniziale. Una barra iniziale sostituirebbe la parte /3 del percorso di base.

C#
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);

Passaggio 4: analizza il JSON in record

La risposta usa nomi in snake case come release_date. Imposta JsonNamingPolicy.SnakeCaseLower, novità di .NET 8, e scrivi membri in PascalCase. I campi che non dichiari vengono ignorati.

L'oggetto ratings compare perché la chiamata dei dettagli aggiunge append_to_response=ratings. Ognuna delle sue cinque fonti è un oggetto con value e votes, oppure null quando il punteggio manca. Anche votes può essere null: la risposta reale per Fight Club non ha il numero di voti per Kinopoisk, Metacritic e Rotten Tomatoes. La pagina film elenca tutti gli altri campi.

C#
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);

Passaggio 5: gestisci errori e limiti di richieste

Racchiudi la chiamata in un ciclo che reagisce al codice di stato.

StatoSignificatoCosa fa l'app
401Chiave mancante, sbagliata o sospesaStampa un messaggio e si ferma, un nuovo tentativo non può aiutare.
402Il periodo del piano è terminatoStampa un messaggio e si ferma. Rinnova nella pagina dei prezzi.
404Nessun record per quell'IDLo segnala ed esce.
429Oltre le tue richieste al minutoAttende i secondi di Retry-After, con un massimo di 30, e ritenta al massimo due volte.
500 o 504Errore lato serverSegnala lo stato: il comando va rieseguito più tardi.

I guasti di rete emergono come HttpRequestException, e un timeout emerge come TaskCanceledException. Intercettali entrambi al livello più alto. Ogni risposta con chiave porta anche X-RateLimit-Remaining, così un job lungo può rallentare prima di arrivare a un 429. Vedi limiti di richieste ed errori per gli elenchi completi.

C#
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
}

Passaggio 6: metti le risposte in cache su disco

Il controllo del limite di richieste viene prima della cache edge, quindi una risposta servita dall'edge conta comunque nelle tue richieste al minuto. Una cache nel tuo programma è l'unico modo per risparmiare richieste. L'edge tiene i dettagli per 6 ore e i risultati di ricerca per 10 minuti, e l'app usa gli stessi tempi. Un file nella cartella temporanea è fresco quando la sua data di scrittura rientra nel TTL.

C#
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;
    }
}

Passaggio 7: metti tutto insieme

Questo è il Program.cs completo. Aggiunge il metodo Set alla cache e la classe AmodbClient che combina la cache, il ciclo di nuovi tentativi e le opzioni JSON.

C#
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)!;
        }
    }
}

Eseguilo con un titolo. Questo è l'output per il primo risultato di ricerca, il film 550. I conteggi dei voti crescono nel tempo, quindi i tuoi saranno un po' diversi.

La riga TMDB mostra 8,4 perché il codice stampa un decimale dell'8.437 di ratings.tmdb.value. L'host delle locandine img.amodb.dev non richiede chiave, quindi puoi passare l'URL della locandina direttamente a un browser o a un controllo immagine. Vedi immagini per l'elenco delle dimensioni.

Shell
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.jpg

Prossimi passi

  • Cerca con un anno o con la paginazione in ricerca, e filtra le liste con discover.
  • Mostra tutti i punteggi e le loro regole sul null con valutazioni.
  • Sposta il codice della console in un servizio ASP.NET Core con AddHttpClient e IMemoryCache.
  • Confronta lo stesso compito in Java o in Go.