Tutorial de API de filmes em C# para .NET 8

Este tutorial cria um app de console .NET 8 que recebe o título de um filme, busca no amodb, carrega o melhor resultado com as avaliações e imprime um cartão curto. Usa só HttpClient e System.Text.Json, lê a chave de AMODB_KEY, repete a requisição em caso de 429 e guarda as respostas em arquivos. Leva cerca de 15 minutos.

.cs C# Atualizado em Ver em Markdown

O que você vai construir

Você vai construir o MovieLookup, um app de console que recebe um título na linha de comando, busca no amodb, carrega o melhor resultado com as avaliações e imprime um cartão curto com o link do pôster. Você precisa do SDK do .NET 8, de uma chave do amodb e de cerca de 15 minutos. O programa usa só HttpClient e System.Text.Json, então não há pacotes NuGet para instalar.

Passo 1: obtenha uma chave de API

Escolha um plano em preços. Os planos diferem apenas na taxa, com 20, 40 ou 120 requisições por minuto. Crie uma chave no painel da sua conta, uma sequência de 32 caracteres hexadecimais minúsculos. Exporte-a e teste com uma chamada:

Um 200 significa que a chave funciona. Um 401 significa que a chave está errada ou ausente. No PowerShell, defina a variável com $env:AMODB_KEY = "YOUR_API_KEY". A página de autenticação explica o parâmetro de consulta api_key como alternativa ao cabeçalho.

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"

Passo 2: crie o projeto

O template do .NET 8 ativa os usings implícitos e os tipos de referência anuláveis. Isso já importa System, System.IO, System.Linq e System.Net.Http. As outras linhas using você adiciona no arquivo.

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

Passo 3: envie a primeira requisição

Crie um único HttpClient com endereço base, timeout de 10 segundos e o cabeçalho Bearer. Termine o endereço base com uma barra e escreva os caminhos relativos sem barra inicial. Uma barra inicial substituiria a parte /3 do caminho 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);

Passo 4: converta o JSON em records

A resposta usa nomes em snake case, como release_date. Defina JsonNamingPolicy.SnakeCaseLower, que é novidade do .NET 8, e escreva os membros em PascalCase. Os campos que você não declarar são ignorados.

O objeto ratings aparece porque a chamada de detalhes adiciona append_to_response=ratings. Cada uma das cinco fontes é um objeto com value e votes, ou null quando a nota não existe. votes também pode ser null: a resposta real de Fight Club não traz contagem de votos para Kinopoisk, Metacritic nem Rotten Tomatoes. A página de filmes lista todos os outros campos.

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

Passo 5: trate erros e limites de requisições

Envolva a chamada em um loop que reage ao código de status.

StatusSignificadoO que o app faz
401Chave ausente, errada ou suspensaMostra uma mensagem e para, repetir não ajuda.
402O período do plano acabouMostra uma mensagem e para. Renove em preços.
404Nenhum registro para esse idInforma o erro e encerra.
429Acima do seu limite de requisições por minutoEspera Retry-After segundos, no máximo 30, e tenta de novo no máximo duas vezes.
500 ou 504Falha no lado do servidorInforma o status e pede para rodar o comando de novo mais tarde.

Falhas de rede aparecem como HttpRequestException, e um timeout aparece como TaskCanceledException. Capture as duas no nível superior. Toda resposta com chave também traz X-RateLimit-Remaining, então um trabalho longo pode desacelerar antes de chegar a um 429. Veja limites de requisições e erros para as listas completas.

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
}

Passo 6: guarde as respostas em cache no disco

A verificação do limite de requisições roda antes do cache de borda, então uma resposta servida pela borda ainda conta nas suas requisições por minuto. Um cache no seu próprio programa é a única forma de economizar requisições. A borda guarda detalhes por 6 horas e resultados de busca por 10 minutos, então o app usa os mesmos tempos. Um arquivo na pasta temporária está válido quando a data de escrita está dentro do 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;
    }
}

Passo 7: junte tudo

Este é o Program.cs completo. Ele adiciona o método Set ao cache e a classe AmodbClient, que combina o cache, o loop de repetição e as opções de 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)!;
        }
    }
}

Execute-o com um título. Esta é a saída do primeiro resultado da busca, o filme 550. As contagens de votos crescem com o tempo, então as suas serão um pouco diferentes.

A linha do TMDB mostra 8.4 porque o código imprime uma casa decimal de 8.437 em ratings.tmdb.value. O host de pôsteres img.amodb.dev não precisa de chave, então você pode passar a URL do pôster direto para um navegador ou um controle de imagem. Veja imagens para a lista de tamanhos.

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

Próximos passos

  • Busque com ano ou paginação em busca e filtre listas com o endpoint discover.
  • Mostre todas as notas e as regras de null com avaliações.
  • Mova o código do console para um serviço ASP.NET Core com AddHttpClient e IMemoryCache.
  • Compare a mesma tarefa em Java ou Go.