Tutorial de API de películas en Java

Crea una guía de episodios en Java para la línea de comandos que imprime los episodios de una temporada de TV con número, fecha de emisión y duración. Usa el HttpClient del JDK y Jackson, convierte el JSON en records, reintenta un 429 tras la espera de Retry-After y guarda respuestas en archivos. Requiere Java 17 y Maven 3.9.

.java Java Actualizado el Ver en Markdown

Vas a construir EpisodeGuide, un programa de línea de comandos. Ejecútalo con el id de una serie de TV y un número de temporada y mostrará cada episodio con número, fecha de emisión, duración y nombre. Usa el HttpClient del JDK, Jackson 2.x, Java 17 o superior y Maven 3.9. Calcula 20 minutos.

Paso 1: obtén una clave de API

Una clave requiere un plan prepago, así que empieza en precios. Todos los planes ofrecen los mismos datos y solo cambia la velocidad por minuto. Crea la clave en el panel de tu cuenta y expórtala:

El programa lee System.getenv("AMODB_KEY") y se niega a arrancar sin ella.

Shell
export AMODB_KEY="YOUR_API_KEY"

Paso 2: crea el proyecto

Jackson es la única dependencia. Crea la carpeta, un pom.xml mínimo y el directorio de código fuente:

También funciona una versión 2.x más reciente de jackson-databind.

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

Paso 3: envía la primera solicitud

El endpoint de temporada es GET /3/tv/{tv_id}/season/{season_number}, y Breaking Bad es la serie 1396. Esta clase imprime el código de estado. Guárdala como src/main/java/First.java para probarla sola. El comando de ejecución del paso 7 también sirve para ella si defines -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());
    }
}

Paso 4: convierte el JSON en records

La respuesta de una temporada tiene un name y un array episodes. Cada episodio tiene episode_number, name, air_date y runtime, y los dos últimos pueden ser null en un episodio que aún no se ha emitido. Usa Integer para un número que puede faltar. La lista completa de campos está en temporadas y episodios.

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

Paso 5: gestiona errores y límites de solicitudes

Lee el estado antes de tocar el cuerpo.

EstadoQué hace el programa
200Devuelve el cuerpo para Jackson.
401Lanza IOException que nombra AMODB_KEY. Una clave incorrecta falla en todas las llamadas, así que no hay reintento.
404Lanza con el status_message, por ejemplo cuando la temporada no existe.
429Lee Retry-After, espera de 1 a 30 segundos y envía la solicitud de nuevo, 3 intentos como máximo.
Tiempo agotado o fallo de conexiónSe relanza como network error con la causa adjunta.

El cuerpo de error tiene la forma de TMDB, así que status_message es el texto que se muestra. La página de errores lista todos los códigos, y límites de solicitudes explica la ventana de 60 segundos.

Paso 6: guarda las respuestas en archivos

El caché perimetral guarda los detalles 6 horas, así que una lista de episodios rara vez cambia entre ejecuciones. El programa escribe cada cuerpo en un archivo del directorio temporal, con un nombre derivado de la ruta de la solicitud, y lo vuelve a leer mientras el archivo sea más reciente que el TTL. Un acierto de caché no envía nada y no gasta ninguna de tus solicitudes por minuto.

Paso 7: el programa completo

Guárdalo 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);
        }
    }
}

Compílalo y ejecútalo para la temporada 1 de la serie 1396:

La salida tiene una línea de encabezado con el nombre de la temporada y el número de episodios, y después una fila por episodio. Un episodio sin fecha de emisión muestra TBA y uno sin duración muestra ?. Ejecuta el comando dos veces: la segunda ejecución lee el archivo del directorio temporal y no envía ninguna solicitud, hasta que pasen 6 horas. Borra la carpeta amodb-cache para forzar datos nuevos.

Mantén el HttpClient y el ObjectMapper en campos estáticos. Ambos son seguros para hilos y costosos de construir, así que una instancia por proceso es el patrón habitual, también en un servicio web.

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

Próximos pasos

  • Recorre de la temporada 1 a number_of_seasons desde los detalles de series de TV e imprime la serie completa.
  • Muestra el siguiente episodio de una serie en emisión con next_episode_to_air.
  • Añade un tercer argumento para el parámetro language y consulta qué etiquetas se sirven en idiomas.
  • Compara con el mismo patrón en Go o parte de la visión general de la API de guía de episodios.