Tutorial de API de filmes em Java

Crie um guia de episódios em Java, de linha de comando, que imprime cada episódio de uma temporada de série com número, data de exibição e duração. Usa HttpClient do JDK e Jackson, mapeia o JSON em records, repete um 429 após o atraso de Retry-After e guarda respostas em arquivos. Exige Java 17 ou mais recente e Maven 3.9.

.java Java Atualizado em Ver em Markdown

Você vai construir EpisodeGuide, um programa de linha de comando. Execute-o com o id de uma série de TV e o número de uma temporada, e ele imprime cada episódio com número, data de exibição, duração e nome. Usa o HttpClient do JDK, o Jackson 2.x, Java 17 ou mais recente e Maven 3.9. Conte com 20 minutos.

Passo 1: obtenha uma chave de API

Uma chave exige um plano pré-pago, então comece em preços. Todos os planos servem os mesmos dados, e só a taxa por minuto muda. Crie a chave no painel da sua conta e exporte-a:

O programa lê System.getenv("AMODB_KEY") e se recusa a iniciar sem ela.

Shell
export AMODB_KEY="YOUR_API_KEY"

Passo 2: crie o projeto

O Jackson é a única dependência. Crie a pasta, um pom.xml mínimo e o diretório de código-fonte:

Uma versão 2.x mais nova do jackson-databind também funciona.

Shell
mkdir episode-guide && cd episode-guide
mkdir -p src/main/java
cat > pom.xml <<'EOF'
<project>
  <modelVersion>4.0.0</modelVersion>
  <groupId>example</groupId>
  <artifactId>episode-guide</artifactId>
  <version>1.0</version>
  <properties>
    <maven.compiler.release>17</maven.compiler.release>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
  </properties>
  <dependencies>
    <dependency>
      <groupId>com.fasterxml.jackson.core</groupId>
      <artifactId>jackson-databind</artifactId>
      <version>2.18.2</version>
    </dependency>
  </dependencies>
</project>
EOF

Passo 3: envie a primeira requisição

O endpoint de temporada é GET /3/tv/{tv_id}/season/{season_number}, e Breaking Bad é a série 1396. Esta classe imprime o código de status. Salve-a como src/main/java/First.java para testá-la sozinha. O comando de execução do passo 7 também funciona para ela, se você definir -Dexec.mainClass=First.

Java
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;

public class First {
    public static void main(String[] args) throws Exception {
        HttpRequest request = HttpRequest.newBuilder(URI.create("https://api.amodb.dev/3/tv/1396/season/1"))
                .timeout(Duration.ofSeconds(10))
                .header("Authorization", "Bearer " + System.getenv("AMODB_KEY"))
                .build();
        HttpResponse<String> response = HttpClient.newHttpClient()
                .send(request, HttpResponse.BodyHandlers.ofString());
        System.out.println(response.statusCode());
    }
}

Passo 4: mapeie o JSON em records

A resposta de uma temporada tem um name e um array episodes. Cada episódio tem episode_number, name, air_date e runtime, e os dois últimos podem ser null em um episódio que ainda não foi ao ar. Use Integer para um número que pode faltar. A lista completa de campos está em temporadas e episódios.

Java
@JsonIgnoreProperties(ignoreUnknown = true)
public record Episode(
        @JsonProperty("episode_number") int number,
        String name,
        @JsonProperty("air_date") String airDate,
        Integer runtime) {}

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

Leia o status antes de tocar no corpo.

StatusO que o programa faz
200Retorna o corpo para o Jackson.
401Lança IOException citando AMODB_KEY. Uma chave inválida falha em toda chamada, então não há repetição.
404Lança com o status_message, por exemplo quando a temporada não existe.
429Lê Retry-After, espera de 1 a 30 segundos e envia a requisição de novo, no máximo 3 tentativas.
Timeout ou falha de conexãoRelançado como network error com a causa anexada.

O corpo do erro tem o formato do TMDB, então status_message é o texto a mostrar. A página de erros lista todos os códigos, e limites de requisições explica a janela de 60 segundos.

Passo 6: guarde as respostas em arquivos

A borda guarda os detalhes por 6 horas, então uma lista de episódios raramente muda entre execuções. O programa grava cada corpo em um arquivo no diretório temporário, nomeado a partir do caminho da requisição, e o lê de volta enquanto o arquivo for mais novo que o TTL. Um acerto de cache não envia nada e não usa nenhuma das suas requisições por minuto.

Passo 7: o programa completo

Salve isto como src/main/java/EpisodeGuide.java.

Java
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.ObjectMapper;

import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Objects;

