diff --git a/CHANGELOG.md b/CHANGELOG.md index ad92d73..07e2f41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [4.0.0] - 2026-01-02 + +### Added + +- JSpecify `@NonNull` annotations to public API methods and classes for improved nullability contracts. +- `org.jspecify:jspecify:1.0.0` dependency for nullability annotations. + ## [3.0.4] - 2025-12-30 ### Changed @@ -41,14 +48,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- A new type `UpcomingBusArrival` representing upcoming bus arrival information. -- A new type `BusCoordinates` representing bus coordinates (latitude, longitude, heading). -- An `arrivals` field of type `List` has been added to the `Bus` class to provide information about upcoming bus arrivals. +- A new type `UpcomingBusArrival` representing upcoming vehicle arrival information. +- A new type `BusCoordinates` representing vehicle coordinates (latitude, longitude, heading). +- An `arrivals` field of type `List` has been added to the `Bus` class to provide information about upcoming vehicle arrivals. ### Changed - BREAKING CHANGE: Organized classes into packages by functionality: - - `com.cta4j.bus` for bus-related classes + - `com.cta4j.vehicle` for vehicle-related classes - `com.cta4j.train` for train-related classes - `com.cta4j.common` for shared/common classes - BREAKING CHANGE: Public concrete client classes replaced by interfaces with a fluent `Builder` API (e.g. `BusClient`, `TrainClient`) @@ -129,7 +136,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `Vehicle` class and `getVehicle` method in `BusClient` class. - `parseString` method from `BusPredictionType` enum. - `fromExternal` method from `Detour` class. -- `fromExternal` method from bus `Route` class. +- `fromExternal` method from vehicle `Route` class. - `fromExternal` method from `Stop` class. - `fromExternal` method from `StopArrival` class. - `parseString` method from train `Route` enum. @@ -146,7 +153,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `TrainClient` class with methods to interact with CTA Train API. - `BusClient` class with methods to interact with CTA Bus API. -[Unreleased]: https://github.com/lbkulinski/cta4j-java-sdk/compare/v3.0.4...HEAD +[Unreleased]: https://github.com/lbkulinski/cta4j-java-sdk/compare/v4.0.0...HEAD +[4.0.0]: https://github.com/lbkulinski/cta4j-java-sdk/compare/v3.0.4...v4.0.0 [3.0.4]: https://github.com/lbkulinski/cta4j-java-sdk/compare/v3.0.3...v3.0.4 [3.0.3]: https://github.com/lbkulinski/cta4j-java-sdk/compare/v3.0.2...v3.0.3 [3.0.2]: https://github.com/lbkulinski/cta4j-java-sdk/compare/v3.0.1...v3.0.2 diff --git a/README.md b/README.md index 6796ab8..55b52db 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ implementation("com.cta4j:cta4j-java-sdk:3.0.4") import com.cta4j.train.client.TrainClient; public final class Application { - public static void main(String[] args) { + static void main(String[] args) { TrainClient trainClient = TrainClient.builder() .apiKey("TRAIN_API_KEY") .build(); @@ -79,21 +79,21 @@ public final class Application { } ``` -### Fetch upcoming bus arrivals for a stop +### Fetch upcoming vehicle arrivals for a stop ```java -import com.cta4j.bus.client.BusClient; +import com.cta4j.vehicle.client.BusClient; public final class Application { - public static void main(String[] args) { + static void main(String[] args) { BusClient busClient = BusClient.builder() .apiKey("BUS_API_KEY") .build(); - busClient.getStopArrivals("22", "1828") + busClient.findArrivalsByRouteIdAndStopId("22", "1828") .stream() .map(arrival -> String.format( - "%s-bound bus is arriving at %s in %d minutes", + "%s-bound vehicle is arriving at %s in %d minutes", arrival.destination(), arrival.stopName(), arrival.etaMinutes() @@ -101,8 +101,8 @@ public final class Application { .forEach(System.out::println); // Example output: - // Harrison-bound bus is arriving at Clark & Belmont in 1 minutes - // Harrison-bound bus is arriving at Clark & Belmont in 26 minutes + // Harrison-bound vehicle is arriving at Clark & Belmont in 1 minutes + // Harrison-bound vehicle is arriving at Clark & Belmont in 26 minutes } } ``` diff --git a/pom.xml b/pom.xml index f3da73e..c6b5328 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ 4.0.0 com.cta4j cta4j-java-sdk - 3.0.4 + 4.0.0 21 21 @@ -45,7 +45,7 @@ org.apache.httpcomponents.client5 - httpclient5 + httpclient5-fluent 5.6 @@ -54,9 +54,33 @@ 26.0.2-1 provided + + org.jspecify + jspecify + 1.0.0 + provided + + + org.mapstruct + mapstruct + 1.6.3 + + + org.apache.maven.plugins + maven-compiler-plugin + + + + org.mapstruct + mapstruct-processor + 1.6.3 + + + + org.apache.maven.plugins maven-source-plugin @@ -68,9 +92,9 @@ jar - com/cta4j/bus/client/internal/** - com/cta4j/bus/external/** - com/cta4j/bus/mapper/** + com/cta4j/vehicle/client/internal/** + com/cta4j/vehicle/external/** + com/cta4j/vehicle/mapper/** com/cta4j/train/client/internal/** com/cta4j/train/external/** com/cta4j/train/mapper/** @@ -92,7 +116,7 @@ - com.cta4j.bus.client.internal.*,com.cta4j.bus.external.*,com.cta4j.bus.mapper.*,com.cta4j.train.client.internal.*,com.cta4j.train.external.*,com.cta4j.train.mapper.*,com.cta4j.util.* + com.cta4j.vehicle.client.internal.*,com.cta4j.vehicle.external.*,com.cta4j.vehicle.mapper.*,com.cta4j.train.client.internal.*,com.cta4j.train.external.*,com.cta4j.train.mapper.*,com.cta4j.bus.internal.util.* diff --git a/src/main/java/com/cta4j/bus/BusApi.java b/src/main/java/com/cta4j/bus/BusApi.java new file mode 100644 index 0000000..d82de83 --- /dev/null +++ b/src/main/java/com/cta4j/bus/BusApi.java @@ -0,0 +1,126 @@ +package com.cta4j.bus; + +import com.cta4j.bus.detour.DetoursApi; +import com.cta4j.bus.direction.DirectionsApi; +import com.cta4j.bus.internal.impl.BusApiImpl; +import com.cta4j.bus.locale.LocalesApi; +import com.cta4j.bus.pattern.PatternsApi; +import com.cta4j.bus.prediction.PredictionsApi; +import com.cta4j.bus.route.RoutesApi; +import com.cta4j.bus.stop.StopsApi; +import com.cta4j.bus.vehicle.VehiclesApi; +import org.jspecify.annotations.NullMarked; + +import java.time.Instant; +import java.util.Objects; + +/** + * Primary entry point for interacting with the CTA Bus Tracker API. + *

+ * This interface provides access to the current system time as well as + * grouped sub-APIs for vehicles, routes, directions, stops, patterns, + * predictions, locales, and detours. + *

+ * Instances of {@code BusApi} are immutable and thread-safe once built. + * Use {@link #builder(String)} to construct a configured instance. + */ +@NullMarked +public interface BusApi { + /** + * Returns the current system time reported by the Bus Tracker API. + * + * @return the API system time as an {@link Instant} + */ + Instant systemTime(); + + /** + * Provides access to vehicle-related endpoints. + * + * @return the {@link VehiclesApi} + */ + VehiclesApi vehicles(); + + /** + * Provides access to route-related endpoints. + * + * @return the {@link RoutesApi} + */ + RoutesApi routes(); + + /** + * Provides access to direction-related endpoints. + * + * @return the {@link DirectionsApi} + */ + DirectionsApi directions(); + + /** + * Provides access to stop-related endpoints. + * + * @return the {@link StopsApi} + */ + StopsApi stops(); + + /** + * Provides access to route pattern–related endpoints. + * + * @return the {@link PatternsApi} + */ + PatternsApi patterns(); + + /** + * Provides access to prediction and arrival-related endpoints. + * + * @return the {@link PredictionsApi} + */ + PredictionsApi predictions(); + + /** + * Provides access to locale and language-related endpoints. + * + * @return the {@link LocalesApi} + */ + LocalesApi locales(); + + /** + * Provides access to detour-related endpoints. + * + * @return the {@link DetoursApi} + */ + DetoursApi detours(); + + /** + * Builder for constructing {@link BusApi} instances. + */ + interface Builder { + /** + * Sets the API host to use for requests. + *

+ * If not specified, the default CTA Bus Tracker API host is used. + * + * @param host the API host + * @return this builder instance + */ + Builder host(String host); + + /** + * Builds a configured {@link BusApi} instance. + * + * @return a new {@link BusApi} + */ + BusApi build(); + } + + /** + * Creates a new {@link Builder} for constructing a {@link BusApi}. + * + * @param apiKey the CTA Bus Tracker API key + * @return a new {@link Builder} + * @throws NullPointerException if {@code apiKey} is {@code null} + */ + static Builder builder(String apiKey) { + Objects.requireNonNull(apiKey); + + return new BusApiImpl.BuilderImpl(apiKey); + } +} diff --git a/src/main/java/com/cta4j/bus/client/BusClient.java b/src/main/java/com/cta4j/bus/client/BusClient.java deleted file mode 100644 index b9214e8..0000000 --- a/src/main/java/com/cta4j/bus/client/BusClient.java +++ /dev/null @@ -1,115 +0,0 @@ -package com.cta4j.bus.client; - -import com.cta4j.bus.client.internal.BusClientImpl; -import com.cta4j.bus.model.*; -import com.cta4j.exception.Cta4jException; - -import java.util.List; -import java.util.Optional; - -/** - * A client for interacting with the CTA Bus Tracker API. - */ -public interface BusClient { - /** - * Retrieves a {@link List} of all bus routes. - * - * @return a {@link List} of all bus routes - * @throws Cta4jException if an error occurs while fetching the data - */ - List getRoutes(); - - /** - * Retrieves a {@link List} of directions for a specific bus route. - * - * @param routeId the ID of the bus route - * @return a {@link List} of directions for the specified bus route - * @throws NullPointerException if the specified bus route is {@code null} - * @throws Cta4jException if an error occurs while fetching the data - */ - List getDirections(String routeId); - - /** - * Retrieves a {@link List} of stops for a specific bus route and direction. - * - * @param routeId the ID of the bus route - * @param direction the direction of the bus route - * @return a {@link List} of stops for the specified bus route and direction - * @throws NullPointerException if the specified bus route or direction is {@code null} - * @throws Cta4jException if an error occurs while fetching the data - */ - List getStops(String routeId, String direction); - - /** - * Retrieves a {@link List} of upcoming arrivals for a specific bus route and stop. - * - * @param routeId the ID of the bus route - * @param stopId the ID of the bus stop - * @return a {@link List} of upcoming arrivals for the specified bus route and stop - * @throws NullPointerException if the specified bus route or stop is {@code null} - * @throws Cta4jException if an error occurs while fetching the data - */ - List getStopArrivals(String routeId, String stopId); - - /** - * Retrieves a {@link List} of detours for a specific bus route and direction. - * - * @param routeId the ID of the bus route - * @param direction the direction of the bus route - * @return a {@link List} of detours for the specified bus route and direction - * @throws NullPointerException if the specified bus route or direction is {@code null} - * @throws Cta4jException if an error occurs while fetching the data - */ - List getDetours(String routeId, String direction); - - /** - * Retrieves information about a specific bus by its ID. - * - * @param id the ID of the bus - * @return an {@link Optional} containing the bus information if found, or an empty {@link Optional} if not found - * @throws NullPointerException if the specified bus ID is {@code null} - * @throws Cta4jException if an error occurs while fetching the data - */ - Optional getBus(String id); - - /** - * A builder for configuring and creating {@link BusClient} instances. - * - *

Fluent, non-thread-safe builder. Call {@link #build()} to obtain a configured client. - */ - interface Builder { - /** - * Sets the API host used by the client. - * - * @param host the host - * @return this {@link Builder} for method chaining - * @throws NullPointerException if {@code host} is {@code null} - */ - Builder host(String host); - - /** - * Sets the API key used for authentication. - * - * @param apiKey the API key - * @return this {@link Builder} for method chaining - * @throws NullPointerException if {@code apiKey} is {@code null} - */ - Builder apiKey(String apiKey); - - /** - * Builds a configured {@link BusClient} instance. - * - * @return a new {@link BusClient} - */ - BusClient build(); - } - - /** - * Creates a new {@link Builder} for configuring and building a {@link BusClient}. - * - * @return a new {@link Builder} instance - */ - static Builder builder() { - return new BusClientImpl.BuilderImpl(); - } -} diff --git a/src/main/java/com/cta4j/bus/client/internal/BusClientImpl.java b/src/main/java/com/cta4j/bus/client/internal/BusClientImpl.java deleted file mode 100644 index fd1683e..0000000 --- a/src/main/java/com/cta4j/bus/client/internal/BusClientImpl.java +++ /dev/null @@ -1,418 +0,0 @@ -package com.cta4j.bus.client.internal; - -import com.cta4j.bus.client.BusClient; -import com.cta4j.bus.mapper.*; -import com.cta4j.bus.model.*; -import com.cta4j.exception.Cta4jException; -import com.cta4j.bus.external.detour.CtaDetour; -import com.cta4j.bus.external.detour.CtaDetoursBustimeResponse; -import com.cta4j.bus.external.detour.CtaDetoursResponse; -import com.cta4j.bus.external.direction.CtaDirection; -import com.cta4j.bus.external.direction.CtaDirectionsBustimeResponse; -import com.cta4j.bus.external.direction.CtaDirectionsResponse; -import com.cta4j.bus.external.prediction.CtaPredictionsBustimeResponse; -import com.cta4j.bus.external.prediction.CtaPredictionsPrd; -import com.cta4j.bus.external.prediction.CtaPredictionsResponse; -import com.cta4j.bus.external.route.CtaRoute; -import com.cta4j.bus.external.route.CtaRoutesBustimeResponse; -import com.cta4j.bus.external.route.CtaRoutesResponse; -import com.cta4j.bus.external.stop.CtaStop; -import com.cta4j.bus.external.stop.CtaStopsBustimeResponse; -import com.cta4j.bus.external.stop.CtaStopsResponse; -import com.cta4j.bus.external.vehicle.CtaVehicle; -import com.cta4j.bus.external.vehicle.CtaVehicleBustimeResponse; -import com.cta4j.bus.external.vehicle.CtaVehicleResponse; -import com.cta4j.util.HttpUtils; -import tools.jackson.core.JacksonException; -import tools.jackson.databind.ObjectMapper; -import org.apache.hc.core5.net.URIBuilder; -import org.jetbrains.annotations.ApiStatus; - -import java.util.List; -import java.util.Objects; -import java.util.Optional; - -@ApiStatus.Internal -public final class BusClientImpl implements BusClient { - private final String host; - - private final String apiKey; - - private final ObjectMapper objectMapper; - - private static final String DEFAULT_HOST = "ctabustracker.com"; - - private static final String ROUTES_ENDPOINT = "/bustime/api/v3/getroutes"; - - private static final String DIRECTIONS_ENDPOINT = "/bustime/api/v3/getdirections"; - - private static final String STOPS_ENDPOINT = "/bustime/api/v3/getstops"; - - private static final String PREDICTIONS_ENDPOINT = "/bustime/api/v3/getpredictions"; - - private static final String DETOURS_ENDPOINT = "/bustime/api/v3/getdetours"; - - private static final String VEHICLES_ENDPOINT = "/bustime/api/v3/getvehicles"; - - private BusClientImpl(String host, String apiKey) { - this.host = Objects.requireNonNull(host); - - this.apiKey = Objects.requireNonNull(apiKey); - - this.objectMapper = new ObjectMapper(); - } - - @Override - public List getRoutes() { - String url = new URIBuilder() - .setScheme("https") - .setHost(this.host) - .setPath(ROUTES_ENDPOINT) - .addParameter("key", this.apiKey) - .addParameter("format", "json") - .toString(); - - String response = HttpUtils.get(url); - - CtaRoutesResponse routesResponse; - - try { - routesResponse = this.objectMapper.readValue(response, CtaRoutesResponse.class); - } catch (JacksonException e) { - String message = "Failed to parse response from %s".formatted(ROUTES_ENDPOINT); - - throw new Cta4jException(message, e); - } - - CtaRoutesBustimeResponse bustimeResponse = routesResponse.bustimeResponse(); - - if (bustimeResponse == null) { - throw new Cta4jException("Invalid response from %s".formatted(ROUTES_ENDPOINT)); - } - - List routes = bustimeResponse.routes(); - - if ((routes == null) || routes.isEmpty()) { - return List.of(); - } - - return routes.stream() - .map(RouteMapper::fromExternal) - .toList(); - } - - @Override - public List getDirections(String routeId) { - Objects.requireNonNull(routeId); - - String url = new URIBuilder() - .setScheme("https") - .setHost(this.host) - .setPath(DIRECTIONS_ENDPOINT) - .addParameter("rt", routeId) - .addParameter("key", this.apiKey) - .addParameter("format", "json") - .toString(); - - String response = HttpUtils.get(url); - - CtaDirectionsResponse directionsResponse; - - try { - directionsResponse = this.objectMapper.readValue(response, CtaDirectionsResponse.class); - } catch (JacksonException e) { - String message = "Failed to parse response from %s".formatted(DIRECTIONS_ENDPOINT); - - throw new Cta4jException(message, e); - } - - CtaDirectionsBustimeResponse bustimeResponse = directionsResponse.bustimeResponse(); - - if (bustimeResponse == null) { - throw new Cta4jException("Invalid response from %s".formatted(DIRECTIONS_ENDPOINT)); - } - - List directions = bustimeResponse.directions(); - - if ((directions == null) || directions.isEmpty()) { - return List.of(); - } - - return directions.stream() - .map(CtaDirection::id) - .toList(); - } - - @Override - public List getStops(String routeId, String direction) { - Objects.requireNonNull(routeId); - - Objects.requireNonNull(direction); - - String url = new URIBuilder() - .setScheme("https") - .setHost(this.host) - .setPath(STOPS_ENDPOINT) - .addParameter("rt", routeId) - .addParameter("dir", direction) - .addParameter("key", this.apiKey) - .addParameter("format", "json") - .toString(); - - String response = HttpUtils.get(url); - - CtaStopsResponse stopsResponse; - - try { - stopsResponse = this.objectMapper.readValue(response, CtaStopsResponse.class); - } catch (JacksonException e) { - String message = "Failed to parse response from %s".formatted(STOPS_ENDPOINT); - - throw new Cta4jException(message, e); - } - - CtaStopsBustimeResponse bustimeResponse = stopsResponse.bustimeResponse(); - - if (bustimeResponse == null) { - throw new Cta4jException("Invalid response from %s".formatted(STOPS_ENDPOINT)); - } - - List stops = bustimeResponse.stops(); - - if ((stops == null) || stops.isEmpty()) { - return List.of(); - } - - return stops.stream() - .map(StopMapper::fromExternal) - .toList(); - } - - @Override - public List getStopArrivals(String routeId, String stopId) { - Objects.requireNonNull(routeId); - - Objects.requireNonNull(stopId); - - String url = new URIBuilder() - .setScheme("https") - .setHost(this.host) - .setPath(PREDICTIONS_ENDPOINT) - .addParameter("rt", routeId) - .addParameter("stpid", stopId) - .addParameter("key", this.apiKey) - .addParameter("format", "json") - .toString(); - - String response = HttpUtils.get(url); - - CtaPredictionsResponse predictionsResponse; - - try { - predictionsResponse = this.objectMapper.readValue(response, CtaPredictionsResponse.class); - } catch (JacksonException e) { - String message = "Failed to parse response from %s".formatted(PREDICTIONS_ENDPOINT); - - throw new Cta4jException(message, e); - } - - CtaPredictionsBustimeResponse bustimeResponse = predictionsResponse.bustimeResponse(); - - if (bustimeResponse == null) { - throw new Cta4jException("Invalid response from %s".formatted(PREDICTIONS_ENDPOINT)); - } - - List prd = bustimeResponse.prd(); - - if ((prd == null) || prd.isEmpty()) { - return List.of(); - } - - return prd.stream() - .map(StopArrivalMapper::fromExternal) - .toList(); - } - - @Override - public List getDetours(String routeId, String direction) { - Objects.requireNonNull(routeId); - - Objects.requireNonNull(direction); - - String url = new URIBuilder() - .setScheme("https") - .setHost(this.host) - .setPath(DETOURS_ENDPOINT) - .addParameter("rt", routeId) - .addParameter("dir", direction) - .addParameter("key", this.apiKey) - .addParameter("format", "json") - .toString(); - - String response = HttpUtils.get(url); - - CtaDetoursResponse detoursResponse; - - try { - detoursResponse = this.objectMapper.readValue(response, CtaDetoursResponse.class); - } catch (JacksonException e) { - String message = "Failed to parse response from %s".formatted(DETOURS_ENDPOINT); - - throw new Cta4jException(message, e); - } - - CtaDetoursBustimeResponse bustimeResponse = detoursResponse.bustimeResponse(); - - if (bustimeResponse == null) { - throw new Cta4jException("Invalid response from %s".formatted(DETOURS_ENDPOINT)); - } - - List dtrs = bustimeResponse.dtrs(); - - if ((dtrs == null) || dtrs.isEmpty()) { - return List.of(); - } - - return dtrs.stream() - .map(DetourMapper::fromExternal) - .toList(); - } - - private List getUpcomingBusArrivals(String id) { - Objects.requireNonNull(id); - - String url = new URIBuilder() - .setScheme("https") - .setHost(this.host) - .setPath(PREDICTIONS_ENDPOINT) - .addParameter("vid", id) - .addParameter("key", this.apiKey) - .addParameter("format", "json") - .toString(); - - String response = HttpUtils.get(url); - - CtaPredictionsResponse predictionsResponse; - - try { - predictionsResponse = this.objectMapper.readValue(response, CtaPredictionsResponse.class); - } catch (JacksonException e) { - String message = "Failed to parse response from %s".formatted(PREDICTIONS_ENDPOINT); - - throw new Cta4jException(message, e); - } - - CtaPredictionsBustimeResponse bustimeResponse = predictionsResponse.bustimeResponse(); - - if (bustimeResponse == null) { - throw new Cta4jException("Invalid response from %s".formatted(PREDICTIONS_ENDPOINT)); - } - - List prd = bustimeResponse.prd(); - - if ((prd == null) || prd.isEmpty()) { - return List.of(); - } - - return prd.stream() - .map(UpcomingBusArrivalMapper::fromExternal) - .toList(); - } - - @Override - public Optional getBus(String id) { - Objects.requireNonNull(id); - - String url = new URIBuilder() - .setScheme("https") - .setHost(this.host) - .setPath(VEHICLES_ENDPOINT) - .addParameter("vid", id) - .addParameter("key", this.apiKey) - .addParameter("format", "json") - .toString(); - - String response = HttpUtils.get(url); - - CtaVehicleResponse vehicleResponse; - - try { - vehicleResponse = this.objectMapper.readValue(response, CtaVehicleResponse.class); - } catch (JacksonException e) { - String message = "Failed to parse response from %s".formatted(VEHICLES_ENDPOINT); - - throw new Cta4jException(message, e); - } - - CtaVehicleBustimeResponse bustimeResponse = vehicleResponse.bustimeResponse(); - - if (bustimeResponse == null) { - throw new Cta4jException("Invalid response from %s".formatted(VEHICLES_ENDPOINT)); - } - - List vehicles = bustimeResponse.vehicle(); - - if ((vehicles == null) || vehicles.isEmpty()) { - return Optional.empty(); - } - - if (vehicles.size() > 1) { - String message = "Multiple buses found for ID %s".formatted(id); - - throw new Cta4jException(message); - } - - CtaVehicle vehicle = vehicles.getFirst(); - - String route = vehicle.rt(); - - String destination = vehicle.des(); - - BusCoordinates coordinates = BusCoordinatesMapper.fromExternal(vehicle); - - List upcomingArrivals = this.getUpcomingBusArrivals(id); - - Boolean delayed = vehicle.dly(); - - Bus bus = new Bus(id, route, destination, coordinates, upcomingArrivals, delayed); - - return Optional.of(bus); - } - - public static final class BuilderImpl implements BusClient.Builder { - private String host; - - private String apiKey; - - public BuilderImpl() { - this.host = null; - - this.apiKey = null; - } - - @Override - public Builder host(String host) { - this.host = Objects.requireNonNull(host); - - return this; - } - - @Override - public Builder apiKey(String apiKey) { - this.apiKey = Objects.requireNonNull(apiKey); - - return this; - } - - @Override - public BusClient build() { - String finalHost = (this.host == null) ? DEFAULT_HOST : this.host; - - if (this.apiKey == null) { - throw new IllegalStateException("API key must not be null"); - } - - return new BusClientImpl(finalHost, this.apiKey); - } - } -} diff --git a/src/main/java/com/cta4j/bus/detour/DetoursApi.java b/src/main/java/com/cta4j/bus/detour/DetoursApi.java new file mode 100644 index 0000000..49759d6 --- /dev/null +++ b/src/main/java/com/cta4j/bus/detour/DetoursApi.java @@ -0,0 +1,44 @@ +package com.cta4j.bus.detour; + +import com.cta4j.bus.detour.model.Detour; +import org.jspecify.annotations.NullMarked; + +import java.util.List; + +/** + * Provides access to detour-related endpoints of the CTA BusTime API. + *

+ * This API allows retrieval of active service detours across all routes, + * or filtered by route and direction. + */ +@NullMarked +public interface DetoursApi { + /** + * Retrieves all active detours. + * + * @return a {@link List} of active {@link Detour}s; + * an empty {@link List} if no detours are currently active + */ + List list(); + + /** + * Retrieves all active detours for the specified route ID. + * + * @param routeId the route ID + * @return a {@link List} of {@link Detour}s associated with the route ID; + * an empty {@link List} if no detours exist for the route ID + * @throws NullPointerException if {@code routeId} is {@code null} + */ + List findByRouteId(String routeId); + + /** + * Retrieves all active detours for the specified route ID and direction. + * + * @param routeId the route ID + * @param direction the travel direction (e.g. Northbound, Southbound) + * @return a {@link List} of {@link Detour}s associated with the route ID and direction; + * an empty {@link List} if no detours exist for the route ID and direction + * @throws NullPointerException if {@code routeId} or {@code direction} is {@code null} + */ + List findByRouteIdAndDirection(String routeId, String direction); +} diff --git a/src/main/java/com/cta4j/bus/detour/internal/impl/DetoursApiImpl.java b/src/main/java/com/cta4j/bus/detour/internal/impl/DetoursApiImpl.java new file mode 100644 index 0000000..275daf7 --- /dev/null +++ b/src/main/java/com/cta4j/bus/detour/internal/impl/DetoursApiImpl.java @@ -0,0 +1,115 @@ +package com.cta4j.bus.detour.internal.impl; + +import com.cta4j.bus.internal.context.BusApiContext; +import com.cta4j.bus.internal.wire.CtaBustimeResponse; +import com.cta4j.bus.internal.wire.CtaError; +import com.cta4j.bus.internal.wire.CtaResponse; +import com.cta4j.bus.internal.util.ApiUtils; +import com.cta4j.bus.detour.DetoursApi; +import com.cta4j.bus.detour.internal.wire.CtaDetour; +import com.cta4j.bus.detour.internal.mapper.DetourMapper; +import com.cta4j.bus.detour.model.Detour; +import com.cta4j.exception.Cta4jException; +import com.cta4j.bus.internal.util.HttpUtils; +import org.apache.hc.core5.net.URIBuilder; +import org.jetbrains.annotations.ApiStatus; +import org.jspecify.annotations.NullMarked; +import tools.jackson.core.JacksonException; +import tools.jackson.core.type.TypeReference; + +import java.util.List; +import java.util.Objects; + +@NullMarked +@ApiStatus.Internal +public final class DetoursApiImpl implements DetoursApi { + private static final String DETOURS_ENDPOINT = String.format("%s/getdetours", ApiUtils.API_PREFIX); + + private final BusApiContext context; + + public DetoursApiImpl(BusApiContext context) { + this.context = Objects.requireNonNull(context); + } + + @Override + public List list() { + String url = new URIBuilder() + .setScheme(ApiUtils.SCHEME) + .setHost(this.context.host()) + .setPath(DETOURS_ENDPOINT) + .addParameter("key", this.context.apiKey()) + .addParameter("format", "json") + .toString(); + + return this.makeRequest(url); + } + + @Override + public List findByRouteId(String routeId) { + Objects.requireNonNull(routeId); + + String url = new URIBuilder() + .setScheme(ApiUtils.SCHEME) + .setHost(this.context.host()) + .setPath(DETOURS_ENDPOINT) + .addParameter("rt", routeId) + .addParameter("key", this.context.apiKey()) + .addParameter("format", "json") + .toString(); + + return this.makeRequest(url); + } + + @Override + public List findByRouteIdAndDirection(String routeId, String direction) { + Objects.requireNonNull(routeId); + Objects.requireNonNull(direction); + + String url = new URIBuilder() + .setScheme(ApiUtils.SCHEME) + .setHost(this.context.host()) + .setPath(DETOURS_ENDPOINT) + .addParameter("rt", routeId) + .addParameter("rtdir", direction) + .addParameter("key", this.context.apiKey()) + .addParameter("format", "json") + .toString(); + + return this.makeRequest(url); + } + + private List makeRequest(String url) { + String response = HttpUtils.get(url); + + TypeReference>> typeReference = new TypeReference<>() {}; + CtaResponse> detoursResponse; + + try { + detoursResponse = this.context.objectMapper() + .readValue(response, typeReference); + } catch (JacksonException e) { + String message = String.format("Failed to parse response from %s", DETOURS_ENDPOINT); + + throw new Cta4jException(message, e); + } + + CtaBustimeResponse> bustimeResponse = detoursResponse.bustimeResponse(); + + List errors = bustimeResponse.error(); + List detours = bustimeResponse.data(); + + if ((errors != null) && !errors.isEmpty()) { + String message = ApiUtils.buildErrorMessage(DETOURS_ENDPOINT, errors); + + throw new Cta4jException(message); + } + + if ((detours == null) || detours.isEmpty()) { + return List.of(); + } + + return detours.stream() + .map(DetourMapper.INSTANCE::toDomain) + .toList(); + } +} diff --git a/src/main/java/com/cta4j/bus/detour/internal/mapper/DetourMapper.java b/src/main/java/com/cta4j/bus/detour/internal/mapper/DetourMapper.java new file mode 100644 index 0000000..1cc4b1e --- /dev/null +++ b/src/main/java/com/cta4j/bus/detour/internal/mapper/DetourMapper.java @@ -0,0 +1,30 @@ +package com.cta4j.bus.detour.internal.mapper; + +import com.cta4j.bus.detour.internal.wire.CtaDetour; +import com.cta4j.bus.detour.internal.wire.CtaDetoursRouteDirection; +import com.cta4j.bus.internal.mapper.Qualifiers; +import com.cta4j.bus.detour.model.Detour; +import com.cta4j.bus.detour.model.DetourRouteDirection; +import org.jetbrains.annotations.ApiStatus; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.factory.Mappers; + +@Mapper(uses = Qualifiers.class) +@ApiStatus.Internal +public interface DetourMapper { + DetourMapper INSTANCE = Mappers.getMapper(DetourMapper.class); + + @Mapping(source = "ver", target = "version") + @Mapping(source = "st", target = "active", qualifiedByName = "mapActive") + @Mapping(source = "desc", target = "description") + @Mapping(source = "rtdirs", target = "routeDirections") + @Mapping(source = "startdt", target = "startTime", qualifiedByName = "mapTimestamp") + @Mapping(source = "enddt", target = "endTime", qualifiedByName = "mapTimestamp") + @Mapping(source = "rtpidatafeed", target = "dataFeed") + Detour toDomain(CtaDetour dto); + + @Mapping(source = "rt", target = "routeId") + @Mapping(source = "dir", target = "direction") + DetourRouteDirection toDomain(CtaDetoursRouteDirection dto); +} diff --git a/src/main/java/com/cta4j/bus/detour/internal/wire/CtaDetour.java b/src/main/java/com/cta4j/bus/detour/internal/wire/CtaDetour.java new file mode 100644 index 0000000..82c8864 --- /dev/null +++ b/src/main/java/com/cta4j/bus/detour/internal/wire/CtaDetour.java @@ -0,0 +1,41 @@ +package com.cta4j.bus.detour.internal.wire; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import org.jetbrains.annotations.ApiStatus; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +import java.util.List; +import java.util.Objects; + +@NullMarked +@ApiStatus.Internal +@JsonIgnoreProperties(ignoreUnknown = true) +public record CtaDetour( + String id, + + int ver, + + int st, + + String desc, + + List rtdirs, + + String startdt, + + String enddt, + + @Nullable + String rtpidatafeed +) { + public CtaDetour { + Objects.requireNonNull(id); + Objects.requireNonNull(desc); + Objects.requireNonNull(rtdirs); + Objects.requireNonNull(startdt); + Objects.requireNonNull(enddt); + + rtdirs.forEach(Objects::requireNonNull); + } +} diff --git a/src/main/java/com/cta4j/bus/external/detour/CtaDetoursRouteDirection.java b/src/main/java/com/cta4j/bus/detour/internal/wire/CtaDetoursRouteDirection.java similarity index 50% rename from src/main/java/com/cta4j/bus/external/detour/CtaDetoursRouteDirection.java rename to src/main/java/com/cta4j/bus/detour/internal/wire/CtaDetoursRouteDirection.java index 64e6ff6..a96c3ab 100644 --- a/src/main/java/com/cta4j/bus/external/detour/CtaDetoursRouteDirection.java +++ b/src/main/java/com/cta4j/bus/detour/internal/wire/CtaDetoursRouteDirection.java @@ -1,8 +1,12 @@ -package com.cta4j.bus.external.detour; +package com.cta4j.bus.detour.internal.wire; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import org.jetbrains.annotations.ApiStatus; +import org.jspecify.annotations.NullMarked; +import java.util.Objects; + +@NullMarked @ApiStatus.Internal @JsonIgnoreProperties(ignoreUnknown = true) public record CtaDetoursRouteDirection( @@ -10,4 +14,8 @@ public record CtaDetoursRouteDirection( String dir ) { + public CtaDetoursRouteDirection { + Objects.requireNonNull(rt); + Objects.requireNonNull(dir); + } } diff --git a/src/main/java/com/cta4j/bus/detour/model/Detour.java b/src/main/java/com/cta4j/bus/detour/model/Detour.java new file mode 100644 index 0000000..0a98b04 --- /dev/null +++ b/src/main/java/com/cta4j/bus/detour/model/Detour.java @@ -0,0 +1,67 @@ +package com.cta4j.bus.detour.model; + +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +import java.time.Instant; +import java.util.List; +import java.util.Objects; + +/** + * Represents a service detour affecting one or more routes and directions within a specific time window. + * + * @param id the unique ID of the detour + * @param version the version of the detour + * @param active whether the detour is currently active + * @param description the human-readable description of the detour + * @param routeDirections the routes and directions affected by the detour + * @param startTime the time at which the detour begins + * @param endTime the time at which the detour ends + * @param dataFeed the identifier for the data feed that supplied the detour, or {@code null} if not available + */ +@NullMarked +public record Detour( + String id, + + String version, + + boolean active, + + String description, + + List routeDirections, + + Instant startTime, + + Instant endTime, + + @Nullable + String dataFeed +) { + /** + * Creates a {@code Detour}. + * + * @param id the unique ID of the detour + * @param version the version of the detour + * @param active whether the detour is currently active + * @param description the human-readable description of the detour + * @param routeDirections the routes and directions affected by the detour + * @param startTime the time at which the detour begins + * @param endTime the time at which the detour ends + * @param dataFeed the identifier for the data feed that supplied the detour, or {@code null} if not available + * @throws NullPointerException if {@code id}, {@code version}, {@code description}, {@code routeDirections}, + * {@code startTime}, or {@code endTime} is {@code null} + */ + public Detour { + Objects.requireNonNull(id); + Objects.requireNonNull(version); + Objects.requireNonNull(description); + Objects.requireNonNull(routeDirections); + Objects.requireNonNull(startTime); + Objects.requireNonNull(endTime); + + routeDirections.forEach(Objects::requireNonNull); + + routeDirections = List.copyOf(routeDirections); + } +} diff --git a/src/main/java/com/cta4j/bus/detour/model/DetourRouteDirection.java b/src/main/java/com/cta4j/bus/detour/model/DetourRouteDirection.java new file mode 100644 index 0000000..ef888e3 --- /dev/null +++ b/src/main/java/com/cta4j/bus/detour/model/DetourRouteDirection.java @@ -0,0 +1,30 @@ +package com.cta4j.bus.detour.model; + +import org.jspecify.annotations.NullMarked; + +import java.util.Objects; + +/** + * Represents a route and direction affected by a detour. + * + * @param routeId the route ID of the detour + * @param direction the direction of the detour + */ +@NullMarked +public record DetourRouteDirection( + String routeId, + + String direction +) { + /** + * Creates a {@code DetourRouteDirection}. + * + * @param routeId the route ID of the detour + * @param direction the direction of the detour + * @throws NullPointerException if {@code routeId} or {@code direction} is {@code null} + */ + public DetourRouteDirection { + Objects.requireNonNull(routeId); + Objects.requireNonNull(direction); + } +} diff --git a/src/main/java/com/cta4j/bus/direction/DirectionsApi.java b/src/main/java/com/cta4j/bus/direction/DirectionsApi.java new file mode 100644 index 0000000..6f94ea1 --- /dev/null +++ b/src/main/java/com/cta4j/bus/direction/DirectionsApi.java @@ -0,0 +1,23 @@ +package com.cta4j.bus.direction; + +import org.jspecify.annotations.NullMarked; + +import java.util.List; + +/** + * Provides access to direction-related endpoints of the CTA BusTime API. + *

+ * This API allows retrieval of available travel directions for a given route. + */ +@NullMarked +public interface DirectionsApi { + /** + * Retrieves the available travel directions for the specified route (e.g., Northbound, Southbound). + * + * @param routeId the route identifier + * @return a {@link List} of direction identifiers for the route; + * an empty {@link List} if the route has no associated directions + * @throws NullPointerException if {@code routeId} is {@code null} + */ + List findByRouteId(String routeId); +} diff --git a/src/main/java/com/cta4j/bus/direction/internal/impl/DirectionsApiImpl.java b/src/main/java/com/cta4j/bus/direction/internal/impl/DirectionsApiImpl.java new file mode 100644 index 0000000..dc471ec --- /dev/null +++ b/src/main/java/com/cta4j/bus/direction/internal/impl/DirectionsApiImpl.java @@ -0,0 +1,78 @@ +package com.cta4j.bus.direction.internal.impl; + +import com.cta4j.bus.internal.context.BusApiContext; +import com.cta4j.bus.internal.util.ApiUtils; +import com.cta4j.bus.direction.DirectionsApi; +import com.cta4j.bus.internal.wire.CtaBustimeResponse; +import com.cta4j.bus.direction.internal.wire.CtaDirection; +import com.cta4j.bus.internal.wire.CtaError; +import com.cta4j.bus.internal.wire.CtaResponse; +import com.cta4j.exception.Cta4jException; +import com.cta4j.bus.internal.util.HttpUtils; +import org.apache.hc.core5.net.URIBuilder; +import org.jetbrains.annotations.ApiStatus; +import org.jspecify.annotations.NullMarked; +import tools.jackson.core.JacksonException; +import tools.jackson.core.type.TypeReference; + +import java.util.List; +import java.util.Objects; + +@NullMarked +@ApiStatus.Internal +public final class DirectionsApiImpl implements DirectionsApi { + private static final String DIRECTIONS_ENDPOINT = String.format("%s/getdirections", ApiUtils.API_PREFIX); + + private final BusApiContext context; + + public DirectionsApiImpl(BusApiContext context) { + this.context = Objects.requireNonNull(context); + } + + @Override + public List findByRouteId(String routeId) { + Objects.requireNonNull(routeId); + + String url = new URIBuilder() + .setScheme(ApiUtils.SCHEME) + .setHost(this.context.host()) + .setPath(DIRECTIONS_ENDPOINT) + .addParameter("rt", routeId) + .addParameter("key", this.context.apiKey()) + .addParameter("format", "json") + .toString(); + + String response = HttpUtils.get(url); + + TypeReference>> typeReference = new TypeReference<>() {}; + CtaResponse> directionsResponse; + + try { + directionsResponse = this.context.objectMapper() + .readValue(response, typeReference); + } catch (JacksonException e) { + String message = String.format("Failed to parse response from %s", DIRECTIONS_ENDPOINT); + + throw new Cta4jException(message, e); + } + + CtaBustimeResponse> bustimeResponse = directionsResponse.bustimeResponse(); + + List errors = bustimeResponse.error(); + List directions = bustimeResponse.data(); + + if ((errors != null) && !errors.isEmpty()) { + String message = ApiUtils.buildErrorMessage(DIRECTIONS_ENDPOINT, errors); + + throw new Cta4jException(message); + } + + if ((directions == null) || directions.isEmpty()) { + return List.of(); + } + + return directions.stream() + .map(CtaDirection::id) + .toList(); + } +} diff --git a/src/main/java/com/cta4j/bus/external/direction/CtaDirection.java b/src/main/java/com/cta4j/bus/direction/internal/wire/CtaDirection.java similarity index 50% rename from src/main/java/com/cta4j/bus/external/direction/CtaDirection.java rename to src/main/java/com/cta4j/bus/direction/internal/wire/CtaDirection.java index c107782..acfa03e 100644 --- a/src/main/java/com/cta4j/bus/external/direction/CtaDirection.java +++ b/src/main/java/com/cta4j/bus/direction/internal/wire/CtaDirection.java @@ -1,8 +1,12 @@ -package com.cta4j.bus.external.direction; +package com.cta4j.bus.direction.internal.wire; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import org.jetbrains.annotations.ApiStatus; +import org.jspecify.annotations.NullMarked; +import java.util.Objects; + +@NullMarked @ApiStatus.Internal @JsonIgnoreProperties(ignoreUnknown = true) public record CtaDirection( @@ -10,4 +14,8 @@ public record CtaDirection( String name ) { + public CtaDirection { + Objects.requireNonNull(id); + Objects.requireNonNull(name); + } } diff --git a/src/main/java/com/cta4j/bus/external/detour/CtaDetour.java b/src/main/java/com/cta4j/bus/external/detour/CtaDetour.java deleted file mode 100644 index 53d2651..0000000 --- a/src/main/java/com/cta4j/bus/external/detour/CtaDetour.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.cta4j.bus.external.detour; - -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import org.jetbrains.annotations.ApiStatus; - -import java.util.List; - -@ApiStatus.Internal -@JsonIgnoreProperties(ignoreUnknown = true) -public record CtaDetour( - String id, - - String ver, - - String st, - - String desc, - - List rtdirs, - - String startdt, - - String enddt -) { -} diff --git a/src/main/java/com/cta4j/bus/external/detour/CtaDetoursBustimeResponse.java b/src/main/java/com/cta4j/bus/external/detour/CtaDetoursBustimeResponse.java deleted file mode 100644 index 940250c..0000000 --- a/src/main/java/com/cta4j/bus/external/detour/CtaDetoursBustimeResponse.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.cta4j.bus.external.detour; - -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import org.jetbrains.annotations.ApiStatus; - -import java.util.List; - -@ApiStatus.Internal -@JsonIgnoreProperties(ignoreUnknown = true) -public record CtaDetoursBustimeResponse( - List dtrs -) { -} diff --git a/src/main/java/com/cta4j/bus/external/detour/CtaDetoursResponse.java b/src/main/java/com/cta4j/bus/external/detour/CtaDetoursResponse.java deleted file mode 100644 index afa7d3d..0000000 --- a/src/main/java/com/cta4j/bus/external/detour/CtaDetoursResponse.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.cta4j.bus.external.detour; - -import com.fasterxml.jackson.annotation.JsonAlias; -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import org.jetbrains.annotations.ApiStatus; - -@ApiStatus.Internal -@JsonIgnoreProperties(ignoreUnknown = true) -public record CtaDetoursResponse( - @JsonAlias("bustime-response") - CtaDetoursBustimeResponse bustimeResponse -) { -} diff --git a/src/main/java/com/cta4j/bus/external/direction/CtaDirectionsBustimeResponse.java b/src/main/java/com/cta4j/bus/external/direction/CtaDirectionsBustimeResponse.java deleted file mode 100644 index 06507c7..0000000 --- a/src/main/java/com/cta4j/bus/external/direction/CtaDirectionsBustimeResponse.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.cta4j.bus.external.direction; - -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import org.jetbrains.annotations.ApiStatus; - -import java.util.List; - -@ApiStatus.Internal -@JsonIgnoreProperties(ignoreUnknown = true) -public record CtaDirectionsBustimeResponse( - List directions -) { -} diff --git a/src/main/java/com/cta4j/bus/external/direction/CtaDirectionsResponse.java b/src/main/java/com/cta4j/bus/external/direction/CtaDirectionsResponse.java deleted file mode 100644 index a8236c6..0000000 --- a/src/main/java/com/cta4j/bus/external/direction/CtaDirectionsResponse.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.cta4j.bus.external.direction; - -import com.fasterxml.jackson.annotation.JsonAlias; -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import org.jetbrains.annotations.ApiStatus; - -@ApiStatus.Internal -@JsonIgnoreProperties(ignoreUnknown = true) -public record CtaDirectionsResponse( - @JsonAlias("bustime-response") - CtaDirectionsBustimeResponse bustimeResponse -) { -} diff --git a/src/main/java/com/cta4j/bus/external/prediction/CtaPredictionsBustimeResponse.java b/src/main/java/com/cta4j/bus/external/prediction/CtaPredictionsBustimeResponse.java deleted file mode 100644 index 973e787..0000000 --- a/src/main/java/com/cta4j/bus/external/prediction/CtaPredictionsBustimeResponse.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.cta4j.bus.external.prediction; - -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import org.jetbrains.annotations.ApiStatus; - -import java.util.List; - -@ApiStatus.Internal -@JsonIgnoreProperties(ignoreUnknown = true) -public record CtaPredictionsBustimeResponse( - List prd -) { -} diff --git a/src/main/java/com/cta4j/bus/external/prediction/CtaPredictionsPrd.java b/src/main/java/com/cta4j/bus/external/prediction/CtaPredictionsPrd.java deleted file mode 100644 index 0333a4e..0000000 --- a/src/main/java/com/cta4j/bus/external/prediction/CtaPredictionsPrd.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.cta4j.bus.external.prediction; - -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import org.jetbrains.annotations.ApiStatus; - -@ApiStatus.Internal -@JsonIgnoreProperties(ignoreUnknown = true) -public record CtaPredictionsPrd( - String tmstmp, - String typ, - String stpnm, - String stpid, - String vid, - String dstp, - String rt, - String rtdd, - String rtdir, - String des, - String prdtm, - String dly, - String dyn, - String tablockid, - String tatripid, - String origtatripno, - String prdctdn, - String zone, - String psgld, - String stst, - String stsd, - String flagstop -) { -} diff --git a/src/main/java/com/cta4j/bus/external/prediction/CtaPredictionsResponse.java b/src/main/java/com/cta4j/bus/external/prediction/CtaPredictionsResponse.java deleted file mode 100644 index 9dcdadb..0000000 --- a/src/main/java/com/cta4j/bus/external/prediction/CtaPredictionsResponse.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.cta4j.bus.external.prediction; - -import com.fasterxml.jackson.annotation.JsonAlias; -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import org.jetbrains.annotations.ApiStatus; - -@ApiStatus.Internal -@JsonIgnoreProperties(ignoreUnknown = true) -public record CtaPredictionsResponse( - @JsonAlias("bustime-response") - CtaPredictionsBustimeResponse bustimeResponse -) { -} diff --git a/src/main/java/com/cta4j/bus/external/route/CtaRoute.java b/src/main/java/com/cta4j/bus/external/route/CtaRoute.java deleted file mode 100644 index 06635b1..0000000 --- a/src/main/java/com/cta4j/bus/external/route/CtaRoute.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.cta4j.bus.external.route; - -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import org.jetbrains.annotations.ApiStatus; - -@ApiStatus.Internal -@JsonIgnoreProperties(ignoreUnknown = true) -public record CtaRoute( - String rt, - String rtnm, - String rtdd, - String rtclr -) { -} diff --git a/src/main/java/com/cta4j/bus/external/route/CtaRoutesBustimeResponse.java b/src/main/java/com/cta4j/bus/external/route/CtaRoutesBustimeResponse.java deleted file mode 100644 index 1d88dfb..0000000 --- a/src/main/java/com/cta4j/bus/external/route/CtaRoutesBustimeResponse.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.cta4j.bus.external.route; - -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import org.jetbrains.annotations.ApiStatus; - -import java.util.List; - -@ApiStatus.Internal -@JsonIgnoreProperties(ignoreUnknown = true) -public record CtaRoutesBustimeResponse( - List routes -) { -} diff --git a/src/main/java/com/cta4j/bus/external/route/CtaRoutesResponse.java b/src/main/java/com/cta4j/bus/external/route/CtaRoutesResponse.java deleted file mode 100644 index 3e9e743..0000000 --- a/src/main/java/com/cta4j/bus/external/route/CtaRoutesResponse.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.cta4j.bus.external.route; - -import com.fasterxml.jackson.annotation.JsonAlias; -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import org.jetbrains.annotations.ApiStatus; - -@ApiStatus.Internal -@JsonIgnoreProperties(ignoreUnknown = true) -public record CtaRoutesResponse( - @JsonAlias("bustime-response") - CtaRoutesBustimeResponse bustimeResponse -) { -} diff --git a/src/main/java/com/cta4j/bus/external/stop/CtaStop.java b/src/main/java/com/cta4j/bus/external/stop/CtaStop.java deleted file mode 100644 index 2d0fec9..0000000 --- a/src/main/java/com/cta4j/bus/external/stop/CtaStop.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.cta4j.bus.external.stop; - -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import org.jetbrains.annotations.ApiStatus; - -@ApiStatus.Internal -@JsonIgnoreProperties(ignoreUnknown = true) -public record CtaStop( - String stpid, - String stpnm, - String lat, - String lon -) { -} diff --git a/src/main/java/com/cta4j/bus/external/stop/CtaStopsBustimeResponse.java b/src/main/java/com/cta4j/bus/external/stop/CtaStopsBustimeResponse.java deleted file mode 100644 index 0ae0707..0000000 --- a/src/main/java/com/cta4j/bus/external/stop/CtaStopsBustimeResponse.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.cta4j.bus.external.stop; - -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import org.jetbrains.annotations.ApiStatus; - -import java.util.List; - -@ApiStatus.Internal -@JsonIgnoreProperties(ignoreUnknown = true) -public record CtaStopsBustimeResponse( - List stops -) { -} diff --git a/src/main/java/com/cta4j/bus/external/stop/CtaStopsResponse.java b/src/main/java/com/cta4j/bus/external/stop/CtaStopsResponse.java deleted file mode 100644 index e561262..0000000 --- a/src/main/java/com/cta4j/bus/external/stop/CtaStopsResponse.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.cta4j.bus.external.stop; - -import com.fasterxml.jackson.annotation.JsonAlias; -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import org.jetbrains.annotations.ApiStatus; - -@ApiStatus.Internal -@JsonIgnoreProperties(ignoreUnknown = true) -public record CtaStopsResponse( - @JsonAlias("bustime-response") - CtaStopsBustimeResponse bustimeResponse -) { -} diff --git a/src/main/java/com/cta4j/bus/external/vehicle/CtaVehicle.java b/src/main/java/com/cta4j/bus/external/vehicle/CtaVehicle.java deleted file mode 100644 index 79d8947..0000000 --- a/src/main/java/com/cta4j/bus/external/vehicle/CtaVehicle.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.cta4j.bus.external.vehicle; - -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import org.jetbrains.annotations.ApiStatus; - -@ApiStatus.Internal -@JsonIgnoreProperties(ignoreUnknown = true) -public record CtaVehicle( - String vid, - String tmstmp, - String lat, - String lon, - String hdg, - String pid, - String rt, - String des, - String pdist, - Boolean dly, - String tatripid, - String origtatripno, - String tablockid, - String zone, - String mode, - String psgld, - String stst, - String stsd -) { -} diff --git a/src/main/java/com/cta4j/bus/external/vehicle/CtaVehicleBustimeResponse.java b/src/main/java/com/cta4j/bus/external/vehicle/CtaVehicleBustimeResponse.java deleted file mode 100644 index a121eb1..0000000 --- a/src/main/java/com/cta4j/bus/external/vehicle/CtaVehicleBustimeResponse.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.cta4j.bus.external.vehicle; - -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import org.jetbrains.annotations.ApiStatus; - -import java.util.List; - -@ApiStatus.Internal -@JsonIgnoreProperties(ignoreUnknown = true) -public record CtaVehicleBustimeResponse( - List vehicle -) { -} diff --git a/src/main/java/com/cta4j/bus/external/vehicle/CtaVehicleResponse.java b/src/main/java/com/cta4j/bus/external/vehicle/CtaVehicleResponse.java deleted file mode 100644 index c7de011..0000000 --- a/src/main/java/com/cta4j/bus/external/vehicle/CtaVehicleResponse.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.cta4j.bus.external.vehicle; - -import com.fasterxml.jackson.annotation.JsonAlias; -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import org.jetbrains.annotations.ApiStatus; - -@ApiStatus.Internal -@JsonIgnoreProperties(ignoreUnknown = true) -public record CtaVehicleResponse( - @JsonAlias("bustime-response") - CtaVehicleBustimeResponse bustimeResponse -) { -} diff --git a/src/main/java/com/cta4j/bus/internal/context/BusApiContext.java b/src/main/java/com/cta4j/bus/internal/context/BusApiContext.java new file mode 100644 index 0000000..c3dd73e --- /dev/null +++ b/src/main/java/com/cta4j/bus/internal/context/BusApiContext.java @@ -0,0 +1,23 @@ +package com.cta4j.bus.internal.context; + +import org.jetbrains.annotations.ApiStatus; +import org.jspecify.annotations.NullMarked; +import tools.jackson.databind.ObjectMapper; + +import java.util.Objects; + +@NullMarked +@ApiStatus.Internal +public record BusApiContext( + String host, + + String apiKey, + + ObjectMapper objectMapper +) { + public BusApiContext { + Objects.requireNonNull(host); + Objects.requireNonNull(apiKey); + Objects.requireNonNull(objectMapper); + } +} diff --git a/src/main/java/com/cta4j/bus/internal/impl/BusApiImpl.java b/src/main/java/com/cta4j/bus/internal/impl/BusApiImpl.java new file mode 100644 index 0000000..2997ca5 --- /dev/null +++ b/src/main/java/com/cta4j/bus/internal/impl/BusApiImpl.java @@ -0,0 +1,205 @@ +package com.cta4j.bus.internal.impl; + +import com.cta4j.bus.internal.context.BusApiContext; +import com.cta4j.bus.internal.util.ApiUtils; +import com.cta4j.bus.BusApi; +import com.cta4j.bus.detour.DetoursApi; +import com.cta4j.bus.detour.internal.impl.DetoursApiImpl; +import com.cta4j.bus.direction.DirectionsApi; +import com.cta4j.bus.locale.LocalesApi; +import com.cta4j.bus.locale.internal.impl.LocalesApiImpl; +import com.cta4j.bus.pattern.PatternsApi; +import com.cta4j.bus.prediction.PredictionsApi; +import com.cta4j.bus.direction.internal.impl.DirectionsApiImpl; +import com.cta4j.bus.pattern.internal.impl.PatternsApiImpl; +import com.cta4j.bus.prediction.internal.impl.PredictionsApiImpl; +import com.cta4j.bus.route.RoutesApi; +import com.cta4j.bus.stop.StopsApi; +import com.cta4j.bus.route.internal.impl.RoutesApiImpl; +import com.cta4j.bus.stop.internal.impl.StopsApiImpl; +import com.cta4j.bus.vehicle.VehiclesApi; +import com.cta4j.bus.vehicle.internal.impl.VehiclesApiImpl; +import com.cta4j.bus.internal.wire.CtaBustimeResponse; +import com.cta4j.bus.internal.wire.CtaError; +import com.cta4j.bus.internal.wire.CtaResponse; +import com.cta4j.bus.internal.mapper.Qualifiers; +import com.cta4j.exception.Cta4jException; +import com.cta4j.bus.internal.util.HttpUtils; +import org.apache.hc.core5.net.URIBuilder; +import org.jetbrains.annotations.ApiStatus; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; +import tools.jackson.core.JacksonException; +import tools.jackson.core.type.TypeReference; +import tools.jackson.databind.ObjectMapper; + +import java.time.Instant; +import java.util.List; +import java.util.Objects; + +@NullMarked +@ApiStatus.Internal +public final class BusApiImpl implements BusApi { + private static final String SYSTEM_TIME_ENDPOINT = String.format("%s/gettime", ApiUtils.API_PREFIX); + + private final BusApiContext context; + private final VehiclesApi vehiclesApi; + private final RoutesApi routesApi; + private final DirectionsApi directionsApi; + private final StopsApi stopsApi; + private final PatternsApi patternsApi; + private final PredictionsApi predictionsApi; + private final LocalesApi localesApi; + private final DetoursApi detoursApi; + + public BusApiImpl( + String host, + String apiKey + ) { + Objects.requireNonNull(host); + Objects.requireNonNull(apiKey); + + ObjectMapper objectMapper = new ObjectMapper(); + + this.context = new BusApiContext(host, apiKey, objectMapper); + this.vehiclesApi = new VehiclesApiImpl(this.context); + this.routesApi = new RoutesApiImpl(this.context); + this.directionsApi = new DirectionsApiImpl(this.context); + this.stopsApi = new StopsApiImpl(this.context); + this.patternsApi = new PatternsApiImpl(this.context); + this.predictionsApi = new PredictionsApiImpl(this.context); + this.localesApi = new LocalesApiImpl(this.context); + this.detoursApi = new DetoursApiImpl(this.context); + } + + @Override + public Instant systemTime() { + String url = new URIBuilder() + .setScheme(ApiUtils.SCHEME) + .setHost(this.context.host()) + .setPath(SYSTEM_TIME_ENDPOINT) + .addParameter("key", this.context.apiKey()) + .addParameter("format", "json") + .toString(); + + String response = HttpUtils.get(url); + + TypeReference> typeReference = new TypeReference<>() {}; + CtaResponse timeResponse; + + try { + timeResponse = this.context.objectMapper() + .readValue(response, typeReference); + } catch (JacksonException e) { + String message = String.format("Failed to parse response from %s", SYSTEM_TIME_ENDPOINT); + + throw new Cta4jException(message, e); + } + + CtaBustimeResponse bustimeResponse = timeResponse.bustimeResponse(); + + List errors = bustimeResponse.error(); + String systemTime = bustimeResponse.data(); + + if ((errors == null) && (systemTime == null)) { + String message = String.format( + "System time bustime response missing both error and data from %s", + SYSTEM_TIME_ENDPOINT + ); + + throw new Cta4jException(message); + } + + if ((errors != null) && !errors.isEmpty()) { + String message = ApiUtils.buildErrorMessage(SYSTEM_TIME_ENDPOINT, errors); + + throw new Cta4jException(message); + } + + if (systemTime == null) { + String message = String.format("No system time data returned from %s", SYSTEM_TIME_ENDPOINT); + + throw new Cta4jException(message); + } + + Instant systemInstant = Qualifiers.mapTimestamp(systemTime); + + if (systemInstant == null) { + String message = String.format( + "Failed to map system time '%s' to Instant from %s", + systemTime, + SYSTEM_TIME_ENDPOINT + ); + + throw new Cta4jException(message); + } + + return systemInstant; + } + + @Override + public VehiclesApi vehicles() { + return this.vehiclesApi; + } + + @Override + public RoutesApi routes() { + return this.routesApi; + } + + @Override + public DirectionsApi directions() { + return this.directionsApi; + } + + @Override + public StopsApi stops() { + return this.stopsApi; + } + + @Override + public PatternsApi patterns() { + return this.patternsApi; + } + + @Override + public PredictionsApi predictions() { + return this.predictionsApi; + } + + @Override + public LocalesApi locales() { + return this.localesApi; + } + + @Override + public DetoursApi detours() { + return this.detoursApi; + } + + public static final class BuilderImpl implements BusApi.Builder { + private final String apiKey; + + @Nullable + private String host; + + public BuilderImpl(String apiKey) { + this.apiKey = Objects.requireNonNull(apiKey); + this.host = null; + } + + @Override + public Builder host(String host) { + this.host = Objects.requireNonNull(host); + + return this; + } + + @Override + public BusApi build() { + String finalHost = Objects.requireNonNullElse(this.host, ApiUtils.DEFAULT_HOST); + + return new BusApiImpl(finalHost, this.apiKey); + } + } +} diff --git a/src/main/java/com/cta4j/bus/internal/mapper/Qualifiers.java b/src/main/java/com/cta4j/bus/internal/mapper/Qualifiers.java new file mode 100644 index 0000000..ca455ae --- /dev/null +++ b/src/main/java/com/cta4j/bus/internal/mapper/Qualifiers.java @@ -0,0 +1,139 @@ +package com.cta4j.bus.internal.mapper; + +import com.cta4j.bus.prediction.model.DynamicAction; +import com.cta4j.bus.prediction.model.FlagStop; +import com.cta4j.bus.prediction.model.PassengerLoad; +import com.cta4j.bus.pattern.model.PatternPointType; +import com.cta4j.bus.prediction.model.PredictionType; +import com.cta4j.bus.vehicle.model.TransitMode; +import org.jetbrains.annotations.ApiStatus; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; +import org.mapstruct.Named; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.Locale; +import java.util.Objects; + +@NullMarked +@ApiStatus.Internal +public final class Qualifiers { + private static final DateTimeFormatter TIMESTAMP_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd HH:mm[:ss]"); + private static final ZoneId CHICAGO_ZONE_ID = ZoneId.of("America/Chicago"); + + private Qualifiers() { + throw new UnsupportedOperationException("This is a utility class and cannot be instantiated"); + } + + @Named("mapPredictionType") + public static PredictionType mapPredictionType(String typ) { + Objects.requireNonNull(typ); + + return switch (typ) { + case "A" -> PredictionType.ARRIVAL; + case "D" -> PredictionType.DEPARTURE; + default -> { + String message = String.format("Unknown prediction type: %s", typ); + + throw new IllegalArgumentException(message); + } + }; + } + + @Named("mapTimestamp") + public static @Nullable Instant mapTimestamp(@Nullable String timestamp) { + if (timestamp == null) { + return null; + } + + return LocalDateTime.parse(timestamp, TIMESTAMP_FORMATTER) + .atZone(CHICAGO_ZONE_ID) + .toInstant(); + } + + @Named("mapDynamicAction") + public static DynamicAction mapDynamicAction(int dyn) { + for (DynamicAction dynamicAction : DynamicAction.values()) { + if (dynamicAction.getCode() == dyn) { + return dynamicAction; + } + } + + String message = String.format("Unknown dynamic action code: %d", dyn); + + throw new IllegalArgumentException(message); + } + + @Named("mapPassengerLoad") + public static PassengerLoad mapPassengerLoad(String psgld) { + Objects.requireNonNull(psgld); + + return switch (psgld) { + case "FULL" -> PassengerLoad.FULL; + case "HALF_EMPTY" -> PassengerLoad.HALF_EMPTY; + case "EMPTY" -> PassengerLoad.EMPTY; + case "N/A", "" -> PassengerLoad.UNKNOWN; + default -> { + String message = String.format("Unknown passenger load: %s", psgld); + + throw new IllegalArgumentException(message); + } + }; + } + + @Named("mapFlagStop") + public static FlagStop mapFlagStop(int flagstop) { + for (FlagStop flagStop : FlagStop.values()) { + if (flagStop.getCode() == flagstop) { + return flagStop; + } + } + + String message = String.format("Unknown flag stop code: %d", flagstop); + + throw new IllegalArgumentException(message); + } + + @Named("mapTransitMode") + public static TransitMode mapTransitMode(int mode) { + for (TransitMode transitMode : TransitMode.values()) { + if (transitMode.getCode() == mode) { + return transitMode; + } + } + + String message = String.format("Unknown transit mode code: %d", mode); + + throw new IllegalArgumentException(message); + } + + @Named("mapActive") + public static boolean mapActive(int st) { + return st == 1; + } + + @Named("mapPatternPointType") + public static PatternPointType mapPatternPointType(String type) { + Objects.requireNonNull(type); + + return switch (type) { + case "S" -> PatternPointType.STOP; + case "W" -> PatternPointType.WAYPOINT; + default -> { + String message = String.format("Unknown pattern point type: %s", type); + + throw new IllegalArgumentException(message); + } + }; + } + + @Named("mapLocale") + public static Locale mapLocale(String locale) { + Objects.requireNonNull(locale); + + return Locale.of(locale); + } +} diff --git a/src/main/java/com/cta4j/bus/internal/util/ApiUtils.java b/src/main/java/com/cta4j/bus/internal/util/ApiUtils.java new file mode 100644 index 0000000..d9046f0 --- /dev/null +++ b/src/main/java/com/cta4j/bus/internal/util/ApiUtils.java @@ -0,0 +1,34 @@ +package com.cta4j.bus.internal.util; + +import com.cta4j.bus.internal.wire.CtaError; +import org.jetbrains.annotations.ApiStatus; +import org.jspecify.annotations.NullMarked; + +import java.util.List; +import java.util.Objects; + +@NullMarked +@ApiStatus.Internal +public final class ApiUtils { + public static final String SCHEME = "https"; + public static final String DEFAULT_HOST = "ctabustracker.com"; + public static final String API_PREFIX = "/bustime/api/v3"; + + private ApiUtils() { + throw new UnsupportedOperationException("This is a utility class and cannot be instantiated"); + } + + public static String buildErrorMessage(String endpoint, List errors) { + Objects.requireNonNull(endpoint); + Objects.requireNonNull(errors); + + errors.forEach(Objects::requireNonNull); + + String message = errors.stream() + .map(CtaError::msg) + .reduce("%s; %s"::formatted) + .orElse("Unknown error"); + + return String.format("Error response from %s: %s", endpoint, message); + } +} diff --git a/src/main/java/com/cta4j/util/DateTimeUtils.java b/src/main/java/com/cta4j/bus/internal/util/DateTimeUtils.java similarity index 52% rename from src/main/java/com/cta4j/util/DateTimeUtils.java rename to src/main/java/com/cta4j/bus/internal/util/DateTimeUtils.java index 45715af..da99c35 100644 --- a/src/main/java/com/cta4j/util/DateTimeUtils.java +++ b/src/main/java/com/cta4j/bus/internal/util/DateTimeUtils.java @@ -1,12 +1,10 @@ -package com.cta4j.util; +package com.cta4j.bus.internal.util; import org.jetbrains.annotations.ApiStatus; import java.time.Instant; import java.time.LocalDateTime; import java.time.ZoneId; -import java.time.format.DateTimeFormatter; -import java.util.Objects; @ApiStatus.Internal public final class DateTimeUtils { @@ -15,7 +13,9 @@ private DateTimeUtils() { } public static Instant parseTrainTimestamp(String timestamp) { - Objects.requireNonNull(timestamp); + if (timestamp == null) { + throw new IllegalArgumentException("timestamp must not be null"); + } ZoneId chicagoId = ZoneId.of("America/Chicago"); @@ -23,16 +23,4 @@ public static Instant parseTrainTimestamp(String timestamp) { .atZone(chicagoId) .toInstant(); } - - public static Instant parseBusTimestamp(String timestamp) { - Objects.requireNonNull(timestamp); - - DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyyMMdd HH:mm"); - - ZoneId chicagoId = ZoneId.of("America/Chicago"); - - return LocalDateTime.parse(timestamp, formatter) - .atZone(chicagoId) - .toInstant(); - } } diff --git a/src/main/java/com/cta4j/bus/internal/util/HttpUtils.java b/src/main/java/com/cta4j/bus/internal/util/HttpUtils.java new file mode 100644 index 0000000..f661b47 --- /dev/null +++ b/src/main/java/com/cta4j/bus/internal/util/HttpUtils.java @@ -0,0 +1,44 @@ +package com.cta4j.bus.internal.util; + +import com.cta4j.exception.Cta4jException; +import org.apache.hc.client5.http.fluent.Request; +import org.jetbrains.annotations.ApiStatus; + +import java.io.IOException; +import java.net.URI; + +@ApiStatus.Internal +public final class HttpUtils { + private HttpUtils() { + throw new UnsupportedOperationException("This is a utility class and cannot be instantiated"); + } + + public static String get(String url) { + URI uri; + + try { + uri = URI.create(url); + } catch (IllegalArgumentException e) { + String message = "Invalid URL"; + + throw new Cta4jException(message, e); + } + + String response; + + try { + response = Request.get(url) + .execute() + .returnContent() + .asString(); + } catch (IOException e) { + String path = uri.getPath(); + + String message = String.format("Request to %s failed due to an I/O error", path); + + throw new Cta4jException(message, e); + } + + return response; + } +} diff --git a/src/main/java/com/cta4j/bus/internal/wire/CtaBustimeResponse.java b/src/main/java/com/cta4j/bus/internal/wire/CtaBustimeResponse.java new file mode 100644 index 0000000..fa8bf59 --- /dev/null +++ b/src/main/java/com/cta4j/bus/internal/wire/CtaBustimeResponse.java @@ -0,0 +1,32 @@ +package com.cta4j.bus.internal.wire; + +import com.fasterxml.jackson.annotation.JsonAlias; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import org.jetbrains.annotations.ApiStatus; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +import java.util.List; + +@NullMarked +@ApiStatus.Internal +@JsonIgnoreProperties(ignoreUnknown = true) +public record CtaBustimeResponse( + @Nullable + List error, + + @Nullable + @JsonAlias({ + "tm", + "routes", + "directions", + "stops", + "ptr", + "prd", + "dtrs", + "vehicle", + "locale" + }) + T data +) { +} diff --git a/src/main/java/com/cta4j/bus/internal/wire/CtaError.java b/src/main/java/com/cta4j/bus/internal/wire/CtaError.java new file mode 100644 index 0000000..04f2b6d --- /dev/null +++ b/src/main/java/com/cta4j/bus/internal/wire/CtaError.java @@ -0,0 +1,18 @@ +package com.cta4j.bus.internal.wire; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import org.jetbrains.annotations.ApiStatus; +import org.jspecify.annotations.NullMarked; + +import java.util.Objects; + +@NullMarked +@ApiStatus.Internal +@JsonIgnoreProperties(ignoreUnknown = true) +public record CtaError( + String msg +) { + public CtaError { + Objects.requireNonNull(msg); + } +} diff --git a/src/main/java/com/cta4j/bus/internal/wire/CtaResponse.java b/src/main/java/com/cta4j/bus/internal/wire/CtaResponse.java new file mode 100644 index 0000000..c532cc5 --- /dev/null +++ b/src/main/java/com/cta4j/bus/internal/wire/CtaResponse.java @@ -0,0 +1,20 @@ +package com.cta4j.bus.internal.wire; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import org.jetbrains.annotations.ApiStatus; +import org.jspecify.annotations.NullMarked; + +import java.util.Objects; + +@NullMarked +@ApiStatus.Internal +@JsonIgnoreProperties(ignoreUnknown = true) +public record CtaResponse( + @JsonProperty("bustime-response") + CtaBustimeResponse bustimeResponse +) { + public CtaResponse { + Objects.requireNonNull(bustimeResponse); + } +} diff --git a/src/main/java/com/cta4j/bus/locale/LocalesApi.java b/src/main/java/com/cta4j/bus/locale/LocalesApi.java new file mode 100644 index 0000000..772cce1 --- /dev/null +++ b/src/main/java/com/cta4j/bus/locale/LocalesApi.java @@ -0,0 +1,16 @@ +package com.cta4j.bus.locale; + +import com.cta4j.bus.locale.model.SupportedLocale; +import org.jspecify.annotations.NullMarked; + +import java.util.List; +import java.util.Locale; + +@NullMarked +public interface LocalesApi { + List getLocales(); + + List getLocales(Locale displayLocale); + + List getLocalesInNativeLanguage(); +} diff --git a/src/main/java/com/cta4j/bus/locale/internal/impl/LocalesApiImpl.java b/src/main/java/com/cta4j/bus/locale/internal/impl/LocalesApiImpl.java new file mode 100644 index 0000000..b050450 --- /dev/null +++ b/src/main/java/com/cta4j/bus/locale/internal/impl/LocalesApiImpl.java @@ -0,0 +1,114 @@ +package com.cta4j.bus.locale.internal.impl; + +import com.cta4j.bus.internal.context.BusApiContext; +import com.cta4j.bus.internal.wire.CtaBustimeResponse; +import com.cta4j.bus.internal.wire.CtaError; +import com.cta4j.bus.internal.wire.CtaResponse; +import com.cta4j.bus.internal.util.ApiUtils; +import com.cta4j.bus.locale.LocalesApi; +import com.cta4j.bus.locale.internal.wire.CtaLocale; +import com.cta4j.bus.locale.internal.mapper.SupportedLocaleMapper; +import com.cta4j.bus.locale.model.SupportedLocale; +import com.cta4j.exception.Cta4jException; +import com.cta4j.bus.internal.util.HttpUtils; +import org.apache.hc.core5.net.URIBuilder; +import org.jetbrains.annotations.ApiStatus; +import org.jspecify.annotations.NullMarked; +import tools.jackson.core.JacksonException; +import tools.jackson.core.type.TypeReference; + +import java.util.List; +import java.util.Locale; +import java.util.Objects; + +@NullMarked +@ApiStatus.Internal +public final class LocalesApiImpl implements LocalesApi { + private static final String LOCALES_ENDPOINT = String.format("%s/getlocalelist", ApiUtils.API_PREFIX); + + private final BusApiContext context; + + public LocalesApiImpl(BusApiContext context) { + this.context = Objects.requireNonNull(context); + } + + @Override + public List getLocales() { + String uri = new URIBuilder() + .setScheme(ApiUtils.SCHEME) + .setHost(this.context.host()) + .setPath(LOCALES_ENDPOINT) + .addParameter("key", this.context.apiKey()) + .addParameter("format", "json") + .toString(); + + return this.makeRequest(uri); + } + + @Override + public List getLocales(Locale displayLocale) { + Objects.requireNonNull(displayLocale); + + String languageTag = displayLocale.toLanguageTag(); + + String uri = new URIBuilder() + .setScheme(ApiUtils.SCHEME) + .setHost(this.context.host()) + .setPath(LOCALES_ENDPOINT) + .addParameter("key", this.context.apiKey()) + .addParameter("format", "json") + .addParameter("locale", languageTag) + .toString(); + + return this.makeRequest(uri); + } + + @Override + public List getLocalesInNativeLanguage() { + String uri = new URIBuilder() + .setScheme(ApiUtils.SCHEME) + .setHost(this.context.host()) + .setPath(LOCALES_ENDPOINT) + .addParameter("key", this.context.apiKey()) + .addParameter("format", "json") + .addParameter("inLocaleLanguage", "true") + .toString(); + + return this.makeRequest(uri); + } + + private List makeRequest(String url) { + String response = HttpUtils.get(url); + + TypeReference>> typeReference = new TypeReference<>() {}; + CtaResponse> localeResponse; + + try { + localeResponse = this.context.objectMapper() + .readValue(response, typeReference); + } catch (JacksonException e) { + String message = String.format("Failed to parse response from %s", LOCALES_ENDPOINT); + + throw new Cta4jException(message, e); + } + + CtaBustimeResponse> bustimeResponse = localeResponse.bustimeResponse(); + + List errors = bustimeResponse.error(); + List locales = bustimeResponse.data(); + + if ((errors != null) && !errors.isEmpty()) { + String message = ApiUtils.buildErrorMessage(LOCALES_ENDPOINT, errors); + + throw new Cta4jException(message); + } + + if ((locales == null) || locales.isEmpty()) { + return List.of(); + } + + return locales.stream() + .map(SupportedLocaleMapper.INSTANCE::toDomain) + .toList(); + } +} diff --git a/src/main/java/com/cta4j/bus/locale/internal/mapper/SupportedLocaleMapper.java b/src/main/java/com/cta4j/bus/locale/internal/mapper/SupportedLocaleMapper.java new file mode 100644 index 0000000..1011aff --- /dev/null +++ b/src/main/java/com/cta4j/bus/locale/internal/mapper/SupportedLocaleMapper.java @@ -0,0 +1,17 @@ +package com.cta4j.bus.locale.internal.mapper; + +import com.cta4j.bus.internal.mapper.Qualifiers; +import com.cta4j.bus.locale.internal.wire.CtaLocale; +import com.cta4j.bus.locale.model.SupportedLocale; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.factory.Mappers; + +@Mapper(uses = Qualifiers.class) +public interface SupportedLocaleMapper { + SupportedLocaleMapper INSTANCE = Mappers.getMapper(SupportedLocaleMapper.class); + + @Mapping(source = "localestring", target = "locale", qualifiedByName = "mapLocale") + @Mapping(source = "displayname", target = "displayName") + SupportedLocale toDomain(CtaLocale locale); +} diff --git a/src/main/java/com/cta4j/bus/locale/internal/wire/CtaLocale.java b/src/main/java/com/cta4j/bus/locale/internal/wire/CtaLocale.java new file mode 100644 index 0000000..53d3bdd --- /dev/null +++ b/src/main/java/com/cta4j/bus/locale/internal/wire/CtaLocale.java @@ -0,0 +1,21 @@ +package com.cta4j.bus.locale.internal.wire; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import org.jetbrains.annotations.ApiStatus; +import org.jspecify.annotations.NullMarked; + +import java.util.Objects; + +@NullMarked +@ApiStatus.Internal +@JsonIgnoreProperties(ignoreUnknown = true) +public record CtaLocale( + String localestring, + + String displayname +) { + public CtaLocale { + Objects.requireNonNull(localestring); + Objects.requireNonNull(displayname); + } +} diff --git a/src/main/java/com/cta4j/bus/locale/model/SupportedLocale.java b/src/main/java/com/cta4j/bus/locale/model/SupportedLocale.java new file mode 100644 index 0000000..7a3f072 --- /dev/null +++ b/src/main/java/com/cta4j/bus/locale/model/SupportedLocale.java @@ -0,0 +1,30 @@ +package com.cta4j.bus.locale.model; + +import org.jspecify.annotations.NullMarked; + +import java.util.Locale; +import java.util.Objects; + +/** + * Represents a locale supported by the CTA Bus API. + * + * @param locale the supported {@link Locale} + * @param displayName the human-readable name of the locale + */ +@NullMarked +public record SupportedLocale( + Locale locale, + + String displayName +) { + /** + * Creates a {@code SupportedLocale}. + * + * @param locale the supported {@link Locale} + * @param displayName the human-readable name of the locale + */ + public SupportedLocale { + Objects.requireNonNull(locale); + Objects.requireNonNull(displayName); + } +} diff --git a/src/main/java/com/cta4j/bus/mapper/BusCoordinatesMapper.java b/src/main/java/com/cta4j/bus/mapper/BusCoordinatesMapper.java deleted file mode 100644 index 6908f9e..0000000 --- a/src/main/java/com/cta4j/bus/mapper/BusCoordinatesMapper.java +++ /dev/null @@ -1,59 +0,0 @@ -package com.cta4j.bus.mapper; - -import com.cta4j.bus.external.vehicle.CtaVehicle; -import com.cta4j.bus.model.BusCoordinates; -import org.jetbrains.annotations.ApiStatus; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.math.BigDecimal; -import java.util.Objects; - -@ApiStatus.Internal -public final class BusCoordinatesMapper { - private static final Logger logger; - - static { - logger = LoggerFactory.getLogger(BusCoordinatesMapper.class); - } - - private BusCoordinatesMapper() { - throw new UnsupportedOperationException("This is a utility class and cannot be instantiated"); - } - - public static BusCoordinates fromExternal(CtaVehicle vehicle) { - Objects.requireNonNull(vehicle); - - BigDecimal latitude = null; - - if (vehicle.lat() != null) { - try { - latitude = new BigDecimal(vehicle.lat()); - } catch (NumberFormatException e) { - logger.warn("Invalid latitude value {}", vehicle.lat()); - } - } - - BigDecimal longitude = null; - - if (vehicle.lon() != null) { - try { - longitude = new BigDecimal(vehicle.lon()); - } catch (NumberFormatException e) { - logger.warn("Invalid longitude value {}", vehicle.lon()); - } - } - - Integer heading = null; - - if (vehicle.hdg() != null) { - try { - heading = Integer.parseInt(vehicle.hdg()); - } catch (NumberFormatException e) { - logger.warn("Invalid heading value {}", vehicle.hdg()); - } - } - - return new BusCoordinates(latitude, longitude, heading); - } -} diff --git a/src/main/java/com/cta4j/bus/mapper/BusPredictionTypeMapper.java b/src/main/java/com/cta4j/bus/mapper/BusPredictionTypeMapper.java deleted file mode 100644 index 994b283..0000000 --- a/src/main/java/com/cta4j/bus/mapper/BusPredictionTypeMapper.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.cta4j.bus.mapper; - -import com.cta4j.bus.model.BusPredictionType; -import org.jetbrains.annotations.ApiStatus; - -import java.util.Objects; - -@ApiStatus.Internal -public final class BusPredictionTypeMapper { - private BusPredictionTypeMapper() { - throw new UnsupportedOperationException("This is a utility class and cannot be instantiated"); - } - - public static BusPredictionType fromExternal(String string) { - Objects.requireNonNull(string); - - string = string.toUpperCase(); - - return switch (string) { - case "A" -> BusPredictionType.ARRIVAL; - case "D" -> BusPredictionType.DEPARTURE; - default -> { - String message = "A bus prediction type with the name \"%s\" does not exist".formatted(string); - - throw new IllegalArgumentException(message); - } - }; - } -} diff --git a/src/main/java/com/cta4j/bus/mapper/DetourMapper.java b/src/main/java/com/cta4j/bus/mapper/DetourMapper.java deleted file mode 100644 index 64bb4e7..0000000 --- a/src/main/java/com/cta4j/bus/mapper/DetourMapper.java +++ /dev/null @@ -1,76 +0,0 @@ -package com.cta4j.bus.mapper; - -import com.cta4j.bus.external.detour.CtaDetour; -import com.cta4j.bus.model.Detour; -import com.cta4j.bus.model.DetourRouteDirection; -import com.cta4j.util.DateTimeUtils; -import org.jetbrains.annotations.ApiStatus; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.time.Instant; -import java.time.format.DateTimeParseException; -import java.util.List; -import java.util.Objects; - -@ApiStatus.Internal -public final class DetourMapper { - private static final Logger logger; - - static { - logger = LoggerFactory.getLogger(DetourMapper.class); - } - - private DetourMapper() { - throw new UnsupportedOperationException("This is a utility class and cannot be instantiated"); - } - - public static Detour fromExternal(CtaDetour detour) { - Objects.requireNonNull(detour); - - Boolean active = null; - - if (detour.st() != null) { - active = "1".equals(detour.st()); - } - - List routeDirections = null; - - if (detour.rtdirs() != null) { - routeDirections = detour.rtdirs() - .stream() - .map(rd -> new DetourRouteDirection(rd.rt(), rd.dir())) - .toList(); - } - - Instant startTime = null; - - if (detour.startdt() != null) { - try { - startTime = DateTimeUtils.parseBusTimestamp(detour.startdt()); - } catch (DateTimeParseException e) { - logger.warn("Invalid start time value {}", detour.startdt()); - } - } - - Instant endTime = null; - - if (detour.enddt() != null) { - try { - endTime = DateTimeUtils.parseBusTimestamp(detour.enddt()); - } catch (DateTimeParseException e) { - logger.warn("Invalid end time value {}", detour.enddt()); - } - } - - return new Detour( - detour.id(), - detour.ver(), - active, - detour.desc(), - routeDirections, - startTime, - endTime - ); - } -} diff --git a/src/main/java/com/cta4j/bus/mapper/RouteMapper.java b/src/main/java/com/cta4j/bus/mapper/RouteMapper.java deleted file mode 100644 index e1901a8..0000000 --- a/src/main/java/com/cta4j/bus/mapper/RouteMapper.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.cta4j.bus.mapper; - -import com.cta4j.bus.external.route.CtaRoute; -import com.cta4j.bus.model.Route; -import org.jetbrains.annotations.ApiStatus; - -import java.util.Objects; - -@ApiStatus.Internal -public final class RouteMapper { - private RouteMapper() { - throw new UnsupportedOperationException("This is a utility class and cannot be instantiated"); - } - - public static Route fromExternal(CtaRoute route) { - Objects.requireNonNull(route); - - return new Route( - route.rt(), - route.rtnm() - ); - } -} diff --git a/src/main/java/com/cta4j/bus/mapper/StopArrivalMapper.java b/src/main/java/com/cta4j/bus/mapper/StopArrivalMapper.java deleted file mode 100644 index 1fc2204..0000000 --- a/src/main/java/com/cta4j/bus/mapper/StopArrivalMapper.java +++ /dev/null @@ -1,81 +0,0 @@ -package com.cta4j.bus.mapper; - -import com.cta4j.bus.external.prediction.CtaPredictionsPrd; -import com.cta4j.bus.model.BusPredictionType; -import com.cta4j.bus.model.StopArrival; -import com.cta4j.util.DateTimeUtils; -import org.jetbrains.annotations.ApiStatus; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.math.BigInteger; -import java.time.Instant; -import java.time.format.DateTimeParseException; -import java.util.Objects; - -@ApiStatus.Internal -public final class StopArrivalMapper { - private static final Logger logger; - - static { - logger = LoggerFactory.getLogger(StopArrivalMapper.class); - } - - private StopArrivalMapper() { - throw new UnsupportedOperationException("This is a utility class and cannot be instantiated"); - } - - public static StopArrival fromExternal(CtaPredictionsPrd prd) { - Objects.requireNonNull(prd); - - BusPredictionType type = null; - - if (prd.typ() != null) { - try { - type = BusPredictionTypeMapper.fromExternal(prd.typ()); - } catch (IllegalArgumentException e) { - logger.warn("Invalid bus prediction type {}", prd.typ()); - } - } - - BigInteger distanceToStop = null; - - if (prd.dstp() != null) { - try { - distanceToStop = new BigInteger(prd.dstp()); - } catch (NumberFormatException e) { - logger.warn("Invalid distance to stop value {}", prd.dstp()); - } - } - - Instant arrivalTime = null; - - if (prd.prdtm() != null) { - try { - arrivalTime = DateTimeUtils.parseBusTimestamp(prd.prdtm()); - } catch (DateTimeParseException e) { - logger.warn("Invalid arrival time value {}", prd.prdtm()); - } - } - - Boolean delayed = null; - - if (prd.dly() != null) { - delayed = Boolean.parseBoolean(prd.dly()); - } - - return new StopArrival( - type, - prd.stpnm(), - prd.stpid(), - prd.vid(), - distanceToStop, - prd.rt(), - prd.rtdd(), - prd.rtdir(), - prd.des(), - arrivalTime, - delayed - ); - } -} diff --git a/src/main/java/com/cta4j/bus/mapper/StopMapper.java b/src/main/java/com/cta4j/bus/mapper/StopMapper.java deleted file mode 100644 index 71897be..0000000 --- a/src/main/java/com/cta4j/bus/mapper/StopMapper.java +++ /dev/null @@ -1,54 +0,0 @@ -package com.cta4j.bus.mapper; - -import com.cta4j.bus.external.stop.CtaStop; -import com.cta4j.bus.model.Stop; -import org.jetbrains.annotations.ApiStatus; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.math.BigDecimal; -import java.util.Objects; - -@ApiStatus.Internal -public final class StopMapper { - private static final Logger logger; - - static { - logger = LoggerFactory.getLogger(StopMapper.class); - } - - private StopMapper() { - throw new UnsupportedOperationException("This is a utility class and cannot be instantiated"); - } - - public static Stop fromExternal(CtaStop stop) { - Objects.requireNonNull(stop); - - BigDecimal latitude = null; - - if (stop.lat() != null) { - try { - latitude = new BigDecimal(stop.lat()); - } catch (NumberFormatException e) { - logger.warn("Invalid latitude value {}", stop.lat()); - } - } - - BigDecimal longitude = null; - - if (stop.lon() != null) { - try { - longitude = new BigDecimal(stop.lon()); - } catch (NumberFormatException e) { - logger.warn("Invalid longitude value {}", stop.lon()); - } - } - - return new Stop( - stop.stpid(), - stop.stpnm(), - latitude, - longitude - ); - } -} diff --git a/src/main/java/com/cta4j/bus/mapper/UpcomingBusArrivalMapper.java b/src/main/java/com/cta4j/bus/mapper/UpcomingBusArrivalMapper.java deleted file mode 100644 index 0d24a43..0000000 --- a/src/main/java/com/cta4j/bus/mapper/UpcomingBusArrivalMapper.java +++ /dev/null @@ -1,81 +0,0 @@ -package com.cta4j.bus.mapper; - -import com.cta4j.bus.external.prediction.CtaPredictionsPrd; -import com.cta4j.bus.model.BusPredictionType; -import com.cta4j.bus.model.UpcomingBusArrival; -import com.cta4j.util.DateTimeUtils; -import org.jetbrains.annotations.ApiStatus; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.math.BigInteger; -import java.time.Instant; -import java.time.format.DateTimeParseException; -import java.util.Objects; - -@ApiStatus.Internal -public final class UpcomingBusArrivalMapper { - private static final Logger logger; - - static { - logger = LoggerFactory.getLogger(UpcomingBusArrivalMapper.class); - } - - private UpcomingBusArrivalMapper() { - throw new UnsupportedOperationException("This is a utility class and cannot be instantiated"); - } - - public static UpcomingBusArrival fromExternal(CtaPredictionsPrd prd) { - Objects.requireNonNull(prd); - - BusPredictionType type = null; - - if (prd.typ() != null) { - try { - type = BusPredictionTypeMapper.fromExternal(prd.typ()); - } catch (IllegalArgumentException e) { - logger.warn("Invalid bus prediction type {}", prd.typ()); - } - } - - BigInteger distanceToStop = null; - - if (prd.dstp() != null) { - try { - distanceToStop = new BigInteger(prd.dstp()); - } catch (NumberFormatException e) { - logger.warn("Invalid distance to stop value {}", prd.dstp()); - } - } - - Instant arrivalTime = null; - - if (prd.prdtm() != null) { - try { - arrivalTime = DateTimeUtils.parseBusTimestamp(prd.prdtm()); - } catch (DateTimeParseException e) { - logger.warn("Invalid arrival time value {}", prd.prdtm()); - } - } - - Boolean delayed = null; - - if (prd.dly() != null) { - delayed = Boolean.parseBoolean(prd.dly()); - } - - return new UpcomingBusArrival( - type, - prd.stpnm(), - prd.stpid(), - prd.vid(), - distanceToStop, - prd.rt(), - prd.rtdd(), - prd.rtdir(), - prd.des(), - arrivalTime, - delayed - ); - } -} diff --git a/src/main/java/com/cta4j/bus/model/Bus.java b/src/main/java/com/cta4j/bus/model/Bus.java deleted file mode 100644 index 534e2b7..0000000 --- a/src/main/java/com/cta4j/bus/model/Bus.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.cta4j.bus.model; - -import java.util.List; - -/** - * A bus currently in service. - * - * @param id the unique identifier of the bus - * @param coordinates the coordinates and heading of the bus - * @param arrivals the list of upcoming bus arrivals for the bus - * @param route the route identifier the bus is serving - * @param destination the destination of the bus - * @param delayed whether the bus is currently delayed - */ -public record Bus( - String id, - - String route, - - String destination, - - BusCoordinates coordinates, - - List arrivals, - - Boolean delayed -) { -} diff --git a/src/main/java/com/cta4j/bus/model/BusCoordinates.java b/src/main/java/com/cta4j/bus/model/BusCoordinates.java deleted file mode 100644 index f1b3539..0000000 --- a/src/main/java/com/cta4j/bus/model/BusCoordinates.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.cta4j.bus.model; - -import java.math.BigDecimal; - -/** - * The coordinates and heading of a bus. - * - * @param latitude the latitude of the bus's current location - * @param longitude the longitude of the bus's current location - * @param heading the heading of the bus in degrees (0-359) - */ -public record BusCoordinates( - BigDecimal latitude, - - BigDecimal longitude, - - Integer heading -) { -} diff --git a/src/main/java/com/cta4j/bus/model/BusPredictionType.java b/src/main/java/com/cta4j/bus/model/BusPredictionType.java deleted file mode 100644 index 5578b6f..0000000 --- a/src/main/java/com/cta4j/bus/model/BusPredictionType.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.cta4j.bus.model; - -/** - * A type of bus prediction, either an arrival or a departure. - */ -public enum BusPredictionType { - /** - * Indicates an arrival prediction. - */ - ARRIVAL, - - /** - * Indicates a departure prediction. - */ - DEPARTURE -} diff --git a/src/main/java/com/cta4j/bus/model/Detour.java b/src/main/java/com/cta4j/bus/model/Detour.java deleted file mode 100644 index 81e0319..0000000 --- a/src/main/java/com/cta4j/bus/model/Detour.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.cta4j.bus.model; - -import java.time.Instant; -import java.util.List; - -/** - * A detour affecting one or more bus routes. - * - * @param id the unique identifier of the detour - * @param version the version number of the detour - * @param active whether the detour is currently active - * @param description a description of the detour - * @param routeDirections the list of route directions affected by the detour - * @param startTime the start time of the detour - * @param endTime the end time of the detour - */ -public record Detour( - String id, - - String version, - - Boolean active, - - String description, - - List routeDirections, - - Instant startTime, - - Instant endTime -) { -} diff --git a/src/main/java/com/cta4j/bus/model/DetourRouteDirection.java b/src/main/java/com/cta4j/bus/model/DetourRouteDirection.java deleted file mode 100644 index 01dbd5f..0000000 --- a/src/main/java/com/cta4j/bus/model/DetourRouteDirection.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.cta4j.bus.model; - -/** - * A bus route and its direction affected by a detour. - * - * @param route the bus route identifier - * @param direction the direction of the bus route (e.g., "Northbound", "Southbound") - */ -public record DetourRouteDirection( - String route, - - String direction -) { -} diff --git a/src/main/java/com/cta4j/bus/model/Route.java b/src/main/java/com/cta4j/bus/model/Route.java deleted file mode 100644 index 885f561..0000000 --- a/src/main/java/com/cta4j/bus/model/Route.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.cta4j.bus.model; - -/** - * A bus route. - * - * @param id the unique identifier of the bus route - * @param name the name of the bus route - */ -public record Route( - String id, - - String name -) { -} diff --git a/src/main/java/com/cta4j/bus/model/Stop.java b/src/main/java/com/cta4j/bus/model/Stop.java deleted file mode 100644 index 965e97e..0000000 --- a/src/main/java/com/cta4j/bus/model/Stop.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.cta4j.bus.model; - -import java.math.BigDecimal; - -/** - * A bus stop. - * - * @param id the unique identifier of the bus stop - * @param name the name of the bus stop - * @param latitude the latitude coordinate of the bus stop - * @param longitude the longitude coordinate of the bus stop - */ -public record Stop( - String id, - - String name, - - BigDecimal latitude, - - BigDecimal longitude -) { -} diff --git a/src/main/java/com/cta4j/bus/model/StopArrival.java b/src/main/java/com/cta4j/bus/model/StopArrival.java deleted file mode 100644 index 3efa955..0000000 --- a/src/main/java/com/cta4j/bus/model/StopArrival.java +++ /dev/null @@ -1,59 +0,0 @@ -package com.cta4j.bus.model; - -import java.math.BigInteger; -import java.time.Duration; -import java.time.Instant; - -/** - * An arrival prediction for a bus at a specific stop. - * - * @param predictionType the type of prediction (arrival or departure) - * @param stopName the name of the bus stop - * @param stopId the unique identifier of the bus stop - * @param vehicleId the unique identifier of the bus vehicle - * @param distanceToStop the distance from the bus to the stop in feet - * @param route the bus route identifier - * @param routeDesignator additional designator for the route, if any - * @param routeDirection the direction of the bus route (e.g., Northbound, Southbound) - * @param destination the final destination of the bus - * @param arrivalTime the predicted arrival time at the stop - * @param delayed indicates whether the bus is delayed - */ -public record StopArrival( - BusPredictionType predictionType, - - String stopName, - - String stopId, - - String vehicleId, - - BigInteger distanceToStop, - - String route, - - String routeDesignator, - - String routeDirection, - - String destination, - - Instant arrivalTime, - - Boolean delayed -) { - /** - * Calculates the estimated time of arrival (ETA) in minutes from the current time to the predicted arrival time. - * If the arrival time is in the past, it returns 0. - * - * @return the ETA in minutes, or 0 if the arrival time is in the past - */ - public Long etaMinutes() { - Instant now = Instant.now(); - - long minutes = Duration.between(now, this.arrivalTime) - .toMinutes(); - - return Math.max(minutes, 0L); - } -} diff --git a/src/main/java/com/cta4j/bus/model/UpcomingBusArrival.java b/src/main/java/com/cta4j/bus/model/UpcomingBusArrival.java deleted file mode 100644 index a18e09d..0000000 --- a/src/main/java/com/cta4j/bus/model/UpcomingBusArrival.java +++ /dev/null @@ -1,59 +0,0 @@ -package com.cta4j.bus.model; - -import java.math.BigInteger; -import java.time.Duration; -import java.time.Instant; - -/** - * An upcoming arrival prediction for a bus at a stop. - * - * @param predictionType the type of prediction (arrival or departure) - * @param stopName the name of the bus stop - * @param stopId the unique identifier of the bus stop - * @param vehicleId the unique identifier of the bus vehicle - * @param distanceToStop the distance from the bus to the stop in feet - * @param route the bus route identifier - * @param routeDesignator additional designator for the route, if any - * @param routeDirection the direction of the bus route (e.g., Northbound, Southbound) - * @param destination the final destination of the bus - * @param arrivalTime the predicted arrival time at the stop - * @param delayed indicates whether the bus is delayed - */ -public record UpcomingBusArrival( - BusPredictionType predictionType, - - String stopName, - - String stopId, - - String vehicleId, - - BigInteger distanceToStop, - - String route, - - String routeDesignator, - - String routeDirection, - - String destination, - - Instant arrivalTime, - - Boolean delayed -) { - /** - * Calculates the estimated time of arrival (ETA) in minutes from the current time to the predicted arrival time. - * If the arrival time is in the past, it returns 0. - * - * @return the ETA in minutes, or 0 if the arrival time is in the past - */ - public Long etaMinutes() { - Instant now = Instant.now(); - - long minutes = Duration.between(now, this.arrivalTime) - .toMinutes(); - - return Math.max(minutes, 0L); - } -} diff --git a/src/main/java/com/cta4j/bus/pattern/PatternsApi.java b/src/main/java/com/cta4j/bus/pattern/PatternsApi.java new file mode 100644 index 0000000..0297334 --- /dev/null +++ b/src/main/java/com/cta4j/bus/pattern/PatternsApi.java @@ -0,0 +1,23 @@ +package com.cta4j.bus.pattern; + +import com.cta4j.bus.pattern.model.RoutePattern; +import org.jspecify.annotations.NullMarked; + +import java.util.Collection; +import java.util.List; +import java.util.Objects; + +@NullMarked +public interface PatternsApi { + List findByIds(Collection patternIds); + + default List findById(String patternId) { + Objects.requireNonNull(patternId); + + List ids = List.of(patternId); + + return this.findByIds(ids); + } + + List findByRouteId(String routeId); +} diff --git a/src/main/java/com/cta4j/bus/pattern/internal/impl/PatternsApiImpl.java b/src/main/java/com/cta4j/bus/pattern/internal/impl/PatternsApiImpl.java new file mode 100644 index 0000000..3b25636 --- /dev/null +++ b/src/main/java/com/cta4j/bus/pattern/internal/impl/PatternsApiImpl.java @@ -0,0 +1,116 @@ +package com.cta4j.bus.pattern.internal.impl; + +import com.cta4j.bus.internal.context.BusApiContext; +import com.cta4j.bus.internal.util.ApiUtils; +import com.cta4j.bus.pattern.PatternsApi; +import com.cta4j.bus.pattern.internal.wire.CtaPattern; +import com.cta4j.bus.pattern.internal.mapper.RoutePatternMapper; +import com.cta4j.bus.pattern.model.RoutePattern; +import com.cta4j.bus.internal.wire.CtaBustimeResponse; +import com.cta4j.bus.internal.wire.CtaError; +import com.cta4j.bus.internal.wire.CtaResponse; +import com.cta4j.exception.Cta4jException; +import com.cta4j.bus.internal.util.HttpUtils; +import org.apache.hc.core5.net.URIBuilder; +import org.jetbrains.annotations.ApiStatus; +import org.jspecify.annotations.NullMarked; +import tools.jackson.core.JacksonException; +import tools.jackson.core.type.TypeReference; + +import java.util.Collection; +import java.util.List; +import java.util.Objects; + +@NullMarked +@ApiStatus.Internal +public final class PatternsApiImpl implements PatternsApi { + private static final String PATTERNS_ENDPOINT = String.format("%s/getpatterns", ApiUtils.API_PREFIX); + private static final int MAX_PATTERN_IDS_PER_REQUEST = 10; + + private final BusApiContext context; + + public PatternsApiImpl(BusApiContext context) { + this.context = Objects.requireNonNull(context); + } + + @Override + public List findByIds(Collection patternIds) { + Objects.requireNonNull(patternIds); + + patternIds.forEach(Objects::requireNonNull); + + if (patternIds.size() > MAX_PATTERN_IDS_PER_REQUEST) { + String message = String.format( + "A maximum of %d pattern IDs can be requested at once, but %d were provided", + MAX_PATTERN_IDS_PER_REQUEST, + patternIds.size() + ); + + throw new IllegalArgumentException(message); + } + + String patternIdsString = String.join(",", patternIds); + + String url = new URIBuilder() + .setScheme(ApiUtils.SCHEME) + .setHost(this.context.host()) + .setPath(PATTERNS_ENDPOINT) + .addParameter("pid", patternIdsString) + .addParameter("key", this.context.apiKey()) + .addParameter("format", "json") + .toString(); + + return this.makeRequest(url); + } + + @Override + public List findByRouteId(String routeId) { + Objects.requireNonNull(routeId); + + String url = new URIBuilder() + .setScheme(ApiUtils.SCHEME) + .setHost(this.context.host()) + .setPath(PATTERNS_ENDPOINT) + .addParameter("rt", routeId) + .addParameter("key", this.context.apiKey()) + .addParameter("format", "json") + .toString(); + + return this.makeRequest(url); + } + + private List makeRequest(String url) { + String response = HttpUtils.get(url); + + TypeReference>> typeReference = new TypeReference<>() {}; + CtaResponse> patternsResponse; + + try { + patternsResponse = this.context.objectMapper() + .readValue(response, typeReference); + } catch (JacksonException e) { + String message = String.format("Failed to parse response from %s", PATTERNS_ENDPOINT); + + throw new Cta4jException(message, e); + } + + CtaBustimeResponse> bustimeResponse = patternsResponse.bustimeResponse(); + + List errors = bustimeResponse.error(); + List patterns = bustimeResponse.data(); + + if ((errors != null) && !errors.isEmpty()) { + String message = ApiUtils.buildErrorMessage(PATTERNS_ENDPOINT, errors); + + throw new Cta4jException(message); + } + + if ((patterns == null) || patterns.isEmpty()) { + return List.of(); + } + + return patterns.stream() + .map(RoutePatternMapper.INSTANCE::toDomain) + .toList(); + } +} diff --git a/src/main/java/com/cta4j/bus/pattern/internal/mapper/RoutePatternMapper.java b/src/main/java/com/cta4j/bus/pattern/internal/mapper/RoutePatternMapper.java new file mode 100644 index 0000000..0215f95 --- /dev/null +++ b/src/main/java/com/cta4j/bus/pattern/internal/mapper/RoutePatternMapper.java @@ -0,0 +1,34 @@ +package com.cta4j.bus.pattern.internal.mapper; + +import com.cta4j.bus.pattern.internal.wire.CtaPattern; +import com.cta4j.bus.pattern.internal.wire.CtaPoint; +import com.cta4j.bus.internal.mapper.Qualifiers; +import com.cta4j.bus.pattern.model.PatternPoint; +import com.cta4j.bus.pattern.model.RoutePattern; +import org.jetbrains.annotations.ApiStatus; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.factory.Mappers; + +@Mapper(uses = Qualifiers.class) +@ApiStatus.Internal +public interface RoutePatternMapper { + RoutePatternMapper INSTANCE = Mappers.getMapper(RoutePatternMapper.class); + + @Mapping(source = "pid", target = "id") + @Mapping(source = "ln", target = "length") + @Mapping(source = "rtdir", target = "direction") + @Mapping(source = "pt", target = "points") + @Mapping(source = "dtrid", target = "detourId") + @Mapping(source = "dtrpt", target = "detourPoints") + RoutePattern toDomain(CtaPattern pattern); + + @Mapping(source = "seq", target = "sequence") + @Mapping(source = "typ", target = "type", qualifiedByName = "mapPatternPointType") + @Mapping(source = "stpid", target = "stopId") + @Mapping(source = "stpnm", target = "stopName") + @Mapping(source = "pdist", target = "distanceToPatternPoint") + @Mapping(source = "lat", target = "latitude") + @Mapping(source = "lon", target = "longitude") + PatternPoint toDomain(CtaPoint point); +} diff --git a/src/main/java/com/cta4j/bus/pattern/internal/wire/CtaPattern.java b/src/main/java/com/cta4j/bus/pattern/internal/wire/CtaPattern.java new file mode 100644 index 0000000..362806d --- /dev/null +++ b/src/main/java/com/cta4j/bus/pattern/internal/wire/CtaPattern.java @@ -0,0 +1,43 @@ +package com.cta4j.bus.pattern.internal.wire; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import org.jetbrains.annotations.ApiStatus; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +import java.util.List; +import java.util.Objects; + +@NullMarked +@ApiStatus.Internal +@JsonIgnoreProperties(ignoreUnknown = true) +public record CtaPattern( + int pid, + + int ln, + + String rtdir, + + List pt, + + @Nullable + String dtrid, + + @Nullable + List dtrpt +) { + public CtaPattern { + Objects.requireNonNull(rtdir); + Objects.requireNonNull(pt); + + pt.forEach(Objects::requireNonNull); + + pt = List.copyOf(pt); + + if (dtrpt != null) { + dtrpt.forEach(Objects::requireNonNull); + + dtrpt = List.copyOf(dtrpt); + } + } +} diff --git a/src/main/java/com/cta4j/bus/pattern/internal/wire/CtaPoint.java b/src/main/java/com/cta4j/bus/pattern/internal/wire/CtaPoint.java new file mode 100644 index 0000000..07fd622 --- /dev/null +++ b/src/main/java/com/cta4j/bus/pattern/internal/wire/CtaPoint.java @@ -0,0 +1,34 @@ +package com.cta4j.bus.pattern.internal.wire; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import org.jetbrains.annotations.ApiStatus; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +import java.util.Objects; + +@NullMarked +@ApiStatus.Internal +@JsonIgnoreProperties(ignoreUnknown = true) +public record CtaPoint( + int seq, + + String typ, + + @Nullable + String stpid, + + @Nullable + String stpnm, + + @Nullable + Float pdist, + + double lat, + + double lon +) { + public CtaPoint { + Objects.requireNonNull(typ); + } +} diff --git a/src/main/java/com/cta4j/bus/pattern/model/PatternPoint.java b/src/main/java/com/cta4j/bus/pattern/model/PatternPoint.java new file mode 100644 index 0000000..f13a886 --- /dev/null +++ b/src/main/java/com/cta4j/bus/pattern/model/PatternPoint.java @@ -0,0 +1,55 @@ +package com.cta4j.bus.pattern.model; + +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +import java.math.BigDecimal; +import java.util.Objects; + +/** + * Represents a point in a bus route pattern. + * + * @param sequence the position of this point in the overall sequence of points + * @param type the type of pattern point + * @param stopId the identifier of the stop, if applicable + * @param stopName the name of the stop, if applicable + * @param distanceToPatternPoint the distance to the next pattern point, if applicable + * @param latitude the latitude coordinate of the pattern point + * @param longitude the longitude coordinate of the pattern point + */ +@NullMarked +public record PatternPoint( + int sequence, + + PatternPointType type, + + @Nullable + String stopId, + + @Nullable + String stopName, + + @Nullable + BigDecimal distanceToPatternPoint, + + BigDecimal latitude, + + BigDecimal longitude +) { + /** + * Constructs a {@code PatternPoint}. + * + * @param sequence the position of this point in the overall sequence of points + * @param type the type of pattern point + * @param stopId the identifier of the stop, if applicable + * @param stopName the name of the stop, if applicable + * @param distanceToPatternPoint the distance to the next pattern point, if applicable + * @param latitude the latitude coordinate of the pattern point + * @param longitude the longitude coordinate of the pattern point + */ + public PatternPoint { + Objects.requireNonNull(type); + Objects.requireNonNull(latitude); + Objects.requireNonNull(longitude); + } +} diff --git a/src/main/java/com/cta4j/bus/pattern/model/PatternPointType.java b/src/main/java/com/cta4j/bus/pattern/model/PatternPointType.java new file mode 100644 index 0000000..17d504e --- /dev/null +++ b/src/main/java/com/cta4j/bus/pattern/model/PatternPointType.java @@ -0,0 +1,16 @@ +package com.cta4j.bus.pattern.model; + +/** + * Represents the type of point within a route or pattern geometry. + */ +public enum PatternPointType { + /** + * Indicates a stop along the route. + */ + STOP, + + /** + * Indicates a waypoint along the route. + */ + WAYPOINT +} diff --git a/src/main/java/com/cta4j/bus/pattern/model/RoutePattern.java b/src/main/java/com/cta4j/bus/pattern/model/RoutePattern.java new file mode 100644 index 0000000..daf9844 --- /dev/null +++ b/src/main/java/com/cta4j/bus/pattern/model/RoutePattern.java @@ -0,0 +1,60 @@ +package com.cta4j.bus.pattern.model; + +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +import java.util.List; +import java.util.Objects; + +/** + * Represents a bus route pattern. + * + * @param id the unique identifier of the route pattern + * @param length the length of the route pattern in feet + * @param direction the direction of the route pattern (e.g., "Northbound", "Southbound") + * @param points the list of pattern points that make up the route pattern + * @param detourId the identifier of the detour, if applicable + * @param detourPoints the list of pattern points for the detour, if applicable + */ +@NullMarked +public record RoutePattern( + String id, + + int length, + + String direction, + + List points, + + @Nullable + String detourId, + + @Nullable + List detourPoints +) { + /** + * Constructs a {@code RoutePattern}. + * + * @param id the unique identifier of the route pattern + * @param length the length of the route pattern in feet + * @param direction the direction of the route pattern (e.g., "Northbound", "Southbound") + * @param points the list of pattern points that make up the route pattern + * @param detourId the identifier of the detour, if applicable + * @param detourPoints the list of pattern points for the detour, if applicable + */ + public RoutePattern { + Objects.requireNonNull(id); + Objects.requireNonNull(direction); + Objects.requireNonNull(points); + + points.forEach(Objects::requireNonNull); + + points = List.copyOf(points); + + if (detourPoints != null) { + detourPoints.forEach(Objects::requireNonNull); + + detourPoints = List.copyOf(detourPoints); + } + } +} diff --git a/src/main/java/com/cta4j/bus/prediction/PredictionsApi.java b/src/main/java/com/cta4j/bus/prediction/PredictionsApi.java new file mode 100644 index 0000000..a81e842 --- /dev/null +++ b/src/main/java/com/cta4j/bus/prediction/PredictionsApi.java @@ -0,0 +1,41 @@ +package com.cta4j.bus.prediction; + +import com.cta4j.bus.prediction.model.Prediction; +import com.cta4j.bus.prediction.query.StopsPredictionsQuery; +import com.cta4j.bus.prediction.query.VehiclesPredictionsQuery; +import org.jspecify.annotations.NullMarked; + +import java.util.List; +import java.util.Objects; + +@NullMarked +public interface PredictionsApi { + List findByStopIds(StopsPredictionsQuery query); + + List findByVehicleIds(VehiclesPredictionsQuery query); + + default List findByRouteIdAndStopId(String routeId, String stopId) { + Objects.requireNonNull(routeId); + Objects.requireNonNull(stopId); + + List stopIds = List.of(stopId); + List routeIds = List.of(routeId); + + StopsPredictionsQuery query = StopsPredictionsQuery.builder(stopIds) + .routeIds(routeIds) + .build(); + + return this.findByStopIds(query); + } + + default List findByVehicleId(String vehicleId) { + Objects.requireNonNull(vehicleId); + + List vehicleIds = List.of(vehicleId); + + VehiclesPredictionsQuery query = VehiclesPredictionsQuery.builder(vehicleIds) + .build(); + + return this.findByVehicleIds(query); + } +} diff --git a/src/main/java/com/cta4j/bus/prediction/internal/impl/PredictionsApiImpl.java b/src/main/java/com/cta4j/bus/prediction/internal/impl/PredictionsApiImpl.java new file mode 100644 index 0000000..c94dbb7 --- /dev/null +++ b/src/main/java/com/cta4j/bus/prediction/internal/impl/PredictionsApiImpl.java @@ -0,0 +1,154 @@ +package com.cta4j.bus.prediction.internal.impl; + +import com.cta4j.bus.internal.context.BusApiContext; +import com.cta4j.bus.internal.util.ApiUtils; +import com.cta4j.bus.prediction.PredictionsApi; +import com.cta4j.bus.prediction.internal.wire.CtaPrediction; +import com.cta4j.bus.prediction.internal.mapper.PredictionMapper; +import com.cta4j.bus.prediction.model.Prediction; +import com.cta4j.bus.prediction.query.StopsPredictionsQuery; +import com.cta4j.bus.prediction.query.VehiclesPredictionsQuery; +import com.cta4j.bus.internal.wire.CtaBustimeResponse; +import com.cta4j.bus.internal.wire.CtaError; +import com.cta4j.bus.internal.wire.CtaResponse; +import com.cta4j.exception.Cta4jException; +import com.cta4j.bus.internal.util.HttpUtils; +import org.apache.hc.core5.net.URIBuilder; +import org.jetbrains.annotations.ApiStatus; +import org.jspecify.annotations.NullMarked; +import tools.jackson.core.JacksonException; +import tools.jackson.core.type.TypeReference; + +import java.util.List; +import java.util.Objects; + +@NullMarked +@ApiStatus.Internal +public final class PredictionsApiImpl implements PredictionsApi { + private static final String PREDICTIONS_ENDPOINT = String.format("%s/getpredictions", ApiUtils.API_PREFIX); + private static final int MAX_STOP_IDS_PER_REQUEST = 10; + private static final int MAX_VEHICLE_IDS_PER_REQUEST = 10; + + private final BusApiContext context; + + public PredictionsApiImpl(BusApiContext context) { + this.context = Objects.requireNonNull(context); + } + + @Override + public List findByStopIds(StopsPredictionsQuery query) { + Objects.requireNonNull(query); + + List stopIds = query.stopIds(); + + if (stopIds.size() > MAX_STOP_IDS_PER_REQUEST) { + String message = String.format( + "A maximum of %d stop IDs can be requested at once, but %d were provided", + MAX_STOP_IDS_PER_REQUEST, + stopIds.size() + ); + + throw new IllegalArgumentException(message); + } + + String stopIdsString = String.join(",", stopIds); + + URIBuilder builder = new URIBuilder() + .setScheme(ApiUtils.SCHEME) + .setHost(this.context.host()) + .setPath(PREDICTIONS_ENDPOINT) + .addParameter("stpid", stopIdsString) + .addParameter("tmres", "s") + .addParameter("key", this.context.apiKey()) + .addParameter("format", "json"); + + if (query.routeIds() != null) { + String routeIdsString = String.join(",", query.routeIds()); + + builder.addParameter("rt", routeIdsString); + } + + if (query.maxResults() != null) { + String maxResultsString = String.valueOf(query.maxResults()); + + builder.addParameter("top", maxResultsString); + } + + String url = builder.toString(); + + return this.makeRequest(url); + } + + @Override + public List findByVehicleIds(VehiclesPredictionsQuery query) { + Objects.requireNonNull(query); + + List vehicleIds = query.vehicleIds(); + + if (vehicleIds.size() > MAX_VEHICLE_IDS_PER_REQUEST) { + String message = String.format( + "A maximum of %d vehicle IDs can be requested at once, but %d were provided", + MAX_STOP_IDS_PER_REQUEST, + vehicleIds.size() + ); + + throw new IllegalArgumentException(message); + } + + String vehicleIdsString = String.join(",", vehicleIds); + + URIBuilder builder = new URIBuilder() + .setScheme(ApiUtils.SCHEME) + .setHost(this.context.host()) + .setPath(PREDICTIONS_ENDPOINT) + .addParameter("vid", vehicleIdsString) + .addParameter("tmres", "s") + .addParameter("key", this.context.apiKey()) + .addParameter("format", "json"); + + if (query.maxResults() != null) { + String maxResultsString = String.valueOf(query.maxResults()); + + builder.addParameter("top", maxResultsString); + } + + String url = builder.toString(); + + return this.makeRequest(url); + } + + private List makeRequest(String url) { + String response = HttpUtils.get(url); + + TypeReference>> typeReference = new TypeReference<>() {}; + CtaResponse> predictionsResponse; + + try { + predictionsResponse = this.context.objectMapper() + .readValue(response, typeReference); + } catch (JacksonException e) { + String message = String.format("Failed to parse response from %s", PREDICTIONS_ENDPOINT); + + throw new Cta4jException(message, e); + } + + CtaBustimeResponse> bustimeResponse = predictionsResponse.bustimeResponse(); + + List errors = bustimeResponse.error(); + List predictions = bustimeResponse.data(); + + if ((errors != null) && !errors.isEmpty()) { + String message = ApiUtils.buildErrorMessage(PREDICTIONS_ENDPOINT, errors); + + throw new Cta4jException(message); + } + + if ((predictions == null) || predictions.isEmpty()) { + return List.of(); + } + + return predictions.stream() + .map(PredictionMapper.INSTANCE::toDomain) + .toList(); + } +} diff --git a/src/main/java/com/cta4j/bus/prediction/internal/mapper/PredictionMapper.java b/src/main/java/com/cta4j/bus/prediction/internal/mapper/PredictionMapper.java new file mode 100644 index 0000000..a13f2d7 --- /dev/null +++ b/src/main/java/com/cta4j/bus/prediction/internal/mapper/PredictionMapper.java @@ -0,0 +1,40 @@ +package com.cta4j.bus.prediction.internal.mapper; + +import com.cta4j.bus.prediction.internal.wire.CtaPrediction; +import com.cta4j.bus.internal.mapper.Qualifiers; +import com.cta4j.bus.prediction.model.Prediction; +import org.jetbrains.annotations.ApiStatus; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.factory.Mappers; + +@Mapper(uses = Qualifiers.class) +@ApiStatus.Internal +public interface PredictionMapper { + PredictionMapper INSTANCE = Mappers.getMapper(PredictionMapper.class); + + @Mapping(source = "typ", target = "predictionType", qualifiedByName = "mapPredictionType") + @Mapping(source = "stpid", target = "stopId") + @Mapping(source = "stpnm", target = "stopName") + @Mapping(source = "vid", target = "vehicleId") + @Mapping(source = "dstp", target = "distanceToStop") + @Mapping(source = "rt", target = "route") + @Mapping(source = "rtdd", target = "routeDesignator") + @Mapping(source = "rtdir", target = "routeDirection") + @Mapping(source = "des", target = "destination") + @Mapping(source = "prdtm", target = "arrivalTime", qualifiedByName = "mapTimestamp") + @Mapping(source = "dly", target = "delayed") + @Mapping(source = "tmstmp", target = "metadata.timestamp", qualifiedByName = "mapTimestamp") + @Mapping(source = "dyn", target = "metadata.dynamicAction", qualifiedByName = "mapDynamicAction") + @Mapping(source = "tablockid", target = "metadata.blockId") + @Mapping(source = "tatripid", target = "metadata.tripId") + @Mapping(source = "origtatripno", target = "metadata.originalTripNumber") + @Mapping(source = "zone", target = "metadata.zone") + @Mapping(source = "psgld", target = "metadata.passengerLoad", qualifiedByName = "mapPassengerLoad") + @Mapping(source = "gtfsseq", target = "metadata.gtfsSequence") + @Mapping(source = "nbus", target = "metadata.nextBus") + @Mapping(source = "stst", target = "metadata.scheduledStartSeconds") + @Mapping(source = "stsd", target = "metadata.scheduledStartDate") + @Mapping(source = "flagstop", target = "metadata.flagStop", qualifiedByName = "mapFlagStop") + Prediction toDomain(CtaPrediction prediction); +} diff --git a/src/main/java/com/cta4j/bus/prediction/internal/wire/CtaPrediction.java b/src/main/java/com/cta4j/bus/prediction/internal/wire/CtaPrediction.java new file mode 100644 index 0000000..67046d6 --- /dev/null +++ b/src/main/java/com/cta4j/bus/prediction/internal/wire/CtaPrediction.java @@ -0,0 +1,81 @@ +package com.cta4j.bus.prediction.internal.wire; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import org.jetbrains.annotations.ApiStatus; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +import java.util.Objects; + +@NullMarked +@ApiStatus.Internal +@JsonIgnoreProperties(ignoreUnknown = true) +public record CtaPrediction( + String tmstmp, + + String typ, + + String stpid, + + String stpnm, + + int vid, + + int dstp, + + String rt, + + String rtdd, + + String rtdir, + + String des, + + String prdtm, + + @Nullable + Boolean dly, + + int dyn, + + String tablockid, + + String tatripid, + + String origtatripno, + + String zone, + + String psgld, + + @Nullable + Integer gtfsseq, + + @Nullable + String nbus, + + @Nullable + Integer stst, + + @Nullable + String stsd, + + int flagstop +) { + public CtaPrediction { + Objects.requireNonNull(tmstmp); + Objects.requireNonNull(typ); + Objects.requireNonNull(stpid); + Objects.requireNonNull(stpnm); + Objects.requireNonNull(rt); + Objects.requireNonNull(rtdd); + Objects.requireNonNull(rtdir); + Objects.requireNonNull(des); + Objects.requireNonNull(prdtm); + Objects.requireNonNull(tablockid); + Objects.requireNonNull(tatripid); + Objects.requireNonNull(origtatripno); + Objects.requireNonNull(zone); + Objects.requireNonNull(psgld); + } +} diff --git a/src/main/java/com/cta4j/bus/prediction/model/DynamicAction.java b/src/main/java/com/cta4j/bus/prediction/model/DynamicAction.java new file mode 100644 index 0000000..42640d1 --- /dev/null +++ b/src/main/java/com/cta4j/bus/prediction/model/DynamicAction.java @@ -0,0 +1,119 @@ +package com.cta4j.bus.prediction.model; + +/** + * Represents the various dynamic actions that can be applied to a bus trip. + */ +public enum DynamicAction { + /** + * Indicates that no dynamic action has been applied. + */ + NONE(0), + + /** + * Indicates that the event or trip has been canceled. + */ + CANCELLED(1), + + /** + * Indicates that the event or trip will be handled by a different vehicle or operator. + */ + REASSIGNED(2), + + /** + * Indicates that the time of the event, or the entire trip, has been moved. + */ + SHIFTED(3), + + /** + * Indicates that the event is “drop-off only” and will not stop to pick up passengers. + */ + EXPRESSED(4), + + /** + * Indicates that the trip has events that are affected by Disruption Management changes, but the trip itself is + * not affected. + */ + STOPS_AFFECTED(6), + + /** + * Indicates that the trip was created dynamically and does not appear in the TA schedule. + */ + NEW_TRIP(8), + + /** + * Indicates one of the following: + *

    + *
  • + * The trip has been split, and this part of the split is using the original trip identifier(s). + *
  • + *
  • + * The trip has been short-turned leading to the removal of short-turned stops from the trip resulting in + * the trip being partial. + *
  • + *
+ */ + PARTIAL_TRIP(9), + + /** + * Indicates the trip has been split, and this part of the split has been assigned a new trip identifier(s). + */ + PARTIAL_TRIP_NEW(10), + + /** + * Indicates that the event or trip has been marked as canceled, but the cancellation should not be shown to the + * public. + */ + DELAYED_CANCEL(12), + + /** + * Indicates that event has been added to the trip. It was not originally scheduled. + */ + ADDED_STOP(13), + + /** + * Indicates that the trip has been affected by a delay. + */ + UNKNOWN_DELAY(14), + + /** + * Indicates that the trip, which was created dynamically, has been affected by a delay. + */ + UNKNOWN_DELAY_NEW(15), + + /** + * Indicates that the trip has been invalidated. Predictions for it should not be shown to the public. + */ + INVALIDATED_TRIP(16), + + /** + * Indicates that the trip, which was created dynamically, has been invalidated. Predictions for it should not be + * shown to the public. + */ + INVALIDATED_TRIP_NEW(17), + + /** + * Indicates that the trip, which was created dynamically, has been canceled. + */ + CANCELLED_TRIP_NEW(18), + + /** + * Indicates that the trip, which was created dynamically, has events that are affected by Disruption Management + * changes, but the trip itself is not affected. + */ + STOPS_AFFECTED_NEW(19); + + private final int code; + + DynamicAction(int code) { + this.code = code; + } + + /** + * Gets the code associated with this dynamic action. + * + * @return the dynamic action code + */ + public int getCode() { + return this.code; + } +} diff --git a/src/main/java/com/cta4j/bus/prediction/model/FlagStop.java b/src/main/java/com/cta4j/bus/prediction/model/FlagStop.java new file mode 100644 index 0000000..1ca7774 --- /dev/null +++ b/src/main/java/com/cta4j/bus/prediction/model/FlagStop.java @@ -0,0 +1,21 @@ +package com.cta4j.bus.prediction.model; + +public enum FlagStop { + UNDEFINED(-1), + + NORMAL(0), + + PICKUP_AND_DISCHARGE(1), + + ONLY_DISCHARGE(2); + + private final int code; + + FlagStop(int code) { + this.code = code; + } + + public int getCode() { + return this.code; + } +} diff --git a/src/main/java/com/cta4j/bus/prediction/model/PassengerLoad.java b/src/main/java/com/cta4j/bus/prediction/model/PassengerLoad.java new file mode 100644 index 0000000..018e40c --- /dev/null +++ b/src/main/java/com/cta4j/bus/prediction/model/PassengerLoad.java @@ -0,0 +1,11 @@ +package com.cta4j.bus.prediction.model; + +public enum PassengerLoad { + FULL, + + HALF_EMPTY, + + EMPTY, + + UNKNOWN +} diff --git a/src/main/java/com/cta4j/bus/prediction/model/Prediction.java b/src/main/java/com/cta4j/bus/prediction/model/Prediction.java new file mode 100644 index 0000000..b2ce3eb --- /dev/null +++ b/src/main/java/com/cta4j/bus/prediction/model/Prediction.java @@ -0,0 +1,60 @@ +package com.cta4j.bus.prediction.model; + +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +import java.math.BigInteger; +import java.time.Duration; +import java.time.Instant; +import java.util.Objects; + +@NullMarked +public record Prediction( + PredictionType predictionType, + + String stopId, + + String stopName, + + String vehicleId, + + BigInteger distanceToStop, + + String route, + + String routeDesignator, + + String routeDirection, + + String destination, + + Instant arrivalTime, + + @Nullable + Boolean delayed, + + PredictionMetadata metadata +) { + public Prediction { + Objects.requireNonNull(predictionType); + Objects.requireNonNull(stopId); + Objects.requireNonNull(stopName); + Objects.requireNonNull(vehicleId); + Objects.requireNonNull(distanceToStop); + Objects.requireNonNull(route); + Objects.requireNonNull(routeDesignator); + Objects.requireNonNull(routeDirection); + Objects.requireNonNull(destination); + Objects.requireNonNull(arrivalTime); + Objects.requireNonNull(metadata); + } + + public long etaMinutes() { + Instant now = Instant.now(); + + long minutes = Duration.between(now, this.arrivalTime) + .toMinutes(); + + return Math.max(minutes, 0L); + } +} diff --git a/src/main/java/com/cta4j/bus/prediction/model/PredictionMetadata.java b/src/main/java/com/cta4j/bus/prediction/model/PredictionMetadata.java new file mode 100644 index 0000000..9dc8a13 --- /dev/null +++ b/src/main/java/com/cta4j/bus/prediction/model/PredictionMetadata.java @@ -0,0 +1,49 @@ +package com.cta4j.bus.prediction.model; + +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +import java.time.Instant; +import java.util.Objects; + +@NullMarked +public record PredictionMetadata( + Instant timestamp, + + DynamicAction dynamicAction, + + String blockId, + + String tripId, + + String originalTripNumber, + + String zone, + + PassengerLoad passengerLoad, + + @Nullable + Integer gtfsSequence, + + @Nullable + String nextBus, + + @Nullable + Integer scheduledStartSeconds, + + @Nullable + String scheduledStartDate, + + FlagStop flagStop +) { + public PredictionMetadata { + Objects.requireNonNull(timestamp); + Objects.requireNonNull(dynamicAction); + Objects.requireNonNull(blockId); + Objects.requireNonNull(tripId); + Objects.requireNonNull(originalTripNumber); + Objects.requireNonNull(zone); + Objects.requireNonNull(passengerLoad); + Objects.requireNonNull(flagStop); + } +} diff --git a/src/main/java/com/cta4j/bus/prediction/model/PredictionType.java b/src/main/java/com/cta4j/bus/prediction/model/PredictionType.java new file mode 100644 index 0000000..87c52f3 --- /dev/null +++ b/src/main/java/com/cta4j/bus/prediction/model/PredictionType.java @@ -0,0 +1,7 @@ +package com.cta4j.bus.prediction.model; + +public enum PredictionType { + ARRIVAL, + + DEPARTURE +} diff --git a/src/main/java/com/cta4j/bus/prediction/query/StopsPredictionsQuery.java b/src/main/java/com/cta4j/bus/prediction/query/StopsPredictionsQuery.java new file mode 100644 index 0000000..5606a5b --- /dev/null +++ b/src/main/java/com/cta4j/bus/prediction/query/StopsPredictionsQuery.java @@ -0,0 +1,94 @@ +package com.cta4j.bus.prediction.query; + +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +import java.util.List; +import java.util.Objects; + +@NullMarked +public record StopsPredictionsQuery( + List stopIds, + + @Nullable + List routeIds, + + @Nullable + Integer maxResults +) { + public StopsPredictionsQuery { + Objects.requireNonNull(stopIds); + + stopIds.forEach(Objects::requireNonNull); + + stopIds = List.copyOf(stopIds); + + if (routeIds != null) { + routeIds.forEach(Objects::requireNonNull); + + routeIds = List.copyOf(routeIds); + } + + if ((maxResults != null) && (maxResults <= 0)) { + throw new IllegalArgumentException("maxResults must be positive"); + } + } + + public static Builder builder(List stopIds) { + Objects.requireNonNull(stopIds); + + stopIds.forEach(Objects::requireNonNull); + + stopIds = List.copyOf(stopIds); + + return new Builder(stopIds); + } + + public static final class Builder { + private final List stopIds; + + @Nullable + private List routeIds; + + @Nullable + private Integer maxResults; + + public Builder(List stopIds) { + Objects.requireNonNull(stopIds); + + stopIds.forEach(Objects::requireNonNull); + + this.stopIds = List.copyOf(stopIds); + } + + public Builder routeIds(List routeIds) { + Objects.requireNonNull(routeIds); + + routeIds.forEach(Objects::requireNonNull); + + this.routeIds = List.copyOf(routeIds); + + return this; + } + + public Builder maxResults(Integer maxResults) { + Objects.requireNonNull(maxResults); + + if (maxResults <= 0) { + throw new IllegalArgumentException("maxResults must be positive"); + } + + this.maxResults = maxResults; + + return this; + } + + public StopsPredictionsQuery build() { + return new StopsPredictionsQuery( + this.stopIds, + this.routeIds, + this.maxResults + ); + } + } +} diff --git a/src/main/java/com/cta4j/bus/prediction/query/VehiclesPredictionsQuery.java b/src/main/java/com/cta4j/bus/prediction/query/VehiclesPredictionsQuery.java new file mode 100644 index 0000000..4fb4182 --- /dev/null +++ b/src/main/java/com/cta4j/bus/prediction/query/VehiclesPredictionsQuery.java @@ -0,0 +1,69 @@ +package com.cta4j.bus.prediction.query; + +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +import java.util.List; +import java.util.Objects; + +@NullMarked +public record VehiclesPredictionsQuery( + List vehicleIds, + + @Nullable + Integer maxResults +) { + public VehiclesPredictionsQuery { + Objects.requireNonNull(vehicleIds); + + vehicleIds.forEach(Objects::requireNonNull); + + vehicleIds = List.copyOf(vehicleIds); + + if ((maxResults != null) && (maxResults <= 0)) { + throw new IllegalArgumentException("maxResults must be positive"); + } + } + + public static Builder builder(List stopIds) { + Objects.requireNonNull(stopIds); + + stopIds.forEach(Objects::requireNonNull); + + return new Builder(stopIds); + } + + public static final class Builder { + private final List vehicleIds; + + @Nullable + private Integer maxResults; + + public Builder(List vehicleIds) { + Objects.requireNonNull(vehicleIds); + + vehicleIds.forEach(Objects::requireNonNull); + + this.vehicleIds = List.copyOf(vehicleIds); + } + + public Builder maxResults(Integer maxResults) { + Objects.requireNonNull(maxResults); + + if (maxResults <= 0) { + throw new IllegalArgumentException("maxResults must be positive"); + } + + this.maxResults = maxResults; + + return this; + } + + public VehiclesPredictionsQuery build() { + return new VehiclesPredictionsQuery( + this.vehicleIds, + this.maxResults + ); + } + } +} diff --git a/src/main/java/com/cta4j/bus/route/RoutesApi.java b/src/main/java/com/cta4j/bus/route/RoutesApi.java new file mode 100644 index 0000000..ca1615a --- /dev/null +++ b/src/main/java/com/cta4j/bus/route/RoutesApi.java @@ -0,0 +1,11 @@ +package com.cta4j.bus.route; + +import com.cta4j.bus.route.model.Route; +import org.jspecify.annotations.NullMarked; + +import java.util.List; + +@NullMarked +public interface RoutesApi { + List list(); +} diff --git a/src/main/java/com/cta4j/bus/route/internal/impl/RoutesApiImpl.java b/src/main/java/com/cta4j/bus/route/internal/impl/RoutesApiImpl.java new file mode 100644 index 0000000..b10a1a7 --- /dev/null +++ b/src/main/java/com/cta4j/bus/route/internal/impl/RoutesApiImpl.java @@ -0,0 +1,87 @@ +package com.cta4j.bus.route.internal.impl; + +import com.cta4j.bus.internal.context.BusApiContext; +import com.cta4j.bus.internal.util.ApiUtils; +import com.cta4j.bus.route.RoutesApi; +import com.cta4j.bus.route.internal.wire.CtaRoute; +import com.cta4j.bus.route.internal.mapper.RouteMapper; +import com.cta4j.bus.route.model.Route; +import com.cta4j.bus.internal.wire.CtaBustimeResponse; +import com.cta4j.bus.internal.wire.CtaError; +import com.cta4j.bus.internal.wire.CtaResponse; +import com.cta4j.exception.Cta4jException; +import com.cta4j.bus.internal.util.HttpUtils; +import org.apache.hc.core5.net.URIBuilder; +import org.jetbrains.annotations.ApiStatus; +import org.jspecify.annotations.NullMarked; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import tools.jackson.core.JacksonException; +import tools.jackson.core.type.TypeReference; + +import java.util.List; +import java.util.Objects; + +@NullMarked +@ApiStatus.Internal +public final class RoutesApiImpl implements RoutesApi { + private static final Logger log = LoggerFactory.getLogger(RoutesApiImpl.class); + + private static final String ROUTES_ENDPOINT = String.format("%s/getroutes", ApiUtils.API_PREFIX); + + private final BusApiContext context; + + public RoutesApiImpl(BusApiContext context) { + this.context = Objects.requireNonNull(context); + } + + @Override + public List list() { + String url = new URIBuilder() + .setScheme(ApiUtils.SCHEME) + .setHost(this.context.host()) + .setPath(ROUTES_ENDPOINT) + .addParameter("key", this.context.apiKey()) + .addParameter("format", "json") + .toString(); + + String response = HttpUtils.get(url); + + TypeReference>> typeReference = new TypeReference<>() {}; + CtaResponse> routesResponse; + + try { + routesResponse = this.context.objectMapper() + .readValue(response, typeReference); + } catch (JacksonException e) { + String message = String.format("Failed to parse response from %s", ROUTES_ENDPOINT); + + throw new Cta4jException(message, e); + } + + CtaBustimeResponse> bustimeResponse = routesResponse.bustimeResponse(); + + List errors = bustimeResponse.error(); + List routes = bustimeResponse.data(); + + if ((errors == null) && (routes == null)) { + log.debug("Routes bustime response missing both error and data from {}", ROUTES_ENDPOINT); + + return List.of(); + } + + if ((errors != null) && !errors.isEmpty()) { + String message = ApiUtils.buildErrorMessage(ROUTES_ENDPOINT, errors); + + throw new Cta4jException(message); + } + + if ((routes == null) || routes.isEmpty()) { + return List.of(); + } + + return routes.stream() + .map(RouteMapper.INSTANCE::toDomain) + .toList(); + } +} diff --git a/src/main/java/com/cta4j/bus/route/internal/mapper/RouteMapper.java b/src/main/java/com/cta4j/bus/route/internal/mapper/RouteMapper.java new file mode 100644 index 0000000..a93a077 --- /dev/null +++ b/src/main/java/com/cta4j/bus/route/internal/mapper/RouteMapper.java @@ -0,0 +1,21 @@ +package com.cta4j.bus.route.internal.mapper; + +import com.cta4j.bus.route.internal.wire.CtaRoute; +import com.cta4j.bus.route.model.Route; +import org.jetbrains.annotations.ApiStatus; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.factory.Mappers; + +@Mapper +@ApiStatus.Internal +public interface RouteMapper { + RouteMapper INSTANCE = Mappers.getMapper(RouteMapper.class); + + @Mapping(source = "rt", target = "id") + @Mapping(source = "rtnm", target = "name") + @Mapping(source = "rtclr", target = "color") + @Mapping(source = "rtdd", target = "designator") + @Mapping(source = "rtpidatafeed", target = "dataFeed") + Route toDomain(CtaRoute route); +} diff --git a/src/main/java/com/cta4j/bus/route/internal/wire/CtaRoute.java b/src/main/java/com/cta4j/bus/route/internal/wire/CtaRoute.java new file mode 100644 index 0000000..a77ab89 --- /dev/null +++ b/src/main/java/com/cta4j/bus/route/internal/wire/CtaRoute.java @@ -0,0 +1,31 @@ +package com.cta4j.bus.route.internal.wire; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import org.jetbrains.annotations.ApiStatus; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +import java.util.Objects; + +@NullMarked +@ApiStatus.Internal +@JsonIgnoreProperties(ignoreUnknown = true) +public record CtaRoute( + String rt, + + String rtnm, + + String rtclr, + + String rtdd, + + @Nullable + String rtpidatafeed +) { + public CtaRoute { + Objects.requireNonNull(rt); + Objects.requireNonNull(rtnm); + Objects.requireNonNull(rtclr); + Objects.requireNonNull(rtdd); + } +} diff --git a/src/main/java/com/cta4j/bus/route/model/Route.java b/src/main/java/com/cta4j/bus/route/model/Route.java new file mode 100644 index 0000000..99a9194 --- /dev/null +++ b/src/main/java/com/cta4j/bus/route/model/Route.java @@ -0,0 +1,27 @@ +package com.cta4j.bus.route.model; + +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +import java.util.Objects; + +@NullMarked +public record Route( + String id, + + String name, + + String color, + + String designator, + + @Nullable + String dataFeed +) { + public Route { + Objects.requireNonNull(id); + Objects.requireNonNull(name); + Objects.requireNonNull(color); + Objects.requireNonNull(designator); + } +} diff --git a/src/main/java/com/cta4j/bus/stop/StopsApi.java b/src/main/java/com/cta4j/bus/stop/StopsApi.java new file mode 100644 index 0000000..73dbce0 --- /dev/null +++ b/src/main/java/com/cta4j/bus/stop/StopsApi.java @@ -0,0 +1,37 @@ +package com.cta4j.bus.stop; + +import com.cta4j.bus.stop.model.Stop; +import com.cta4j.exception.Cta4jException; +import org.jspecify.annotations.NullMarked; + +import java.util.Collection; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +@NullMarked +public interface StopsApi { + List findByRouteIdAndDirection(String routeId, String direction); + + List findByIds(Collection stopIds); + + default Optional findById(String stopId) { + Objects.requireNonNull(stopId); + + List ids = List.of(stopId); + + List stops = this.findByIds(ids); + + if (stops.isEmpty()) { + return Optional.empty(); + } if (stops.size() > 1) { + String message = String.format("Multiple stops found for ID: %s", stopId); + + throw new Cta4jException(message); + } + + Stop stop = stops.getFirst(); + + return Optional.of(stop); + } +} diff --git a/src/main/java/com/cta4j/bus/stop/internal/impl/StopsApiImpl.java b/src/main/java/com/cta4j/bus/stop/internal/impl/StopsApiImpl.java new file mode 100644 index 0000000..c07c75c --- /dev/null +++ b/src/main/java/com/cta4j/bus/stop/internal/impl/StopsApiImpl.java @@ -0,0 +1,118 @@ +package com.cta4j.bus.stop.internal.impl; + +import com.cta4j.bus.internal.context.BusApiContext; +import com.cta4j.bus.internal.util.ApiUtils; +import com.cta4j.bus.stop.StopsApi; +import com.cta4j.bus.stop.internal.wire.CtaStop; +import com.cta4j.bus.stop.internal.mapper.StopMapper; +import com.cta4j.bus.stop.model.Stop; +import com.cta4j.bus.internal.wire.CtaBustimeResponse; +import com.cta4j.bus.internal.wire.CtaError; +import com.cta4j.bus.internal.wire.CtaResponse; +import com.cta4j.exception.Cta4jException; +import com.cta4j.bus.internal.util.HttpUtils; +import org.apache.hc.core5.net.URIBuilder; +import org.jetbrains.annotations.ApiStatus; +import org.jspecify.annotations.NullMarked; +import tools.jackson.core.JacksonException; +import tools.jackson.core.type.TypeReference; + +import java.util.Collection; +import java.util.List; +import java.util.Objects; + +@NullMarked +@ApiStatus.Internal +public final class StopsApiImpl implements StopsApi { + private static final String STOPS_ENDPOINT = String.format("%s/getstops", ApiUtils.API_PREFIX); + private static final int MAX_STOP_IDS_PER_REQUEST = 10; + + private final BusApiContext context; + + public StopsApiImpl(BusApiContext context) { + this.context = Objects.requireNonNull(context); + } + + @Override + public List findByRouteIdAndDirection(String routeId, String direction) { + Objects.requireNonNull(routeId); + Objects.requireNonNull(direction); + + String url = new URIBuilder() + .setScheme(ApiUtils.SCHEME) + .setHost(this.context.host()) + .setPath(STOPS_ENDPOINT) + .addParameter("rt", routeId) + .addParameter("dir", direction) + .addParameter("key", this.context.apiKey()) + .addParameter("format", "json") + .toString(); + + return this.makeRequest(url); + } + + @Override + public List findByIds(Collection stopIds) { + Objects.requireNonNull(stopIds); + + stopIds.forEach(Objects::requireNonNull); + + if (stopIds.size() > MAX_STOP_IDS_PER_REQUEST) { + String message = String.format( + "A maximum of %d stop IDs can be requested at once, but %d were provided", + MAX_STOP_IDS_PER_REQUEST, + stopIds.size() + ); + + throw new IllegalArgumentException(message); + } + + String stopIdsString = String.join(",", stopIds); + + String url = new URIBuilder() + .setScheme(ApiUtils.SCHEME) + .setHost(this.context.host()) + .setPath(STOPS_ENDPOINT) + .addParameter("stpid", stopIdsString) + .addParameter("key", this.context.apiKey()) + .addParameter("format", "json") + .toString(); + + return this.makeRequest(url); + } + + private List makeRequest(String url) { + String response = HttpUtils.get(url); + + TypeReference>> typeReference = new TypeReference<>() {}; + CtaResponse> stopsResponse; + + try { + stopsResponse = this.context.objectMapper() + .readValue(response, typeReference); + } catch (JacksonException e) { + String message = String.format("Failed to parse response from %s", STOPS_ENDPOINT); + + throw new Cta4jException(message, e); + } + + CtaBustimeResponse> bustimeResponse = stopsResponse.bustimeResponse(); + + List errors = bustimeResponse.error(); + List stops = bustimeResponse.data(); + + if ((errors != null) && !errors.isEmpty()) { + String message = ApiUtils.buildErrorMessage(STOPS_ENDPOINT, errors); + + throw new Cta4jException(message); + } + + if ((stops == null) || stops.isEmpty()) { + return List.of(); + } + + return stops.stream() + .map(StopMapper.INSTANCE::toDomain) + .toList(); + } +} diff --git a/src/main/java/com/cta4j/bus/stop/internal/mapper/StopMapper.java b/src/main/java/com/cta4j/bus/stop/internal/mapper/StopMapper.java new file mode 100644 index 0000000..05536f5 --- /dev/null +++ b/src/main/java/com/cta4j/bus/stop/internal/mapper/StopMapper.java @@ -0,0 +1,24 @@ +package com.cta4j.bus.stop.internal.mapper; + +import com.cta4j.bus.stop.internal.wire.CtaStop; +import com.cta4j.bus.stop.model.Stop; +import org.jetbrains.annotations.ApiStatus; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.factory.Mappers; + +@Mapper +@ApiStatus.Internal +public interface StopMapper { + StopMapper INSTANCE = Mappers.getMapper(StopMapper.class); + + @Mapping(source = "stpid", target = "id") + @Mapping(source = "stpnm", target = "name") + @Mapping(source = "lat", target = "latitude") + @Mapping(source = "lon", target = "longitude") + @Mapping(source = "dtradd", target = "detoursAdded") + @Mapping(source = "dtrrem", target = "detoursRemoved") + @Mapping(source = "gtfsseq", target = "gtfsSequence") + @Mapping(source = "ada", target = "adaAccessible") + Stop toDomain(CtaStop stop); +} diff --git a/src/main/java/com/cta4j/bus/stop/internal/wire/CtaStop.java b/src/main/java/com/cta4j/bus/stop/internal/wire/CtaStop.java new file mode 100644 index 0000000..34ea8ff --- /dev/null +++ b/src/main/java/com/cta4j/bus/stop/internal/wire/CtaStop.java @@ -0,0 +1,51 @@ +package com.cta4j.bus.stop.internal.wire; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import org.jetbrains.annotations.ApiStatus; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +import java.util.List; +import java.util.Objects; + +@NullMarked +@ApiStatus.Internal +@JsonIgnoreProperties(ignoreUnknown = true) +public record CtaStop( + String stpid, + + String stpnm, + + double lat, + + double lon, + + @Nullable + List dtradd, + + @Nullable + List dtrrem, + + @Nullable + Integer gtfsseq, + + @Nullable + Boolean ada +) { + public CtaStop { + Objects.requireNonNull(stpid); + Objects.requireNonNull(stpnm); + + if (dtradd != null) { + dtradd.forEach(Objects::requireNonNull); + + dtradd = List.copyOf(dtradd); + } + + if (dtrrem != null) { + dtrrem.forEach(Objects::requireNonNull); + + dtrrem = List.copyOf(dtrrem); + } + } +} diff --git a/src/main/java/com/cta4j/bus/stop/model/Stop.java b/src/main/java/com/cta4j/bus/stop/model/Stop.java new file mode 100644 index 0000000..fc822bd --- /dev/null +++ b/src/main/java/com/cta4j/bus/stop/model/Stop.java @@ -0,0 +1,50 @@ +package com.cta4j.bus.stop.model; + +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Objects; + +@NullMarked +public record Stop( + String id, + + String name, + + BigDecimal latitude, + + BigDecimal longitude, + + @Nullable + List detoursAdded, + + @Nullable + List detoursRemoved, + + @Nullable + Integer gtfsSequence, + + @Nullable + Boolean adaAccessible +) { + public Stop { + Objects.requireNonNull(id); + Objects.requireNonNull(name); + Objects.requireNonNull(latitude); + Objects.requireNonNull(longitude); + + if (detoursAdded != null) { + detoursAdded.forEach(Objects::requireNonNull); + + detoursAdded = List.copyOf(detoursAdded); + } + + if (detoursRemoved != null) { + detoursRemoved.forEach(Objects::requireNonNull); + + detoursRemoved = List.copyOf(detoursRemoved); + } + } +} diff --git a/src/main/java/com/cta4j/bus/vehicle/VehiclesApi.java b/src/main/java/com/cta4j/bus/vehicle/VehiclesApi.java new file mode 100644 index 0000000..b96d2e0 --- /dev/null +++ b/src/main/java/com/cta4j/bus/vehicle/VehiclesApi.java @@ -0,0 +1,45 @@ +package com.cta4j.bus.vehicle; + +import com.cta4j.bus.vehicle.model.Vehicle; +import com.cta4j.exception.Cta4jException; +import org.jspecify.annotations.NullMarked; + +import java.util.Collection; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +@NullMarked +public interface VehiclesApi { + List findByIds(Collection ids); + + default Optional findById(String id) { + Objects.requireNonNull(id); + + List ids = List.of(id); + + List vehicles = this.findByIds(ids); + + if (vehicles.isEmpty()) { + return Optional.empty(); + } else if (vehicles.size() > 1) { + String message = String.format("Expected at most one bus for ID: %s, but found %d", id, vehicles.size()); + + throw new Cta4jException(message); + } + + Vehicle vehicle = vehicles.getFirst(); + + return Optional.of(vehicle); + } + + List findByRouteIds(Collection routeIds); + + default List findByRouteId(String routeId) { + Objects.requireNonNull(routeId); + + List routeIds = List.of(routeId); + + return this.findByRouteIds(routeIds); + } +} diff --git a/src/main/java/com/cta4j/bus/vehicle/internal/impl/VehiclesApiImpl.java b/src/main/java/com/cta4j/bus/vehicle/internal/impl/VehiclesApiImpl.java new file mode 100644 index 0000000..70e0546 --- /dev/null +++ b/src/main/java/com/cta4j/bus/vehicle/internal/impl/VehiclesApiImpl.java @@ -0,0 +1,119 @@ +package com.cta4j.bus.vehicle.internal.impl; + +import com.cta4j.bus.internal.context.BusApiContext; +import com.cta4j.bus.internal.util.ApiUtils; +import com.cta4j.bus.vehicle.VehiclesApi; +import com.cta4j.bus.vehicle.internal.wire.CtaVehicle; +import com.cta4j.bus.vehicle.internal.mapper.VehicleMapper; +import com.cta4j.bus.vehicle.model.Vehicle; +import com.cta4j.bus.internal.wire.CtaBustimeResponse; +import com.cta4j.bus.internal.wire.CtaError; +import com.cta4j.bus.internal.wire.CtaResponse; +import com.cta4j.exception.Cta4jException; +import com.cta4j.bus.internal.util.HttpUtils; +import org.apache.hc.core5.net.URIBuilder; +import org.jetbrains.annotations.ApiStatus; +import org.jspecify.annotations.NullMarked; +import tools.jackson.core.JacksonException; +import tools.jackson.core.type.TypeReference; + +import java.util.Collection; +import java.util.List; +import java.util.Objects; + +@NullMarked +@ApiStatus.Internal +public final class VehiclesApiImpl implements VehiclesApi { + private static final String VEHICLES_ENDPOINT = String.format("%s/getvehicles", ApiUtils.API_PREFIX); + + private final BusApiContext context; + + public VehiclesApiImpl(BusApiContext context) { + this.context = Objects.requireNonNull(context); + } + + @Override + public List findByIds(Collection ids) { + Objects.requireNonNull(ids); + + ids.forEach(Objects::requireNonNull); + + if (ids.isEmpty()) { + return List.of(); + } + + String idsString = String.join(",", ids); + + String url = new URIBuilder() + .setScheme(ApiUtils.SCHEME) + .setHost(this.context.host()) + .setPath(VEHICLES_ENDPOINT) + .addParameter("vid", idsString) + .addParameter("tmres", "s") + .addParameter("key", this.context.apiKey()) + .addParameter("format", "json") + .toString(); + + return this.makeRequest(url); + } + + @Override + public List findByRouteIds(Collection routeIds) { + Objects.requireNonNull(routeIds); + + routeIds.forEach(Objects::requireNonNull); + + if (routeIds.isEmpty()) { + return List.of(); + } + + String routeIdsString = String.join(",", routeIds); + + String url = new URIBuilder() + .setScheme(ApiUtils.SCHEME) + .setHost(this.context.host()) + .setPath(VEHICLES_ENDPOINT) + .addParameter("rt", routeIdsString) + .addParameter("tmres", "s") + .addParameter("key", this.context.apiKey()) + .addParameter("format", "json") + .toString(); + + return this.makeRequest(url); + } + + private List makeRequest(String url) { + String response = HttpUtils.get(url); + + TypeReference>> typeReference = new TypeReference<>() {}; + CtaResponse> vehicleResponse; + + try { + vehicleResponse = this.context.objectMapper() + .readValue(response, typeReference); + } catch (JacksonException e) { + String message = String.format("Failed to parse response from %s", VEHICLES_ENDPOINT); + + throw new Cta4jException(message, e); + } + + CtaBustimeResponse> bustimeResponse = vehicleResponse.bustimeResponse(); + + List errors = bustimeResponse.error(); + List vehicles = bustimeResponse.data(); + + if ((errors != null) && !errors.isEmpty()) { + String message = ApiUtils.buildErrorMessage(VEHICLES_ENDPOINT, errors); + + throw new Cta4jException(message); + } + + if ((vehicles == null) || vehicles.isEmpty()) { + return List.of(); + } + + return vehicles.stream() + .map(VehicleMapper.INSTANCE::toDomain) + .toList(); + } +} diff --git a/src/main/java/com/cta4j/bus/vehicle/internal/mapper/VehicleMapper.java b/src/main/java/com/cta4j/bus/vehicle/internal/mapper/VehicleMapper.java new file mode 100644 index 0000000..0428175 --- /dev/null +++ b/src/main/java/com/cta4j/bus/vehicle/internal/mapper/VehicleMapper.java @@ -0,0 +1,44 @@ +package com.cta4j.bus.vehicle.internal.mapper; + +import com.cta4j.bus.vehicle.internal.wire.CtaVehicle; +import com.cta4j.bus.internal.mapper.Qualifiers; +import com.cta4j.bus.vehicle.model.Vehicle; +import org.jetbrains.annotations.ApiStatus; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.factory.Mappers; + +@Mapper(uses = Qualifiers.class) +@ApiStatus.Internal +public interface VehicleMapper { + VehicleMapper INSTANCE = Mappers.getMapper(VehicleMapper.class); + + @Mapping(source = "vid", target = "id") + @Mapping(source = "rtpidatafeed", target = "metadata.dataFeed") + @Mapping(source = "tmpstmp", target = "metadata.lastUpdated", qualifiedByName = "mapTimestamp") + @Mapping(source = "lat", target = "coordinates.latitude") + @Mapping(source = "lon", target = "coordinates.longitude") + @Mapping(source = "hdg", target = "coordinates.heading") + @Mapping(source = "pid", target = "metadata.patternId") + @Mapping(source = "rt", target = "route") + @Mapping(source = "des", target = "destination") + @Mapping(source = "pdist", target = "metadata.distanceToPatternPoint") + @Mapping(source = "stopstatus", target = "metadata.stopStatus") + @Mapping(source = "timepointid", target = "metadata.timepointId") + @Mapping(source = "stopid", target = "metadata.stopId") + @Mapping(source = "sequence", target = "metadata.sequence") + @Mapping(source = "gtfsseq", target = "metadata.gtfsSequence") + @Mapping(source = "dly", target = "delayed") + @Mapping(source = "srvtmstmp", target = "metadata.serverTimestamp", qualifiedByName = "mapTimestamp") + @Mapping(source = "spd", target = "metadata.speed") + @Mapping(source = "blk", target = "metadata.block") + @Mapping(source = "tablockid", target = "metadata.blockId") + @Mapping(source = "tatripid", target = "metadata.tripId") + @Mapping(source = "origtatripno", target = "metadata.originalTripNumber") + @Mapping(source = "zone", target = "metadata.zone") + @Mapping(source = "mode", target = "metadata.mode", qualifiedByName = "mapTransitMode") + @Mapping(source = "psgld", target = "metadata.passengerLoad", qualifiedByName = "mapPassengerLoad") + @Mapping(source = "stst", target = "metadata.scheduledStartSeconds") + @Mapping(source = "stsd", target = "metadata.scheduledStartDate") + Vehicle toDomain(CtaVehicle vehicle); +} diff --git a/src/main/java/com/cta4j/bus/vehicle/internal/wire/CtaVehicle.java b/src/main/java/com/cta4j/bus/vehicle/internal/wire/CtaVehicle.java new file mode 100644 index 0000000..1338f12 --- /dev/null +++ b/src/main/java/com/cta4j/bus/vehicle/internal/wire/CtaVehicle.java @@ -0,0 +1,90 @@ +package com.cta4j.bus.vehicle.internal.wire; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import org.jetbrains.annotations.ApiStatus; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +import java.util.Objects; + +@NullMarked +@ApiStatus.Internal +@JsonIgnoreProperties(ignoreUnknown = true) +public record CtaVehicle( + String vid, + + @Nullable + String rtpidatafeed, + + @Nullable + String tmpstmp, + + double lat, + + double lon, + + int hdg, + + int pid, + + String rt, + + String des, + + int pdist, + + @Nullable + Integer stopstatus, + + @Nullable + Integer timepointid, + + @Nullable + String stopid, + + @Nullable + Integer sequence, + + @Nullable + Integer gtfsseq, + + boolean dly, + + @Nullable + String srvtmstmp, + + @Nullable + Integer spd, + + @Nullable + Integer blk, + + String tablockid, + + String tatripid, + + String origtatripno, + + String zone, + + int mode, + + String psgld, + + @Nullable + Integer stst, + + @Nullable + String stsd +) { + public CtaVehicle { + Objects.requireNonNull(vid); + Objects.requireNonNull(rt); + Objects.requireNonNull(des); + Objects.requireNonNull(tablockid); + Objects.requireNonNull(tatripid); + Objects.requireNonNull(origtatripno); + Objects.requireNonNull(zone); + Objects.requireNonNull(psgld); + } +} diff --git a/src/main/java/com/cta4j/bus/vehicle/model/TransitMode.java b/src/main/java/com/cta4j/bus/vehicle/model/TransitMode.java new file mode 100644 index 0000000..e62858d --- /dev/null +++ b/src/main/java/com/cta4j/bus/vehicle/model/TransitMode.java @@ -0,0 +1,23 @@ +package com.cta4j.bus.vehicle.model; + +public enum TransitMode { + NONE(0), + + BUS(1), + + FERRY(2), + + RAIL(3), + + PEOPLE_MOVER(4); + + private final int code; + + TransitMode(int code) { + this.code = code; + } + + public int getCode() { + return this.code; + } +} diff --git a/src/main/java/com/cta4j/bus/vehicle/model/Vehicle.java b/src/main/java/com/cta4j/bus/vehicle/model/Vehicle.java new file mode 100644 index 0000000..d9db006 --- /dev/null +++ b/src/main/java/com/cta4j/bus/vehicle/model/Vehicle.java @@ -0,0 +1,28 @@ +package com.cta4j.bus.vehicle.model; + +import org.jspecify.annotations.NullMarked; + +import java.util.Objects; + +@NullMarked +public record Vehicle( + String id, + + String route, + + String destination, + + VehicleCoordinates coordinates, + + boolean delayed, + + VehicleMetadata metadata +) { + public Vehicle { + Objects.requireNonNull(id); + Objects.requireNonNull(route); + Objects.requireNonNull(destination); + Objects.requireNonNull(coordinates); + Objects.requireNonNull(metadata); + } +} diff --git a/src/main/java/com/cta4j/bus/vehicle/model/VehicleCoordinates.java b/src/main/java/com/cta4j/bus/vehicle/model/VehicleCoordinates.java new file mode 100644 index 0000000..1f3ad86 --- /dev/null +++ b/src/main/java/com/cta4j/bus/vehicle/model/VehicleCoordinates.java @@ -0,0 +1,29 @@ +package com.cta4j.bus.vehicle.model; + +import org.jspecify.annotations.NullMarked; + +import java.math.BigDecimal; +import java.util.Objects; + +@NullMarked +public record VehicleCoordinates( + BigDecimal latitude, + + BigDecimal longitude, + + int heading +) { + private static final int MIN_HEADING = 0; + private static final int MAX_HEADING = 359; + + public VehicleCoordinates { + Objects.requireNonNull(latitude); + Objects.requireNonNull(longitude); + + if ((heading < MIN_HEADING) || (heading > MAX_HEADING)) { + String message = String.format("heading must be between %d and %d (inclusive)", MIN_HEADING, MAX_HEADING); + + throw new IllegalArgumentException(message); + } + } +} diff --git a/src/main/java/com/cta4j/bus/vehicle/model/VehicleMetadata.java b/src/main/java/com/cta4j/bus/vehicle/model/VehicleMetadata.java new file mode 100644 index 0000000..4cdddbb --- /dev/null +++ b/src/main/java/com/cta4j/bus/vehicle/model/VehicleMetadata.java @@ -0,0 +1,73 @@ +package com.cta4j.bus.vehicle.model; + +import com.cta4j.bus.prediction.model.PassengerLoad; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +import java.time.Instant; +import java.time.LocalDate; +import java.util.Objects; + +@NullMarked +public record VehicleMetadata( + @Nullable + String dataFeed, + + @Nullable + Instant lastUpdated, + + int patternId, + + int distanceToPatternPoint, + + @Nullable + Integer stopStatus, + + @Nullable + Integer timepointId, + + @Nullable + String stopId, + + @Nullable + Integer sequence, + + @Nullable + Integer gtfsSequence, + + @Nullable + Instant serverTimestamp, + + @Nullable + Integer speed, + + @Nullable + Integer block, + + String blockId, + + String tripId, + + String originalTripNumber, + + String zone, + + TransitMode mode, + + PassengerLoad passengerLoad, + + @Nullable + Integer scheduledStartSeconds, + + @Nullable + LocalDate scheduledStartDate +) { + public VehicleMetadata { + Objects.requireNonNull(blockId); + Objects.requireNonNull(tripId); + Objects.requireNonNull(originalTripNumber); + Objects.requireNonNull(zone); + Objects.requireNonNull(mode); + Objects.requireNonNull(passengerLoad); + } +} diff --git a/src/main/java/com/cta4j/exception/Cta4jException.java b/src/main/java/com/cta4j/exception/Cta4jException.java index c2b832c..7dba98e 100644 --- a/src/main/java/com/cta4j/exception/Cta4jException.java +++ b/src/main/java/com/cta4j/exception/Cta4jException.java @@ -1,11 +1,11 @@ package com.cta4j.exception; /** - * A custom exception class for handling cta4j specific errors. + * A custom exception class for handling cta4j-specific errors. */ public class Cta4jException extends RuntimeException { /** - * Constructs a new Cta4jException with the specified detail message. + * Constructs a new {@code Cta4jException} with the specified detail message. * * @param message the detail message */ @@ -14,7 +14,7 @@ public Cta4jException(String message) { } /** - * Constructs a new Cta4jException with the specified detail message and cause. + * Constructs a new {@code Cta4jException} with the specified detail message and cause. * * @param message the detail message * @param cause the cause of the exception diff --git a/src/main/java/com/cta4j/train/client/TrainClient.java b/src/main/java/com/cta4j/train/client/TrainClient.java index 99d069a..61efe4b 100644 --- a/src/main/java/com/cta4j/train/client/TrainClient.java +++ b/src/main/java/com/cta4j/train/client/TrainClient.java @@ -4,6 +4,7 @@ import com.cta4j.exception.Cta4jException; import com.cta4j.train.model.StationArrival; import com.cta4j.train.model.Train; +import org.jspecify.annotations.NullMarked; import java.util.List; import java.util.Optional; @@ -11,13 +12,14 @@ /** * A client for interacting with the CTA Train Tracker API. */ +@NullMarked public interface TrainClient { /** * Retrieves a {@link List} of upcoming arrivals for a specific station. * * @param stationId the ID of the station * @return a {@link List} of upcoming arrivals for the specified station - * @throws NullPointerException if the specified station ID is {@code null} + * @throws IllegalArgumentException if the specified station ID is {@code null} * @throws Cta4jException if an error occurs while fetching the data */ List getStationArrivals(String stationId); @@ -27,7 +29,7 @@ public interface TrainClient { * * @param run the run number of the train * @return an {@link Optional} containing the train information if found, or an empty {@link Optional} if not found - * @throws NullPointerException if the specified run number is {@code null} + * @throws IllegalArgumentException if the specified run number is {@code null} * @throws Cta4jException if an error occurs while fetching the data */ Optional getTrain(String run); @@ -43,7 +45,7 @@ interface Builder { * * @param host the host * @return this {@link Builder} for method chaining - * @throws NullPointerException if {@code host} is {@code null} + * @throws IllegalArgumentException if {@code host} is {@code null} */ Builder host(String host); @@ -52,7 +54,7 @@ interface Builder { * * @param apiKey the API key * @return this {@link Builder} for method chaining - * @throws NullPointerException if {@code apiKey} is {@code null} + * @throws IllegalArgumentException if {@code apiKey} is {@code null} */ Builder apiKey(String apiKey); diff --git a/src/main/java/com/cta4j/train/client/internal/TrainClientImpl.java b/src/main/java/com/cta4j/train/client/internal/TrainClientImpl.java index 6369ddf..5a89834 100644 --- a/src/main/java/com/cta4j/train/client/internal/TrainClientImpl.java +++ b/src/main/java/com/cta4j/train/client/internal/TrainClientImpl.java @@ -16,41 +16,48 @@ import com.cta4j.train.model.Train; import com.cta4j.train.model.UpcomingTrainArrival; import com.cta4j.train.model.StationArrival; -import com.cta4j.util.HttpUtils; +import com.cta4j.bus.internal.util.HttpUtils; +import org.apache.hc.core5.net.URIBuilder; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; import tools.jackson.core.JacksonException; import tools.jackson.databind.ObjectMapper; -import org.apache.hc.core5.net.URIBuilder; import org.jetbrains.annotations.ApiStatus; import java.util.List; -import java.util.Objects; import java.util.Optional; +@NullMarked @ApiStatus.Internal +@SuppressWarnings("ConstantConditions") public final class TrainClientImpl implements TrainClient { - private final String host; - - private final String apiKey; - - private final ObjectMapper objectMapper; - private static final String DEFAULT_HOST = "lapi.transitchicago.com"; - private static final String ARRIVALS_ENDPOINT = "/api/1.0/ttarrivals.aspx"; - private static final String FOLLOW_ENDPOINT = "/api/1.0/ttfollow.aspx"; + private final String host; + private final String apiKey; + private final ObjectMapper objectMapper; + private TrainClientImpl(String host, String apiKey) { - this.host = Objects.requireNonNull(host); + if (host == null) { + throw new IllegalArgumentException("host must not be null"); + } - this.apiKey = Objects.requireNonNull(apiKey); + if (apiKey == null) { + throw new IllegalArgumentException("apiKey must not be null"); + } + this.host = host; + this.apiKey = apiKey; this.objectMapper = new ObjectMapper(); } @Override public List getStationArrivals(String stationId) { - Objects.requireNonNull(stationId); + if (stationId == null) { + throw new IllegalArgumentException("stationId must not be null"); + } String url = new URIBuilder() .setScheme("https") @@ -92,7 +99,9 @@ public List getStationArrivals(String stationId) { @Override public Optional getTrain(String run) { - Objects.requireNonNull(run); + if (run == null) { + throw new IllegalArgumentException("run must not be null"); + } String url = new URIBuilder() .setScheme("https") @@ -150,26 +159,35 @@ public Optional getTrain(String run) { } public static final class BuilderImpl implements TrainClient.Builder { + @Nullable private String host; + @Nullable private String apiKey; public BuilderImpl() { this.host = null; - this.apiKey = null; } @Override public Builder host(String host) { - this.host = Objects.requireNonNull(host); + if (host == null) { + throw new IllegalArgumentException("host must not be null"); + } + + this.host = host; return this; } @Override public Builder apiKey(String apiKey) { - this.apiKey = Objects.requireNonNull(apiKey); + if (apiKey == null) { + throw new IllegalArgumentException("apiKey must not be null"); + } + + this.apiKey = apiKey; return this; } diff --git a/src/main/java/com/cta4j/train/mapper/RouteMapper.java b/src/main/java/com/cta4j/train/mapper/RouteMapper.java index 06f2c08..a70aee1 100644 --- a/src/main/java/com/cta4j/train/mapper/RouteMapper.java +++ b/src/main/java/com/cta4j/train/mapper/RouteMapper.java @@ -3,8 +3,6 @@ import com.cta4j.train.model.Route; import org.jetbrains.annotations.ApiStatus; -import java.util.Objects; - @ApiStatus.Internal public final class RouteMapper { private RouteMapper() { @@ -12,7 +10,9 @@ private RouteMapper() { } public static Route fromExternal(String string) { - Objects.requireNonNull(string); + if (string == null) { + throw new IllegalArgumentException("string must not be null"); + } string = string.toUpperCase(); diff --git a/src/main/java/com/cta4j/train/mapper/StationArrivalMapper.java b/src/main/java/com/cta4j/train/mapper/StationArrivalMapper.java index bf684b7..e893eb9 100644 --- a/src/main/java/com/cta4j/train/mapper/StationArrivalMapper.java +++ b/src/main/java/com/cta4j/train/mapper/StationArrivalMapper.java @@ -3,14 +3,13 @@ import com.cta4j.train.external.arrival.CtaArrivalsEta; import com.cta4j.train.model.Route; import com.cta4j.train.model.StationArrival; -import com.cta4j.util.DateTimeUtils; +import com.cta4j.bus.internal.util.DateTimeUtils; import org.jetbrains.annotations.ApiStatus; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.math.BigDecimal; import java.time.Instant; -import java.util.Objects; @ApiStatus.Internal public final class StationArrivalMapper { @@ -25,7 +24,9 @@ private StationArrivalMapper() { } public static StationArrival fromExternal(CtaArrivalsEta eta) { - Objects.requireNonNull(eta); + if (eta == null) { + throw new IllegalArgumentException("eta must not be null"); + } Route route = null; diff --git a/src/main/java/com/cta4j/train/mapper/TrainCoordinatesMapper.java b/src/main/java/com/cta4j/train/mapper/TrainCoordinatesMapper.java index 9249b19..23b34db 100644 --- a/src/main/java/com/cta4j/train/mapper/TrainCoordinatesMapper.java +++ b/src/main/java/com/cta4j/train/mapper/TrainCoordinatesMapper.java @@ -7,7 +7,6 @@ import org.slf4j.LoggerFactory; import java.math.BigDecimal; -import java.util.Objects; @ApiStatus.Internal public final class TrainCoordinatesMapper { @@ -22,7 +21,9 @@ private TrainCoordinatesMapper() { } public static TrainCoordinates fromExternal(CtaFollowPosition position) { - Objects.requireNonNull(position); + if (position == null) { + throw new IllegalArgumentException("position must not be null"); + } BigDecimal latitude = null; diff --git a/src/main/java/com/cta4j/train/mapper/UpcomingTrainArrivalMapper.java b/src/main/java/com/cta4j/train/mapper/UpcomingTrainArrivalMapper.java index f01ba5b..fe6cbbc 100644 --- a/src/main/java/com/cta4j/train/mapper/UpcomingTrainArrivalMapper.java +++ b/src/main/java/com/cta4j/train/mapper/UpcomingTrainArrivalMapper.java @@ -1,15 +1,15 @@ package com.cta4j.train.mapper; +import com.cta4j.exception.Cta4jException; import com.cta4j.train.external.follow.CtaFollowEta; import com.cta4j.train.model.Route; import com.cta4j.train.model.UpcomingTrainArrival; -import com.cta4j.util.DateTimeUtils; +import com.cta4j.bus.internal.util.DateTimeUtils; import org.jetbrains.annotations.ApiStatus; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.time.Instant; -import java.util.Objects; @ApiStatus.Internal public final class UpcomingTrainArrivalMapper { @@ -24,16 +24,24 @@ private UpcomingTrainArrivalMapper() { } public static UpcomingTrainArrival fromExternal(CtaFollowEta eta) { - Objects.requireNonNull(eta); + if (eta == null) { + throw new IllegalArgumentException("eta must not be null"); + } - Route route = null; + if (eta.rt() == null) { + throw new Cta4jException("ETA route is missing"); + } - if (eta.rt() != null) { - try { - route = RouteMapper.fromExternal(eta.rt()); - } catch (IllegalArgumentException e) { - logger.warn("Invalid route {}", eta.rt()); - } + + + Route route; + + try { + route = RouteMapper.fromExternal(eta.rt()); + } catch (IllegalArgumentException e) { + String message = String.format("Invalid route value %s", eta.rt()); + + throw new Cta4jException(message, e); } Integer direction = null; diff --git a/src/main/java/com/cta4j/train/model/StationArrival.java b/src/main/java/com/cta4j/train/model/StationArrival.java index 6f1764d..2879a93 100644 --- a/src/main/java/com/cta4j/train/model/StationArrival.java +++ b/src/main/java/com/cta4j/train/model/StationArrival.java @@ -1,5 +1,8 @@ package com.cta4j.train.model; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + import java.math.BigDecimal; import java.time.Duration; import java.time.Instant; @@ -27,6 +30,8 @@ * @param longitude the longitude of the train's current location * @param heading the heading of the train in degrees (0-359) */ +@NullMarked +@SuppressWarnings("ConstantConditions") public record StationArrival( String stationId, @@ -58,14 +63,80 @@ public record StationArrival( Boolean faulted, + @Nullable String flags, + @Nullable BigDecimal latitude, + @Nullable BigDecimal longitude, + @Nullable Integer heading ) { + public StationArrival { + if (stationId == null) { + throw new IllegalArgumentException("stationId must not be null"); + } + + if (stopId == null) { + throw new IllegalArgumentException("stopId must not be null"); + } + + if (stationName == null) { + throw new IllegalArgumentException("stationName must not be null"); + } + + if (stopDescription == null) { + throw new IllegalArgumentException("stopDescription must not be null"); + } + + if (run == null) { + throw new IllegalArgumentException("run must not be null"); + } + + if (route == null) { + throw new IllegalArgumentException("route must not be null"); + } + + if (destinationStopId == null) { + throw new IllegalArgumentException("destinationStopId must not be null"); + } + + if (destinationName == null) { + throw new IllegalArgumentException("destinationName must not be null"); + } + + if (direction == null) { + throw new IllegalArgumentException("direction must not be null"); + } + + if (predictionTime == null) { + throw new IllegalArgumentException("predictionTime must not be null"); + } + + if (arrivalTime == null) { + throw new IllegalArgumentException("arrivalTime must not be null"); + } + + if (approaching == null) { + throw new IllegalArgumentException("approaching must not be null"); + } + + if (scheduled == null) { + throw new IllegalArgumentException("scheduled must not be null"); + } + + if (delayed == null) { + throw new IllegalArgumentException("delayed must not be null"); + } + + if (faulted == null) { + throw new IllegalArgumentException("faulted must not be null"); + } + } + /** * Calculates the estimated time of arrival (ETA) in minutes from the prediction time to the arrival time. If the * arrival time is before the prediction time, it returns 0. diff --git a/src/main/java/com/cta4j/train/model/Train.java b/src/main/java/com/cta4j/train/model/Train.java index fc23a58..2d24451 100644 --- a/src/main/java/com/cta4j/train/model/Train.java +++ b/src/main/java/com/cta4j/train/model/Train.java @@ -1,5 +1,8 @@ package com.cta4j.train.model; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + import java.util.List; /** @@ -8,9 +11,23 @@ * @param coordinates the coordinates and heading of the train * @param arrivals the list of upcoming train arrivals for the train */ +@NullMarked +@SuppressWarnings("ConstantConditions") public record Train( + @Nullable TrainCoordinates coordinates, List arrivals ) { + public Train { + if (arrivals == null) { + throw new IllegalArgumentException("arrivals must not be null"); + } + + for (UpcomingTrainArrival arrival : arrivals) { + if (arrival == null) { + throw new IllegalArgumentException("arrivals must not contain null elements"); + } + } + } } diff --git a/src/main/java/com/cta4j/train/model/TrainCoordinates.java b/src/main/java/com/cta4j/train/model/TrainCoordinates.java index 6bf63e2..e2be5d8 100644 --- a/src/main/java/com/cta4j/train/model/TrainCoordinates.java +++ b/src/main/java/com/cta4j/train/model/TrainCoordinates.java @@ -1,5 +1,7 @@ package com.cta4j.train.model; +import org.jspecify.annotations.NullMarked; + import java.math.BigDecimal; /** @@ -9,11 +11,22 @@ * @param longitude the longitude of the train's current location * @param heading the heading of the train in degrees (0-359) */ +@NullMarked +@SuppressWarnings("ConstantConditions") public record TrainCoordinates( BigDecimal latitude, BigDecimal longitude, - Integer heading + int heading ) { + public TrainCoordinates { + if (latitude == null) { + throw new IllegalArgumentException("latitude must not be null"); + } + + if (longitude == null) { + throw new IllegalArgumentException("longitude must not be null"); + } + } } diff --git a/src/main/java/com/cta4j/train/model/UpcomingTrainArrival.java b/src/main/java/com/cta4j/train/model/UpcomingTrainArrival.java index d86c516..08bfa5a 100644 --- a/src/main/java/com/cta4j/train/model/UpcomingTrainArrival.java +++ b/src/main/java/com/cta4j/train/model/UpcomingTrainArrival.java @@ -1,5 +1,8 @@ package com.cta4j.train.model; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + import java.time.Duration; import java.time.Instant; @@ -23,6 +26,8 @@ * @param faulted whether there is a fault affecting the train * @param flags additional flags associated with the prediction */ +@NullMarked +@SuppressWarnings("ConstantConditions") public record UpcomingTrainArrival( String stationId, @@ -46,16 +51,51 @@ public record UpcomingTrainArrival( Instant arrivalTime, - Boolean approaching, + boolean approaching, - Boolean scheduled, + boolean scheduled, - Boolean delayed, + boolean delayed, - Boolean faulted, + boolean faulted, + @Nullable String flags ) { + public UpcomingTrainArrival { + if (stationId == null) { + throw new IllegalArgumentException("stationId must not be null"); + } + + if (stopId == null) { + throw new IllegalArgumentException("stopId must not be null"); + } + + if (stationName == null) { + throw new IllegalArgumentException("stationName must not be null"); + } + + if (stopDescription == null) { + throw new IllegalArgumentException("stopDescription must not be null"); + } + + if (run == null) { + throw new IllegalArgumentException("run must not be null"); + } + + if (route == null) { + throw new IllegalArgumentException("route must not be null"); + } + + if (predictionTime == null) { + throw new IllegalArgumentException("predictionTime must not be null"); + } + + if (arrivalTime == null) { + throw new IllegalArgumentException("arrivalTime must not be null"); + } + } + /** * Calculates the estimated time of arrival (ETA) in minutes from the prediction time to the arrival time. If the * arrival time is before the prediction time, it returns 0. diff --git a/src/main/java/com/cta4j/util/HttpUtils.java b/src/main/java/com/cta4j/util/HttpUtils.java deleted file mode 100644 index f71cd03..0000000 --- a/src/main/java/com/cta4j/util/HttpUtils.java +++ /dev/null @@ -1,71 +0,0 @@ -package com.cta4j.util; - -import com.cta4j.exception.Cta4jException; -import org.apache.hc.client5.http.classic.methods.HttpGet; -import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; -import org.apache.hc.client5.http.impl.classic.HttpClients; -import org.apache.hc.core5.http.ClassicHttpResponse; -import org.apache.hc.core5.http.HttpEntity; -import org.apache.hc.core5.http.ParseException; -import org.apache.hc.core5.http.io.entity.EntityUtils; -import org.jetbrains.annotations.ApiStatus; - -import java.io.IOException; -import java.net.URI; - -@ApiStatus.Internal -public final class HttpUtils { - private static final CloseableHttpClient httpClient; - - static { - httpClient = HttpClients.createDefault(); - } - - private HttpUtils() { - throw new UnsupportedOperationException("This is a utility class and cannot be instantiated"); - } - - private static String handleResponse(URI uri, ClassicHttpResponse httpResponse) throws IOException, ParseException { - int status = httpResponse.getCode(); - - if (status >= 200 && status < 300) { - HttpEntity entity = httpResponse.getEntity(); - - return EntityUtils.toString(entity); - } - - String path = uri.getPath(); - - String message = String.format("Request to %s failed with status code %d", path, status); - - throw new Cta4jException(message); - } - - public static String get(String url) { - URI uri; - - try { - uri = URI.create(url); - } catch (IllegalArgumentException e) { - String message = "Invalid URL"; - - throw new Cta4jException(message, e); - } - - HttpGet httpGet = new HttpGet(uri); - - String response; - - try { - response = httpClient.execute(httpGet, httpResponse -> HttpUtils.handleResponse(uri, httpResponse)); - } catch (IOException e) { - String path = uri.getPath(); - - String message = String.format("Request to %s failed due to an I/O error", path); - - throw new Cta4jException(message, e); - } - - return response; - } -}