public class EpisodeGuide {
    private static final String BASE = "https://api.amodb.dev/3";
    private static final String KEY = System.getenv("AMODB_KEY");
    private static final ObjectMapper MAPPER = new ObjectMapper();
    private static final HttpClient CLIENT = HttpClient.newBuilder()
            .connectTimeout(Duration.ofSeconds(10))
            .build();
    private static final Path CACHE_DIR = Path.of(System.getProperty("java.io.tmpdir"), "amodb-cache");

    @JsonIgnoreProperties(ignoreUnknown = true)
    public record Episode(
            @JsonProperty("episode_number") int number,
            String name,
            @JsonProperty("air_date") String airDate,
            Integer runtime) {}

    @JsonIgnoreProperties(ignoreUnknown = true)
    public record Season(String name, List<Episode> episodes) {}

    static String get(String path) throws IOException, InterruptedException {
        HttpRequest request = HttpRequest.newBuilder(URI.create(BASE + path))
                .timeout(Duration.ofSeconds(10))
                .header("Authorization", "Bearer " + KEY)
                .header("Accept", "application/json")
                .GET()
                .build();
        for (int attempt = 1; ; attempt++) {
            HttpResponse<String> response;
            try {
                response = CLIENT.send(request, HttpResponse.BodyHandlers.ofString());
            } catch (IOException e) {
                throw new IOException("network error: " + e, e);
            }
            int status = response.statusCode();
            if (status == 200) {
                return response.body();
            }
            if (status == 401) {
                throw new IOException("401: the key is invalid or suspended, check AMODB_KEY");
            }
            if (status == 429 && attempt < 3) {
                long seconds = response.headers().firstValueAsLong("Retry-After").orElse(1);
                Thread.sleep(Math.min(Math.max(seconds, 1), 30) * 1000);
                continue;
            }
            throw new IOException("amodb returned " + status + ": " + message(response.body()));
        }
    }

    static String message(String body) {
        try {
            return MAPPER.readTree(body).path("status_message").asText("no message");
        } catch (IOException e) {
            return "no message";
        }
    }

    static String cachedGet(String path, Duration ttl) throws IOException, InterruptedException {
        Files.createDirectories(CACHE_DIR);
        Path file = CACHE_DIR.resolve(path.replaceAll("[^A-Za-z0-9]", "_") + ".json");
        if (Files.exists(file)) {
            Instant modified = Files.getLastModifiedTime(file).toInstant();
            if (modified.plus(ttl).isAfter(Instant.now())) {
                return Files.readString(file);
            }
        }
        String body = get(path);
        Files.writeString(file, body);
        return body;
    }

    public static void main(String[] args) {
        if (args.length != 2) {
            System.err.println("Usage: EpisodeGuide <tv_id> <season_number>");
            System.exit(2);
        }
        if (KEY == null || KEY.isBlank()) {
            System.err.println("Set AMODB_KEY first.");
            System.exit(2);
        }
        String path;
        try {
            path = "/tv/" + Integer.parseInt(args[0]) + "/season/" + Integer.parseInt(args[1]) + "?language=en-US";
        } catch (NumberFormatException e) {
            System.err.println("The series id and season number must be integers.");
            System.exit(2);
            return;
        }
        try {
            Season season = MAPPER.readValue(cachedGet(path, Duration.ofHours(6)), Season.class);
            System.out.println(season.name() + ", " + season.episodes().size() + " episodes");
            for (Episode ep : season.episodes()) {
                System.out.printf("%2d  %-10s  %-7s  %s%n",
                        ep.number(),
                        Objects.requireNonNullElse(ep.airDate(), "TBA"),
                        ep.runtime() == null ? "?" : ep.runtime() + " min",
                        ep.name());
            }
        } catch (IOException e) {
            System.err.println("error: " + e.getMessage());
            System.exit(1);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            System.exit(1);
        }
    }
}

Compile e execute para a temporada 1 da série 1396:

A saída tem uma linha de cabeçalho com o nome da temporada e a contagem de episódios, depois uma linha por episódio. Um episódio sem data de exibição mostra TBA, e um sem duração mostra ?. Execute o comando duas vezes: a segunda execução lê o arquivo do diretório temporário e não envia nenhuma requisição, até passarem 6 horas. Apague a pasta amodb-cache para forçar dados novos.

Mantenha o HttpClient e o ObjectMapper em campos estáticos. Os dois são thread safe e caros de construir, então uma instância por processo é o padrão normal, inclusive em um serviço web.

Shell
mvn -q compile exec:java -Dexec.mainClass=EpisodeGuide -Dexec.args="1396 1"

Próximos passos

  • Percorra da temporada 1 até number_of_seasons, obtido nos detalhes de séries de TV, e imprima a série inteira.
  • Mostre o próximo episódio de uma série em exibição com next_episode_to_air.
  • Adicione um terceiro argumento para o parâmetro language e veja quais tags são aceitas em idiomas.
  • Compare com o mesmo padrão em Go ou comece pela visão geral da API de guia de episódios.