diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index d82d7941..64dd65b4 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -18,14 +18,14 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Set up JDK - uses: actions/setup-java@v4 + uses: actions/setup-java@v5.6.0 with: - distribution: "corretto" - java-version: "21" - cache: "maven" + distribution: 'corretto' + java-version: '25' + cache: 'maven' - name: Read project version from pom.xml id: ver diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index f65a5203..74cbfb7e 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -17,16 +17,16 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: fetch-depth: 0 fetch-tags: true - name: Set up JDK - uses: actions/setup-java@v4 + uses: actions/setup-java@v5.6.0 with: distribution: corretto - java-version: '21' + java-version: '25' server-id: central server-username: OSSRH_USERNAME server-password: OSSRH_PASSWORD diff --git a/.gitignore b/.gitignore index a110e81b..6a0b2185 100644 --- a/.gitignore +++ b/.gitignore @@ -5,10 +5,7 @@ target/ .kotlin ### IntelliJ IDEA ### -.idea/modules.xml -.idea/jarRepositories.xml -.idea/compiler.xml -.idea/libraries/ +.idea/ *.iws *.iml *.ipr @@ -37,3 +34,6 @@ build/ ### Mac OS ### .DS_Store.superpowers/ + +### Misc ### +.env diff --git a/CHANGELOG.md b/CHANGELOG.md index 67273485..0b66f261 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,61 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [7.0.0] - 2026-08-01 + +### Added + +- A new `AlertApi` composed entry point for the CTA Customer Alerts API, unauthenticated and constructed via + `AlertApi.builder().build()` (no API key required). + - `RouteStatusApi`, exposing route/line service status (`list()`, `findByTypes`, `findByBusRouteIds`, + `findByLines`, `findByStationId`). + - `DetailedAlertsApi`, exposing detailed service alerts (`list(AlertsQuery)`, plus filtered lookups by bus + route IDs, train lines, and station IDs). + - New domain models (`RouteStatus`, `Alert`, `ImpactedService`, `Severity`, `AlertTrainLine`, `ServiceType`), + feature-specific exceptions (`Cta4jRouteStatusException`, `Cta4jDetailedAlertsException`, both extending + the new shared `Cta4jAlertException`), and error-code enums (`RouteStatusErrorCode`, + `DetailedAlertsErrorCode`). +- `ArrivalsApi.findByMapIds`/`findByStopIds` — multi-value lookups accepting up to 4 map/stop IDs per request + (`MapArrivalsQuery`/`StopArrivalsQuery`, plus `Collection` convenience overloads), alongside the + existing single-ID `findByMapId`/`findByStopId`. +- `PredictionsApi.findByStopIds`/`findByVehicleIds` — convenience overloads accepting a bare + `Collection` without requiring a full `StopPredictionsQuery`/`VehiclePredictionsQuery` to be + constructed, matching `ArrivalsApi`'s equivalent overloads. + +### Changed + +- `TrainLine.fromCode(String)` no longer throws `IllegalArgumentException` for an unrecognized code; it now + returns `null` and logs a warning, matching the `@Nullable`-based degrade pattern used elsewhere in the SDK. +- `ArrivalsErrorCode.fromCode`/`FollowErrorCode.fromCode`/`LocationsErrorCode.fromCode` no longer fall back to + an `UNKNOWN` constant for an unrecognized code; they now return `null`, matching the same degrade pattern as + `TrainLine.fromCode`. +- Renamed `VehiclesApi.findByIds`'s parameter from `ids` to `vehicleIds`, and `findById`'s parameter from `id` + to `vehicleId`, for consistency with `StopsApi`/`PatternsApi`'s equivalent methods. +- Bumped `tools.jackson.core:jackson-databind` from **3.2.0** → **3.2.1** +- Bumped `org.apache.httpcomponents.client5:httpclient5-fluent` from **5.6.1** → **5.6.2** +- Bumped `ch.qos.logback:logback-classic` from **1.5.38** → **1.6.0** + +### Breaking Changes ⚠️ + +- **Builder constructors are now `private`** on all query-parameter builders (`StopPredictionsQuery`, + `VehiclePredictionsQuery`, `MapArrivalsQuery`, `StopArrivalsQuery`, `AlertsQuery`, `BusRouteAlertsQuery`, + `LineAlertsQuery`, `StationAlertsQuery`). Construct instances via the static `builder(...)` factory method + only. +- Renamed `StopsPredictionsQuery`/`VehiclesPredictionsQuery` to `StopPredictionsQuery`/`VehiclePredictionsQuery` + for consistency with the rest of the query-builder naming. +- `MapArrivalQuery`/`StopArrivalQuery` have been renamed and reshaped to `MapArrivalsQuery`/`StopArrivalsQuery`: + the single `String mapId`/`stopId` component is now a `List mapIds`/`stopIds` component (max 4 IDs). +- `ArrivalsApi.findByMapId(MapArrivalQuery)`/`findByStopId(StopArrivalQuery)` have been renamed to + `findByMapIds(MapArrivalsQuery)`/`findByStopIds(StopArrivalsQuery)` to match the new query types. +- `ArrivalsErrorCode.UNKNOWN`/`FollowErrorCode.UNKNOWN`/`LocationsErrorCode.UNKNOWN` have been removed; code + that referenced these constants directly (e.g. `switch` statements, equality checks) must handle `null` + instead. +- `MapArrivalsQuery.Builder.maxResults`/`StopArrivalsQuery.Builder.maxResults` now take `int` instead of + `Integer`; passing `null` no longer compiles. +- `Arrival.line`/`TrainLocations.line` are now `@Nullable`, a direct consequence of `TrainLine.fromCode` no + longer throwing — code that assumed either was always non-null must add a null check. +- `LocationsApi.findAll()` has been renamed to `LocationsApi.list()`. + ## [6.0.0] - 2026-07-05 ### Added @@ -267,7 +322,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/v6.0.0...HEAD +[Unreleased]: https://github.com/lbkulinski/cta4j-java-sdk/compare/v7.0.0...HEAD +[7.0.0]: https://github.com/lbkulinski/cta4j-java-sdk/compare/v6.0.0...v7.0.0 [6.0.0]: https://github.com/lbkulinski/cta4j-java-sdk/compare/v5.0.0...v6.0.0 [5.0.0]: https://github.com/lbkulinski/cta4j-java-sdk/compare/v4.1.0...v5.0.0 [4.1.0]: https://github.com/lbkulinski/cta4j-java-sdk/compare/v4.0.3...v4.1.0 diff --git a/CLAUDE.md b/CLAUDE.md index 0afa9bae..20ed0b4c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,16 +2,17 @@ ## Project Overview -Java SDK for the CTA Bus Tracker and Train Tracker APIs. Published to Maven Central. Consumers instantiate `BusApi` or `TrainApi` via their respective builders and access sub-APIs from there. +Java SDK for the CTA Bus Tracker, Train Tracker, and Customer Alerts APIs. Published to Maven Central. Consumers instantiate `BusApi`, `TrainApi`, or `AlertApi` via their respective builders and access sub-APIs from there. ## Entry Points ```java BusApi busApi = BusApi.builder("apiKey").build(); TrainApi trainApi = TrainApi.builder("apiKey").build(); +AlertApi alertApi = AlertApi.builder().build(); ``` -Both builders accept an optional `.host(String)` override; `TrainApi.Builder` also accepts `.stationsUrl(String)`. +`BusApi`/`TrainApi` builders require an API key and accept an optional `.host(String)` override; `TrainApi.Builder` also accepts `.stationsUrl(String)`. The CTA Customer Alerts API is unauthenticated, so `AlertApi.builder()` takes no API key and only accepts an optional `.host(String)` override. ## API Surface @@ -32,9 +33,13 @@ Both builders accept an optional `.host(String)` override; `TrainApi.Builder` al - `follow()` — `FollowApi` - `locations()` — `LocationsApi` +**Alert (`AlertApi`)** +- `routeStatus()` — `RouteStatusApi` +- `detailedAlerts()` — `DetailedAlertsApi` + ## Package Layout -Transit type (`bus`, `train`) then feature then layer: +Transit type (`bus`, `train`, `alert`) then feature then layer: ``` com.cta4j.bus.route/ @@ -46,33 +51,39 @@ com.cta4j.bus.route/ mapper/RouteMapper.java ← MapStruct mapper ``` -Shared types live in `bus/common/` or `train/common/`. Cross-cutting types (geo, exceptions) live in `common/` or `exception/`. +Shared types live in `bus/common/`, `train/common/`, or `alert/common/`. Cross-cutting types (geo, exceptions) live in `common/` or `exception/`. ## Wire Layer Conventions - All wire records are `@ApiStatus.Internal` and not part of the public API. - **Bus**: each feature has a typed `CtaBustimeResponse` record (envelope field `bustimeResponse`, mapped from `"bustime-response"`) with `@Nullable` fields for both the data list and a typed `CtaError` list. Each `CtaError` implements `CtaError` and overrides `notFound()` using its own typed fields (e.g. `rt`, `stpid`, `vid`) to identify which input caused the error — do not collapse these into a generic map. - **Train**: there is no shared `CtaError`-style record. Each feature's wire response record carries `errCd`/`errNm` fields directly; `errCd` is parsed to an `int` and mapped via `ErrorCode.fromCode(int)` to a feature-specific enum (e.g. `ArrivalsErrorCode`) whose constants identify resource-specific ("not found") codes. +- **Alert**: like Train, there is no shared `CtaError`-style record, and each feature's error shape is its own — do not assume `RouteStatus` and `DetailedAlerts` match each other. `CtaRoutes` (route status) carries `ErrorCode`/`ErrorMessage` as `List`, since the CTA API can return multiple distinct codes for a single request; `CtaAlerts` (detailed alerts) carries a single non-nullable `ErrorCode` `String` and a `@Nullable ErrorMessage` `String`. Both map their code(s) via a feature-specific `ErrorCode.fromCode(int)` enum (`RouteStatusErrorCode`, `DetailedAlertsErrorCode`), following the Train pattern. - All wire records use `@JsonIgnoreProperties(ignoreUnknown = true)`. -- The outer envelope is `CtaResponse`, with a `bustimeResponse` field (bus) or a `ctatt` field (train). +- **Bus and Train** share a single generic `CtaResponse` record per module (`bus/common/internal/wire/CtaResponse`, `train/common/internal/wire/CtaResponse`) with a fixed field name — `bustimeResponse` (bus) or `ctatt` (train). +- **Alert** has no shared generic envelope type; each feature declares its own concretely-typed response record with its own field name (e.g. `CtaRouteStatusResponse.ctaRoutes` mapped from `"CTARoutes"`, `CtaDetailedAlertsResponse.ctaAlerts` mapped from `"CTAAlerts"`). ## Error Handling Pattern **Bus** `*ApiImpl` classes returning a `List` follow this pattern in `makeRequest`: 1. If the data list is non-null and non-empty → map and return it. -2. Otherwise, call `ApiUtils.checkErrors(errors, endpoint)` (`bus/common/internal/util/ApiUtils`): it logs a warn and returns if the error list is null/empty, returns if every error's `notFound()` is `true`, or throws `Cta4jBusException` otherwise. +2. Otherwise, call `BusApiUtils.checkErrors(errors, endpoint)` (`bus/common/internal/util/BusApiUtils`): it logs a warn and returns if the error list is null/empty, returns if every error's `notFound()` is `true`, or throws `Cta4jBusException` otherwise. 3. Return `List.of()`. -`SystemTimeApiImpl` is the one exception: it returns a single `Instant`, not a `List`, so a missing value has no valid "empty" result — it throws directly instead of calling `ApiUtils.checkErrors`. +`SystemTimeApiImpl` is the one exception: it returns a single `Instant`, not a `List`, so a missing value has no valid "empty" result — it throws directly instead of calling `BusApiUtils.checkErrors`. **Train** `*ApiImpl` classes follow a related but distinct pattern (no shared helper — each impl inlines it with its own exception type): parse `errCd` to the feature's `*ErrorCode` enum; if it's a resource-specific not-found code → return an empty result (or `Optional.empty()`); if it isn't `OK` → throw the feature-specific exception (e.g. `Cta4jArrivalsException`) using `errNm` as the message, falling back to a default message when `errNm` is `null` or blank. +**Alert** `*ApiImpl` classes follow the same inlined, no-shared-helper pattern as Train, but each feature's `errCd`/`errNm` shape differs (see Wire Layer Conventions above): `RouteStatusApiImpl` reads the first of a possibly-multi-value error-code list (logging a warn if more than one distinct code is present) and falls back to `"An unknown error occurred."` when the message is null/blank; `DetailedAlertsApiImpl` reads the single scalar `errCd`/`errNm` directly. Both throw their feature-specific exception (`Cta4jRouteStatusException`/`Cta4jDetailedAlertsException`, both extending the shared `Cta4jAlertException`) for any code other than `OK`/not-found. + `find*` methods return an empty `List` (or `Optional.empty()`) for not-found; they never throw for missing resources. -## Annotation Ordering +## Annotations -Always apply annotations in this order: **Jackson/framework → `@ApiStatus.Internal` → `@NullMarked`** +- Annotations always stack one per line above the declaration. +- They should adhere to the following order: Jackson/framework → `@ApiStatus.Internal` → `@NullMarked`. +- This applies to classes, interfaces, records, fields, and methods alike. ```java @JsonIgnoreProperties(ignoreUnknown = true) @@ -81,6 +92,11 @@ Always apply annotations in this order: **Jackson/framework → `@ApiStatus.Inte public record CtaFoo(...) { } ``` +- **Exception — Record components:** A component with a single annotation may keep it inline with the type + (`@Nullable String foo`) instead of stacking. Two or more annotations on a component still stack one per + line above the type, and when any component in a record stacks, blank-line-separate every component in + that record (see `CtaLocation` vs. the single-annotation `CtaStation`). + ## Null Safety - All classes, records, interfaces, and enums in `src/main` must have `@NullMarked`. @@ -99,3 +115,49 @@ MapStruct mappers live in `internal/mapper/`. They are interfaces annotated with - No comments unless the why is non-obvious. - No `Optional` for fields or parameters. Use method overloading or `@Nullable` fields instead. - Prefer `List.of()` for empty returns; use `List.copyOf()` for defensive copies. +- All files must end with a trailing newline. + +## Javadoc Conventions + +This project uses Markdown documentation comments (`///`, JEP 467, JDK 23+) +instead of traditional `/** */` HTML Javadoc. Do not use `{@code}`, `{@link}`, +or HTML tags — use plain Markdown (backticks, `[Type]` links, etc.). + +References: +- Content/style conventions (summary sentence, tag usage): + https://www.oracle.com/technical-resources/articles/java/javadoc-tool.html +- Markdown comment syntax (`///`, CommonMark extensions, JDK 23+): + https://docs.oracle.com/en/java/javase/25/javadoc/using-markdown-documentation-comments.html + +- **Summary sentence:** The first line is a standalone summary fragment + ending in a period, third-person descriptive ("Returns the active + arrivals for a station," not "This method returns..."). +- **Single-item accessor verb:** Always "Returns the X" — no exceptions. + This applies uniformly to every single-value accessor, including + wire-code accessors on domain-value enums (e.g. `getCode()` on + `TransitMode`, `DynamicAction`, `TrainLine`) and numeric/status-code + accessors on error-code enums or exceptions alike. Do not use "Gets the + X" to distinguish the two. +- **Builder creator methods:** One template for every builder, top-level + client builders and query-parameter builders alike — no terser variant. + The static `builder(...)` method: "Creates a new `Builder` for + constructing a/an `X`.", `@return` tag "a new `Builder`". The `build()` + method: "Builds a configured `X` instance.", `@return` tag "a new `X`". +- **Builder setter `@return`:** Always backticked, "this `Builder` instance" — + never the unbacked "this builder instance" variant. +- **Tag order:** `@param` → `@return` → `@deprecated` → `@since` → `@throws` + → `@see`. +- **@param / @throws descriptions:** Lowercase phrase, no trailing period. +- **Code references:** Use backtick spans (`` `RoutesApi` ``, `` `List` ``) + instead of `{@code}`. Use Markdown reference links (`[RoutesApi]`) instead + of `{@link}` only when the cross-reference meaningfully aids understanding. +- **What gets documented:** Public interfaces (`*Api`), public domain models + (`model/`), and builders always. Wire records (`internal/wire/`), mappers, + and `*ApiImpl` classes are `@ApiStatus.Internal` and are not documented + unless the "why" is non-obvious (per Code Style). +- **Package docs:** Every package containing at least one non-internal + type (public `*Api` interfaces, public domain models, builders — per + "What gets documented" above) gets a `package-info.java` with a + one-paragraph summary of the package's responsibility, written in the + same Markdown style. Packages containing only `internal/wire`, mapper, + or `*ApiImpl` classes do not require a package-info.java. diff --git a/README.md b/README.md index f1938ffe..433319a3 100644 --- a/README.md +++ b/README.md @@ -1,29 +1,38 @@ # cta4j Java SDK -A lightweight Java SDK for interacting with the [Chicago Transit Authority (CTA)](https://www.transitchicago.com/) APIs — both Train Tracker and Bus Tracker. +[![Maven Central](https://img.shields.io/maven-central/v/com.cta4j/cta4j-java-sdk)](https://central.sonatype.com/artifact/com.cta4j/cta4j-java-sdk) +[![Build & Test](https://github.com/lbkulinski/cta4j-java-sdk/actions/workflows/build.yaml/badge.svg)](https://github.com/lbkulinski/cta4j-java-sdk/actions/workflows/build.yaml) +[![Javadoc](https://javadoc.io/badge2/com.cta4j/cta4j-java-sdk/javadoc.svg)](https://javadoc.io/doc/com.cta4j/cta4j-java-sdk) +![Java Version](https://img.shields.io/badge/Java-21%2B-orange) +[![License](https://img.shields.io/github/license/lbkulinski/cta4j-java-sdk)](LICENSE) + +A lightweight Java SDK for interacting with the [Chicago Transit Authority (CTA)](https://www.transitchicago.com/) APIs — Train Tracker, Bus Tracker, and Customer Alerts. Built for simplicity, reliability, and minimal external dependencies. --- -## 🚆 Overview +## 🏙️ Overview `cta4j-java-sdk` provides a clean, type-safe interface for accessing CTA's public transit data. -It wraps the official Train and Bus Tracker APIs with intuitive Java models and error handling. +It wraps the official Train Tracker, Bus Tracker, and Customer Alerts APIs with intuitive Java models and error +handling. **Features:** - Simple, dependency-light HTTP client (uses Apache HttpClient 5) - DTOs modeled as Java records -- Works with both **Train Tracker** and **Bus Tracker** APIs +- Works with the **Train Tracker**, **Bus Tracker**, and **Customer Alerts** APIs --- ## 🔑 Getting API Keys -You'll need a free API key from CTA to use the SDK. +You'll need a free API key from CTA to use the Train Tracker or Bus Tracker APIs. The Customer Alerts API is +unauthenticated and needs no key. - **Train Tracker API** → [Apply here](https://www.transitchicago.com/developers/traintrackerapply/) - **Bus Tracker API** → [Apply here](https://www.transitchicago.com/developers/bustracker/) +- **Customer Alerts API** → No API key is needed After applying, you'll receive an API key by email. Keep it safe — you'll use it when initializing the client. @@ -36,13 +45,13 @@ After applying, you'll receive an API key by email. Keep it safe — you'll use com.cta4j cta4j-java-sdk - 6.0.0 + 7.0.0 ``` ### Gradle (Kotlin DSL) ```kotlin -implementation("com.cta4j:cta4j-java-sdk:6.0.0") +implementation("com.cta4j:cta4j-java-sdk:7.0.0") ``` --- @@ -111,6 +120,38 @@ public final class Application { } ``` +### Fetch detailed alerts for a route ID + +> **Note:** `AlertApi` requires no API key — the CTA Customer Alerts API is unauthenticated. + +```java +import com.cta4j.alert.AlertApi; + +public final class Application { + public static void main(String[] args) { + AlertApi alertApi = AlertApi.builder() + .build(); + + alertApi.detailedAlerts() + .findByBusRouteId("70") + .forEach(alert -> System.out.printf( + "Alert ID: %s%nDescription: %s%nFrom: %s%nTo: %s%n%n", + alert.id(), + alert.shortDescription(), + alert.startTime(), + alert.endTime() + )); + + // Example output: + // Alert ID: 114946 + // Description: EB #70 buses will operate via Division, Wells, Oak, and Dearborn. WB buses will operate via Clark, Oak, Wells, and Division. + // From: 2026-08-01T12:30:00Z + // To: 2026-08-01T22:00:00Z + // ... + } +} +``` + --- ## 🧠 Design Goals @@ -123,7 +164,6 @@ public final class Application { ## 🛠️ Planned Improvements -- Add support for more API endpoints, like service alerts - Implement caching for frequently requested data - Add asynchronous request support diff --git a/pom.xml b/pom.xml index df621d44..2f5b5aa5 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ 4.0.0 com.cta4j cta4j-java-sdk - 6.0.0 + 7.0.0 21 UTF-8 diff --git a/src/main/java/com/cta4j/alert/AlertApi.java b/src/main/java/com/cta4j/alert/AlertApi.java new file mode 100644 index 00000000..8e8efee9 --- /dev/null +++ b/src/main/java/com/cta4j/alert/AlertApi.java @@ -0,0 +1,50 @@ +package com.cta4j.alert; + +import com.cta4j.alert.common.internal.impl.AlertApiImpl; +import com.cta4j.alert.detailedalert.DetailedAlertsApi; +import com.cta4j.alert.routestatus.RouteStatusApi; +import org.jspecify.annotations.NullMarked; + +/// Primary entry point for interacting with the CTA Alerts API. +/// +/// This interface provides grouped sub-APIs for different aspects of the CTA Alerts API, such as route status and +/// detailed alerts. +/// +/// Instances of `AlertApi` are immutable and thread-safe once built. Use [#builder()] to construct a configured +/// instance. +@NullMarked +public interface AlertApi { + /// Provides access to route status-related endpoints. + /// + /// @return the [RouteStatusApi] + RouteStatusApi routeStatus(); + + /// Provides access to detailed alert-related endpoints. + /// + /// @return the [DetailedAlertsApi] + DetailedAlertsApi detailedAlerts(); + + /// Builder for constructing [AlertApi] instances. + interface Builder { + /// Sets the API host to use for requests. + /// + /// If not specified, the default CTA Alerts API host is used. + /// + /// @param host the API host + /// @return this `Builder` instance + /// @throws NullPointerException if `host` is `null` + Builder host(String host); + + /// Builds a configured `AlertApi` instance. + /// + /// @return a new `AlertApi` + AlertApi build(); + } + + /// Creates a new `Builder` for constructing an `AlertApi`. + /// + /// @return a new `Builder` + static Builder builder() { + return new AlertApiImpl.BuilderImpl(); + } +} diff --git a/src/main/java/com/cta4j/alert/common/exception/Cta4jAlertException.java b/src/main/java/com/cta4j/alert/common/exception/Cta4jAlertException.java new file mode 100644 index 00000000..dc3c3c23 --- /dev/null +++ b/src/main/java/com/cta4j/alert/common/exception/Cta4jAlertException.java @@ -0,0 +1,54 @@ +package com.cta4j.alert.common.exception; + +import com.cta4j.common.exception.Cta4jException; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/// A custom exception class for handling cta4j alert-specific errors. +@NullMarked +public class Cta4jAlertException extends Cta4jException { + @Nullable + private final Integer rawErrorCode; + + /// Constructs a `Cta4jAlertException`. + /// + /// @param message the detail message + /// @param endpoint the endpoint associated with the exception + /// @throws NullPointerException if `endpoint` is `null` + public Cta4jAlertException(String message, String endpoint) { + super(message, endpoint); + + this.rawErrorCode = null; + } + + /// Constructs a `Cta4jAlertException`. + /// + /// @param message the detail message + /// @param endpoint the endpoint associated with the exception + /// @param cause the cause of the exception + /// @throws NullPointerException if `endpoint` is `null` + public Cta4jAlertException(String message, String endpoint, Throwable cause) { + super(message, endpoint, cause); + + this.rawErrorCode = null; + } + + /// Constructs a `Cta4jAlertException` with a raw error code. + /// + /// @param message the detail message + /// @param endpoint the endpoint associated with the exception + /// @param rawErrorCode the raw error code associated with the exception + /// @throws NullPointerException if `endpoint` is `null` + public Cta4jAlertException(String message, String endpoint, int rawErrorCode) { + super(message, endpoint); + + this.rawErrorCode = rawErrorCode; + } + + /// Returns the raw error code associated with this exception, if available. + /// + /// @return the raw error code, or `null` if not available + public @Nullable Integer getRawErrorCode() { + return this.rawErrorCode; + } +} diff --git a/src/main/java/com/cta4j/alert/common/exception/package-info.java b/src/main/java/com/cta4j/alert/common/exception/package-info.java new file mode 100644 index 00000000..8d8a2f27 --- /dev/null +++ b/src/main/java/com/cta4j/alert/common/exception/package-info.java @@ -0,0 +1,2 @@ +/// Base exception type shared across the CTA Alerts API's detailed alert and route status sub-APIs. +package com.cta4j.alert.common.exception; diff --git a/src/main/java/com/cta4j/alert/common/internal/config/AlertApiConfig.java b/src/main/java/com/cta4j/alert/common/internal/config/AlertApiConfig.java new file mode 100644 index 00000000..ef443a74 --- /dev/null +++ b/src/main/java/com/cta4j/alert/common/internal/config/AlertApiConfig.java @@ -0,0 +1,23 @@ +package com.cta4j.alert.common.internal.config; + +import org.jetbrains.annotations.ApiStatus; +import org.jspecify.annotations.NullMarked; + +import java.util.Objects; + +@ApiStatus.Internal +@NullMarked +public record AlertApiConfig( + String scheme, + String host, + int port +) { + public AlertApiConfig { + Objects.requireNonNull(scheme); + Objects.requireNonNull(host); + } + + public AlertApiConfig(String scheme, String host) { + this(scheme, host, -1); + } +} diff --git a/src/main/java/com/cta4j/alert/common/internal/impl/AlertApiImpl.java b/src/main/java/com/cta4j/alert/common/internal/impl/AlertApiImpl.java new file mode 100644 index 00000000..a17bc57e --- /dev/null +++ b/src/main/java/com/cta4j/alert/common/internal/impl/AlertApiImpl.java @@ -0,0 +1,63 @@ +package com.cta4j.alert.common.internal.impl; + +import com.cta4j.alert.AlertApi; +import com.cta4j.alert.common.internal.config.AlertApiConfig; +import com.cta4j.alert.common.internal.util.AlertApiConstants; +import com.cta4j.alert.detailedalert.DetailedAlertsApi; +import com.cta4j.alert.detailedalert.internal.impl.DetailedAlertsApiImpl; +import com.cta4j.alert.routestatus.RouteStatusApi; +import com.cta4j.alert.routestatus.internal.impl.RouteStatusApiImpl; +import org.jetbrains.annotations.ApiStatus; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +import java.util.Objects; + +@ApiStatus.Internal +@NullMarked +public final class AlertApiImpl implements AlertApi { + private final RouteStatusApi routeStatusApi; + private final DetailedAlertsApi detailedAlertsApi; + + public AlertApiImpl(AlertApiConfig config) { + Objects.requireNonNull(config); + + this.routeStatusApi = new RouteStatusApiImpl(config); + this.detailedAlertsApi = new DetailedAlertsApiImpl(config); + } + + @Override + public RouteStatusApi routeStatus() { + return this.routeStatusApi; + } + + @Override + public DetailedAlertsApi detailedAlerts() { + return this.detailedAlertsApi; + } + + public static final class BuilderImpl implements AlertApi.Builder { + @Nullable + private String host; + + public BuilderImpl() { + this.host = null; + } + + @Override + public Builder host(String host) { + this.host = Objects.requireNonNull(host); + + return this; + } + + @Override + public AlertApi build() { + String finalHost = Objects.requireNonNullElse(this.host, AlertApiConstants.DEFAULT_HOST); + + AlertApiConfig config = new AlertApiConfig(AlertApiConstants.SCHEME, finalHost); + + return new AlertApiImpl(config); + } + } +} diff --git a/src/main/java/com/cta4j/alert/common/internal/mapper/Qualifiers.java b/src/main/java/com/cta4j/alert/common/internal/mapper/Qualifiers.java new file mode 100644 index 00000000..b862069a --- /dev/null +++ b/src/main/java/com/cta4j/alert/common/internal/mapper/Qualifiers.java @@ -0,0 +1,93 @@ +package com.cta4j.alert.common.internal.mapper; + +import com.cta4j.alert.common.model.ServiceType; +import com.cta4j.common.internal.util.BooleanParser; +import com.cta4j.common.internal.util.TimestampParser; +import org.jetbrains.annotations.ApiStatus; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; +import org.mapstruct.Named; + +import java.net.URI; +import java.net.URISyntaxException; +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeFormatterBuilder; +import java.time.temporal.ChronoField; +import java.util.Objects; + +@ApiStatus.Internal +@NullMarked +public final class Qualifiers { + private static final DateTimeFormatter TIMESTAMP_FORMATTER = new DateTimeFormatterBuilder() + .appendPattern("yyyy-MM-dd") + .optionalStart() + .appendPattern("'T'HH:mm:ss") + .optionalEnd() + .parseDefaulting(ChronoField.HOUR_OF_DAY, 0) + .parseDefaulting(ChronoField.MINUTE_OF_HOUR, 0) + .parseDefaulting(ChronoField.SECOND_OF_MINUTE, 0) + .toFormatter(); + + 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("mapUri") + public static URI mapUri(String value) { + Objects.requireNonNull(value); + + try { + return new URI(value); + } catch (URISyntaxException e) { + String message = "Failed to parse URI: %s".formatted(value); + + throw new IllegalArgumentException(message, e); + } + } + + @Named("mapTimestamp") + public static @Nullable Instant mapTimestamp(@Nullable String timestamp) { + return TimestampParser.parseNullable(timestamp, TIMESTAMP_FORMATTER, CHICAGO_ZONE_ID); + } + + @Named("map01ToBoolean") + public static boolean map01ToBoolean(String value) { + Objects.requireNonNull(value); + + return BooleanParser.parse01(value); + } + + @Named("mapScore") + public static int mapScore(String value) { + Objects.requireNonNull(value); + + try { + return Integer.parseInt(value); + } catch (NumberFormatException e) { + String message = "Failed to parse score: %s".formatted(value); + + throw new IllegalArgumentException(message, e); + } + } + + @Named("mapServiceType") + public static ServiceType mapServiceType(String serviceType) { + Objects.requireNonNull(serviceType); + + return switch (serviceType) { + case "B" -> ServiceType.BUS; + case "R" -> ServiceType.RAIL; + case "T" -> ServiceType.STATION; + case "X" -> ServiceType.SYSTEMWIDE; + default -> { + String message = "Unknown service type: %s".formatted(serviceType); + + throw new IllegalArgumentException(message); + } + }; + } +} diff --git a/src/main/java/com/cta4j/alert/common/internal/util/AlertApiConstants.java b/src/main/java/com/cta4j/alert/common/internal/util/AlertApiConstants.java new file mode 100644 index 00000000..71a928be --- /dev/null +++ b/src/main/java/com/cta4j/alert/common/internal/util/AlertApiConstants.java @@ -0,0 +1,20 @@ +package com.cta4j.alert.common.internal.util; + +import org.jetbrains.annotations.ApiStatus; +import org.jspecify.annotations.NullMarked; + +@ApiStatus.Internal +@NullMarked +public final class AlertApiConstants { + public static final String SCHEME = "https"; + public static final String DEFAULT_HOST = "www.transitchicago.com"; + + private static final String API_PREFIX = "/api/1.0"; + + public static final String DETAILED_ALERTS_ENDPOINT = "%s/alerts.aspx".formatted(API_PREFIX); + public static final String ROUTE_STATUS_ENDPOINT = "%s/routes.aspx".formatted(API_PREFIX); + + private AlertApiConstants() { + throw new UnsupportedOperationException("This is a utility class and cannot be instantiated"); + } +} diff --git a/src/main/java/com/cta4j/alert/common/internal/wire/CtaCdata.java b/src/main/java/com/cta4j/alert/common/internal/wire/CtaCdata.java new file mode 100644 index 00000000..6e26e89f --- /dev/null +++ b/src/main/java/com/cta4j/alert/common/internal/wire/CtaCdata.java @@ -0,0 +1,19 @@ +package com.cta4j.alert.common.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; + +@JsonIgnoreProperties(ignoreUnknown = true) +@ApiStatus.Internal +@NullMarked +public record CtaCdata( + @JsonProperty("#cdata-section") String cdataSection +) { + public CtaCdata { + Objects.requireNonNull(cdataSection); + } +} diff --git a/src/main/java/com/cta4j/alert/common/model/AlertTrainLine.java b/src/main/java/com/cta4j/alert/common/model/AlertTrainLine.java new file mode 100644 index 00000000..ec48ebfc --- /dev/null +++ b/src/main/java/com/cta4j/alert/common/model/AlertTrainLine.java @@ -0,0 +1,75 @@ +package com.cta4j.alert.common.model; + +import org.jspecify.annotations.NullMarked; + +import java.util.Objects; + +/// Represents a train line as filterable through the CTA Alerts API. +/// +/// Unlike the Train Tracker API, which has no concept of express service, the Alerts API treats the Purple Line +/// Express as a distinct route designator (`"Pexp"`) from the regular Purple Line (`"P"`); per CTA's documentation, +/// alerts affecting the Purple Line may be tagged with either designator, or both. +@NullMarked +public enum AlertTrainLine { + /// Indicates the Red Line. + RED("Red"), + + /// Indicates the Blue Line. + BLUE("Blue"), + + /// Indicates the Brown Line. + BROWN("Brn"), + + /// Indicates the Green Line. + GREEN("G"), + + /// Indicates the Orange Line. + ORANGE("Org"), + + /// Indicates the Purple Line, excluding express service. + PURPLE("P"), + + /// Indicates the Purple Line Express. + PURPLE_EXPRESS("Pexp"), + + /// Indicates the Pink Line. + PINK("Pink"), + + /// Indicates the Yellow Line. + YELLOW("Y"); + + private final String code; + + AlertTrainLine(String code) { + this.code = Objects.requireNonNull(code); + } + + /// Returns the CTA Alerts API route designator for this train line. + /// + /// @return the route designator + public String getCode() { + return this.code; + } + + /// Returns the `AlertTrainLine` corresponding to the given route designator. + /// + /// @param code the CTA Alerts API route designator of the train line (case-insensitive) + /// @return the corresponding `AlertTrainLine` + /// @throws IllegalArgumentException if the code does not correspond to any known train line + public static AlertTrainLine fromCode(String code) { + Objects.requireNonNull(code); + + return switch (code.toUpperCase()) { + case "RED" -> AlertTrainLine.RED; + case "BLUE" -> AlertTrainLine.BLUE; + case "BRN" -> AlertTrainLine.BROWN; + case "G" -> AlertTrainLine.GREEN; + case "ORG" -> AlertTrainLine.ORANGE; + case "P" -> AlertTrainLine.PURPLE; + case "PEXP" -> AlertTrainLine.PURPLE_EXPRESS; + case "PINK" -> AlertTrainLine.PINK; + case "Y" -> AlertTrainLine.YELLOW; + default -> throw new IllegalArgumentException("Invalid alert train line: %s".formatted(code)); + }; + } +} diff --git a/src/main/java/com/cta4j/alert/common/model/ServiceType.java b/src/main/java/com/cta4j/alert/common/model/ServiceType.java new file mode 100644 index 00000000..fe787465 --- /dev/null +++ b/src/main/java/com/cta4j/alert/common/model/ServiceType.java @@ -0,0 +1,19 @@ +package com.cta4j.alert.common.model; + +import org.jspecify.annotations.NullMarked; + +/// Represents a category of CTA service - a bus route, train route, train station, or systemwide grouping. +@NullMarked +public enum ServiceType { + /// Indicates bus routes. + BUS, + + /// Indicates rail (train) routes. + RAIL, + + /// Indicates train stations. + STATION, + + /// Indicates systemwide categories, such as all routes, all bus routes, or all train routes. + SYSTEMWIDE +} diff --git a/src/main/java/com/cta4j/alert/common/model/package-info.java b/src/main/java/com/cta4j/alert/common/model/package-info.java new file mode 100644 index 00000000..e34a133f --- /dev/null +++ b/src/main/java/com/cta4j/alert/common/model/package-info.java @@ -0,0 +1,3 @@ +/// Domain model types shared across the CTA Alerts API's detailed alert and route status sub-APIs, such as train +/// line and service type designators. +package com.cta4j.alert.common.model; diff --git a/src/main/java/com/cta4j/alert/common/package-info.java b/src/main/java/com/cta4j/alert/common/package-info.java new file mode 100644 index 00000000..17f23843 --- /dev/null +++ b/src/main/java/com/cta4j/alert/common/package-info.java @@ -0,0 +1,3 @@ +/// Shared types, configuration, and internal plumbing used across the CTA Alerts API's detailed alert and route +/// status sub-APIs. +package com.cta4j.alert.common; diff --git a/src/main/java/com/cta4j/alert/detailedalert/DetailedAlertsApi.java b/src/main/java/com/cta4j/alert/detailedalert/DetailedAlertsApi.java new file mode 100644 index 00000000..6cc24046 --- /dev/null +++ b/src/main/java/com/cta4j/alert/detailedalert/DetailedAlertsApi.java @@ -0,0 +1,171 @@ +package com.cta4j.alert.detailedalert; + +import com.cta4j.alert.common.model.AlertTrainLine; +import com.cta4j.alert.detailedalert.exception.Cta4jDetailedAlertsException; +import com.cta4j.alert.detailedalert.model.Alert; +import com.cta4j.alert.detailedalert.query.AlertsQuery; +import com.cta4j.alert.detailedalert.query.BusRouteAlertsQuery; +import com.cta4j.alert.detailedalert.query.LineAlertsQuery; +import com.cta4j.alert.detailedalert.query.StationAlertsQuery; +import org.jspecify.annotations.NullMarked; + +import java.util.Collection; +import java.util.List; +import java.util.Objects; + +/// Provides access to detailed alert-related endpoints of the CTA Alerts API. +/// +/// This API allows retrieval of all alerts, or filtered by bus route ID, train line, or station ID. +@NullMarked +public interface DetailedAlertsApi { + /// Retrieves alerts matching the given query parameters. + /// + /// @param query the query parameters for fetching alerts + /// @return a [List] of [Alert]s matching the query, or an empty [List] if no alerts are found + /// @throws NullPointerException if `query` is `null` + /// @throws Cta4jDetailedAlertsException if the API returns an error response or the response cannot be parsed + List list(AlertsQuery query); + + /// Retrieves alerts using the default query parameters. + /// + /// @return a [List] of [Alert]s matching the default query, or an empty [List] if no alerts are found + /// @throws Cta4jDetailedAlertsException if the API returns an error response or the response cannot be parsed + default List list() { + AlertsQuery query = AlertsQuery.builder() + .build(); + + return this.list(query); + } + + /// Retrieves alerts by bus route IDs. + /// + /// @param query the query parameters for fetching alerts by bus route IDs + /// @return a [List] of [Alert]s corresponding to the provided bus route IDs, + /// or an empty [List] if no alerts are found + /// @throws NullPointerException if `query` is `null` + /// @throws IllegalArgumentException if any of the query's route IDs matches a train line code (e.g., "Red"); + /// use [#findByLines(LineAlertsQuery)] instead + /// @throws Cta4jDetailedAlertsException if the API returns an error response or the response cannot be parsed + List findByBusRouteIds(BusRouteAlertsQuery query); + + /// Retrieves alerts by bus route IDs. + /// + /// @param routeIds a [Collection] of bus route IDs + /// @return a [List] of [Alert]s corresponding to the provided bus route IDs, + /// or an empty [List] if no alerts are found + /// @throws NullPointerException if `routeIds` is `null`, or if any element of `routeIds` is `null` + /// @throws IllegalArgumentException if any of the `routeIds` matches a train line code (e.g., "Red"); + /// use [#findByLines(Collection)] instead + /// @throws Cta4jDetailedAlertsException if the API returns an error response or the response cannot be parsed + default List findByBusRouteIds(Collection routeIds) { + Objects.requireNonNull(routeIds); + + List routeIdsList = List.copyOf(routeIds); + + BusRouteAlertsQuery query = BusRouteAlertsQuery.builder(routeIdsList) + .build(); + + return this.findByBusRouteIds(query); + } + + /// Retrieves alerts by bus route ID. + /// + /// @param routeId the bus route ID + /// @return a [List] of [Alert]s corresponding to the provided bus route ID, + /// or an empty [List] if no alerts are found + /// @throws NullPointerException if `routeId` is `null` + /// @throws IllegalArgumentException if `routeId` matches a train line code (e.g., "Red"); + /// use [#findByLine(AlertTrainLine)] instead + /// @throws Cta4jDetailedAlertsException if the API returns an error response or the response cannot be parsed + default List findByBusRouteId(String routeId) { + Objects.requireNonNull(routeId); + + List routeIds = List.of(routeId); + + return this.findByBusRouteIds(routeIds); + } + + /// Retrieves alerts by train lines. + /// + /// @param query the query parameters for fetching alerts by train lines + /// @return a [List] of [Alert]s corresponding to the provided train lines, or an empty [List] if no alerts are + /// found + /// @throws NullPointerException if `query` is `null` + /// @throws Cta4jDetailedAlertsException if the API returns an error response or the response cannot be parsed + List findByLines(LineAlertsQuery query); + + /// Retrieves alerts by train lines. + /// + /// @param lines a [Collection] of train lines + /// @return a [List] of [Alert]s corresponding to the provided train lines, or an empty [List] if no alerts are + /// found + /// @throws NullPointerException if `lines` is `null`, or if any element of `lines` is `null` + /// @throws Cta4jDetailedAlertsException if the API returns an error response or the response cannot be parsed + default List findByLines(Collection lines) { + Objects.requireNonNull(lines); + + List linesList = List.copyOf(lines); + + LineAlertsQuery query = LineAlertsQuery.builder(linesList) + .build(); + + return this.findByLines(query); + } + + /// Retrieves alerts by train line. + /// + /// @param line the train line + /// @return a [List] of [Alert]s corresponding to the provided train line, or an empty [List] if no alerts are + /// found + /// @throws NullPointerException if `line` is `null` + /// @throws Cta4jDetailedAlertsException if the API returns an error response or the response cannot be parsed + default List findByLine(AlertTrainLine line) { + Objects.requireNonNull(line); + + List lines = List.of(line); + + return this.findByLines(lines); + } + + /// Retrieves alerts by station IDs. + /// + /// @param query the query parameters for fetching alerts by station IDs + /// @return a [List] of [Alert]s corresponding to the provided station IDs, or an empty [List] if no alerts are + /// found + /// @throws NullPointerException if `query` is `null` + /// @throws Cta4jDetailedAlertsException if the API returns an error response or the response cannot be parsed + List findByStationIds(StationAlertsQuery query); + + /// Retrieves alerts by station IDs. + /// + /// @param stationIds a [Collection] of station IDs + /// @return a [List] of [Alert]s corresponding to the provided station IDs, or an empty [List] if no alerts are + /// found + /// @throws NullPointerException if `stationIds` is `null`, or if any element of `stationIds` is `null` + /// @throws Cta4jDetailedAlertsException if the API returns an error response or the response cannot be parsed + default List findByStationIds(Collection stationIds) { + Objects.requireNonNull(stationIds); + + List stationIdsList = List.copyOf(stationIds); + + StationAlertsQuery query = StationAlertsQuery.builder(stationIdsList) + .build(); + + return this.findByStationIds(query); + } + + /// Retrieves alerts by station ID. + /// + /// @param stationId the station ID + /// @return a [List] of [Alert]s corresponding to the provided station ID, or an empty [List] if no alerts are + /// found + /// @throws NullPointerException if `stationId` is `null` + /// @throws Cta4jDetailedAlertsException if the API returns an error response or the response cannot be parsed + default List findByStationId(String stationId) { + Objects.requireNonNull(stationId); + + List stationIds = List.of(stationId); + + return this.findByStationIds(stationIds); + } +} diff --git a/src/main/java/com/cta4j/alert/detailedalert/exception/Cta4jDetailedAlertsException.java b/src/main/java/com/cta4j/alert/detailedalert/exception/Cta4jDetailedAlertsException.java new file mode 100644 index 00000000..e2c905e9 --- /dev/null +++ b/src/main/java/com/cta4j/alert/detailedalert/exception/Cta4jDetailedAlertsException.java @@ -0,0 +1,40 @@ +package com.cta4j.alert.detailedalert.exception; + +import com.cta4j.alert.common.exception.Cta4jAlertException; +import com.cta4j.alert.common.internal.util.AlertApiConstants; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/// A custom exception class for handling cta4j detailed alerts-specific errors. +@NullMarked +public final class Cta4jDetailedAlertsException extends Cta4jAlertException { + @Nullable + private final DetailedAlertsErrorCode errorCode; + + /// Constructs a `Cta4jDetailedAlertsException`. + /// + /// @param message the detail message + /// @param cause the cause of the exception + public Cta4jDetailedAlertsException(String message, Throwable cause) { + super(message, AlertApiConstants.DETAILED_ALERTS_ENDPOINT, cause); + + this.errorCode = null; + } + + /// Constructs a `Cta4jDetailedAlertsException`. + /// + /// @param message the detail message + /// @param rawErrorCode the raw error code associated with the exception + public Cta4jDetailedAlertsException(String message, int rawErrorCode) { + super(message, AlertApiConstants.DETAILED_ALERTS_ENDPOINT, rawErrorCode); + + this.errorCode = DetailedAlertsErrorCode.fromCode(rawErrorCode); + } + + /// Returns the error code associated with this exception, if available. + /// + /// @return the error code, or `null` if not available + public @Nullable DetailedAlertsErrorCode getErrorCode() { + return this.errorCode; + } +} diff --git a/src/main/java/com/cta4j/alert/detailedalert/exception/DetailedAlertsErrorCode.java b/src/main/java/com/cta4j/alert/detailedalert/exception/DetailedAlertsErrorCode.java new file mode 100644 index 00000000..525e03fe --- /dev/null +++ b/src/main/java/com/cta4j/alert/detailedalert/exception/DetailedAlertsErrorCode.java @@ -0,0 +1,86 @@ +package com.cta4j.alert.detailedalert.exception; + +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/// Represents the error codes returned by the CTA Detailed Alerts API. +@NullMarked +public enum DetailedAlertsErrorCode { + /// Indicates that the request was successful and there were no errors. + OK(0), + + /// Indicates that there are no active alerts. + NO_ACTIVE_ALERTS(25), + + /// Indicates that there are no active alerts based on the provided filter criteria. + NO_ACTIVE_ALERTS_FOR_FILTER(50), + + /// Indicates that the provided `activeonly` value is invalid. + INVALID_ACTIVEONLY(100), + + /// Indicates that the provided `accessibility` value is invalid. + INVALID_ACCESSIBILITY(101), + + /// Indicates that the provided `planned` value is invalid. + INVALID_PLANNED(102), + + /// Indicates that the provided station ID is not an integer. + STATIONID_NOT_INTEGER(103), + + /// Indicates that the provided `bystartdate` value is not a valid date in `yyyyMMdd` format. + INVALID_BYSTARTDATE(104), + + /// Indicates that the provided `recentdays` value is not an integer. + RECENTDAYS_NOT_INTEGER(105), + + /// Indicates that the `routeid` and `stationid` parameters were both provided, which is not allowed. + ROUTEID_STATIONID_CONFLICT(106), + + /// Indicates that the `recentdays` and `bystartdate` parameters were both provided, which is not allowed. + RECENTDAYS_BYSTARTDATE_CONFLICT(107), + + /// Indicates that the query string contains a parameter that is not recognized by the API. The supported API + /// parameters are `activeonly`, `accessibility`, `planned`, `routeid`, `stationid`, `bystartdate`, `recentdays`, + /// and `outputType`. + INVALID_PARAMETER(500), + + /// Indicates that the server encountered an unexpected error that prevented it from fulfilling the request. + SERVER_ERROR(900); + + private final int code; + + DetailedAlertsErrorCode(int code) { + this.code = code; + } + + /// Returns the integer code associated with this error code. + /// + /// @return the integer code + public int getCode() { + return this.code; + } + + /// Returns the `DetailedAlertsErrorCode` corresponding to the given integer code. + /// + /// @param code the integer code to look up + /// @return the corresponding `DetailedAlertsErrorCode`, or `null` if the code does not match any defined error + /// code + public static @Nullable DetailedAlertsErrorCode fromCode(int code) { + return switch (code) { + case 0 -> OK; + case 25 -> NO_ACTIVE_ALERTS; + case 50 -> NO_ACTIVE_ALERTS_FOR_FILTER; + case 100 -> INVALID_ACTIVEONLY; + case 101 -> INVALID_ACCESSIBILITY; + case 102 -> INVALID_PLANNED; + case 103 -> STATIONID_NOT_INTEGER; + case 104 -> INVALID_BYSTARTDATE; + case 105 -> RECENTDAYS_NOT_INTEGER; + case 106 -> ROUTEID_STATIONID_CONFLICT; + case 107 -> RECENTDAYS_BYSTARTDATE_CONFLICT; + case 500 -> INVALID_PARAMETER; + case 900 -> SERVER_ERROR; + default -> null; + }; + } +} diff --git a/src/main/java/com/cta4j/alert/detailedalert/exception/package-info.java b/src/main/java/com/cta4j/alert/detailedalert/exception/package-info.java new file mode 100644 index 00000000..d8ffa051 --- /dev/null +++ b/src/main/java/com/cta4j/alert/detailedalert/exception/package-info.java @@ -0,0 +1,2 @@ +/// Exception type and error code enum thrown by the CTA Detailed Alerts API. +package com.cta4j.alert.detailedalert.exception; diff --git a/src/main/java/com/cta4j/alert/detailedalert/internal/impl/DetailedAlertsApiImpl.java b/src/main/java/com/cta4j/alert/detailedalert/internal/impl/DetailedAlertsApiImpl.java new file mode 100644 index 00000000..7cfe27de --- /dev/null +++ b/src/main/java/com/cta4j/alert/detailedalert/internal/impl/DetailedAlertsApiImpl.java @@ -0,0 +1,251 @@ +package com.cta4j.alert.detailedalert.internal.impl; + +import com.cta4j.alert.common.internal.config.AlertApiConfig; +import com.cta4j.alert.common.internal.util.AlertApiConstants; +import com.cta4j.alert.common.model.AlertTrainLine; +import com.cta4j.alert.detailedalert.DetailedAlertsApi; +import com.cta4j.alert.detailedalert.exception.Cta4jDetailedAlertsException; +import com.cta4j.alert.detailedalert.exception.DetailedAlertsErrorCode; +import com.cta4j.alert.detailedalert.internal.mapper.AlertMapper; +import com.cta4j.alert.detailedalert.internal.wire.CtaAlert; +import com.cta4j.alert.detailedalert.internal.wire.CtaAlerts; +import com.cta4j.alert.detailedalert.internal.wire.CtaDetailedAlertsResponse; +import com.cta4j.alert.detailedalert.model.Alert; +import com.cta4j.alert.detailedalert.query.AlertsQuery; +import com.cta4j.alert.detailedalert.query.BusRouteAlertsQuery; +import com.cta4j.alert.detailedalert.query.LineAlertsQuery; +import com.cta4j.alert.detailedalert.query.StationAlertsQuery; +import org.apache.hc.client5.http.fluent.Request; +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.databind.json.JsonMapper; + +import java.io.IOException; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; + +@ApiStatus.Internal +@NullMarked +public final class DetailedAlertsApiImpl implements DetailedAlertsApi { + private final AlertApiConfig config; + + public DetailedAlertsApiImpl(AlertApiConfig config) { + this.config = Objects.requireNonNull(config); + } + + @Override + public List list(AlertsQuery query) { + Objects.requireNonNull(query); + + String activeOnlyString = String.valueOf(query.activeOnly()); + String accessibilityString = String.valueOf(query.accessibility()); + String plannedString = String.valueOf(query.planned()); + + URIBuilder builder = new URIBuilder() + .setScheme(this.config.scheme()) + .setHost(this.config.host()) + .setPort(this.config.port()) + .setPath(AlertApiConstants.DETAILED_ALERTS_ENDPOINT) + .addParameter("activeonly", activeOnlyString) + .addParameter("accessibility", accessibilityString) + .addParameter("planned", plannedString) + .addParameter("outputType", "JSON"); + + return this.makeRequest(builder, query.byStartDate(), query.recentDays()); + } + + @Override + public List findByBusRouteIds(BusRouteAlertsQuery query) { + Objects.requireNonNull(query); + + List routeIds = query.routeIds(); + + if (routeIds.isEmpty()) { + return List.of(); + } + + for (String routeId : routeIds) { + if (isTrainLine(routeId)) { + String message = """ + %s is a train line, not a bus route; \ + use findByLines(Collection) instead""".formatted(routeId); + + throw new IllegalArgumentException(message); + } + } + + return this.makeRequest( + query.activeOnly(), + query.accessibility(), + query.planned(), + routeIds, + "routeid", + query.byStartDate(), + query.recentDays() + ); + } + + @Override + public List findByLines(LineAlertsQuery query) { + Objects.requireNonNull(query); + + List lines = query.lines(); + + if (lines.isEmpty()) { + return List.of(); + } + + List lineStrings = lines.stream() + .map(AlertTrainLine::getCode) + .toList(); + + return this.makeRequest( + query.activeOnly(), + query.accessibility(), + query.planned(), + lineStrings, + "routeid", + query.byStartDate(), + query.recentDays() + ); + } + + @Override + public List findByStationIds(StationAlertsQuery query) { + Objects.requireNonNull(query); + + List stationIds = query.stationIds(); + + if (stationIds.isEmpty()) { + return List.of(); + } + + return this.makeRequest( + query.activeOnly(), + query.accessibility(), + query.planned(), + stationIds, + "stationid", + query.byStartDate(), + query.recentDays() + ); + } + + private List makeRequest( + boolean activeOnly, + boolean accessibility, + boolean planned, + List ids, + String idsParameterName, + @Nullable LocalDate byStartDate, + @Nullable Integer recentDays + ) { + String activeOnlyString = String.valueOf(activeOnly); + String accessibilityString = String.valueOf(accessibility); + String plannedString = String.valueOf(planned); + String idsString = String.join(",", ids); + + URIBuilder builder = new URIBuilder() + .setScheme(this.config.scheme()) + .setHost(this.config.host()) + .setPort(this.config.port()) + .setPath(AlertApiConstants.DETAILED_ALERTS_ENDPOINT) + .addParameter("activeonly", activeOnlyString) + .addParameter("accessibility", accessibilityString) + .addParameter("planned", plannedString) + .addParameter(idsParameterName, idsString) + .addParameter("outputType", "JSON"); + + return this.makeRequest(builder, byStartDate, recentDays); + } + + private List makeRequest( + URIBuilder builder, + @Nullable LocalDate byStartDate, + @Nullable Integer recentDays + ) { + if (byStartDate != null) { + String byStartDateString = DateTimeFormatter.BASIC_ISO_DATE.format(byStartDate); + + builder.addParameter("bystartdate", byStartDateString); + } + + if (recentDays != null) { + String recentDaysString = String.valueOf(recentDays); + + builder.addParameter("recentdays", recentDaysString); + } + + String url = builder.toString(); + + String response; + + try { + response = Request.get(url) + .execute() + .returnContent() + .asString(); + } catch (IOException e) { + String message = Objects.requireNonNullElse(e.getMessage(), "Request failed"); + + throw new Cta4jDetailedAlertsException(message, e); + } + + CtaDetailedAlertsResponse detailedAlertsResponse; + + try { + detailedAlertsResponse = JsonMapper.shared() + .readValue(response, CtaDetailedAlertsResponse.class); + } catch (JacksonException e) { + throw new Cta4jDetailedAlertsException("Failed to parse response", e); + } + + CtaAlerts ctaAlerts = detailedAlertsResponse.ctaAlerts(); + + List alert = ctaAlerts.alert(); + + if (alert != null && !alert.isEmpty()) { + return alert.stream() + .map(AlertMapper.INSTANCE::toDomain) + .toList(); + } + + String errorCodeString = ctaAlerts.errorCode(); + + int integerCode; + + try { + integerCode = Integer.parseInt(errorCodeString); + } catch (NumberFormatException e) { + throw new Cta4jDetailedAlertsException("Failed to parse error code", e); + } + + DetailedAlertsErrorCode errorCode = DetailedAlertsErrorCode.fromCode(integerCode); + + if (errorCode == DetailedAlertsErrorCode.OK + || errorCode == DetailedAlertsErrorCode.NO_ACTIVE_ALERTS + || errorCode == DetailedAlertsErrorCode.NO_ACTIVE_ALERTS_FOR_FILTER) { + return List.of(); + } + + String errorMessage = ctaAlerts.errorMessage(); + + String message = errorMessage == null || errorMessage.isBlank() + ? "An unknown error occurred." + : errorMessage; + + throw new Cta4jDetailedAlertsException(message, integerCode); + } + + private static boolean isTrainLine(String routeId) { + return Arrays.stream(AlertTrainLine.values()) + .map(AlertTrainLine::getCode) + .anyMatch(code -> code.equalsIgnoreCase(routeId)); + } +} diff --git a/src/main/java/com/cta4j/alert/detailedalert/internal/mapper/AlertMapper.java b/src/main/java/com/cta4j/alert/detailedalert/internal/mapper/AlertMapper.java new file mode 100644 index 00000000..181c769d --- /dev/null +++ b/src/main/java/com/cta4j/alert/detailedalert/internal/mapper/AlertMapper.java @@ -0,0 +1,38 @@ +package com.cta4j.alert.detailedalert.internal.mapper; + +import com.cta4j.alert.common.internal.mapper.Qualifiers; +import com.cta4j.alert.detailedalert.internal.wire.CtaAlert; +import com.cta4j.alert.detailedalert.internal.wire.CtaImpactedService; +import com.cta4j.alert.detailedalert.model.Alert; +import com.cta4j.alert.detailedalert.model.ImpactedService; +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 AlertMapper { + AlertMapper INSTANCE = Mappers.getMapper(AlertMapper.class); + + @Mapping(target = "id", source = "alertId") + @Mapping(target = "fullDescription", source = "fullDescription.cdataSection") + @Mapping(target = "severity.score", source = "severityScore", qualifiedByName = "mapScore") + @Mapping(target = "severity.color", source = "severityColor") + @Mapping(target = "severity.css", source = "severityCss") + @Mapping(target = "startTime", source = "eventStart", qualifiedByName = "mapTimestamp") + @Mapping(target = "endTime", source = "eventEnd", qualifiedByName = "mapTimestamp") + @Mapping(target = "openEnded", source = "tbd", qualifiedByName = "map01ToBoolean") + @Mapping(target = "major", source = "majorAlert", qualifiedByName = "map01ToBoolean") + @Mapping(target = "url", source = "alertUrl.cdataSection", qualifiedByName = "mapUri") + @Mapping(target = "impactedServices", source = "impactedService.service") + Alert toDomain(CtaAlert alert); + + @Mapping(target = "type", source = "serviceType", qualifiedByName = "mapServiceType") + @Mapping(target = "typeDescription", source = "serviceTypeDescription") + @Mapping(target = "name", source = "serviceName") + @Mapping(target = "color", source = "serviceBackColor") + @Mapping(target = "textColor", source = "serviceTextColor") + @Mapping(target = "url", source = "serviceUrl.cdataSection", qualifiedByName = "mapUri") + ImpactedService toDomain(CtaImpactedService impactedService); +} diff --git a/src/main/java/com/cta4j/alert/detailedalert/internal/wire/CtaAlert.java b/src/main/java/com/cta4j/alert/detailedalert/internal/wire/CtaAlert.java new file mode 100644 index 00000000..569b69b2 --- /dev/null +++ b/src/main/java/com/cta4j/alert/detailedalert/internal/wire/CtaAlert.java @@ -0,0 +1,82 @@ +package com.cta4j.alert.detailedalert.internal.wire; + +import com.cta4j.alert.common.internal.wire.CtaCdata; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import org.jetbrains.annotations.ApiStatus; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +import java.util.Objects; + +@JsonIgnoreProperties(ignoreUnknown = true) +@ApiStatus.Internal +@NullMarked +public record CtaAlert( + @JsonProperty("AlertId") + String alertId, + + @JsonProperty("Headline") + String headline, + + @JsonProperty("ShortDescription") + String shortDescription, + + @JsonProperty("FullDescription") + CtaCdata fullDescription, + + @JsonProperty("SeverityScore") + String severityScore, + + @JsonProperty("SeverityColor") + String severityColor, + + @JsonProperty("SeverityCSS") + String severityCss, + + @JsonProperty("Impact") + String impact, + + @JsonProperty("EventStart") + String eventStart, + + @JsonProperty("EventEnd") + @Nullable + String eventEnd, + + @JsonProperty("TBD") + String tbd, + + @JsonProperty("MajorAlert") + String majorAlert, + + @JsonProperty("AlertURL") + CtaCdata alertUrl, + + @JsonProperty("ImpactedService") + CtaImpactedServices impactedService, + + @JsonProperty("ttim") + @Nullable + String ttim, + + @JsonProperty("GUID") + @Nullable + String guid +) { + public CtaAlert { + Objects.requireNonNull(alertId); + Objects.requireNonNull(headline); + Objects.requireNonNull(shortDescription); + Objects.requireNonNull(fullDescription); + Objects.requireNonNull(severityScore); + Objects.requireNonNull(severityColor); + Objects.requireNonNull(severityCss); + Objects.requireNonNull(impact); + Objects.requireNonNull(eventStart); + Objects.requireNonNull(tbd); + Objects.requireNonNull(majorAlert); + Objects.requireNonNull(alertUrl); + Objects.requireNonNull(impactedService); + } +} diff --git a/src/main/java/com/cta4j/alert/detailedalert/internal/wire/CtaAlerts.java b/src/main/java/com/cta4j/alert/detailedalert/internal/wire/CtaAlerts.java new file mode 100644 index 00000000..f7285509 --- /dev/null +++ b/src/main/java/com/cta4j/alert/detailedalert/internal/wire/CtaAlerts.java @@ -0,0 +1,38 @@ +package com.cta4j.alert.detailedalert.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 org.jspecify.annotations.Nullable; + +import java.util.List; +import java.util.Objects; + +@JsonIgnoreProperties(ignoreUnknown = true) +@ApiStatus.Internal +@NullMarked +public record CtaAlerts( + @JsonProperty("TimeStamp") + String timestamp, + + @JsonProperty("ErrorCode") + String errorCode, + + @JsonProperty("ErrorMessage") + @Nullable + String errorMessage, + + @JsonProperty("Alert") + @Nullable + List alert +) { + public CtaAlerts { + Objects.requireNonNull(timestamp); + Objects.requireNonNull(errorCode); + + if (alert != null) { + alert = List.copyOf(alert); + } + } +} diff --git a/src/main/java/com/cta4j/alert/detailedalert/internal/wire/CtaDetailedAlertsResponse.java b/src/main/java/com/cta4j/alert/detailedalert/internal/wire/CtaDetailedAlertsResponse.java new file mode 100644 index 00000000..369012d4 --- /dev/null +++ b/src/main/java/com/cta4j/alert/detailedalert/internal/wire/CtaDetailedAlertsResponse.java @@ -0,0 +1,19 @@ +package com.cta4j.alert.detailedalert.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; + +@JsonIgnoreProperties(ignoreUnknown = true) +@ApiStatus.Internal +@NullMarked +public record CtaDetailedAlertsResponse( + @JsonProperty("CTAAlerts") CtaAlerts ctaAlerts +) { + public CtaDetailedAlertsResponse { + Objects.requireNonNull(ctaAlerts); + } +} diff --git a/src/main/java/com/cta4j/alert/detailedalert/internal/wire/CtaImpactedService.java b/src/main/java/com/cta4j/alert/detailedalert/internal/wire/CtaImpactedService.java new file mode 100644 index 00000000..2038eb3c --- /dev/null +++ b/src/main/java/com/cta4j/alert/detailedalert/internal/wire/CtaImpactedService.java @@ -0,0 +1,32 @@ +package com.cta4j.alert.detailedalert.internal.wire; + +import com.cta4j.alert.common.internal.wire.CtaCdata; +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; + +@JsonIgnoreProperties(ignoreUnknown = true) +@ApiStatus.Internal +@NullMarked +public record CtaImpactedService( + @JsonProperty("ServiceType") String serviceType, + @JsonProperty("ServiceTypeDescription") String serviceTypeDescription, + @JsonProperty("ServiceName") String serviceName, + @JsonProperty("ServiceId") String serviceId, + @JsonProperty("ServiceBackColor") String serviceBackColor, + @JsonProperty("ServiceTextColor") String serviceTextColor, + @JsonProperty("ServiceURL") CtaCdata serviceUrl +) { + public CtaImpactedService { + Objects.requireNonNull(serviceType); + Objects.requireNonNull(serviceTypeDescription); + Objects.requireNonNull(serviceName); + Objects.requireNonNull(serviceId); + Objects.requireNonNull(serviceBackColor); + Objects.requireNonNull(serviceTextColor); + Objects.requireNonNull(serviceUrl); + } +} diff --git a/src/main/java/com/cta4j/alert/detailedalert/internal/wire/CtaImpactedServices.java b/src/main/java/com/cta4j/alert/detailedalert/internal/wire/CtaImpactedServices.java new file mode 100644 index 00000000..c01ea58d --- /dev/null +++ b/src/main/java/com/cta4j/alert/detailedalert/internal/wire/CtaImpactedServices.java @@ -0,0 +1,25 @@ +package com.cta4j.alert.detailedalert.internal.wire; + +import com.fasterxml.jackson.annotation.JsonFormat; +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.List; +import java.util.Objects; + +@JsonIgnoreProperties(ignoreUnknown = true) +@ApiStatus.Internal +@NullMarked +public record CtaImpactedServices( + @JsonProperty("Service") + @JsonFormat(with = JsonFormat.Feature.ACCEPT_SINGLE_VALUE_AS_ARRAY) + List service +) { + public CtaImpactedServices { + Objects.requireNonNull(service); + + service = List.copyOf(service); + } +} diff --git a/src/main/java/com/cta4j/alert/detailedalert/model/Alert.java b/src/main/java/com/cta4j/alert/detailedalert/model/Alert.java new file mode 100644 index 00000000..7ea5f724 --- /dev/null +++ b/src/main/java/com/cta4j/alert/detailedalert/model/Alert.java @@ -0,0 +1,81 @@ +package com.cta4j.alert.detailedalert.model; + +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +import java.net.URI; +import java.time.Instant; +import java.util.List; +import java.util.Objects; + +/// Represents a detailed alert describing an event that affects one or more CTA services. +/// +/// @param id the unique ID of this alert (e.g., "115070") +/// @param headline the headline of this alert +/// @param shortDescription the short description of this alert +/// @param fullDescription the full description of this alert +/// @param severity the severity of this alert +/// @param impact the descriptive text of the impact this alert has on service +/// (e.g., "Elevator Status", "Bus Stop Relocation", "Planned Reroute") +/// @param startTime the start time of this alert +/// @param endTime the end time of this alert, or `null` if not known +/// @param openEnded whether this alert is open-ended (has no known end time) +/// @param major whether this alert is of major significance +/// @param url the URL of this alert's detail page on transitchicago.com +/// @param impactedServices the services impacted by this alert +/// @param ttim an undocumented field returned by the CTA Alerts API; its meaning is not specified and its presence is +/// not guaranteed, or `null` if not returned +/// @param guid an undocumented field returned by the CTA Alerts API that appears to be a stable, globally unique +/// identifier for this alert, distinct from [#id], or `null` if not returned +@NullMarked +public record Alert( + String id, + String headline, + String shortDescription, + String fullDescription, + Severity severity, + String impact, + Instant startTime, + @Nullable Instant endTime, + boolean openEnded, + boolean major, + URI url, + List impactedServices, + @Nullable String ttim, + @Nullable String guid +) { + /// Constructs an `Alert`. + /// + /// @param id the unique ID of the alert (e.g., "115070") + /// @param headline the headline of the alert + /// @param shortDescription the short description of the alert + /// @param fullDescription the full description of the alert + /// @param severity the severity of the alert + /// @param impact the descriptive text of the impact the alert has on service + /// (e.g., "Elevator Status", "Bus Stop Relocation", "Planned Reroute") + /// @param startTime the start time of the alert + /// @param endTime the end time of the alert, or `null` if not known + /// @param openEnded whether the alert is open-ended (has no known end time) + /// @param major whether the alert is of major significance + /// @param url the URL of the alert's detail page on transitchicago.com + /// @param impactedServices the services impacted by the alert + /// @param ttim an undocumented field returned by the CTA Alerts API; its meaning is not specified and its presence + /// is not guaranteed, or `null` if not returned + /// @param guid an undocumented field returned by the CTA Alerts API that appears to be a stable, globally unique + /// identifier for the alert, distinct from `id`, or `null` if not returned + /// @throws NullPointerException if `id`, `headline`, `shortDescription`, `fullDescription`, `severity`, `impact`, + /// `startTime`, `url`, or `impactedServices` is `null`, or if any element of `impactedServices` is `null` + public Alert { + Objects.requireNonNull(id); + Objects.requireNonNull(headline); + Objects.requireNonNull(shortDescription); + Objects.requireNonNull(fullDescription); + Objects.requireNonNull(severity); + Objects.requireNonNull(impact); + Objects.requireNonNull(startTime); + Objects.requireNonNull(url); + Objects.requireNonNull(impactedServices); + + impactedServices = List.copyOf(impactedServices); + } +} diff --git a/src/main/java/com/cta4j/alert/detailedalert/model/ImpactedService.java b/src/main/java/com/cta4j/alert/detailedalert/model/ImpactedService.java new file mode 100644 index 00000000..7c287029 --- /dev/null +++ b/src/main/java/com/cta4j/alert/detailedalert/model/ImpactedService.java @@ -0,0 +1,52 @@ +package com.cta4j.alert.detailedalert.model; + +import com.cta4j.alert.common.model.ServiceType; +import org.jspecify.annotations.NullMarked; + +import java.net.URI; +import java.util.Objects; + +/// Represents a single service - a bus route, train route, train station, or systemwide grouping - impacted by an +/// alert. +/// +/// @param type the type of service this service represents +/// @param typeDescription the plain English description of `type` (e.g., "Bus Route") +/// @param name the name of this service (e.g., "Clark", "Red Line", "Jackson", "All Bus Routes") +/// @param serviceId the identifier of this service; matches GTFS route or station IDs, except for systemwide +/// groupings, which use a fixed identifier instead (e.g., "22", "Red", "Systemwide") +/// @param color the color of this service used in maps; casing varies (e.g., "565a5c", "0065BD") +/// @param textColor the suggested color of text displayed against `color`; casing varies (e.g., "ffffff", "FFFFFF") +/// @param url the URL of this service's page on transitchicago.com +@NullMarked +public record ImpactedService( + ServiceType type, + String typeDescription, + String name, + String serviceId, + String color, + String textColor, + URI url +) { + /// Constructs an `ImpactedService`. + /// + /// @param type the type of service the service represents + /// @param typeDescription the plain English description of `type` (e.g., "Bus Route") + /// @param name the name of the service (e.g., "Clark", "Red Line", "Jackson", "All Bus Routes") + /// @param serviceId the identifier of the service; matches GTFS route or station IDs, except for systemwide + /// groupings, which use a fixed identifier instead (e.g., "22", "Red", "Systemwide") + /// @param color the color of the service used in maps; casing varies (e.g., "565a5c", "0065BD") + /// @param textColor the suggested color of text displayed against `color`; casing varies + /// (e.g., "ffffff", "FFFFFF") + /// @param url the URL of the service's page on transitchicago.com + /// @throws NullPointerException if `type`, `typeDescription`, `name`, `serviceId`, `color`, `textColor`, or `url` + /// is `null` + public ImpactedService { + Objects.requireNonNull(type); + Objects.requireNonNull(typeDescription); + Objects.requireNonNull(name); + Objects.requireNonNull(serviceId); + Objects.requireNonNull(color); + Objects.requireNonNull(textColor); + Objects.requireNonNull(url); + } +} diff --git a/src/main/java/com/cta4j/alert/detailedalert/model/Severity.java b/src/main/java/com/cta4j/alert/detailedalert/model/Severity.java new file mode 100644 index 00000000..7e39e3fe --- /dev/null +++ b/src/main/java/com/cta4j/alert/detailedalert/model/Severity.java @@ -0,0 +1,39 @@ +package com.cta4j.alert.detailedalert.model; + +import org.jspecify.annotations.NullMarked; + +import java.util.Objects; + +/// Represents the severity of an alert. +/// +/// @param score the numerical score used to rank this severity, based on the alert's impact on overall service, +/// between 0 and 99 (inclusive) +/// @param color the hexadecimal RGB color code used to color this severity's text on transitchicago.com; length and +/// casing vary (e.g., "000000", "06c", "B45F04") +/// @param css the category used to pick the icon and display style of the alert; not limited to the four documented +/// values (e.g., "normal", "planned", "minor", "major", "special-note") +@NullMarked +public record Severity( + int score, + String color, + String css +) { + /// Constructs a `Severity`. + /// + /// @param score the numerical score used to rank the severity, based on the alert's impact on overall service, + /// between 0 and 99 (inclusive) + /// @param color the hexadecimal RGB color code used to color the severity's text on transitchicago.com; length and + /// casing vary (e.g., "000000", "06c", "B45F04") + /// @param css the category used to pick the icon and display style of the alert; not limited to the four + /// documented values (e.g., "normal", "planned", "minor", "major", "special-note") + /// @throws NullPointerException if `color` or `css` is `null` + /// @throws IllegalArgumentException if `score` is not between 0 and 99 (inclusive) + public Severity { + Objects.requireNonNull(color); + Objects.requireNonNull(css); + + if (score < 0 || score > 99) { + throw new IllegalArgumentException("score must be between 0 and 99 (inclusive)"); + } + } +} diff --git a/src/main/java/com/cta4j/alert/detailedalert/model/package-info.java b/src/main/java/com/cta4j/alert/detailedalert/model/package-info.java new file mode 100644 index 00000000..08cc23b1 --- /dev/null +++ b/src/main/java/com/cta4j/alert/detailedalert/model/package-info.java @@ -0,0 +1,2 @@ +/// Domain model types returned by the CTA Detailed Alerts API, representing alerts and their impacted services. +package com.cta4j.alert.detailedalert.model; diff --git a/src/main/java/com/cta4j/alert/detailedalert/package-info.java b/src/main/java/com/cta4j/alert/detailedalert/package-info.java new file mode 100644 index 00000000..3f26fd01 --- /dev/null +++ b/src/main/java/com/cta4j/alert/detailedalert/package-info.java @@ -0,0 +1,2 @@ +/// Retrieval of detailed CTA service alerts, filterable by bus route ID, train line, or station ID. +package com.cta4j.alert.detailedalert; diff --git a/src/main/java/com/cta4j/alert/detailedalert/query/AlertsQuery.java b/src/main/java/com/cta4j/alert/detailedalert/query/AlertsQuery.java new file mode 100644 index 00000000..17c07a4a --- /dev/null +++ b/src/main/java/com/cta4j/alert/detailedalert/query/AlertsQuery.java @@ -0,0 +1,143 @@ +package com.cta4j.alert.detailedalert.query; + +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +import java.time.LocalDate; +import java.util.Objects; + +/// Represents a query for detailed alerts. +/// +/// @param activeOnly whether to include only alerts that are currently active +/// @param accessibility whether to include alerts that affect accessible paths in stations +/// @param planned whether to include common planned alerts +/// @param byStartDate the optional date; only alerts with a start date before this date are included +/// @param recentDays the optional number of days; only alerts that started within this many days of today are included +@NullMarked +public record AlertsQuery( + boolean activeOnly, + boolean accessibility, + boolean planned, + @Nullable LocalDate byStartDate, + @Nullable Integer recentDays +) { + /// Constructs an `AlertsQuery`. + /// + /// @param activeOnly whether to include only alerts that are currently active + /// @param accessibility whether to include alerts that affect accessible paths in stations + /// @param planned whether to include common planned alerts + /// @param byStartDate the optional date; only alerts with a start date before this date are included + /// @param recentDays the optional number of days; only alerts that started within this many days of today are + /// included + /// @throws IllegalArgumentException if both `byStartDate` and `recentDays` are specified, or if `recentDays` is + /// non-`null` and not positive + public AlertsQuery { + if (byStartDate != null && recentDays != null) { + throw new IllegalArgumentException("byStartDate and recentDays cannot both be specified"); + } + + if (recentDays != null && recentDays <= 0) { + throw new IllegalArgumentException("recentDays must be positive"); + } + } + + /// Creates a new `Builder` for constructing an `AlertsQuery`. + /// + /// @return a new `Builder` + public static Builder builder() { + return new Builder(); + } + + /// A builder for `AlertsQuery`. + public static final class Builder { + private boolean activeOnly; + + private boolean accessibility; + + private boolean planned; + + @Nullable + private LocalDate byStartDate; + + @Nullable + private Integer recentDays; + + private Builder() { + this.activeOnly = false; + this.accessibility = true; + this.planned = true; + } + + /// Sets whether to include only alerts that are currently active. + /// + /// @param activeOnly whether to include only active alerts + /// @return this `Builder` instance + public Builder activeOnly(boolean activeOnly) { + this.activeOnly = activeOnly; + + return this; + } + + /// Sets whether to include alerts that affect accessible paths in stations. + /// + /// @param accessibility whether to include accessibility-related alerts + /// @return this `Builder` instance + public Builder accessibility(boolean accessibility) { + this.accessibility = accessibility; + + return this; + } + + /// Sets whether to include common planned alerts. + /// + /// @param planned whether to include planned alerts + /// @return this `Builder` instance + public Builder planned(boolean planned) { + this.planned = planned; + + return this; + } + + /// Sets the date; only alerts with a start date before this date are included. + /// + /// @param byStartDate the date to filter alerts by + /// @return this `Builder` instance + /// @throws NullPointerException if `byStartDate` is `null` + public Builder byStartDate(LocalDate byStartDate) { + Objects.requireNonNull(byStartDate); + + this.byStartDate = byStartDate; + + return this; + } + + /// Sets the number of days; only alerts that started within this many days of today are included. + /// + /// @param recentDays the number of days to filter alerts by + /// @return this `Builder` instance + /// @throws IllegalArgumentException if `recentDays` is not positive + public Builder recentDays(int recentDays) { + if (recentDays <= 0) { + throw new IllegalArgumentException("recentDays must be positive"); + } + + this.recentDays = recentDays; + + return this; + } + + /// Builds a configured `AlertsQuery` instance. + /// + /// @return a new `AlertsQuery` + /// @throws IllegalArgumentException if both `byStartDate` and `recentDays` were specified + public AlertsQuery build() { + return new AlertsQuery( + this.activeOnly, + this.accessibility, + this.planned, + this.byStartDate, + this.recentDays + ); + } + } +} diff --git a/src/main/java/com/cta4j/alert/detailedalert/query/BusRouteAlertsQuery.java b/src/main/java/com/cta4j/alert/detailedalert/query/BusRouteAlertsQuery.java new file mode 100644 index 00000000..7bd6b655 --- /dev/null +++ b/src/main/java/com/cta4j/alert/detailedalert/query/BusRouteAlertsQuery.java @@ -0,0 +1,161 @@ +package com.cta4j.alert.detailedalert.query; + +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +import java.time.LocalDate; +import java.util.Collection; +import java.util.List; +import java.util.Objects; + +/// Represents a query for detailed bus route alerts. +/// +/// @param routeIds the [List] of bus route IDs to retrieve alerts for +/// @param activeOnly whether to include only alerts that are currently active +/// @param accessibility whether to include alerts that affect accessible paths in stations +/// @param planned whether to include common planned alerts +/// @param byStartDate the optional date; only alerts with a start date before this date are included +/// @param recentDays the optional number of days; only alerts that started within this many days of today are included +@NullMarked +public record BusRouteAlertsQuery( + List routeIds, + boolean activeOnly, + boolean accessibility, + boolean planned, + @Nullable LocalDate byStartDate, + @Nullable Integer recentDays +) { + /// Constructs a `BusRouteAlertsQuery`. + /// + /// @param routeIds the [List] of bus route IDs to retrieve alerts for + /// @param activeOnly whether to include only alerts that are currently active + /// @param accessibility whether to include alerts that affect accessible paths in stations + /// @param planned whether to include common planned alerts + /// @param byStartDate the optional date; only alerts with a start date before this date are included + /// @param recentDays the optional number of days; only alerts that started within this many days of today are + /// included + /// @throws NullPointerException if `routeIds` is `null`, or if any element of `routeIds` is `null` + /// @throws IllegalArgumentException if both `byStartDate` and `recentDays` are specified, or if `recentDays` is + /// non-`null` and not positive + public BusRouteAlertsQuery { + Objects.requireNonNull(routeIds); + + routeIds = List.copyOf(routeIds); + + if (byStartDate != null && recentDays != null) { + throw new IllegalArgumentException("byStartDate and recentDays cannot both be specified"); + } + + if (recentDays != null && recentDays <= 0) { + throw new IllegalArgumentException("recentDays must be positive"); + } + } + + /// Creates a new `Builder` for constructing a `BusRouteAlertsQuery`. + /// + /// @param routeIds the [Collection] of bus route IDs to retrieve alerts for + /// @return a new `Builder` + /// @throws NullPointerException if `routeIds` is `null`, or if any element of `routeIds` is `null` + public static Builder builder(Collection routeIds) { + return new Builder(routeIds); + } + + /// A builder for `BusRouteAlertsQuery`. + public static final class Builder { + private final List routeIds; + + private boolean activeOnly; + + private boolean accessibility; + + private boolean planned; + + @Nullable + private LocalDate byStartDate; + + @Nullable + private Integer recentDays; + + private Builder(Collection routeIds) { + Objects.requireNonNull(routeIds); + + this.routeIds = List.copyOf(routeIds); + this.activeOnly = false; + this.accessibility = true; + this.planned = true; + } + + /// Sets whether to include only alerts that are currently active. + /// + /// @param activeOnly whether to include only active alerts + /// @return this `Builder` instance + public Builder activeOnly(boolean activeOnly) { + this.activeOnly = activeOnly; + + return this; + } + + /// Sets whether to include alerts that affect accessible paths in stations. + /// + /// @param accessibility whether to include accessibility-related alerts + /// @return this `Builder` instance + public Builder accessibility(boolean accessibility) { + this.accessibility = accessibility; + + return this; + } + + /// Sets whether to include common planned alerts. + /// + /// @param planned whether to include planned alerts + /// @return this `Builder` instance + public Builder planned(boolean planned) { + this.planned = planned; + + return this; + } + + /// Sets the date; only alerts with a start date before this date are included. + /// + /// @param byStartDate the date to filter alerts by + /// @return this `Builder` instance + /// @throws NullPointerException if `byStartDate` is `null` + public Builder byStartDate(LocalDate byStartDate) { + Objects.requireNonNull(byStartDate); + + this.byStartDate = byStartDate; + + return this; + } + + /// Sets the number of days; only alerts that started within this many days of today are included. + /// + /// @param recentDays the number of days to filter alerts by + /// @return this `Builder` instance + /// @throws IllegalArgumentException if `recentDays` is not positive + public Builder recentDays(int recentDays) { + if (recentDays <= 0) { + throw new IllegalArgumentException("recentDays must be positive"); + } + + this.recentDays = recentDays; + + return this; + } + + /// Builds a configured `BusRouteAlertsQuery` instance. + /// + /// @return a new `BusRouteAlertsQuery` + /// @throws IllegalArgumentException if both `byStartDate` and `recentDays` were specified + public BusRouteAlertsQuery build() { + return new BusRouteAlertsQuery( + this.routeIds, + this.activeOnly, + this.accessibility, + this.planned, + this.byStartDate, + this.recentDays + ); + } + } +} diff --git a/src/main/java/com/cta4j/alert/detailedalert/query/LineAlertsQuery.java b/src/main/java/com/cta4j/alert/detailedalert/query/LineAlertsQuery.java new file mode 100644 index 00000000..1998859e --- /dev/null +++ b/src/main/java/com/cta4j/alert/detailedalert/query/LineAlertsQuery.java @@ -0,0 +1,162 @@ +package com.cta4j.alert.detailedalert.query; + +import com.cta4j.alert.common.model.AlertTrainLine; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +import java.time.LocalDate; +import java.util.Collection; +import java.util.List; +import java.util.Objects; + +/// Represents a query for detailed train line alerts. +/// +/// @param lines the [List] of [AlertTrainLine]s to retrieve alerts for +/// @param activeOnly whether to include only alerts that are currently active +/// @param accessibility whether to include alerts that affect accessible paths in stations +/// @param planned whether to include common planned alerts +/// @param byStartDate the optional date; only alerts with a start date before this date are included +/// @param recentDays the optional number of days; only alerts that started within this many days of today are included +@NullMarked +public record LineAlertsQuery( + List lines, + boolean activeOnly, + boolean accessibility, + boolean planned, + @Nullable LocalDate byStartDate, + @Nullable Integer recentDays +) { + /// Constructs a `LineAlertsQuery`. + /// + /// @param lines the [List] of [AlertTrainLine]s to retrieve alerts for + /// @param activeOnly whether to include only alerts that are currently active + /// @param accessibility whether to include alerts that affect accessible paths in stations + /// @param planned whether to include common planned alerts + /// @param byStartDate the optional date; only alerts with a start date before this date are included + /// @param recentDays the optional number of days; only alerts that started within this many days of today are + /// included + /// @throws NullPointerException if `lines` is `null`, or if any element of `lines` is `null` + /// @throws IllegalArgumentException if both `byStartDate` and `recentDays` are specified, or if `recentDays` is + /// non-`null` and not positive + public LineAlertsQuery { + Objects.requireNonNull(lines); + + lines = List.copyOf(lines); + + if (byStartDate != null && recentDays != null) { + throw new IllegalArgumentException("byStartDate and recentDays cannot both be specified"); + } + + if (recentDays != null && recentDays <= 0) { + throw new IllegalArgumentException("recentDays must be positive"); + } + } + + /// Creates a new `Builder` for constructing a `LineAlertsQuery`. + /// + /// @param lines the [Collection] of [AlertTrainLine]s to retrieve alerts for + /// @return a new `Builder` + /// @throws NullPointerException if `lines` is `null`, or if any element of `lines` is `null` + public static Builder builder(Collection lines) { + return new Builder(lines); + } + + /// A builder for `LineAlertsQuery`. + public static final class Builder { + private final List lines; + + private boolean activeOnly; + + private boolean accessibility; + + private boolean planned; + + @Nullable + private LocalDate byStartDate; + + @Nullable + private Integer recentDays; + + private Builder(Collection lines) { + Objects.requireNonNull(lines); + + this.lines = List.copyOf(lines); + this.activeOnly = false; + this.accessibility = true; + this.planned = true; + } + + /// Sets whether to include only alerts that are currently active. + /// + /// @param activeOnly whether to include only active alerts + /// @return this `Builder` instance + public Builder activeOnly(boolean activeOnly) { + this.activeOnly = activeOnly; + + return this; + } + + /// Sets whether to include alerts that affect accessible paths in stations. + /// + /// @param accessibility whether to include accessibility-related alerts + /// @return this `Builder` instance + public Builder accessibility(boolean accessibility) { + this.accessibility = accessibility; + + return this; + } + + /// Sets whether to include common planned alerts. + /// + /// @param planned whether to include planned alerts + /// @return this `Builder` instance + public Builder planned(boolean planned) { + this.planned = planned; + + return this; + } + + /// Sets the date; only alerts with a start date before this date are included. + /// + /// @param byStartDate the date to filter alerts by + /// @return this `Builder` instance + /// @throws NullPointerException if `byStartDate` is `null` + public Builder byStartDate(LocalDate byStartDate) { + Objects.requireNonNull(byStartDate); + + this.byStartDate = byStartDate; + + return this; + } + + /// Sets the number of days; only alerts that started within this many days of today are included. + /// + /// @param recentDays the number of days to filter alerts by + /// @return this `Builder` instance + /// @throws IllegalArgumentException if `recentDays` is not positive + public Builder recentDays(int recentDays) { + if (recentDays <= 0) { + throw new IllegalArgumentException("recentDays must be positive"); + } + + this.recentDays = recentDays; + + return this; + } + + /// Builds a configured `LineAlertsQuery` instance. + /// + /// @return a new `LineAlertsQuery` + /// @throws IllegalArgumentException if both `byStartDate` and `recentDays` were specified + public LineAlertsQuery build() { + return new LineAlertsQuery( + this.lines, + this.activeOnly, + this.accessibility, + this.planned, + this.byStartDate, + this.recentDays + ); + } + } +} diff --git a/src/main/java/com/cta4j/alert/detailedalert/query/StationAlertsQuery.java b/src/main/java/com/cta4j/alert/detailedalert/query/StationAlertsQuery.java new file mode 100644 index 00000000..a598a8a1 --- /dev/null +++ b/src/main/java/com/cta4j/alert/detailedalert/query/StationAlertsQuery.java @@ -0,0 +1,161 @@ +package com.cta4j.alert.detailedalert.query; + +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +import java.time.LocalDate; +import java.util.Collection; +import java.util.List; +import java.util.Objects; + +/// Represents a query for detailed train station alerts. +/// +/// @param stationIds the [List] of train station IDs to retrieve alerts for +/// @param activeOnly whether to include only alerts that are currently active +/// @param accessibility whether to include alerts that affect accessible paths in stations +/// @param planned whether to include common planned alerts +/// @param byStartDate the optional date; only alerts with a start date before this date are included +/// @param recentDays the optional number of days; only alerts that started within this many days of today are included +@NullMarked +public record StationAlertsQuery( + List stationIds, + boolean activeOnly, + boolean accessibility, + boolean planned, + @Nullable LocalDate byStartDate, + @Nullable Integer recentDays +) { + /// Constructs a `StationAlertsQuery`. + /// + /// @param stationIds the [List] of train station IDs to retrieve alerts for + /// @param activeOnly whether to include only alerts that are currently active + /// @param accessibility whether to include alerts that affect accessible paths in stations + /// @param planned whether to include common planned alerts + /// @param byStartDate the optional date; only alerts with a start date before this date are included + /// @param recentDays the optional number of days; only alerts that started within this many days of today are + /// included + /// @throws NullPointerException if `stationIds` is `null`, or if any element of `stationIds` is `null` + /// @throws IllegalArgumentException if both `byStartDate` and `recentDays` are specified, or if `recentDays` is + /// non-`null` and not positive + public StationAlertsQuery { + Objects.requireNonNull(stationIds); + + stationIds = List.copyOf(stationIds); + + if (byStartDate != null && recentDays != null) { + throw new IllegalArgumentException("byStartDate and recentDays cannot both be specified"); + } + + if (recentDays != null && recentDays <= 0) { + throw new IllegalArgumentException("recentDays must be positive"); + } + } + + /// Creates a new `Builder` for constructing a `StationAlertsQuery`. + /// + /// @param stationIds the [Collection] of train station IDs to retrieve alerts for + /// @return a new `Builder` + /// @throws NullPointerException if `stationIds` is `null`, or if any element of `stationIds` is `null` + public static Builder builder(Collection stationIds) { + return new Builder(stationIds); + } + + /// A builder for `StationAlertsQuery`. + public static final class Builder { + private final List stationIds; + + private boolean activeOnly; + + private boolean accessibility; + + private boolean planned; + + @Nullable + private LocalDate byStartDate; + + @Nullable + private Integer recentDays; + + private Builder(Collection stationIds) { + Objects.requireNonNull(stationIds); + + this.stationIds = List.copyOf(stationIds); + this.activeOnly = false; + this.accessibility = true; + this.planned = true; + } + + /// Sets whether to include only alerts that are currently active. + /// + /// @param activeOnly whether to include only active alerts + /// @return this `Builder` instance + public Builder activeOnly(boolean activeOnly) { + this.activeOnly = activeOnly; + + return this; + } + + /// Sets whether to include alerts that affect accessible paths in stations. + /// + /// @param accessibility whether to include accessibility-related alerts + /// @return this `Builder` instance + public Builder accessibility(boolean accessibility) { + this.accessibility = accessibility; + + return this; + } + + /// Sets whether to include common planned alerts. + /// + /// @param planned whether to include planned alerts + /// @return this `Builder` instance + public Builder planned(boolean planned) { + this.planned = planned; + + return this; + } + + /// Sets the date; only alerts with a start date before this date are included. + /// + /// @param byStartDate the date to filter alerts by + /// @return this `Builder` instance + /// @throws NullPointerException if `byStartDate` is `null` + public Builder byStartDate(LocalDate byStartDate) { + Objects.requireNonNull(byStartDate); + + this.byStartDate = byStartDate; + + return this; + } + + /// Sets the number of days; only alerts that started within this many days of today are included. + /// + /// @param recentDays the number of days to filter alerts by + /// @return this `Builder` instance + /// @throws IllegalArgumentException if `recentDays` is not positive + public Builder recentDays(int recentDays) { + if (recentDays <= 0) { + throw new IllegalArgumentException("recentDays must be positive"); + } + + this.recentDays = recentDays; + + return this; + } + + /// Builds a configured `StationAlertsQuery` instance. + /// + /// @return a new `StationAlertsQuery` + /// @throws IllegalArgumentException if both `byStartDate` and `recentDays` were specified + public StationAlertsQuery build() { + return new StationAlertsQuery( + this.stationIds, + this.activeOnly, + this.accessibility, + this.planned, + this.byStartDate, + this.recentDays + ); + } + } +} diff --git a/src/main/java/com/cta4j/alert/detailedalert/query/package-info.java b/src/main/java/com/cta4j/alert/detailedalert/query/package-info.java new file mode 100644 index 00000000..df63160d --- /dev/null +++ b/src/main/java/com/cta4j/alert/detailedalert/query/package-info.java @@ -0,0 +1,3 @@ +/// Query builder types for filtering requests to the CTA Detailed Alerts API by bus route ID, train line, or +/// station ID. +package com.cta4j.alert.detailedalert.query; diff --git a/src/main/java/com/cta4j/alert/package-info.java b/src/main/java/com/cta4j/alert/package-info.java new file mode 100644 index 00000000..f647b72a --- /dev/null +++ b/src/main/java/com/cta4j/alert/package-info.java @@ -0,0 +1,2 @@ +/// Entry point for the CTA Alerts API, exposing sub-APIs for detailed alerts and route status information. +package com.cta4j.alert; diff --git a/src/main/java/com/cta4j/alert/routestatus/RouteStatusApi.java b/src/main/java/com/cta4j/alert/routestatus/RouteStatusApi.java new file mode 100644 index 00000000..09f68471 --- /dev/null +++ b/src/main/java/com/cta4j/alert/routestatus/RouteStatusApi.java @@ -0,0 +1,109 @@ +package com.cta4j.alert.routestatus; + +import com.cta4j.alert.common.model.AlertTrainLine; +import com.cta4j.alert.common.model.ServiceType; +import com.cta4j.alert.routestatus.exception.Cta4jRouteStatusException; +import com.cta4j.alert.routestatus.model.RouteStatus; +import org.jspecify.annotations.NullMarked; + +import java.util.Collection; +import java.util.List; +import java.util.Objects; + +/// Provides access to route status-related endpoints of the CTA Alerts API. +/// +/// This API allows retrieval of the status of all bus and train routes, or filtered by service type, bus route ID, +/// train line, or station ID. +@NullMarked +public interface RouteStatusApi { + /// Retrieves the status of all bus and train routes. + /// + /// @return a [List] of [RouteStatus]es, or an empty [List] if no route statuses are found + /// @throws Cta4jRouteStatusException if the API returns an error response or the response cannot be parsed + List list(); + + /// Retrieves route statuses by their service types. + /// + /// @param types a [Collection] of service types + /// @return a [List] of [RouteStatus]es corresponding to the provided types, or an empty [List] if no route + /// statuses are found + /// @throws NullPointerException if `types` is `null`, or if any element of `types` is `null` + /// @throws Cta4jRouteStatusException if the API returns an error response or the response cannot be parsed + List findByTypes(Collection types); + + /// Retrieves route statuses by a service type. + /// + /// @param type the service type + /// @return a [List] of [RouteStatus]es corresponding to the provided type, or an empty [List] if no route statuses + /// are found + /// @throws NullPointerException if `type` is `null` + /// @throws Cta4jRouteStatusException if the API returns an error response or the response cannot be parsed + default List findByType(ServiceType type) { + Objects.requireNonNull(type); + + List types = List.of(type); + + return this.findByTypes(types); + } + + /// Retrieves route statuses by bus route IDs. + /// + /// @param routeIds a [Collection] of bus route IDs + /// @return a [List] of [RouteStatus]es corresponding to the provided bus route IDs, or an empty [List] if no route + /// statuses are found for the bus route IDs + /// @throws NullPointerException if `routeIds` is `null`, or if any element of `routeIds` is `null` + /// @throws IllegalArgumentException if any of the `routeIds` matches a train line code (e.g., "Red"); + /// use [#findByLines(Collection)] instead + /// @throws Cta4jRouteStatusException if the API returns an error response or the response cannot be parsed + List findByBusRouteIds(Collection routeIds); + + /// Retrieves route statuses by bus route ID. + /// + /// @param routeId the bus route ID + /// @return a [List] of [RouteStatus]es corresponding to the provided bus route ID, or an empty [List] if no route + /// statuses are found for the bus route ID + /// @throws NullPointerException if `routeId` is `null` + /// @throws IllegalArgumentException if `routeId` matches a train line code (e.g., "Red"); + /// use [#findByLine(AlertTrainLine)] instead + /// @throws Cta4jRouteStatusException if the API returns an error response or the response cannot be parsed + default List findByBusRouteId(String routeId) { + Objects.requireNonNull(routeId); + + List routeIds = List.of(routeId); + + return this.findByBusRouteIds(routeIds); + } + + /// Retrieves route statuses by train lines. + /// + /// @param lines a [Collection] of train lines + /// @return a [List] of [RouteStatus]es corresponding to the provided train lines, or an empty [List] if no route + /// statuses are found for the train lines + /// @throws NullPointerException if `lines` is `null`, or if any element of `lines` is `null` + /// @throws Cta4jRouteStatusException if the API returns an error response or the response cannot be parsed + List findByLines(Collection lines); + + /// Retrieves route statuses by train line. + /// + /// @param line the train line + /// @return a [List] of [RouteStatus]es corresponding to the provided train line, or an empty [List] if no route + /// statuses are found for the train line + /// @throws NullPointerException if `line` is `null` + /// @throws Cta4jRouteStatusException if the API returns an error response or the response cannot be parsed + default List findByLine(AlertTrainLine line) { + Objects.requireNonNull(line); + + List lines = List.of(line); + + return this.findByLines(lines); + } + + /// Retrieves route statuses by station ID. + /// + /// @param stationId the station ID + /// @return a [List] of [RouteStatus]es corresponding to the provided station ID, or an empty [List] if no route + /// statuses are found for the station ID + /// @throws NullPointerException if `stationId` is `null` + /// @throws Cta4jRouteStatusException if the API returns an error response or the response cannot be parsed + List findByStationId(String stationId); +} diff --git a/src/main/java/com/cta4j/alert/routestatus/exception/Cta4jRouteStatusException.java b/src/main/java/com/cta4j/alert/routestatus/exception/Cta4jRouteStatusException.java new file mode 100644 index 00000000..27c71aa7 --- /dev/null +++ b/src/main/java/com/cta4j/alert/routestatus/exception/Cta4jRouteStatusException.java @@ -0,0 +1,40 @@ +package com.cta4j.alert.routestatus.exception; + +import com.cta4j.alert.common.exception.Cta4jAlertException; +import com.cta4j.alert.common.internal.util.AlertApiConstants; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/// A custom exception class for handling cta4j route status-specific errors. +@NullMarked +public final class Cta4jRouteStatusException extends Cta4jAlertException { + @Nullable + private final RouteStatusErrorCode errorCode; + + /// Constructs a `Cta4jRouteStatusException`. + /// + /// @param message the detail message + /// @param cause the cause of the exception + public Cta4jRouteStatusException(String message, Throwable cause) { + super(message, AlertApiConstants.ROUTE_STATUS_ENDPOINT, cause); + + this.errorCode = null; + } + + /// Constructs a `Cta4jRouteStatusException`. + /// + /// @param message the detail message + /// @param rawErrorCode the raw error code associated with the exception + public Cta4jRouteStatusException(String message, int rawErrorCode) { + super(message, AlertApiConstants.ROUTE_STATUS_ENDPOINT, rawErrorCode); + + this.errorCode = RouteStatusErrorCode.fromCode(rawErrorCode); + } + + /// Returns the error code associated with this exception, if available. + /// + /// @return the error code, or `null` if not available + public @Nullable RouteStatusErrorCode getErrorCode() { + return this.errorCode; + } +} diff --git a/src/main/java/com/cta4j/alert/routestatus/exception/RouteStatusErrorCode.java b/src/main/java/com/cta4j/alert/routestatus/exception/RouteStatusErrorCode.java new file mode 100644 index 00000000..2b970ea3 --- /dev/null +++ b/src/main/java/com/cta4j/alert/routestatus/exception/RouteStatusErrorCode.java @@ -0,0 +1,70 @@ +package com.cta4j.alert.routestatus.exception; + +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/// Represents the error codes returned by the CTA Route Status API. +@NullMarked +public enum RouteStatusErrorCode { + /// Indicates that the request was successful and there were no errors. + OK(0), + + /// Indicates that no routes or stations matched the provided filter criteria. + /// + /// This code is not documented in the CTA Route Status API documentation, but has been observed in practice. + NO_RESULTS(50), + + /// Indicates that the provided station ID is not an integer. + STATIONID_NOT_INTEGER(100), + + /// Indicates that the provided service type is invalid. + INVALID_TYPE(101), + + /// Indicates that the `routeid` and `stationid` parameters were both provided, which is not allowed. + ROUTEID_STATIONID_CONFLICT(102), + + /// Indicates that the `routeid` and `type` parameters were both provided, which is not allowed. + ROUTEID_TYPE_CONFLICT(103), + + /// Indicates that the `stationid` and `type` parameters were both provided, which is not allowed. + STATIONID_TYPE_CONFLICT(104), + + /// Indicates that the query string contains a parameter that is not recognized by the API. The supported API + /// parameters are `type`, `routeid`, `stationid`, and `outputType`. + INVALID_PARAMETER(500), + + /// Indicates that the server encountered an unexpected error that prevented it from fulfilling the request. + SERVER_ERROR(900); + + private final int code; + + RouteStatusErrorCode(int code) { + this.code = code; + } + + /// Returns the integer code associated with this error code. + /// + /// @return the integer code + public int getCode() { + return this.code; + } + + /// Returns the `RouteStatusErrorCode` corresponding to the given integer code. + /// + /// @param code the integer code to look up + /// @return the corresponding `RouteStatusErrorCode`, or `null` if the code does not match any defined error code + public static @Nullable RouteStatusErrorCode fromCode(int code) { + return switch (code) { + case 0 -> OK; + case 50 -> NO_RESULTS; + case 100 -> STATIONID_NOT_INTEGER; + case 101 -> INVALID_TYPE; + case 102 -> ROUTEID_STATIONID_CONFLICT; + case 103 -> ROUTEID_TYPE_CONFLICT; + case 104 -> STATIONID_TYPE_CONFLICT; + case 500 -> INVALID_PARAMETER; + case 900 -> SERVER_ERROR; + default -> null; + }; + } +} diff --git a/src/main/java/com/cta4j/alert/routestatus/exception/package-info.java b/src/main/java/com/cta4j/alert/routestatus/exception/package-info.java new file mode 100644 index 00000000..6838a0cc --- /dev/null +++ b/src/main/java/com/cta4j/alert/routestatus/exception/package-info.java @@ -0,0 +1,2 @@ +/// Exception type and error code enum thrown by the CTA Route Status API. +package com.cta4j.alert.routestatus.exception; diff --git a/src/main/java/com/cta4j/alert/routestatus/internal/impl/RouteStatusApiImpl.java b/src/main/java/com/cta4j/alert/routestatus/internal/impl/RouteStatusApiImpl.java new file mode 100644 index 00000000..90b276d6 --- /dev/null +++ b/src/main/java/com/cta4j/alert/routestatus/internal/impl/RouteStatusApiImpl.java @@ -0,0 +1,246 @@ +package com.cta4j.alert.routestatus.internal.impl; + +import com.cta4j.alert.common.internal.config.AlertApiConfig; +import com.cta4j.alert.common.internal.util.AlertApiConstants; +import com.cta4j.alert.common.model.AlertTrainLine; +import com.cta4j.alert.common.model.ServiceType; +import com.cta4j.alert.routestatus.RouteStatusApi; +import com.cta4j.alert.routestatus.exception.Cta4jRouteStatusException; +import com.cta4j.alert.routestatus.exception.RouteStatusErrorCode; +import com.cta4j.alert.routestatus.internal.mapper.RouteStatusMapper; +import com.cta4j.alert.routestatus.internal.wire.CtaRouteInfo; +import com.cta4j.alert.routestatus.internal.wire.CtaRouteStatusResponse; +import com.cta4j.alert.routestatus.internal.wire.CtaRoutes; +import com.cta4j.alert.routestatus.model.RouteStatus; +import org.apache.hc.client5.http.fluent.Request; +import org.apache.hc.core5.net.URIBuilder; +import org.jetbrains.annotations.ApiStatus; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.json.JsonMapper; + +import java.io.IOException; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; + +@ApiStatus.Internal +@NullMarked +public final class RouteStatusApiImpl implements RouteStatusApi { + private static final Logger log = LoggerFactory.getLogger(RouteStatusApiImpl.class); + + private final AlertApiConfig config; + + public RouteStatusApiImpl(AlertApiConfig config) { + this.config = Objects.requireNonNull(config); + } + + @Override + public List list() { + String url = new URIBuilder() + .setScheme(this.config.scheme()) + .setHost(this.config.host()) + .setPort(this.config.port()) + .setPath(AlertApiConstants.ROUTE_STATUS_ENDPOINT) + .addParameter("outputType", "JSON") + .toString(); + + return this.makeRequest(url); + } + + @Override + public List findByTypes(Collection types) { + Objects.requireNonNull(types); + + List typesList = List.copyOf(types); + + if (typesList.isEmpty()) { + return List.of(); + } + + String typesString = typesList.stream() + .map(ServiceType::name) + .map(String::toLowerCase) + .collect(Collectors.joining(",")); + + String url = new URIBuilder() + .setScheme(this.config.scheme()) + .setHost(this.config.host()) + .setPort(this.config.port()) + .setPath(AlertApiConstants.ROUTE_STATUS_ENDPOINT) + .addParameter("type", typesString) + .addParameter("outputType", "JSON") + .toString(); + + return this.makeRequest(url); + } + + @Override + public List findByBusRouteIds(Collection routeIds) { + Objects.requireNonNull(routeIds); + + List routeIdsList = List.copyOf(routeIds); + + if (routeIdsList.isEmpty()) { + return List.of(); + } + + for (String routeId : routeIdsList) { + if (isTrainLine(routeId)) { + String message = """ + %s is a train line, not a bus route; \ + use findByLines(Collection) instead""".formatted(routeId); + + throw new IllegalArgumentException(message); + } + } + + String routeIdsString = String.join(",", routeIdsList); + + String url = new URIBuilder() + .setScheme(this.config.scheme()) + .setHost(this.config.host()) + .setPort(this.config.port()) + .setPath(AlertApiConstants.ROUTE_STATUS_ENDPOINT) + .addParameter("routeid", routeIdsString) + .addParameter("outputType", "JSON") + .toString(); + + return this.makeRequest(url); + } + + @Override + public List findByLines(Collection lines) { + Objects.requireNonNull(lines); + + List linesList = List.copyOf(lines); + + if (linesList.isEmpty()) { + return List.of(); + } + + String linesString = linesList.stream() + .map(AlertTrainLine::getCode) + .collect(Collectors.joining(",")); + + String url = new URIBuilder() + .setScheme(this.config.scheme()) + .setHost(this.config.host()) + .setPort(this.config.port()) + .setPath(AlertApiConstants.ROUTE_STATUS_ENDPOINT) + .addParameter("routeid", linesString) + .addParameter("outputType", "JSON") + .toString(); + + return this.makeRequest(url); + } + + @Override + public List findByStationId(String stationId) { + Objects.requireNonNull(stationId); + + String url = new URIBuilder() + .setScheme(this.config.scheme()) + .setHost(this.config.host()) + .setPort(this.config.port()) + .setPath(AlertApiConstants.ROUTE_STATUS_ENDPOINT) + .addParameter("stationid", stationId) + .addParameter("outputType", "JSON") + .toString(); + + return this.makeRequest(url); + } + + private List makeRequest(String url) { + String response; + + try { + response = Request.get(url) + .execute() + .returnContent() + .asString(); + } catch (IOException e) { + String message = Objects.requireNonNullElse(e.getMessage(), "Request failed"); + + throw new Cta4jRouteStatusException(message, e); + } + + CtaRouteStatusResponse routeStatusResponse; + + try { + routeStatusResponse = JsonMapper.shared() + .readValue(response, CtaRouteStatusResponse.class); + } catch (JacksonException e) { + throw new Cta4jRouteStatusException("Failed to parse response", e); + } + + CtaRoutes ctaRoutes = routeStatusResponse.ctaRoutes(); + + List routeInfo = ctaRoutes.routeInfo(); + + if (routeInfo != null && !routeInfo.isEmpty()) { + return routeInfo.stream() + .map(RouteStatusMapper.INSTANCE::toDomain) + .toList(); + } + + List errorCodeStrings = ctaRoutes.errorCode(); + + if (errorCodeStrings == null || errorCodeStrings.isEmpty()) { + log.warn("Received empty response from {}", AlertApiConstants.ROUTE_STATUS_ENDPOINT); + + return List.of(); + } + + long distinctCount = errorCodeStrings.stream() + .distinct() + .count(); + + if (distinctCount > 1L) { + log.warn( + "Received multiple distinct error codes from {}: {}", + AlertApiConstants.ROUTE_STATUS_ENDPOINT, + errorCodeStrings + ); + } + + String errorCodeString = errorCodeStrings.getFirst(); + + int integerCode; + + try { + integerCode = Integer.parseInt(errorCodeString); + } catch (NumberFormatException e) { + throw new Cta4jRouteStatusException("Failed to parse error code", e); + } + + RouteStatusErrorCode errorCode = RouteStatusErrorCode.fromCode(integerCode); + + if (errorCode == RouteStatusErrorCode.OK || errorCode == RouteStatusErrorCode.NO_RESULTS) { + return List.of(); + } + + List<@Nullable String> errorMessages = ctaRoutes.errorMessage(); + + String message = errorMessages == null || errorMessages.isEmpty() + ? "An unknown error occurred." + : errorMessages.getFirst(); + + if (message == null || message.isBlank()) { + message = "An unknown error occurred."; + } + + throw new Cta4jRouteStatusException(message, integerCode); + } + + private static boolean isTrainLine(String routeId) { + return Arrays.stream(AlertTrainLine.values()) + .map(AlertTrainLine::getCode) + .anyMatch(code -> code.equalsIgnoreCase(routeId)); + } +} diff --git a/src/main/java/com/cta4j/alert/routestatus/internal/mapper/RouteStatusMapper.java b/src/main/java/com/cta4j/alert/routestatus/internal/mapper/RouteStatusMapper.java new file mode 100644 index 00000000..06fde909 --- /dev/null +++ b/src/main/java/com/cta4j/alert/routestatus/internal/mapper/RouteStatusMapper.java @@ -0,0 +1,22 @@ +package com.cta4j.alert.routestatus.internal.mapper; + +import com.cta4j.alert.common.internal.mapper.Qualifiers; +import com.cta4j.alert.routestatus.internal.wire.CtaRouteInfo; +import com.cta4j.alert.routestatus.model.RouteStatus; +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 RouteStatusMapper { + RouteStatusMapper INSTANCE = Mappers.getMapper(RouteStatusMapper.class); + + @Mapping(target = "color", source = "routeColorCode") + @Mapping(target = "textColor", source = "routeTextColor") + @Mapping(target = "url", source = "routeUrl.cdataSection", qualifiedByName = "mapUri") + @Mapping(target = "status", source = "routeStatus") + @Mapping(target = "statusColor", source = "routeStatusColor") + RouteStatus toDomain(CtaRouteInfo routeInfo); +} diff --git a/src/main/java/com/cta4j/alert/routestatus/internal/wire/CtaRouteInfo.java b/src/main/java/com/cta4j/alert/routestatus/internal/wire/CtaRouteInfo.java new file mode 100644 index 00000000..3b298cf0 --- /dev/null +++ b/src/main/java/com/cta4j/alert/routestatus/internal/wire/CtaRouteInfo.java @@ -0,0 +1,32 @@ +package com.cta4j.alert.routestatus.internal.wire; + +import com.cta4j.alert.common.internal.wire.CtaCdata; +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; + +@JsonIgnoreProperties(ignoreUnknown = true) +@ApiStatus.Internal +@NullMarked +public record CtaRouteInfo( + @JsonProperty("Route") String route, + @JsonProperty("RouteColorCode") String routeColorCode, + @JsonProperty("RouteTextColor") String routeTextColor, + @JsonProperty("ServiceId") String serviceId, + @JsonProperty("RouteURL") CtaCdata routeUrl, + @JsonProperty("RouteStatus") String routeStatus, + @JsonProperty("RouteStatusColor") String routeStatusColor +) { + public CtaRouteInfo { + Objects.requireNonNull(route); + Objects.requireNonNull(routeColorCode); + Objects.requireNonNull(routeTextColor); + Objects.requireNonNull(serviceId); + Objects.requireNonNull(routeUrl); + Objects.requireNonNull(routeStatus); + Objects.requireNonNull(routeStatusColor); + } +} diff --git a/src/main/java/com/cta4j/alert/routestatus/internal/wire/CtaRouteStatusResponse.java b/src/main/java/com/cta4j/alert/routestatus/internal/wire/CtaRouteStatusResponse.java new file mode 100644 index 00000000..d298a55d --- /dev/null +++ b/src/main/java/com/cta4j/alert/routestatus/internal/wire/CtaRouteStatusResponse.java @@ -0,0 +1,19 @@ +package com.cta4j.alert.routestatus.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; + +@JsonIgnoreProperties(ignoreUnknown = true) +@ApiStatus.Internal +@NullMarked +public record CtaRouteStatusResponse( + @JsonProperty("CTARoutes") CtaRoutes ctaRoutes +) { + public CtaRouteStatusResponse { + Objects.requireNonNull(ctaRoutes); + } +} diff --git a/src/main/java/com/cta4j/alert/routestatus/internal/wire/CtaRoutes.java b/src/main/java/com/cta4j/alert/routestatus/internal/wire/CtaRoutes.java new file mode 100644 index 00000000..c5163f46 --- /dev/null +++ b/src/main/java/com/cta4j/alert/routestatus/internal/wire/CtaRoutes.java @@ -0,0 +1,54 @@ +package com.cta4j.alert.routestatus.internal.wire; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import org.jetbrains.annotations.ApiStatus; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +@JsonIgnoreProperties(ignoreUnknown = true) +@ApiStatus.Internal +@NullMarked +public record CtaRoutes( + @JsonProperty("TimeStamp") + String timestamp, + + @JsonProperty("ErrorCode") + @JsonFormat(with = JsonFormat.Feature.ACCEPT_SINGLE_VALUE_AS_ARRAY) + @Nullable + List errorCode, + + @JsonProperty("ErrorMessage") + @JsonFormat(with = JsonFormat.Feature.ACCEPT_SINGLE_VALUE_AS_ARRAY) + @Nullable + List<@Nullable String> errorMessage, + + @JsonProperty("RouteInfo") + @JsonFormat(with = JsonFormat.Feature.ACCEPT_SINGLE_VALUE_AS_ARRAY) + @Nullable + List routeInfo +) { + public CtaRoutes { + Objects.requireNonNull(timestamp); + + if (errorCode != null) { + errorCode = List.copyOf(errorCode); + } + + if (errorMessage != null) { + errorMessage = new ArrayList<>(errorMessage); + + errorMessage = Collections.unmodifiableList(errorMessage); + } + + if (routeInfo != null) { + routeInfo = List.copyOf(routeInfo); + } + } +} diff --git a/src/main/java/com/cta4j/alert/routestatus/model/RouteStatus.java b/src/main/java/com/cta4j/alert/routestatus/model/RouteStatus.java new file mode 100644 index 00000000..3824480e --- /dev/null +++ b/src/main/java/com/cta4j/alert/routestatus/model/RouteStatus.java @@ -0,0 +1,54 @@ +package com.cta4j.alert.routestatus.model; + +import org.jspecify.annotations.NullMarked; + +import java.net.URI; +import java.util.Objects; + +/// Represents the service status of a single route. +/// +/// @param route the name of this route (e.g., "Clark") +/// @param color the color of this route used in maps; casing varies (e.g., "565a5c", "0065BD") +/// @param textColor the suggested color of text displayed against `color`; casing varies (e.g., "ffffff", "FFFFFF") +/// @param serviceId the unique GTFS route or station identifier of this route (e.g., "22"); except for systemwide +/// groupings, which use a fixed identifier instead (e.g., "Systemwide") +/// @param url the URL of this route's or station's page on transitchicago.com +/// @param status the ultimate, human-readable status of this route +/// (e.g., "Normal Service", "Service Change", "Bus Stop Note") +/// @param statusColor the suggested color associated with `status`; length and casing vary +/// (e.g., "000000", "06c", "B45F04") +@NullMarked +public record RouteStatus( + String route, + String color, + String textColor, + String serviceId, + URI url, + String status, + String statusColor +) { + /// Constructs a `RouteStatus`. + /// + /// @param route the name of the route (e.g., "Clark") + /// @param color the color of the route used in maps; casing varies (e.g., "565a5c", "0065BD") + /// @param textColor the suggested color of text displayed against `color`; casing varies + /// (e.g., "ffffff", "FFFFFF") + /// @param serviceId the unique GTFS route or station identifier of the route (e.g., "22"); except for systemwide + /// groupings, which use a fixed identifier instead (e.g., "Systemwide") + /// @param url the URL of the route's or station's page on transitchicago.com + /// @param status the ultimate, human-readable status of the route + /// (e.g., "Normal Service", "Service Change", "Bus Stop Note") + /// @param statusColor the suggested color associated with `status`; length and casing vary + /// (e.g., "000000", "06c", "B45F04") + /// @throws NullPointerException if `route`, `color`, `textColor`, `serviceId`, `url`, `status`, or `statusColor` + /// is `null` + public RouteStatus { + Objects.requireNonNull(route); + Objects.requireNonNull(color); + Objects.requireNonNull(textColor); + Objects.requireNonNull(serviceId); + Objects.requireNonNull(url); + Objects.requireNonNull(status); + Objects.requireNonNull(statusColor); + } +} diff --git a/src/main/java/com/cta4j/alert/routestatus/model/package-info.java b/src/main/java/com/cta4j/alert/routestatus/model/package-info.java new file mode 100644 index 00000000..665b0f8b --- /dev/null +++ b/src/main/java/com/cta4j/alert/routestatus/model/package-info.java @@ -0,0 +1,2 @@ +/// Domain model type returned by the CTA Route Status API, representing the status of a single route. +package com.cta4j.alert.routestatus.model; diff --git a/src/main/java/com/cta4j/alert/routestatus/package-info.java b/src/main/java/com/cta4j/alert/routestatus/package-info.java new file mode 100644 index 00000000..8a6026a8 --- /dev/null +++ b/src/main/java/com/cta4j/alert/routestatus/package-info.java @@ -0,0 +1,3 @@ +/// Retrieval of the current status of CTA bus and train routes, filterable by service type, bus route ID, train +/// line, or station ID. +package com.cta4j.alert.routestatus; diff --git a/src/main/java/com/cta4j/bus/BusApi.java b/src/main/java/com/cta4j/bus/BusApi.java index f2330621..d701ca99 100644 --- a/src/main/java/com/cta4j/bus/BusApi.java +++ b/src/main/java/com/cta4j/bus/BusApi.java @@ -15,112 +15,83 @@ 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. - */ +/// 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 `BusApi` are immutable and thread-safe once built. Use [#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} - * @throws Cta4jException if the API returns an error response or the response cannot be parsed - */ + /// Returns the current system time reported by the Bus Tracker API. + /// + /// @return the API system time as an [Instant] + /// @throws Cta4jException if the API returns an error response or the response cannot be parsed Instant systemTime(); - /** - * Provides access to vehicle-related endpoints. - * - * @return the {@link VehiclesApi} - */ + /// Provides access to vehicle-related endpoints. + /// + /// @return the [VehiclesApi] VehiclesApi vehicles(); - /** - * Provides access to route-related endpoints. - * - * @return the {@link RoutesApi} - */ + /// Provides access to route-related endpoints. + /// + /// @return the [RoutesApi] RoutesApi routes(); - /** - * Provides access to direction-related endpoints. - * - * @return the {@link DirectionsApi} - */ + /// Provides access to direction-related endpoints. + /// + /// @return the [DirectionsApi] DirectionsApi directions(); - /** - * Provides access to stop-related endpoints. - * - * @return the {@link StopsApi} - */ + /// Provides access to stop-related endpoints. + /// + /// @return the [StopsApi] StopsApi stops(); - /** - * Provides access to route pattern–related endpoints. - * - * @return the {@link PatternsApi} - */ + /// Provides access to route pattern–related endpoints. + /// + /// @return the [PatternsApi] PatternsApi patterns(); - /** - * Provides access to prediction-related endpoints. - * - * @return the {@link PredictionsApi} - */ + /// Provides access to prediction-related endpoints. + /// + /// @return the [PredictionsApi] PredictionsApi predictions(); - /** - * Provides access to locale and language-related endpoints. - * - * @return the {@link LocalesApi} - */ + /// Provides access to locale and language-related endpoints. + /// + /// @return the [LocalesApi] LocalesApi locales(); - /** - * Provides access to detour-related endpoints. - * - * @return the {@link DetoursApi} - */ + /// Provides access to detour-related endpoints. + /// + /// @return the [DetoursApi] DetoursApi detours(); - /** - * Builder for constructing {@link BusApi} instances. - */ + /// Builder for constructing [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 - * @throws NullPointerException if {@code host} is {@code null} - */ + /// 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 + /// @throws NullPointerException if `host` is `null` Builder host(String host); - /** - * Builds a configured {@link BusApi} instance. - * - * @return a new {@link BusApi} - */ + /// Builds a configured `BusApi` instance. + /// + /// @return a new `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} - */ + /// Creates a new `Builder` for constructing a `BusApi`. + /// + /// @param apiKey the CTA Bus Tracker API key + /// @return a new `Builder` + /// @throws NullPointerException if `apiKey` is `null` static Builder builder(String apiKey) { Objects.requireNonNull(apiKey); diff --git a/src/main/java/com/cta4j/bus/common/exception/Cta4jBusException.java b/src/main/java/com/cta4j/bus/common/exception/Cta4jBusException.java index 742905b1..de5c2b30 100644 --- a/src/main/java/com/cta4j/bus/common/exception/Cta4jBusException.java +++ b/src/main/java/com/cta4j/bus/common/exception/Cta4jBusException.java @@ -7,52 +7,37 @@ import java.util.List; import java.util.Objects; -/** - * A custom exception class for handling cta4j bus-specific errors. - */ +/// A custom exception class for handling cta4j bus-specific errors. @NullMarked public final class Cta4jBusException extends Cta4jException { - /** - * Constructs a {@code Cta4jBusException}. - * - * @param message the detail message - * @param endpoint the endpoint associated with the exception - * @throws NullPointerException if {@code endpoint} is {@code null} - */ + /// Constructs a `Cta4jBusException`. + /// + /// @param message the detail message + /// @param endpoint the endpoint associated with the exception + /// @throws NullPointerException if `endpoint` is `null` public Cta4jBusException(String message, String endpoint) { super(message, endpoint); } - /** - * Constructs a {@code Cta4jBusException}. - * - * @param message the detail message - * @param endpoint the endpoint associated with the exception - * @param cause the cause of the exception - * @throws NullPointerException if {@code endpoint} is {@code null} - */ + /// Constructs a `Cta4jBusException`. + /// + /// @param message the detail message + /// @param endpoint the endpoint associated with the exception + /// @param cause the cause of the exception + /// @throws NullPointerException if `endpoint` is `null` public Cta4jBusException(String message, String endpoint, Throwable cause) { super(message, endpoint, cause); } - /** - * Constructs a {@code Cta4jBusException}. - * - * @param errors the list of {@link CtaError} objects - * @param endpoint the endpoint associated with the exception - * @throws NullPointerException if {@code errors} or {@code endpoint} is {@code null}, or if {@code errors} - * contains {@code null} elements - */ + /// Constructs a `Cta4jBusException`. + /// + /// @param errors the list of [CtaError] objects + /// @param endpoint the endpoint associated with the exception + /// @throws NullPointerException if `errors` or `endpoint` is `null`, or if any element of `errors` is `null` public Cta4jBusException(List errors, String endpoint) { super(joinMessages(errors), endpoint); } - /** - * Joins the messages from a list of {@link CtaError} objects into a single string. - * - * @param errors the list of {@link CtaError} objects - * @return a single string containing all error messages, separated by "; " - */ private static String joinMessages(List errors) { Objects.requireNonNull(errors); diff --git a/src/main/java/com/cta4j/bus/common/exception/package-info.java b/src/main/java/com/cta4j/bus/common/exception/package-info.java new file mode 100644 index 00000000..e68a8690 --- /dev/null +++ b/src/main/java/com/cta4j/bus/common/exception/package-info.java @@ -0,0 +1,2 @@ +/// Base exception type thrown by the CTA Bus Tracker API's sub-APIs. +package com.cta4j.bus.common.exception; diff --git a/src/main/java/com/cta4j/bus/common/internal/mapper/Qualifiers.java b/src/main/java/com/cta4j/bus/common/internal/mapper/Qualifiers.java index eb72d01e..1d1c0b11 100644 --- a/src/main/java/com/cta4j/bus/common/internal/mapper/Qualifiers.java +++ b/src/main/java/com/cta4j/bus/common/internal/mapper/Qualifiers.java @@ -6,16 +6,15 @@ import com.cta4j.bus.prediction.model.PassengerLoad; import com.cta4j.bus.prediction.model.PredictionType; import com.cta4j.bus.vehicle.model.TransitMode; +import com.cta4j.common.internal.util.TimestampParser; 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.time.format.DateTimeParseException; import java.util.Locale; import java.util.Objects; @@ -46,19 +45,7 @@ public static PredictionType mapPredictionType(String typ) { @Named("mapTimestamp") public static @Nullable Instant mapTimestamp(@Nullable String timestamp) { - if (timestamp == null) { - return null; - } - - try { - return LocalDateTime.parse(timestamp, TIMESTAMP_FORMATTER) - .atZone(CHICAGO_ZONE_ID) - .toInstant(); - } catch (DateTimeParseException e) { - String message = "Failed to parse timestamp: %s".formatted(timestamp); - - throw new IllegalArgumentException(message, e); - } + return TimestampParser.parseNullable(timestamp, TIMESTAMP_FORMATTER, CHICAGO_ZONE_ID); } @Named("mapDynamicAction") diff --git a/src/main/java/com/cta4j/bus/common/internal/util/ApiUtils.java b/src/main/java/com/cta4j/bus/common/internal/util/BusApiUtils.java similarity index 91% rename from src/main/java/com/cta4j/bus/common/internal/util/ApiUtils.java rename to src/main/java/com/cta4j/bus/common/internal/util/BusApiUtils.java index dc079c14..0442c371 100644 --- a/src/main/java/com/cta4j/bus/common/internal/util/ApiUtils.java +++ b/src/main/java/com/cta4j/bus/common/internal/util/BusApiUtils.java @@ -14,12 +14,12 @@ @ApiStatus.Internal @NullMarked -public final class ApiUtils { - private static final Logger log = LoggerFactory.getLogger(ApiUtils.class); +public final class BusApiUtils { + private static final Logger log = LoggerFactory.getLogger(BusApiUtils.class); public static final int MAX_IDS_PER_REQUEST = 10; - private ApiUtils() { + private BusApiUtils() { throw new UnsupportedOperationException("This is a utility class and cannot be instantiated"); } diff --git a/src/main/java/com/cta4j/bus/common/package-info.java b/src/main/java/com/cta4j/bus/common/package-info.java new file mode 100644 index 00000000..449b9860 --- /dev/null +++ b/src/main/java/com/cta4j/bus/common/package-info.java @@ -0,0 +1,3 @@ +/// Shared types, configuration, and internal plumbing used across the CTA Bus Tracker API's sub-APIs, including the +/// error-handling utilities common to bustime-response endpoints. +package com.cta4j.bus.common; diff --git a/src/main/java/com/cta4j/bus/detour/DetoursApi.java b/src/main/java/com/cta4j/bus/detour/DetoursApi.java index 1583e713..47c2c108 100644 --- a/src/main/java/com/cta4j/bus/detour/DetoursApi.java +++ b/src/main/java/com/cta4j/bus/detour/DetoursApi.java @@ -6,42 +6,33 @@ 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. - */ +/// Provides access to detour-related endpoints of the CTA Bus Tracker 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, or an empty {@link List} if no detours are found - * @throws Cta4jBusException if the API returns an error response or the response cannot be parsed - */ + /// Retrieves all active detours. + /// + /// @return a [List] of active [Detour]s, or an empty [List] if no detours are found + /// @throws Cta4jBusException if the API returns an error response or the response cannot be parsed 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, or an empty {@link List} if no detours - * are found for the route ID - * @throws NullPointerException if {@code routeId} is {@code null} - * @throws Cta4jBusException if the API returns an error response or the response cannot be parsed - */ + /// Retrieves all active detours for the specified route ID. + /// + /// @param routeId the route ID + /// @return a [List] of [Detour]s associated with the route ID, or an empty [List] if no detours are found for the + /// route ID + /// @throws NullPointerException if `routeId` is `null` + /// @throws Cta4jBusException if the API returns an error response or the response cannot be parsed 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, or an empty {@link List} - * if no detours are found for the route ID and direction - * @throws NullPointerException if {@code routeId} or {@code direction} is {@code null} - * @throws Cta4jBusException if the API returns an error response or the response cannot be parsed - */ + /// Retrieves all active detours for the specified route ID and direction. + /// + /// @param routeId the route ID + /// @param direction the direction (e.g., "Northbound", "Southbound") + /// @return a [List] of [Detour]s associated with the route ID and direction, or an empty [List] if no detours are + /// found for the route ID and direction + /// @throws NullPointerException if `routeId` or `direction` is `null` + /// @throws Cta4jBusException if the API returns an error response or the response cannot be parsed 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 index 695be1be..67c6f4e1 100644 --- a/src/main/java/com/cta4j/bus/detour/internal/impl/DetoursApiImpl.java +++ b/src/main/java/com/cta4j/bus/detour/internal/impl/DetoursApiImpl.java @@ -2,8 +2,8 @@ import com.cta4j.bus.common.exception.Cta4jBusException; import com.cta4j.bus.common.internal.config.BusApiConfig; -import com.cta4j.bus.common.internal.util.ApiUtils; import com.cta4j.bus.common.internal.util.BusApiConstants; +import com.cta4j.bus.common.internal.util.BusApiUtils; import com.cta4j.bus.common.internal.wire.CtaResponse; import com.cta4j.bus.detour.DetoursApi; import com.cta4j.bus.detour.internal.mapper.DetourMapper; @@ -119,7 +119,7 @@ private List makeRequest(String url) { .toList(); } - ApiUtils.checkErrors(errors, BusApiConstants.DETOURS_ENDPOINT); + BusApiUtils.checkErrors(errors, BusApiConstants.DETOURS_ENDPOINT); return List.of(); } diff --git a/src/main/java/com/cta4j/bus/detour/internal/wire/CtaDetourBustimeResponse.java b/src/main/java/com/cta4j/bus/detour/internal/wire/CtaDetourBustimeResponse.java index 68dd269f..7b7568ad 100644 --- a/src/main/java/com/cta4j/bus/detour/internal/wire/CtaDetourBustimeResponse.java +++ b/src/main/java/com/cta4j/bus/detour/internal/wire/CtaDetourBustimeResponse.java @@ -23,4 +23,4 @@ public record CtaDetourBustimeResponse( dtrs = List.copyOf(dtrs); } } -} \ No newline at end of file +} diff --git a/src/main/java/com/cta4j/bus/detour/model/Detour.java b/src/main/java/com/cta4j/bus/detour/model/Detour.java index 080b34ed..680ce0bb 100644 --- a/src/main/java/com/cta4j/bus/detour/model/Detour.java +++ b/src/main/java/com/cta4j/bus/detour/model/Detour.java @@ -7,23 +7,19 @@ import java.util.List; import java.util.Objects; -/** - * Represents a service detour affecting one or more routes and directions within a specific time window. - * - *

- * NOTE: {@code dataFeed} is not well-documented by the CTA. As such, its presence here is primarily for - * completeness and may not be populated or described correctly. - *

- * - * @param id the unique identifier of this detour - * @param version the version of this detour - * @param active whether this detour is currently active - * @param description the human-readable description of this detour - * @param routeDirections the routes and directions affected by this detour - * @param startTime the time at which this detour begins - * @param endTime the time at which this detour ends - * @param dataFeed the identifier for the data feed that supplied this detour, or {@code null} if not available - */ +/// Represents a service detour affecting one or more routes and directions within a specific time window. +/// +/// **NOTE:** `dataFeed` is not well-documented by the CTA. As such, its presence here is primarily for completeness +/// and may not be populated or described correctly. +/// +/// @param id the unique identifier of this detour +/// @param version the version of this detour +/// @param active whether this detour is currently active +/// @param description the human-readable description of this detour +/// @param routeDirections the routes and directions affected by this detour +/// @param startTime the time at which this detour begins +/// @param endTime the time at which this detour ends +/// @param dataFeed the identifier for the data feed that supplied this detour, if applicable @NullMarked public record Detour( String id, @@ -35,21 +31,18 @@ public record Detour( Instant endTime, @Nullable String dataFeed ) { - /** - * Constructs a {@code Detour}. - * - * @param id the unique identifier 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}, or if any element of {@code routeDirections} is - * {@code null} - */ + /// Constructs a `Detour`. + /// + /// @param id the unique identifier 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, if applicable + /// @throws NullPointerException if `id`, `version`, `description`, `routeDirections`, `startTime`, or `endTime` is + /// `null`, or if any element of `routeDirections` is `null` public Detour { Objects.requireNonNull(id); Objects.requireNonNull(version); diff --git a/src/main/java/com/cta4j/bus/detour/model/DetourRouteDirection.java b/src/main/java/com/cta4j/bus/detour/model/DetourRouteDirection.java index 9ebfca09..729af5ae 100644 --- a/src/main/java/com/cta4j/bus/detour/model/DetourRouteDirection.java +++ b/src/main/java/com/cta4j/bus/detour/model/DetourRouteDirection.java @@ -4,24 +4,20 @@ import java.util.Objects; -/** - * Represents a route and direction affected by a detour. - * - * @param routeId the route ID of this detour - * @param direction the direction of this detour - */ +/// Represents a route and direction affected by a detour. +/// +/// @param routeId the route ID of this detour +/// @param direction the direction of this detour (e.g., "Northbound", "Southbound") @NullMarked public record DetourRouteDirection( String routeId, String direction ) { - /** - * Constructs 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} - */ + /// Constructs a `DetourRouteDirection`. + /// + /// @param routeId the route ID of the detour + /// @param direction the direction of the detour (e.g., "Northbound", "Southbound") + /// @throws NullPointerException if `routeId` or `direction` is `null` public DetourRouteDirection { Objects.requireNonNull(routeId); Objects.requireNonNull(direction); diff --git a/src/main/java/com/cta4j/bus/detour/model/package-info.java b/src/main/java/com/cta4j/bus/detour/model/package-info.java new file mode 100644 index 00000000..ee17388b --- /dev/null +++ b/src/main/java/com/cta4j/bus/detour/model/package-info.java @@ -0,0 +1,3 @@ +/// Domain model types returned by the CTA Bus Tracker API's detours endpoint, representing an active detour and +/// the route directions it affects. +package com.cta4j.bus.detour.model; diff --git a/src/main/java/com/cta4j/bus/detour/package-info.java b/src/main/java/com/cta4j/bus/detour/package-info.java new file mode 100644 index 00000000..96a027b7 --- /dev/null +++ b/src/main/java/com/cta4j/bus/detour/package-info.java @@ -0,0 +1,2 @@ +/// Retrieval of active CTA bus detours and the route directions they affect. +package com.cta4j.bus.detour; diff --git a/src/main/java/com/cta4j/bus/direction/DirectionsApi.java b/src/main/java/com/cta4j/bus/direction/DirectionsApi.java index 065f00da..ed5e098a 100644 --- a/src/main/java/com/cta4j/bus/direction/DirectionsApi.java +++ b/src/main/java/com/cta4j/bus/direction/DirectionsApi.java @@ -5,21 +5,17 @@ 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. - */ +/// Provides access to direction-related endpoints of the CTA Bus Tracker 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, or an empty {@link List} if no directions are - * found for the route - * @throws NullPointerException if {@code routeId} is {@code null} - * @throws Cta4jBusException if the API returns an error response or the response cannot be parsed - */ + /// Retrieves the available travel directions for the specified route. + /// + /// @param routeId the route ID + /// @return a [List] of direction identifiers for the route (e.g., "Northbound", "Southbound"), or an empty [List] + /// if no directions are found for the route + /// @throws NullPointerException if `routeId` is `null` + /// @throws Cta4jBusException if the API returns an error response or the response cannot be parsed 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 index 696781e6..df0601ab 100644 --- a/src/main/java/com/cta4j/bus/direction/internal/impl/DirectionsApiImpl.java +++ b/src/main/java/com/cta4j/bus/direction/internal/impl/DirectionsApiImpl.java @@ -2,8 +2,8 @@ import com.cta4j.bus.common.exception.Cta4jBusException; import com.cta4j.bus.common.internal.config.BusApiConfig; -import com.cta4j.bus.common.internal.util.ApiUtils; import com.cta4j.bus.common.internal.util.BusApiConstants; +import com.cta4j.bus.common.internal.util.BusApiUtils; import com.cta4j.bus.common.internal.wire.CtaResponse; import com.cta4j.bus.direction.DirectionsApi; import com.cta4j.bus.direction.internal.wire.CtaDirection; @@ -80,7 +80,7 @@ public List findByRouteId(String routeId) { .toList(); } - ApiUtils.checkErrors(errors, BusApiConstants.DIRECTIONS_ENDPOINT); + BusApiUtils.checkErrors(errors, BusApiConstants.DIRECTIONS_ENDPOINT); return List.of(); } diff --git a/src/main/java/com/cta4j/bus/direction/package-info.java b/src/main/java/com/cta4j/bus/direction/package-info.java new file mode 100644 index 00000000..534faf6f --- /dev/null +++ b/src/main/java/com/cta4j/bus/direction/package-info.java @@ -0,0 +1,2 @@ +/// Retrieval of the available travel directions for a given CTA bus route. +package com.cta4j.bus.direction; diff --git a/src/main/java/com/cta4j/bus/locale/LocalesApi.java b/src/main/java/com/cta4j/bus/locale/LocalesApi.java index b329c5cf..a18c5549 100644 --- a/src/main/java/com/cta4j/bus/locale/LocalesApi.java +++ b/src/main/java/com/cta4j/bus/locale/LocalesApi.java @@ -7,39 +7,30 @@ import java.util.List; import java.util.Locale; -/** - * Provides access to locale-related endpoints of the CTA BusTime API. - *

- * This API allows retrieval of supported locales for the CTA BusTime services. - */ +/// Provides access to locale-related endpoints of the CTA Bus Tracker API. +/// +/// This API allows retrieval of supported locales for the CTA Bus Tracker services. @NullMarked public interface LocalesApi { - /** - * Retrieves the supported locales. - * - * @return a {@link List} of {@link SupportedLocale}s, or an empty {@link List} if no supported locales are - * found - * @throws Cta4jBusException if the API returns an error response or the response cannot be parsed - */ + /// Retrieves the supported locales. + /// + /// @return a [List] of [SupportedLocale]s, or an empty [List] if no supported locales are found + /// @throws Cta4jBusException if the API returns an error response or the response cannot be parsed List list(); - /** - * Retrieves the supported locales, with names displayed in the specified locale. - * - * @param displayLocale the locale in which to display the names of the supported locales - * @return a {@link List} of {@link SupportedLocale}s with names in the specified locale, or an empty {@link List} - * if no supported locales are found - * @throws NullPointerException if {@code displayLocale} is {@code null} - * @throws Cta4jBusException if the API returns an error response or the response cannot be parsed - */ + /// Retrieves the supported locales, with names displayed in the specified locale. + /// + /// @param displayLocale the locale in which to display the names of the supported locales + /// @return a [List] of [SupportedLocale]s with names in the specified locale, or an empty [List] if no supported + /// locales are found + /// @throws NullPointerException if `displayLocale` is `null` + /// @throws Cta4jBusException if the API returns an error response or the response cannot be parsed List list(Locale displayLocale); - /** - * Retrieves the supported locales, with names displayed in their native languages. - * - * @return a {@link List} of {@link SupportedLocale}s with names in their native languages, or an empty - * {@link List} if no supported locales are found - * @throws Cta4jBusException if the API returns an error response or the response cannot be parsed - */ + /// Retrieves the supported locales, with names displayed in their native languages. + /// + /// @return a [List] of [SupportedLocale]s with names in their native languages, or an empty [List] if no supported + /// locales are found + /// @throws Cta4jBusException if the API returns an error response or the response cannot be parsed List listInNativeLanguage(); } 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 index de0375e7..f842c748 100644 --- a/src/main/java/com/cta4j/bus/locale/internal/impl/LocalesApiImpl.java +++ b/src/main/java/com/cta4j/bus/locale/internal/impl/LocalesApiImpl.java @@ -3,13 +3,13 @@ import com.cta4j.bus.common.exception.Cta4jBusException; import com.cta4j.bus.common.internal.config.BusApiConfig; import com.cta4j.bus.common.internal.util.BusApiConstants; +import com.cta4j.bus.common.internal.util.BusApiUtils; import com.cta4j.bus.common.internal.wire.CtaResponse; import com.cta4j.bus.locale.LocalesApi; import com.cta4j.bus.locale.internal.mapper.SupportedLocaleMapper; import com.cta4j.bus.locale.internal.wire.CtaLocale; import com.cta4j.bus.locale.internal.wire.CtaLocaleBustimeResponse; import com.cta4j.bus.locale.internal.wire.CtaLocaleError; -import com.cta4j.bus.common.internal.util.ApiUtils; import com.cta4j.bus.locale.model.SupportedLocale; import org.apache.hc.client5.http.fluent.Request; import org.apache.hc.core5.net.URIBuilder; @@ -118,7 +118,7 @@ private List makeRequest(String url) { .toList(); } - ApiUtils.checkErrors(errors, BusApiConstants.LOCALES_ENDPOINT); + BusApiUtils.checkErrors(errors, BusApiConstants.LOCALES_ENDPOINT); return List.of(); } diff --git a/src/main/java/com/cta4j/bus/locale/model/SupportedLocale.java b/src/main/java/com/cta4j/bus/locale/model/SupportedLocale.java index 435df024..7af9745c 100644 --- a/src/main/java/com/cta4j/bus/locale/model/SupportedLocale.java +++ b/src/main/java/com/cta4j/bus/locale/model/SupportedLocale.java @@ -5,24 +5,20 @@ 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 this supported locale - */ +/// Represents a locale supported by the CTA Bus Tracker API. +/// +/// @param locale the supported [Locale] +/// @param displayName the human-readable name of this supported locale (e.g., "English", "Spanish") @NullMarked public record SupportedLocale( Locale locale, String displayName ) { - /** - * Constructs a {@code SupportedLocale}. - * - * @param locale the supported {@link Locale} - * @param displayName the human-readable name of the supported locale - * @throws NullPointerException if {@code locale} or {@code displayName} is {@code null} - */ + /// Constructs a `SupportedLocale`. + /// + /// @param locale the supported [Locale] + /// @param displayName the human-readable name of the supported locale (e.g., "English", "Spanish") + /// @throws NullPointerException if `locale` or `displayName` is `null` public SupportedLocale { Objects.requireNonNull(locale); Objects.requireNonNull(displayName); diff --git a/src/main/java/com/cta4j/bus/locale/model/package-info.java b/src/main/java/com/cta4j/bus/locale/model/package-info.java new file mode 100644 index 00000000..c7060a61 --- /dev/null +++ b/src/main/java/com/cta4j/bus/locale/model/package-info.java @@ -0,0 +1,2 @@ +/// Domain model type returned by the CTA Bus Tracker API's locales endpoint, representing a supported locale. +package com.cta4j.bus.locale.model; diff --git a/src/main/java/com/cta4j/bus/locale/package-info.java b/src/main/java/com/cta4j/bus/locale/package-info.java new file mode 100644 index 00000000..fc4dbf67 --- /dev/null +++ b/src/main/java/com/cta4j/bus/locale/package-info.java @@ -0,0 +1,2 @@ +/// Retrieval of the locales supported by the CTA Bus Tracker API. +package com.cta4j.bus.locale; diff --git a/src/main/java/com/cta4j/bus/package-info.java b/src/main/java/com/cta4j/bus/package-info.java new file mode 100644 index 00000000..5b8300e1 --- /dev/null +++ b/src/main/java/com/cta4j/bus/package-info.java @@ -0,0 +1,3 @@ +/// Entry point for the CTA Bus Tracker API, exposing sub-APIs for vehicles, routes, directions, stops, patterns, +/// predictions, locales, detours, and system time. +package com.cta4j.bus; diff --git a/src/main/java/com/cta4j/bus/pattern/PatternsApi.java b/src/main/java/com/cta4j/bus/pattern/PatternsApi.java index 0ba804a2..2c31da28 100644 --- a/src/main/java/com/cta4j/bus/pattern/PatternsApi.java +++ b/src/main/java/com/cta4j/bus/pattern/PatternsApi.java @@ -10,35 +10,29 @@ import java.util.Objects; import java.util.Optional; -/** - * Provides access to route pattern-related endpoints of the CTA BusTime API. - *

- * This API allows retrieval of route patterns by their IDs or by associated route IDs. - */ +/// Provides access to route pattern-related endpoints of the CTA Bus Tracker API. +/// +/// This API allows retrieval of route patterns by their IDs or by associated route IDs. @NullMarked public interface PatternsApi { - /** - * Retrieves route patterns by their pattern IDs. - * - * @param patternIds a {@link Collection} of route pattern IDs - * @return a {@link List} of {@link RoutePattern}s corresponding to the provided IDs, or an empty {@link List} if - * no patterns are found - * @throws NullPointerException if {@code patternIds} is {@code null} or contains {@code null} elements - * @throws IllegalArgumentException if more than 10 pattern IDs are provided - * @throws Cta4jBusException if the API returns an error response or the response cannot be parsed - */ + /// Retrieves route patterns by their pattern IDs. + /// + /// @param patternIds a [Collection] of route pattern IDs + /// @return a [List] of [RoutePattern]s corresponding to the provided IDs, or an empty [List] if no patterns are + /// found + /// @throws NullPointerException if `patternIds` is `null`, or if any element of `patternIds` is `null` + /// @throws IllegalArgumentException if more than 10 pattern IDs are provided + /// @throws Cta4jBusException if the API returns an error response or the response cannot be parsed List findByIds(Collection patternIds); - /** - * Retrieves a route pattern by its pattern ID. - * - * @param patternId the route pattern ID - * @return an {@link Optional} containing the {@link RoutePattern} if found, or an empty {@link Optional} if no - * pattern is found for the given ID - * @throws NullPointerException if {@code patternId} is {@code null} - * @throws Cta4jBusException if multiple route patterns are found for the given ID, or if the API returns an error - * response or the response cannot be parsed - */ + /// Retrieves a route pattern by its pattern ID. + /// + /// @param patternId the route pattern ID + /// @return an [Optional] containing the [RoutePattern] if found, or an empty [Optional] if no pattern is found for + /// the given ID + /// @throws NullPointerException if `patternId` is `null` + /// @throws Cta4jBusException if multiple route patterns are found for the given ID, or if the API returns an error + /// response or the response cannot be parsed default Optional findById(String patternId) { Objects.requireNonNull(patternId); @@ -61,14 +55,12 @@ default Optional findById(String patternId) { return Optional.of(pattern); } - /** - * Retrieves all route patterns for the specified route ID. - * - * @param routeId the route ID - * @return a {@link List} of {@link RoutePattern}s associated with the route ID, or an empty {@link List} if no - * patterns are found for the route ID - * @throws NullPointerException if {@code routeId} is {@code null} - * @throws Cta4jBusException if the API returns an error response or the response cannot be parsed - */ + /// Retrieves all route patterns for the specified route ID. + /// + /// @param routeId the route ID + /// @return a [List] of [RoutePattern]s associated with the route ID, or an empty [List] if no patterns are found + /// for the route ID + /// @throws NullPointerException if `routeId` is `null` + /// @throws Cta4jBusException if the API returns an error response or the response cannot be parsed 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 index 2e0ff7d4..872c4479 100644 --- a/src/main/java/com/cta4j/bus/pattern/internal/impl/PatternsApiImpl.java +++ b/src/main/java/com/cta4j/bus/pattern/internal/impl/PatternsApiImpl.java @@ -2,8 +2,8 @@ import com.cta4j.bus.common.exception.Cta4jBusException; import com.cta4j.bus.common.internal.config.BusApiConfig; -import com.cta4j.bus.common.internal.util.ApiUtils; import com.cta4j.bus.common.internal.util.BusApiConstants; +import com.cta4j.bus.common.internal.util.BusApiUtils; import com.cta4j.bus.common.internal.wire.CtaResponse; import com.cta4j.bus.pattern.PatternsApi; import com.cta4j.bus.pattern.internal.mapper.RoutePatternMapper; @@ -46,7 +46,7 @@ public List findByIds(Collection patternIds) { return List.of(); } - ApiUtils.requireMaxIds(patternIds, "pattern"); + BusApiUtils.requireMaxIds(patternIds, "pattern"); String patternIdsString = String.join(",", patternIds); @@ -114,7 +114,7 @@ private List makeRequest(String url) { .toList(); } - ApiUtils.checkErrors(errors, BusApiConstants.PATTERNS_ENDPOINT); + BusApiUtils.checkErrors(errors, BusApiConstants.PATTERNS_ENDPOINT); return List.of(); } diff --git a/src/main/java/com/cta4j/bus/pattern/model/PatternPoint.java b/src/main/java/com/cta4j/bus/pattern/model/PatternPoint.java index 0023b1d9..9101c6ba 100644 --- a/src/main/java/com/cta4j/bus/pattern/model/PatternPoint.java +++ b/src/main/java/com/cta4j/bus/pattern/model/PatternPoint.java @@ -6,17 +6,15 @@ import java.math.BigDecimal; import java.util.Objects; -/** - * Represents a point in a bus route pattern. - * - * @param sequence the position of this pattern point in the overall sequence of points - * @param type the type of this pattern point - * @param stopId the identifier of the stop associated with this pattern point, if applicable - * @param stopName the name of the stop associated with this pattern point, if applicable - * @param distanceToPatternPoint the distance from this pattern point to the next, if applicable - * @param latitude the latitude coordinate of this pattern point - * @param longitude the longitude coordinate of this pattern point - */ +/// Represents a point in a bus route pattern. +/// +/// @param sequence the position of this pattern point in the overall sequence of points +/// @param type the type of this pattern point +/// @param stopId the identifier of the stop associated with this pattern point, if applicable +/// @param stopName the name of the stop associated with this pattern point, if applicable +/// @param distanceToPatternPoint the distance from this pattern point to the next, if applicable +/// @param latitude the latitude coordinate of this pattern point +/// @param longitude the longitude coordinate of this pattern point @NullMarked public record PatternPoint( int sequence, @@ -27,18 +25,16 @@ public record PatternPoint( BigDecimal latitude, BigDecimal longitude ) { - /** - * Constructs a {@code PatternPoint}. - * - * @param sequence the position of the pattern point in the overall sequence of points - * @param type the type of the pattern point - * @param stopId the identifier of the stop associated with the pattern point, if applicable - * @param stopName the name of the stop associated with the pattern point, if applicable - * @param distanceToPatternPoint the distance from the pattern point to the next, if applicable - * @param latitude the latitude coordinate of the pattern point - * @param longitude the longitude coordinate of the pattern point - * @throws NullPointerException if {@code type}, {@code latitude}, or {@code longitude} is {@code null} - */ + /// Constructs a `PatternPoint`. + /// + /// @param sequence the position of the pattern point in the overall sequence of points + /// @param type the type of the pattern point + /// @param stopId the identifier of the stop associated with the pattern point, if applicable + /// @param stopName the name of the stop associated with the pattern point, if applicable + /// @param distanceToPatternPoint the distance from the pattern point to the next, if applicable + /// @param latitude the latitude coordinate of the pattern point + /// @param longitude the longitude coordinate of the pattern point + /// @throws NullPointerException if `type`, `latitude`, or `longitude` is `null` public PatternPoint { Objects.requireNonNull(type); Objects.requireNonNull(latitude); diff --git a/src/main/java/com/cta4j/bus/pattern/model/PatternPointType.java b/src/main/java/com/cta4j/bus/pattern/model/PatternPointType.java index e18c5f5d..4b0dec9c 100644 --- a/src/main/java/com/cta4j/bus/pattern/model/PatternPointType.java +++ b/src/main/java/com/cta4j/bus/pattern/model/PatternPointType.java @@ -2,18 +2,12 @@ import org.jspecify.annotations.NullMarked; -/** - * Represents the type of point within a route or pattern geometry. - */ +/// Represents the type of point within a route or pattern geometry. @NullMarked public enum PatternPointType { - /** - * Indicates a stop along the route. - */ + /// Indicates a stop along the route. STOP, - /** - * Indicates a waypoint along the route. - */ + /// 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 index 542d70aa..8b64d77c 100644 --- a/src/main/java/com/cta4j/bus/pattern/model/RoutePattern.java +++ b/src/main/java/com/cta4j/bus/pattern/model/RoutePattern.java @@ -6,17 +6,14 @@ import java.util.List; import java.util.Objects; -/** - * Represents a bus route pattern. - * - * @param id the unique identifier of this route pattern - * @param length the length of this route pattern in feet - * @param direction the direction of this route pattern (e.g., "Northbound", "Southbound") - * @param points the {@link List} of pattern points that make up this route pattern - * @param detourId the identifier of the detour associated with this route pattern, if applicable - * @param detourPoints the {@link List} of pattern points of the detour associated with this route pattern, if - * applicable - */ +/// Represents a bus route pattern. +/// +/// @param id the unique identifier of this route pattern +/// @param length the length of this route pattern in feet +/// @param direction the direction of this route pattern (e.g., "Northbound", "Southbound") +/// @param points the [List] of pattern points that make up this route pattern +/// @param detourId the identifier of the detour associated with this route pattern, if applicable +/// @param detourPoints the [List] of pattern points of the detour associated with this route pattern, if applicable @NullMarked public record RoutePattern( String id, @@ -26,19 +23,16 @@ public record RoutePattern( @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 {@link List} of pattern points that make up the route pattern - * @param detourId the identifier of the detour associated with the route pattern, if applicable - * @param detourPoints the {@link List} of pattern points of the detour associated with the route pattern, if - * applicable - * @throws NullPointerException if {@code id}, {@code direction}, or {@code points} is {@code null}, or if any - * element of {@code points} or {@code detourPoints} is {@code null} - */ + /// Constructs a `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 associated with the route pattern, if applicable + /// @param detourPoints the [List] of pattern points of the detour associated with the route pattern, if applicable + /// @throws NullPointerException if `id`, `direction`, or `points` is `null`, or if any element of `points` or + /// `detourPoints` is `null` public RoutePattern { Objects.requireNonNull(id); Objects.requireNonNull(direction); diff --git a/src/main/java/com/cta4j/bus/pattern/model/package-info.java b/src/main/java/com/cta4j/bus/pattern/model/package-info.java new file mode 100644 index 00000000..31ee17b6 --- /dev/null +++ b/src/main/java/com/cta4j/bus/pattern/model/package-info.java @@ -0,0 +1,3 @@ +/// Domain model types returned by the CTA Bus Tracker API's patterns endpoint, representing a route pattern and +/// its constituent points. +package com.cta4j.bus.pattern.model; diff --git a/src/main/java/com/cta4j/bus/pattern/package-info.java b/src/main/java/com/cta4j/bus/pattern/package-info.java new file mode 100644 index 00000000..fe64d53b --- /dev/null +++ b/src/main/java/com/cta4j/bus/pattern/package-info.java @@ -0,0 +1,2 @@ +/// Retrieval of CTA bus route patterns, describing the sequence of points a route follows. +package com.cta4j.bus.pattern; diff --git a/src/main/java/com/cta4j/bus/prediction/PredictionsApi.java b/src/main/java/com/cta4j/bus/prediction/PredictionsApi.java index aae4871c..75aa4062 100644 --- a/src/main/java/com/cta4j/bus/prediction/PredictionsApi.java +++ b/src/main/java/com/cta4j/bus/prediction/PredictionsApi.java @@ -2,103 +2,128 @@ import com.cta4j.bus.common.exception.Cta4jBusException; 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.prediction.query.StopPredictionsQuery; +import com.cta4j.bus.prediction.query.VehiclePredictionsQuery; import org.jspecify.annotations.NullMarked; +import java.util.Collection; import java.util.List; import java.util.Objects; -/** - * Provides access to prediction-related endpoints of the CTA BusTime API. - *

- * This API allows retrieval of predictions by stop IDs or vehicle IDs. - */ +/// Provides access to prediction-related endpoints of the CTA Bus Tracker API. +/// +/// This API allows retrieval of predictions by stop IDs or vehicle IDs. @NullMarked public interface PredictionsApi { - /** - * Retrieves predictions by stop IDs. - * - * @param query the query parameters for fetching predictions by stop IDs - * @return a {@link List} of {@link Prediction}s corresponding to the provided stop IDs, or an empty {@link List} - * if no predictions are found - * @throws NullPointerException if {@code query} is {@code null} - * @throws Cta4jBusException if the API returns an error response or the response cannot be parsed - */ - List findByStopIds(StopsPredictionsQuery query); - - /** - * Retrieves predictions by vehicle IDs. - * - * @param query the query parameters for fetching predictions by vehicle IDs - * @return a {@link List} of {@link Prediction}s corresponding to the provided vehicle IDs, or an empty - * {@link List} if no predictions are found - * @throws NullPointerException if {@code query} is {@code null} - * @throws Cta4jBusException if the API returns an error response or the response cannot be parsed - */ - List findByVehicleIds(VehiclesPredictionsQuery query); - - /** - * Retrieves predictions by stop ID. - * - * @param stopId the stop ID - * @return a {@link List} of {@link Prediction}s corresponding to the provided stop ID, or an empty {@link List} if - * no predictions are found - * @throws NullPointerException if {@code stopId} is {@code null} - * @throws Cta4jBusException if the API returns an error response or the response cannot be parsed - */ - default List findByStopId(String stopId) { - Objects.requireNonNull(stopId); - - List stopIds = List.of(stopId); - - StopsPredictionsQuery query = StopsPredictionsQuery.builder(stopIds) - .build(); + /// Retrieves predictions by stop IDs. + /// + /// @param query the query parameters for fetching predictions by stop IDs + /// @return a [List] of [Prediction]s corresponding to the provided stop IDs, or an empty [List] if no predictions + /// are found + /// @throws NullPointerException if `query` is `null` + /// @throws Cta4jBusException if the API returns an error response or the response cannot be parsed + List findByStopIds(StopPredictionsQuery query); + + /// Retrieves predictions by stop IDs. + /// + /// @param stopIds a [Collection] of stop IDs + /// @return a [List] of [Prediction]s corresponding to the provided stop IDs, or an empty [List] if no predictions + /// are found + /// @throws NullPointerException if `stopIds` is `null`, or if any element of `stopIds` is `null` + /// @throws Cta4jBusException if the API returns an error response or the response cannot be parsed + default List findByStopIds(Collection stopIds) { + Objects.requireNonNull(stopIds); + + List stopIdsList = List.copyOf(stopIds); + + StopPredictionsQuery query = StopPredictionsQuery.builder(stopIdsList) + .build(); return this.findByStopIds(query); } - /** - * Retrieves predictions by route ID and stop ID. - * - * @param routeId the route ID - * @param stopId the stop ID - * @return a {@link List} of {@link Prediction}s corresponding to the provided route ID and stop ID, or an empty - * {@link List} if no predictions are found - * @throws NullPointerException if {@code routeId} or {@code stopId} is {@code null} - * @throws Cta4jBusException if the API returns an error response or the response cannot be parsed - */ - default List findByRouteIdAndStopId(String routeId, String stopId) { - Objects.requireNonNull(routeId); + /// Retrieves predictions by stop ID. + /// + /// @param stopId the stop ID + /// @return a [List] of [Prediction]s corresponding to the provided stop ID, or an empty [List] if no predictions + /// are found + /// @throws NullPointerException if `stopId` is `null` + /// @throws Cta4jBusException if the API returns an error response or the response cannot be parsed + default List findByStopId(String stopId) { Objects.requireNonNull(stopId); List stopIds = List.of(stopId); - List routeIds = List.of(routeId); - StopsPredictionsQuery query = StopsPredictionsQuery.builder(stopIds) - .routeIds(routeIds) - .build(); + StopPredictionsQuery query = StopPredictionsQuery.builder(stopIds) + .build(); return this.findByStopIds(query); } - /** - * Retrieves predictions by vehicle ID. - * - * @param vehicleId the vehicle ID - * @return a {@link List} of {@link Prediction}s corresponding to the provided vehicle ID, or an empty {@link List} - * if no predictions are found - * @throws NullPointerException if {@code vehicleId} is {@code null} - * @throws Cta4jBusException if the API returns an error response or the response cannot be parsed - */ + /// Retrieves predictions by vehicle IDs. + /// + /// @param query the query parameters for fetching predictions by vehicle IDs + /// @return a [List] of [Prediction]s corresponding to the provided vehicle IDs, or an empty [List] if no + /// predictions are found + /// @throws NullPointerException if `query` is `null` + /// @throws Cta4jBusException if the API returns an error response or the response cannot be parsed + List findByVehicleIds(VehiclePredictionsQuery query); + + /// Retrieves predictions by vehicle IDs. + /// + /// @param vehicleIds a [Collection] of vehicle IDs + /// @return a [List] of [Prediction]s corresponding to the provided vehicle IDs, or an empty [List] if no + /// predictions are found + /// @throws NullPointerException if `vehicleIds` is `null`, or if any element of `vehicleIds` is `null` + /// @throws Cta4jBusException if the API returns an error response or the response cannot be parsed + default List findByVehicleIds(Collection vehicleIds) { + Objects.requireNonNull(vehicleIds); + + List vehicleIdsList = List.copyOf(vehicleIds); + + VehiclePredictionsQuery query = VehiclePredictionsQuery.builder(vehicleIdsList) + .build(); + + return this.findByVehicleIds(query); + } + + /// Retrieves predictions by vehicle ID. + /// + /// @param vehicleId the vehicle ID + /// @return a [List] of [Prediction]s corresponding to the provided vehicle ID, or an empty [List] if no + /// predictions are found + /// @throws NullPointerException if `vehicleId` is `null` + /// @throws Cta4jBusException if the API returns an error response or the response cannot be parsed default List findByVehicleId(String vehicleId) { Objects.requireNonNull(vehicleId); List vehicleIds = List.of(vehicleId); - VehiclesPredictionsQuery query = VehiclesPredictionsQuery.builder(vehicleIds) - .build(); + VehiclePredictionsQuery query = VehiclePredictionsQuery.builder(vehicleIds) + .build(); return this.findByVehicleIds(query); } + + /// Retrieves predictions by route ID and stop ID. + /// + /// @param routeId the route ID + /// @param stopId the stop ID + /// @return a [List] of [Prediction]s corresponding to the provided route ID and stop ID, or an empty [List] if no + /// predictions are found + /// @throws NullPointerException if `routeId` or `stopId` is `null` + /// @throws Cta4jBusException if the API returns an error response or the response cannot be parsed + default List findByRouteIdAndStopId(String routeId, String stopId) { + Objects.requireNonNull(routeId); + Objects.requireNonNull(stopId); + + List stopIds = List.of(stopId); + List routeIds = List.of(routeId); + + StopPredictionsQuery query = StopPredictionsQuery.builder(stopIds) + .routeIds(routeIds) + .build(); + + return this.findByStopIds(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 index 8ab371cf..fabdea3e 100644 --- a/src/main/java/com/cta4j/bus/prediction/internal/impl/PredictionsApiImpl.java +++ b/src/main/java/com/cta4j/bus/prediction/internal/impl/PredictionsApiImpl.java @@ -2,8 +2,8 @@ import com.cta4j.bus.common.exception.Cta4jBusException; import com.cta4j.bus.common.internal.config.BusApiConfig; -import com.cta4j.bus.common.internal.util.ApiUtils; import com.cta4j.bus.common.internal.util.BusApiConstants; +import com.cta4j.bus.common.internal.util.BusApiUtils; import com.cta4j.bus.common.internal.wire.CtaResponse; import com.cta4j.bus.prediction.PredictionsApi; import com.cta4j.bus.prediction.internal.mapper.PredictionMapper; @@ -11,8 +11,8 @@ import com.cta4j.bus.prediction.internal.wire.CtaPredictionBustimeResponse; import com.cta4j.bus.prediction.internal.wire.CtaPredictionError; 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.prediction.query.StopPredictionsQuery; +import com.cta4j.bus.prediction.query.VehiclePredictionsQuery; import org.apache.hc.client5.http.fluent.Request; import org.apache.hc.core5.net.URIBuilder; import org.jetbrains.annotations.ApiStatus; @@ -38,7 +38,7 @@ public PredictionsApiImpl(BusApiConfig config) { } @Override - public List findByStopIds(StopsPredictionsQuery query) { + public List findByStopIds(StopPredictionsQuery query) { Objects.requireNonNull(query); List stopIds = query.stopIds(); @@ -77,7 +77,7 @@ public List findByStopIds(StopsPredictionsQuery query) { } @Override - public List findByVehicleIds(VehiclesPredictionsQuery query) { + public List findByVehicleIds(VehiclePredictionsQuery query) { Objects.requireNonNull(query); List vehicleIds = query.vehicleIds(); @@ -143,7 +143,7 @@ private List makeRequest(String url) { .toList(); } - ApiUtils.checkErrors(errors, BusApiConstants.PREDICTIONS_ENDPOINT); + BusApiUtils.checkErrors(errors, BusApiConstants.PREDICTIONS_ENDPOINT); return List.of(); } diff --git a/src/main/java/com/cta4j/bus/prediction/model/DynamicAction.java b/src/main/java/com/cta4j/bus/prediction/model/DynamicAction.java index 269ed677..0acae5ff 100644 --- a/src/main/java/com/cta4j/bus/prediction/model/DynamicAction.java +++ b/src/main/java/com/cta4j/bus/prediction/model/DynamicAction.java @@ -2,107 +2,65 @@ import org.jspecify.annotations.NullMarked; -/** - * Represents the various dynamic actions that can be applied to a bus trip. - */ +/// Represents the various dynamic actions that can be applied to a bus trip. @NullMarked public enum DynamicAction { - /** - * Indicates that no dynamic action has been applied. - */ + /// Indicates that no dynamic action has been applied. NONE(0), - /** - * Indicates that the event or trip has been canceled. - */ + /// 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. - */ + /// 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. - */ + /// 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. - */ + /// 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. - */ + /// 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. - */ + /// 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. - *
  • - *
- */ + /// 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). - */ + /// 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. - */ + /// 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. - */ + /// 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. - */ + /// 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. - */ + /// 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. - */ + /// 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. - */ + /// 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. - */ + /// 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. - */ + /// 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; @@ -111,22 +69,18 @@ public enum DynamicAction { this.code = code; } - /** - * Gets the code associated with this dynamic action. - * - * @return the dynamic action code - */ + /// Returns the code associated with this dynamic action. + /// + /// @return the dynamic action code public int getCode() { return this.code; } - /** - * Returns the {@code DynamicAction} corresponding to the given code. - * - * @param code the dynamic action code - * @return the corresponding {@code DynamicAction} - * @throws IllegalArgumentException if the code does not correspond to any known dynamic action - */ + /// Returns the `DynamicAction` corresponding to the given code. + /// + /// @param code the dynamic action code + /// @return the corresponding `DynamicAction` + /// @throws IllegalArgumentException if the code does not correspond to any known dynamic action public static DynamicAction fromCode(int code) { return switch (code) { case 0 -> NONE; diff --git a/src/main/java/com/cta4j/bus/prediction/model/FlagStop.java b/src/main/java/com/cta4j/bus/prediction/model/FlagStop.java index 53683d50..5b0e36c6 100644 --- a/src/main/java/com/cta4j/bus/prediction/model/FlagStop.java +++ b/src/main/java/com/cta4j/bus/prediction/model/FlagStop.java @@ -2,29 +2,19 @@ import org.jspecify.annotations.NullMarked; -/** - * Represents the flag-stop information for a prediction. - */ +/// Represents the flag-stop information for a prediction. @NullMarked public enum FlagStop { - /** - * Indicates that no flag-stop information is available. - */ + /// Indicates that no flag-stop information is available. UNDEFINED(-1), - /** - * Indicates a normal stop. - */ + /// Indicates a normal stop. NORMAL(0), - /** - * Indicates a stop where passengers are both picked up and discharged. - */ + /// Indicates a stop where passengers are both picked up and discharged. PICKUP_AND_DISCHARGE(1), - /** - * Indicates a stop where only discharging of passengers occurs. - */ + /// Indicates a stop where only discharging of passengers occurs. ONLY_DISCHARGE(2); private final int code; @@ -33,22 +23,18 @@ public enum FlagStop { this.code = code; } - /** - * Gets the code associated with this flag-stop. - * - * @return the flag-stop code - */ + /// Returns the code associated with this flag-stop. + /// + /// @return the flag-stop code public int getCode() { return this.code; } - /** - * Returns the {@code FlagStop} corresponding to the given code. - * - * @param code the flag-stop code - * @return the corresponding {@code FlagStop} - * @throws IllegalArgumentException if the code does not correspond to any known flag-stop - */ + /// Returns the `FlagStop` corresponding to the given code. + /// + /// @param code the flag-stop code + /// @return the corresponding `FlagStop` + /// @throws IllegalArgumentException if the code does not correspond to any known flag-stop public static FlagStop fromCode(int code) { return switch (code) { case -1 -> UNDEFINED; diff --git a/src/main/java/com/cta4j/bus/prediction/model/PassengerLoad.java b/src/main/java/com/cta4j/bus/prediction/model/PassengerLoad.java index 06e2013f..4bede640 100644 --- a/src/main/java/com/cta4j/bus/prediction/model/PassengerLoad.java +++ b/src/main/java/com/cta4j/bus/prediction/model/PassengerLoad.java @@ -2,28 +2,18 @@ import org.jspecify.annotations.NullMarked; -/** - * Represents the passenger load information for a prediction. - */ +/// Represents the passenger load information for a prediction. @NullMarked public enum PassengerLoad { - /** - * Indicates that the bus is full. - */ + /// Indicates that the bus is full. FULL, - /** - * Indicates that the bus is half full. - */ + /// Indicates that the bus is half full. HALF_EMPTY, - /** - * Indicates that the bus is empty. - */ + /// Indicates that the bus is empty. EMPTY, - /** - * Indicates that no passenger load information is available. - */ + /// Indicates that no passenger load information is available. 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 index 107c8774..c1b572c5 100644 --- a/src/main/java/com/cta4j/bus/prediction/model/Prediction.java +++ b/src/main/java/com/cta4j/bus/prediction/model/Prediction.java @@ -8,24 +8,24 @@ import java.time.Instant; import java.util.Objects; -/** - * Represents a bus arrival prediction. - * - * @param predictionType the type of this prediction - * @param stopId the unique identifier of the stop for which this prediction was generated - * @param stopName the display name of the stop for which this prediction was generated - * @param vehicleId the unique identifier of the vehicle for which this prediction was generated - * @param distanceToStop the feet left to be traveled by the vehicle before it reaches the stop associated with this - * prediction - * @param routeId the alphanumeric designator of the route (e.g. "20" or "X20") for which this prediction was generated - * @param routeDesignator the language-specific route designator of this prediction, intended for display - * @param routeDirection the direction of travel of the route associated with this prediction (e.g. "Eastbound") - * @param destination the final destination of the vehicle associated with this prediction - * @param arrivalTime the predicted date and time (UTC) of a vehicle’s arrival or departure to the stop associated with - * this prediction - * @param delayed whether the vehicle associated with this prediction is currently delayed - * @param metadata the metadata associated with this prediction - */ +/// Represents a bus arrival prediction. +/// +/// @param predictionType the type of this prediction +/// @param stopId the unique identifier of the stop for which this prediction was generated +/// @param stopName the display name of the stop for which this prediction was generated +/// @param vehicleId the unique identifier of the vehicle for which this prediction was generated +/// @param distanceToStop the feet left to be traveled by the vehicle before it reaches the stop associated with this +/// prediction +/// @param routeId the alphanumeric designator of the route (e.g., "20" or "X9") for which this prediction was +/// generated +/// @param routeDesignator the language-specific route designator of this prediction, intended for display; identical +/// to `routeId` in practice (e.g., "20") +/// @param routeDirection the direction of travel of the route associated with this prediction (e.g., "Eastbound") +/// @param destination the final destination of the vehicle associated with this prediction +/// @param arrivalTime the predicted date and time (UTC) of a vehicle’s arrival or departure to the stop associated +/// with this prediction +/// @param delayed whether the vehicle associated with this prediction is currently delayed, if known +/// @param metadata the metadata associated with this prediction @NullMarked public record Prediction( PredictionType predictionType, @@ -41,28 +41,26 @@ public record Prediction( @Nullable Boolean delayed, PredictionMetadata metadata ) { - /** - * Constructs a {@code Prediction}. - * - * @param predictionType the type of the prediction - * @param stopId the unique identifier of the stop for which the prediction was generated - * @param stopName the display name of the stop for which the prediction was generated - * @param vehicleId the unique identifier of the vehicle for which the prediction was generated - * @param distanceToStop the feet left to be traveled by the vehicle before it reaches the stop associated with the - * prediction - * @param routeId the alphanumeric designator of the route (e.g. "20" or "X20") for which the prediction was - * generated - * @param routeDesignator the language-specific route designator of the prediction, intended for display - * @param routeDirection the direction of travel of the route associated with the prediction (e.g. "Eastbound") - * @param destination the final destination of the vehicle associated with the prediction - * @param arrivalTime the predicted date and time (UTC) of a vehicle’s arrival or departure to the stop associated - * with the prediction - * @param delayed whether the vehicle associated with the prediction is currently delayed - * @param metadata the metadata associated with the prediction - * @throws NullPointerException if {@code predictionType}, {@code stopId}, {@code stopName}, {@code vehicleId}, - * {@code distanceToStop}, {@code routeId}, {@code routeDesignator}, {@code routeDirection}, {@code destination}, - * {@code arrivalTime}, or {@code metadata} is {@code null} - */ + /// Constructs a `Prediction`. + /// + /// @param predictionType the type of the prediction + /// @param stopId the unique identifier of the stop for which the prediction was generated + /// @param stopName the display name of the stop for which the prediction was generated + /// @param vehicleId the unique identifier of the vehicle for which the prediction was generated + /// @param distanceToStop the feet left to be traveled by the vehicle before it reaches the stop associated with + /// the prediction + /// @param routeId the alphanumeric designator of the route (e.g., "20" or "X9") for which the prediction was + /// generated + /// @param routeDesignator the language-specific route designator of the prediction, intended for display; + /// identical to `routeId` in practice (e.g., "20") + /// @param routeDirection the direction of travel of the route associated with the prediction (e.g., "Eastbound") + /// @param destination the final destination of the vehicle associated with the prediction + /// @param arrivalTime the predicted date and time (UTC) of a vehicle’s arrival or departure to the stop associated + /// with the prediction + /// @param delayed whether the vehicle associated with the prediction is currently delayed, if known + /// @param metadata the metadata associated with the prediction + /// @throws NullPointerException if `predictionType`, `stopId`, `stopName`, `vehicleId`, `distanceToStop`, + /// `routeId`, `routeDesignator`, `routeDirection`, `destination`, `arrivalTime`, or `metadata` is `null` public Prediction { Objects.requireNonNull(predictionType); Objects.requireNonNull(stopId); @@ -77,11 +75,9 @@ public record Prediction( Objects.requireNonNull(metadata); } - /** - * Calculates the estimated time of arrival (ETA) in minutes from the current time to the predicted arrival time. - * - * @return the ETA in minutes; returns 0 if the predicted arrival time is in the past - */ + /// Calculates the estimated time of arrival (ETA) in minutes from the current time to the predicted arrival time. + /// + /// @return the ETA in minutes; returns 0 if the predicted arrival time is in the past public long etaMinutes() { Instant now = Instant.now(); diff --git a/src/main/java/com/cta4j/bus/prediction/model/PredictionMetadata.java b/src/main/java/com/cta4j/bus/prediction/model/PredictionMetadata.java index 4c3fdd58..37c21286 100644 --- a/src/main/java/com/cta4j/bus/prediction/model/PredictionMetadata.java +++ b/src/main/java/com/cta4j/bus/prediction/model/PredictionMetadata.java @@ -7,29 +7,25 @@ import java.time.LocalDate; import java.util.Objects; -/** - * Represents metadata associated with a bus arrival prediction. - * - *

- * NOTE: {@code gtfsSequence} and {@code nextBus} are not well-documented by the CTA. As such, their - * presence here is primarily for completeness and may not be populated or described correctly. - *

- * - * @param timestamp the date and time (UTC) this prediction was generated - * @param dynamicAction the {@link DynamicAction} affecting this prediction - * @param blockId the scheduled block identifier for the vehicle associated with this prediction - * @param tripId the scheduled trip identifier for the vehicle associated with this prediction - * @param originalTripNumber the trip identifier for the vehicle associated with this prediction - * @param countdownLabel the countdown label associated with this prediction (e.g., "10", "DUE") - * @param zone the zone name for the vehicle associated with this prediction, otherwise blank - * @param passengerLoad the {@link PassengerLoad} of the vehicle associated with this prediction - * @param gtfsSequence the GTFS sequence number associated with this prediction, if applicable - * @param nextBus the next bus identifier associated with this prediction, if applicable - * @param scheduledStartSeconds the scheduled start time in seconds past midnight associated with this prediction, if - * applicable - * @param scheduledStartDate the scheduled start date associated with this prediction, if applicable - * @param flagStop the {@link FlagStop} information of the vehicle associated with this prediction - */ +/// Represents metadata associated with a bus arrival prediction. +/// +/// **NOTE:** `gtfsSequence` and `nextBus` are not well-documented by the CTA. As such, their presence here is +/// primarily for completeness and may not be populated or described correctly. +/// +/// @param timestamp the date and time (UTC) this prediction was generated +/// @param dynamicAction the [DynamicAction] affecting this prediction +/// @param blockId the scheduled block identifier for the vehicle associated with this prediction +/// @param tripId the scheduled trip identifier for the vehicle associated with this prediction +/// @param originalTripNumber the trip identifier for the vehicle associated with this prediction +/// @param countdownLabel the countdown label associated with this prediction (e.g., "10", "DUE") +/// @param zone the zone name for the vehicle associated with this prediction, otherwise blank +/// @param passengerLoad the [PassengerLoad] of the vehicle associated with this prediction +/// @param gtfsSequence the GTFS sequence number associated with this prediction, if applicable +/// @param nextBus the next bus identifier associated with this prediction, if applicable +/// @param scheduledStartSeconds the scheduled start time in seconds past midnight associated with this prediction, if +/// applicable +/// @param scheduledStartDate the scheduled start date associated with this prediction, if applicable +/// @param flagStop the [FlagStop] information of the vehicle associated with this prediction @NullMarked public record PredictionMetadata( Instant timestamp, @@ -46,26 +42,24 @@ public record PredictionMetadata( @Nullable LocalDate scheduledStartDate, FlagStop flagStop ) { - /** - * Constructs a {@code PredictionMetadata}. - * - * @param timestamp the date and time (UTC) the prediction was generated - * @param dynamicAction the {@link DynamicAction} affecting the prediction - * @param blockId the scheduled block identifier for the vehicle associated with the prediction - * @param tripId the scheduled trip identifier for the vehicle associated with the prediction - * @param originalTripNumber the trip identifier for the vehicle associated with the prediction - * @param countdownLabel the countdown label associated with the prediction (e.g., "10", "DUE") - * @param zone the zone name for the vehicle associated with the prediction, otherwise blank - * @param passengerLoad the {@link PassengerLoad} of the vehicle associated with the prediction - * @param gtfsSequence the GTFS sequence number associated with the prediction, if applicable - * @param nextBus the next bus identifier associated with the prediction, if applicable - * @param scheduledStartSeconds the scheduled start time in seconds past midnight associated with the prediction, - * if applicable - * @param scheduledStartDate the scheduled start date associated with the prediction, if applicable - * @param flagStop the {@link FlagStop} information of the vehicle associated with the prediction - * @throws NullPointerException if {@code timestamp}, {@code dynamicAction}, {@code blockId}, {@code tripId}, - * {@code originalTripNumber}, {@code zone}, {@code passengerLoad}, or {@code flagStop} is {@code null} - */ + /// Constructs a `PredictionMetadata`. + /// + /// @param timestamp the date and time (UTC) the prediction was generated + /// @param dynamicAction the [DynamicAction] affecting the prediction + /// @param blockId the scheduled block identifier for the vehicle associated with the prediction + /// @param tripId the scheduled trip identifier for the vehicle associated with the prediction + /// @param originalTripNumber the trip identifier for the vehicle associated with the prediction + /// @param countdownLabel the countdown label associated with the prediction (e.g., "10", "DUE") + /// @param zone the zone name for the vehicle associated with the prediction, otherwise blank + /// @param passengerLoad the [PassengerLoad] of the vehicle associated with the prediction + /// @param gtfsSequence the GTFS sequence number associated with the prediction, if applicable + /// @param nextBus the next bus identifier associated with the prediction, if applicable + /// @param scheduledStartSeconds the scheduled start time in seconds past midnight associated with the prediction, + /// if applicable + /// @param scheduledStartDate the scheduled start date associated with the prediction, if applicable + /// @param flagStop the [FlagStop] information of the vehicle associated with the prediction + /// @throws NullPointerException if `timestamp`, `dynamicAction`, `blockId`, `tripId`, `originalTripNumber`, + /// `zone`, `passengerLoad`, or `flagStop` is `null` public PredictionMetadata { Objects.requireNonNull(timestamp); Objects.requireNonNull(dynamicAction); diff --git a/src/main/java/com/cta4j/bus/prediction/model/PredictionType.java b/src/main/java/com/cta4j/bus/prediction/model/PredictionType.java index 7a6d5fc1..437fc00f 100644 --- a/src/main/java/com/cta4j/bus/prediction/model/PredictionType.java +++ b/src/main/java/com/cta4j/bus/prediction/model/PredictionType.java @@ -2,18 +2,12 @@ import org.jspecify.annotations.NullMarked; -/** - * Represents the type of bus prediction. - */ +/// Represents the type of bus prediction. @NullMarked public enum PredictionType { - /** - * Indicates an arrival prediction. - */ + /// Indicates an arrival prediction. ARRIVAL, - /** - * Indicates a departure prediction. - */ + /// Indicates a departure prediction. DEPARTURE } diff --git a/src/main/java/com/cta4j/bus/prediction/model/package-info.java b/src/main/java/com/cta4j/bus/prediction/model/package-info.java new file mode 100644 index 00000000..4ed5fdff --- /dev/null +++ b/src/main/java/com/cta4j/bus/prediction/model/package-info.java @@ -0,0 +1,3 @@ +/// Domain model types returned by the CTA Bus Tracker API's predictions endpoint, representing an arrival +/// prediction and its metadata. +package com.cta4j.bus.prediction.model; diff --git a/src/main/java/com/cta4j/bus/prediction/package-info.java b/src/main/java/com/cta4j/bus/prediction/package-info.java new file mode 100644 index 00000000..98f9e102 --- /dev/null +++ b/src/main/java/com/cta4j/bus/prediction/package-info.java @@ -0,0 +1,2 @@ +/// Retrieval of real-time CTA bus arrival predictions for stops and vehicles. +package com.cta4j.bus.prediction; diff --git a/src/main/java/com/cta4j/bus/prediction/query/StopPredictionsQuery.java b/src/main/java/com/cta4j/bus/prediction/query/StopPredictionsQuery.java new file mode 100644 index 00000000..f55d35bb --- /dev/null +++ b/src/main/java/com/cta4j/bus/prediction/query/StopPredictionsQuery.java @@ -0,0 +1,111 @@ +package com.cta4j.bus.prediction.query; + +import com.cta4j.bus.common.internal.util.BusApiUtils; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +import java.util.Collection; +import java.util.List; +import java.util.Objects; + +/// Represents a query for bus arrival predictions. +/// +/// @param stopIds the [List] of stop IDs to retrieve predictions for +/// @param routeIds the optional [List] of route IDs to filter predictions by +/// @param maxResults the optional maximum number of predictions to return +@NullMarked +public record StopPredictionsQuery( + List stopIds, + @Nullable List routeIds, + @Nullable Integer maxResults +) { + /// Constructs a `StopPredictionsQuery`. + /// + /// @param stopIds the [List] of stop IDs to retrieve predictions for + /// @param routeIds the optional [List] of route IDs to filter predictions by + /// @param maxResults the optional maximum number of predictions to return + /// @throws NullPointerException if `stopIds` is `null`, or if any element of `stopIds` or `routeIds` is `null` + /// @throws IllegalArgumentException if more than 10 stop IDs are provided, or if `maxResults` is non-`null` and + /// not positive + public StopPredictionsQuery { + Objects.requireNonNull(stopIds); + + BusApiUtils.requireMaxIds(stopIds, "stop"); + + stopIds = List.copyOf(stopIds); + + if (routeIds != null) { + routeIds = List.copyOf(routeIds); + } + + if (maxResults != null && maxResults <= 0) { + throw new IllegalArgumentException("maxResults must be positive"); + } + } + + /// Creates a new `Builder` for constructing a `StopPredictionsQuery`. + /// + /// @param stopIds the [Collection] of stop IDs to retrieve predictions for + /// @return a new `Builder` + /// @throws NullPointerException if `stopIds` is `null`, or if any element of `stopIds` is `null` + public static Builder builder(Collection stopIds) { + return new Builder(stopIds); + } + + /// A builder for `StopPredictionsQuery`. + public static final class Builder { + private final List stopIds; + + @Nullable + private List routeIds; + + @Nullable + private Integer maxResults; + + private Builder(Collection stopIds) { + Objects.requireNonNull(stopIds); + + this.stopIds = List.copyOf(stopIds); + } + + /// Sets the [Collection] of route IDs to filter predictions by. + /// + /// @param routeIds the [Collection] of route IDs + /// @return this `Builder` instance + /// @throws NullPointerException if `routeIds` is `null`, or if any element of `routeIds` is `null` + public Builder routeIds(Collection routeIds) { + Objects.requireNonNull(routeIds); + + this.routeIds = List.copyOf(routeIds); + + return this; + } + + /// Sets the maximum number of predictions to return. + /// + /// @param maxResults the maximum number of predictions + /// @return this `Builder` instance + /// @throws IllegalArgumentException if `maxResults` is not positive + public Builder maxResults(int maxResults) { + if (maxResults <= 0) { + throw new IllegalArgumentException("maxResults must be positive"); + } + + this.maxResults = maxResults; + + return this; + } + + /// Builds a configured `StopPredictionsQuery` instance. + /// + /// @return a new `StopPredictionsQuery` + /// @throws IllegalArgumentException if more than 10 stop IDs are provided + public StopPredictionsQuery build() { + return new StopPredictionsQuery( + this.stopIds, + this.routeIds, + this.maxResults + ); + } + } +} diff --git a/src/main/java/com/cta4j/bus/prediction/query/StopsPredictionsQuery.java b/src/main/java/com/cta4j/bus/prediction/query/StopsPredictionsQuery.java deleted file mode 100644 index 2c2c8e4d..00000000 --- a/src/main/java/com/cta4j/bus/prediction/query/StopsPredictionsQuery.java +++ /dev/null @@ -1,143 +0,0 @@ -package com.cta4j.bus.prediction.query; - -import com.cta4j.bus.common.internal.util.ApiUtils; -import org.jspecify.annotations.NullMarked; -import org.jspecify.annotations.Nullable; - -import java.util.List; -import java.util.Objects; - -/** - * Represents a query for bus arrival predictions. - * - * @param stopIds the {@link List} of stop IDs to retrieve predictions for - * @param routeIds the optional {@link List} of route IDs to filter predictions by - * @param maxResults the optional maximum number of predictions to return - */ -@NullMarked -public record StopsPredictionsQuery( - List stopIds, - @Nullable List routeIds, - @Nullable Integer maxResults -) { - /** - * Constructs a {@code StopsPredictionsQuery}. - * - * @param stopIds the {@link List} of stop IDs to retrieve predictions for - * @param routeIds the optional {@link List} of route IDs to filter predictions by - * @param maxResults the optional maximum number of predictions to return - * @throws NullPointerException if {@code stopIds} is {@code null}, or if any element of {@code stopIds} or - * {@code routeIds} is {@code null} - * @throws IllegalArgumentException if more than 10 stop IDs are provided, or if {@code maxResults} is - * non-{@code null} and not positive - */ - public StopsPredictionsQuery { - Objects.requireNonNull(stopIds); - - ApiUtils.requireMaxIds(stopIds, "stop"); - - stopIds = List.copyOf(stopIds); - - if (routeIds != null) { - routeIds = List.copyOf(routeIds); - } - - if (maxResults != null && maxResults <= 0) { - throw new IllegalArgumentException("maxResults must be positive"); - } - } - - /** - * Creates a builder for {@code StopsPredictionsQuery}. - * - * @param stopIds the {@link List} of stop IDs to retrieve predictions for - * @return a new {@code Builder} instance - * @throws NullPointerException if {@code stopIds} is {@code null}, or if any element of {@code stopIds} is - * {@code null} - */ - public static Builder builder(List stopIds) { - return new Builder(stopIds); - } - - /** - * A builder for {@code StopsPredictionsQuery}. - */ - public static final class Builder { - /** - * The {@link List} of stop IDs to retrieve predictions for. - */ - private final List stopIds; - - /** - * The optional {@link List} of route IDs to filter predictions by. - */ - @Nullable - private List routeIds; - - /** - * The optional maximum number of predictions to return. - */ - @Nullable - private Integer maxResults; - - /** - * Constructs a {@code Builder}. - * - * @param stopIds the {@link List} of stop IDs to retrieve predictions for - * @throws NullPointerException if {@code stopIds} is {@code null}, or if any element of {@code stopIds} is - * {@code null} - */ - public Builder(List stopIds) { - Objects.requireNonNull(stopIds); - - this.stopIds = List.copyOf(stopIds); - } - - /** - * Sets the {@link List} of route IDs to filter predictions by. - * - * @param routeIds the {@link List} of route IDs - * @return this {@code Builder} instance - * @throws NullPointerException if {@code routeIds} is {@code null}, or if any element of {@code routeIds} is - * {@code null} - */ - public Builder routeIds(List routeIds) { - Objects.requireNonNull(routeIds); - - this.routeIds = List.copyOf(routeIds); - - return this; - } - - /** - * Sets the maximum number of predictions to return. - * - * @param maxResults the maximum number of predictions - * @return this {@code Builder} instance - * @throws IllegalArgumentException if {@code maxResults} is not positive - */ - public Builder maxResults(int maxResults) { - if (maxResults <= 0) { - throw new IllegalArgumentException("maxResults must be positive"); - } - - this.maxResults = maxResults; - - return this; - } - - /** - * Builds the {@code StopsPredictionsQuery}. - * - * @return a new {@code StopsPredictionsQuery} instance - * @throws IllegalArgumentException if more than 10 stop IDs are provided - */ - public StopsPredictionsQuery build() { - return new StopsPredictionsQuery( - this.stopIds, - this.routeIds, - this.maxResults - ); - } - } -} diff --git a/src/main/java/com/cta4j/bus/prediction/query/VehiclePredictionsQuery.java b/src/main/java/com/cta4j/bus/prediction/query/VehiclePredictionsQuery.java new file mode 100644 index 00000000..560b3666 --- /dev/null +++ b/src/main/java/com/cta4j/bus/prediction/query/VehiclePredictionsQuery.java @@ -0,0 +1,87 @@ +package com.cta4j.bus.prediction.query; + +import com.cta4j.bus.common.internal.util.BusApiUtils; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +import java.util.Collection; +import java.util.List; +import java.util.Objects; + +/// Represents a query for vehicle arrival predictions. +/// +/// @param vehicleIds the [List] of vehicle IDs to retrieve predictions for +/// @param maxResults the optional maximum number of predictions to return +@NullMarked +public record VehiclePredictionsQuery( + List vehicleIds, + @Nullable Integer maxResults +) { + /// Constructs a `VehiclePredictionsQuery`. + /// + /// @param vehicleIds the [List] of vehicle IDs to retrieve predictions for + /// @param maxResults the optional maximum number of predictions to return + /// @throws NullPointerException if `vehicleIds` is `null`, or if any element of `vehicleIds` is `null` + /// @throws IllegalArgumentException if more than 10 vehicle IDs are provided, or if `maxResults` is non-`null` and + /// not positive + public VehiclePredictionsQuery { + Objects.requireNonNull(vehicleIds); + + BusApiUtils.requireMaxIds(vehicleIds, "vehicle"); + + vehicleIds = List.copyOf(vehicleIds); + + if (maxResults != null && maxResults <= 0) { + throw new IllegalArgumentException("maxResults must be positive"); + } + } + + /// Creates a new `Builder` for constructing a `VehiclePredictionsQuery`. + /// + /// @param vehicleIds the [Collection] of vehicle IDs to retrieve predictions for + /// @return a new `Builder` + /// @throws NullPointerException if `vehicleIds` is `null`, or if any element of `vehicleIds` is `null` + public static Builder builder(Collection vehicleIds) { + return new Builder(vehicleIds); + } + + /// A builder for `VehiclePredictionsQuery`. + public static final class Builder { + private final List vehicleIds; + + @Nullable + private Integer maxResults; + + private Builder(Collection vehicleIds) { + Objects.requireNonNull(vehicleIds); + + this.vehicleIds = List.copyOf(vehicleIds); + } + + /// Sets the maximum number of predictions to return. + /// + /// @param maxResults the maximum number of predictions + /// @return this `Builder` instance + /// @throws IllegalArgumentException if `maxResults` is not positive + public Builder maxResults(int maxResults) { + if (maxResults <= 0) { + throw new IllegalArgumentException("maxResults must be positive"); + } + + this.maxResults = maxResults; + + return this; + } + + /// Builds a configured `VehiclePredictionsQuery` instance. + /// + /// @return a new `VehiclePredictionsQuery` + /// @throws IllegalArgumentException if more than 10 vehicle IDs are provided + public VehiclePredictionsQuery build() { + return new VehiclePredictionsQuery( + this.vehicleIds, + 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 deleted file mode 100644 index 0a09a488..00000000 --- a/src/main/java/com/cta4j/bus/prediction/query/VehiclesPredictionsQuery.java +++ /dev/null @@ -1,113 +0,0 @@ -package com.cta4j.bus.prediction.query; - -import com.cta4j.bus.common.internal.util.ApiUtils; -import org.jspecify.annotations.NullMarked; -import org.jspecify.annotations.Nullable; - -import java.util.List; -import java.util.Objects; - -/** - * Represents a query for vehicle arrival predictions. - * - * @param vehicleIds the {@link List} of vehicle IDs to retrieve predictions for - * @param maxResults the optional maximum number of predictions to return - */ -@NullMarked -public record VehiclesPredictionsQuery( - List vehicleIds, - @Nullable Integer maxResults -) { - /** - * Constructs a {@code VehiclesPredictionsQuery}. - * - * @param vehicleIds the {@link List} of vehicle IDs to retrieve predictions for - * @param maxResults the optional maximum number of predictions to return - * @throws NullPointerException if {@code vehicleIds} is {@code null}, or if any element of {@code vehicleIds} is - * {@code null} - * @throws IllegalArgumentException if more than 10 vehicle IDs are provided, or if {@code maxResults} is - * non-{@code null} and not positive - */ - public VehiclesPredictionsQuery { - Objects.requireNonNull(vehicleIds); - - ApiUtils.requireMaxIds(vehicleIds, "vehicle"); - - vehicleIds = List.copyOf(vehicleIds); - - if (maxResults != null && maxResults <= 0) { - throw new IllegalArgumentException("maxResults must be positive"); - } - } - - /** - * Creates a builder for {@code VehiclesPredictionsQuery}. - * - * @param vehicleIds the {@link List} of vehicle IDs to retrieve predictions for - * @return a new {@code Builder} instance - * @throws NullPointerException if {@code vehicleIds} is {@code null}, or if any element of {@code vehicleIds} is - * {@code null} - */ - public static Builder builder(List vehicleIds) { - return new Builder(vehicleIds); - } - - /** - * Builder for {@code VehiclesPredictionsQuery}. - */ - public static final class Builder { - /** - * The {@link List} of vehicle IDs to retrieve predictions for. - */ - private final List vehicleIds; - - /** - * The optional maximum number of predictions to return. - */ - @Nullable - private Integer maxResults; - - /** - * Constructs a {@code Builder}. - * - * @param vehicleIds the {@link List} of vehicle IDs to retrieve predictions for - * @throws NullPointerException if {@code vehicleIds} is {@code null}, or if any element of - * {@code vehicleIds} is {@code null} - */ - public Builder(List vehicleIds) { - Objects.requireNonNull(vehicleIds); - - this.vehicleIds = List.copyOf(vehicleIds); - } - - /** - * Sets the maximum number of predictions to return. - * - * @param maxResults the maximum number of predictions to return - * @return this {@code Builder} instance - * @throws IllegalArgumentException if {@code maxResults} is not positive - */ - public Builder maxResults(int maxResults) { - if (maxResults <= 0) { - throw new IllegalArgumentException("maxResults must be positive"); - } - - this.maxResults = maxResults; - - return this; - } - - /** - * Builds the {@code VehiclesPredictionsQuery}. - * - * @return the constructed {@code VehiclesPredictionsQuery} - * @throws IllegalArgumentException if more than 10 vehicle IDs are provided - */ - public VehiclesPredictionsQuery build() { - return new VehiclesPredictionsQuery( - this.vehicleIds, - this.maxResults - ); - } - } -} diff --git a/src/main/java/com/cta4j/bus/prediction/query/package-info.java b/src/main/java/com/cta4j/bus/prediction/query/package-info.java new file mode 100644 index 00000000..c8543bb9 --- /dev/null +++ b/src/main/java/com/cta4j/bus/prediction/query/package-info.java @@ -0,0 +1,3 @@ +/// Query builder types for filtering requests to the CTA Bus Tracker API's predictions endpoint by stop or +/// vehicle. +package com.cta4j.bus.prediction.query; diff --git a/src/main/java/com/cta4j/bus/route/RoutesApi.java b/src/main/java/com/cta4j/bus/route/RoutesApi.java index 0137ee13..cbd2eca2 100644 --- a/src/main/java/com/cta4j/bus/route/RoutesApi.java +++ b/src/main/java/com/cta4j/bus/route/RoutesApi.java @@ -6,18 +6,14 @@ import java.util.List; -/** - * Provides access to route-related endpoints of the CTA BusTime API. - *

- * This API allows retrieval of all available routes. - */ +/// Provides access to route-related endpoints of the CTA Bus Tracker API. +/// +/// This API allows retrieval of all available routes. @NullMarked public interface RoutesApi { - /** - * Retrieves all available routes. - * - * @return a {@link List} of all available {@link Route}s, or an empty {@link List} if no routes are found - * @throws Cta4jBusException if the API returns an error response or the response cannot be parsed - */ + /// Retrieves all available routes. + /// + /// @return a [List] of all available [Route]s, or an empty [List] if no routes are found + /// @throws Cta4jBusException if the API returns an error response or the response cannot be parsed 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 index 2bb7144a..be28d0fc 100644 --- a/src/main/java/com/cta4j/bus/route/internal/impl/RoutesApiImpl.java +++ b/src/main/java/com/cta4j/bus/route/internal/impl/RoutesApiImpl.java @@ -2,8 +2,8 @@ import com.cta4j.bus.common.exception.Cta4jBusException; import com.cta4j.bus.common.internal.config.BusApiConfig; -import com.cta4j.bus.common.internal.util.ApiUtils; import com.cta4j.bus.common.internal.util.BusApiConstants; +import com.cta4j.bus.common.internal.util.BusApiUtils; import com.cta4j.bus.common.internal.wire.CtaResponse; import com.cta4j.bus.route.RoutesApi; import com.cta4j.bus.route.internal.mapper.RouteMapper; @@ -80,7 +80,7 @@ public List list() { .toList(); } - ApiUtils.checkErrors(errors, BusApiConstants.ROUTES_ENDPOINT); + BusApiUtils.checkErrors(errors, BusApiConstants.ROUTES_ENDPOINT); return List.of(); } diff --git a/src/main/java/com/cta4j/bus/route/model/Route.java b/src/main/java/com/cta4j/bus/route/model/Route.java index 61000939..a571e3dc 100644 --- a/src/main/java/com/cta4j/bus/route/model/Route.java +++ b/src/main/java/com/cta4j/bus/route/model/Route.java @@ -5,20 +5,17 @@ import java.util.Objects; -/** - * Represents a bus route. - * - *

- * NOTE: {@code dataFeed} is not well-documented by the CTA. As such, its presence here is primarily for - * completeness and may not be populated or described correctly. - *

- * - * @param id the alphanumeric designator of this route (e.g., "22", "J14", "X9") - * @param name the common name of this route (e.g., "Clark", "Jeffery Jump", "Ashland Express") - * @param color the color of this route used in maps (e.g., "#ffffff") - * @param designator the language-specific route designator of this route, intended for display - * @param dataFeed the data feed identifier for this route, if applicable - */ +/// Represents a bus route. +/// +/// **NOTE:** `dataFeed` is not well-documented by the CTA. As such, its presence here is primarily for completeness +/// and may not be populated or described correctly. +/// +/// @param id the alphanumeric designator of this route (e.g., "22", "J14", "X9") +/// @param name the common name of this route (e.g., "Clark", "Jeffery Jump", "Ashland Express") +/// @param color the color of this route used in maps (e.g., "#ffffff") +/// @param designator the language-specific route designator of this route, intended for display; identical to `id` in +/// practice (e.g., "22") +/// @param dataFeed the data feed identifier for this route, if applicable @NullMarked public record Route( String id, @@ -27,16 +24,15 @@ public record Route( String designator, @Nullable String dataFeed ) { - /** - * Constructs a {@code Route}. - * - * @param id the alphanumeric designator of the route (e.g., "22", "J14", "X9") - * @param name the common name of the route (e.g., "Clark", "Jeffery Jump", "Ashland Express") - * @param color the color of the route used in maps (e.g., "#ffffff") - * @param designator the language-specific route designator of the route, intended for display - * @param dataFeed the data feed identifier for the route, if applicable - * @throws NullPointerException if {@code id}, {@code name}, {@code color}, or {@code designator} is {@code null} - */ + /// Constructs a `Route`. + /// + /// @param id the alphanumeric designator of the route (e.g., "22", "J14", "X9") + /// @param name the common name of the route (e.g., "Clark", "Jeffery Jump", "Ashland Express") + /// @param color the color of the route used in maps (e.g., "#ffffff") + /// @param designator the language-specific route designator of the route, intended for display; identical to `id` + /// in practice (e.g., "22") + /// @param dataFeed the data feed identifier for the route, if applicable + /// @throws NullPointerException if `id`, `name`, `color`, or `designator` is `null` public Route { Objects.requireNonNull(id); Objects.requireNonNull(name); diff --git a/src/main/java/com/cta4j/bus/route/model/package-info.java b/src/main/java/com/cta4j/bus/route/model/package-info.java new file mode 100644 index 00000000..8969a002 --- /dev/null +++ b/src/main/java/com/cta4j/bus/route/model/package-info.java @@ -0,0 +1,2 @@ +/// Domain model type returned by the CTA Bus Tracker API's routes endpoint, representing a single bus route. +package com.cta4j.bus.route.model; diff --git a/src/main/java/com/cta4j/bus/route/package-info.java b/src/main/java/com/cta4j/bus/route/package-info.java new file mode 100644 index 00000000..3928147d --- /dev/null +++ b/src/main/java/com/cta4j/bus/route/package-info.java @@ -0,0 +1,2 @@ +/// Retrieval of all available CTA bus routes. +package com.cta4j.bus.route; diff --git a/src/main/java/com/cta4j/bus/stop/StopsApi.java b/src/main/java/com/cta4j/bus/stop/StopsApi.java index 79f992f5..997acd3b 100644 --- a/src/main/java/com/cta4j/bus/stop/StopsApi.java +++ b/src/main/java/com/cta4j/bus/stop/StopsApi.java @@ -10,47 +10,28 @@ import java.util.Objects; import java.util.Optional; -/** - * Provides access to stop-related endpoints of the CTA BusTime API. - *

- * This API allows retrieval of stops by route ID and direction, as well as by stop IDs. - */ +/// Provides access to stop-related endpoints of the CTA Bus Tracker API. +/// +/// This API allows retrieval of stops by route ID and direction, as well as by stop IDs. @NullMarked public interface StopsApi { - /** - * Retrieves stops by route ID and direction. - * - * @param routeId the route ID - * @param direction the direction (e.g., "Northbound", "Southbound") - * @return a {@link List} of {@link Stop}s corresponding to the provided route ID and direction, or an empty - * {@link List} if no stops are found - * @throws NullPointerException if {@code routeId} or {@code direction} is {@code null} - * @throws Cta4jBusException if the API returns an error response or the response cannot be parsed - */ - List findByRouteIdAndDirection(String routeId, String direction); - - /** - * Retrieves stops by their IDs. - * - * @param stopIds a {@link Collection} of stop IDs - * @return a {@link List} of {@link Stop}s corresponding to the provided stop IDs, or an empty {@link List} if no - * stops are found - * @throws NullPointerException if {@code stopIds} is {@code null} or contains {@code null} elements - * @throws IllegalArgumentException if more than 10 stop IDs are provided - * @throws Cta4jBusException if the API returns an error response or the response cannot be parsed - */ + /// Retrieves stops by their IDs. + /// + /// @param stopIds a [Collection] of stop IDs + /// @return a [List] of [Stop]s corresponding to the provided stop IDs, or an empty [List] if no stops are found + /// @throws NullPointerException if `stopIds` is `null`, or if any element of `stopIds` is `null` + /// @throws IllegalArgumentException if more than 10 stop IDs are provided + /// @throws Cta4jBusException if the API returns an error response or the response cannot be parsed List findByIds(Collection stopIds); - /** - * Retrieves a stop by its ID. - * - * @param stopId the stop ID - * @return an {@link Optional} containing the {@link Stop} if found, or an empty {@link Optional} if no stop is - * found for the given ID - * @throws NullPointerException if {@code stopId} is {@code null} - * @throws Cta4jBusException if multiple stops are found for the given ID, or if the API returns an error - * response or the response cannot be parsed - */ + /// Retrieves a stop by its ID. + /// + /// @param stopId the stop ID + /// @return an [Optional] containing the [Stop] if found, or an empty [Optional] if no stop is found for the given + /// ID + /// @throws NullPointerException if `stopId` is `null` + /// @throws Cta4jBusException if multiple stops are found for the given ID, or if the API returns an error response + /// or the response cannot be parsed default Optional findById(String stopId) { Objects.requireNonNull(stopId); @@ -72,4 +53,14 @@ default Optional findById(String stopId) { return Optional.of(stop); } + + /// Retrieves stops by route ID and direction. + /// + /// @param routeId the route ID + /// @param direction the direction (e.g., "Northbound", "Southbound") + /// @return a [List] of [Stop]s corresponding to the provided route ID and direction, or an empty [List] if no + /// stops are found + /// @throws NullPointerException if `routeId` or `direction` is `null` + /// @throws Cta4jBusException if the API returns an error response or the response cannot be parsed + List findByRouteIdAndDirection(String routeId, String direction); } 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 index 1ea4f406..379d65cd 100644 --- a/src/main/java/com/cta4j/bus/stop/internal/impl/StopsApiImpl.java +++ b/src/main/java/com/cta4j/bus/stop/internal/impl/StopsApiImpl.java @@ -2,8 +2,8 @@ import com.cta4j.bus.common.exception.Cta4jBusException; import com.cta4j.bus.common.internal.config.BusApiConfig; -import com.cta4j.bus.common.internal.util.ApiUtils; import com.cta4j.bus.common.internal.util.BusApiConstants; +import com.cta4j.bus.common.internal.util.BusApiUtils; import com.cta4j.bus.common.internal.wire.CtaResponse; import com.cta4j.bus.stop.StopsApi; import com.cta4j.bus.stop.internal.mapper.StopMapper; @@ -64,7 +64,7 @@ public List findByIds(Collection stopIds) { return List.of(); } - ApiUtils.requireMaxIds(stopIds, "stop"); + BusApiUtils.requireMaxIds(stopIds, "stop"); String stopIdsString = String.join(",", stopIds); @@ -115,7 +115,7 @@ private List makeRequest(String url) { .toList(); } - ApiUtils.checkErrors(errors, BusApiConstants.STOPS_ENDPOINT); + BusApiUtils.checkErrors(errors, BusApiConstants.STOPS_ENDPOINT); return List.of(); } diff --git a/src/main/java/com/cta4j/bus/stop/model/Stop.java b/src/main/java/com/cta4j/bus/stop/model/Stop.java index 9371251b..5ef41df8 100644 --- a/src/main/java/com/cta4j/bus/stop/model/Stop.java +++ b/src/main/java/com/cta4j/bus/stop/model/Stop.java @@ -7,23 +7,19 @@ import java.util.List; import java.util.Objects; -/** - * Represents a bus stop. - * - *

- * NOTE: {@code gtfsSequence} is not well-documented by the CTA. As such, its presence here is primarily for - * completeness and may not be populated or described correctly. - *

- * - * @param id the unique identifier of this stop - * @param name the display name of this stop (e.g. "Madison and Clark") - * @param latitude the latitude coordinate of this stop - * @param longitude the longitude coordinate of this stop - * @param detoursAdded the {@link List} of detour IDs which temporarily add service to this stop - * @param detoursRemoved the {@link List} of detour IDs which temporarily remove service from this stop - * @param gtfsSequence the GTFS sequence number of this stop, if applicable - * @param adaAccessible whether this stop is ADA accessible, if known - */ +/// Represents a bus stop. +/// +/// **NOTE:** `gtfsSequence` is not well-documented by the CTA. As such, its presence here is primarily for +/// completeness and may not be populated or described correctly. +/// +/// @param id the unique identifier of this stop +/// @param name the display name of this stop (e.g., "Clark & Addison") +/// @param latitude the latitude coordinate of this stop +/// @param longitude the longitude coordinate of this stop +/// @param detoursAdded the [List] of detour IDs which temporarily add service to this stop, if applicable +/// @param detoursRemoved the [List] of detour IDs which temporarily remove service from this stop, if applicable +/// @param gtfsSequence the GTFS sequence number of this stop, if applicable +/// @param adaAccessible whether this stop is ADA accessible, if known @NullMarked public record Stop( String id, @@ -35,20 +31,18 @@ public record Stop( @Nullable Integer gtfsSequence, @Nullable Boolean adaAccessible ) { - /** - * Constructs a {@code Stop}. - * - * @param id the unique identifier of the stop - * @param name the display name of the stop (e.g. "Madison and Clark") - * @param latitude the latitude coordinate of the stop - * @param longitude the longitude coordinate of the stop - * @param detoursAdded the {@link List} of detour IDs which temporarily add service to the stop - * @param detoursRemoved the {@link List} of detour IDs which temporarily remove service from the stop - * @param gtfsSequence the GTFS sequence number of the stop, if applicable - * @param adaAccessible whether the stop is ADA accessible, if known - * @throws NullPointerException if {@code id}, {@code name}, {@code latitude}, or {@code longitude} is - * {@code null}, or if any element of {@code detoursAdded} or {@code detoursRemoved} is {@code null} - */ + /// Constructs a `Stop`. + /// + /// @param id the unique identifier of the stop + /// @param name the display name of the stop (e.g., "Clark & Addison") + /// @param latitude the latitude coordinate of the stop + /// @param longitude the longitude coordinate of the stop + /// @param detoursAdded the [List] of detour IDs which temporarily add service to the stop, if applicable + /// @param detoursRemoved the [List] of detour IDs which temporarily remove service from the stop, if applicable + /// @param gtfsSequence the GTFS sequence number of the stop, if applicable + /// @param adaAccessible whether the stop is ADA accessible, if known + /// @throws NullPointerException if `id`, `name`, `latitude`, or `longitude` is `null`, or if any element of + /// `detoursAdded` or `detoursRemoved` is `null` public Stop { Objects.requireNonNull(id); Objects.requireNonNull(name); diff --git a/src/main/java/com/cta4j/bus/stop/model/package-info.java b/src/main/java/com/cta4j/bus/stop/model/package-info.java new file mode 100644 index 00000000..ba99b479 --- /dev/null +++ b/src/main/java/com/cta4j/bus/stop/model/package-info.java @@ -0,0 +1,2 @@ +/// Domain model type returned by the CTA Bus Tracker API's stops endpoint, representing a single bus stop. +package com.cta4j.bus.stop.model; diff --git a/src/main/java/com/cta4j/bus/stop/package-info.java b/src/main/java/com/cta4j/bus/stop/package-info.java new file mode 100644 index 00000000..555609f1 --- /dev/null +++ b/src/main/java/com/cta4j/bus/stop/package-info.java @@ -0,0 +1,2 @@ +/// Retrieval of CTA bus stops for a given route and direction. +package com.cta4j.bus.stop; diff --git a/src/main/java/com/cta4j/bus/vehicle/VehiclesApi.java b/src/main/java/com/cta4j/bus/vehicle/VehiclesApi.java index 4a93e5d9..83fccd40 100644 --- a/src/main/java/com/cta4j/bus/vehicle/VehiclesApi.java +++ b/src/main/java/com/cta4j/bus/vehicle/VehiclesApi.java @@ -10,41 +10,34 @@ import java.util.Objects; import java.util.Optional; -/** - * Provides access to vehicle-related endpoints of the CTA BusTime API. - *

- * This API allows retrieval of vehicles by their IDs or by associated route IDs. - */ +/// Provides access to vehicle-related endpoints of the CTA Bus Tracker API. +/// +/// This API allows retrieval of vehicles by their IDs or by associated route IDs. @NullMarked public interface VehiclesApi { - /** - * Retrieves vehicles by their IDs. - * - * @param ids a {@link Collection} of vehicle IDs - * @return a {@link List} of {@link Vehicle}s corresponding to the provided IDs, or an empty {@link List} if no - * vehicles are found - * @throws NullPointerException if {@code ids} is {@code null} or contains {@code null} elements - * @throws IllegalArgumentException if more than 10 vehicle IDs are provided - * @throws Cta4jBusException if the API returns an error response or the response cannot be parsed - */ - List findByIds(Collection ids); + /// Retrieves vehicles by their IDs. + /// + /// @param vehicleIds a [Collection] of vehicle IDs + /// @return a [List] of [Vehicle]s corresponding to the provided IDs, or an empty [List] if no vehicles are found + /// @throws NullPointerException if `vehicleIds` is `null`, or if any element of `vehicleIds` is `null` + /// @throws IllegalArgumentException if more than 10 vehicle IDs are provided + /// @throws Cta4jBusException if the API returns an error response or the response cannot be parsed + List findByIds(Collection vehicleIds); - /** - * Retrieves a vehicle by its ID. - * - * @param id the vehicle ID - * @return an {@link Optional} containing the {@link Vehicle} if found, or an empty {@link Optional} if no vehicle - * is found for the given ID - * @throws NullPointerException if {@code id} is {@code null} - * @throws Cta4jBusException if multiple vehicles are found for the given ID, or if the API returns an error - * response or the response cannot be parsed - */ - default Optional findById(String id) { - Objects.requireNonNull(id); + /// Retrieves a vehicle by its ID. + /// + /// @param vehicleId the vehicle ID + /// @return an [Optional] containing the [Vehicle] if found, or an empty [Optional] if no vehicle is found for the + /// given ID + /// @throws NullPointerException if `vehicleId` is `null` + /// @throws Cta4jBusException if multiple vehicles are found for the given ID, or if the API returns an error + /// response or the response cannot be parsed + default Optional findById(String vehicleId) { + Objects.requireNonNull(vehicleId); - List ids = List.of(id); + List vehicleIds = List.of(vehicleId); - List vehicles = this.findByIds(ids); + List vehicles = this.findByIds(vehicleIds); if (vehicles.isEmpty()) { return Optional.empty(); @@ -52,7 +45,7 @@ default Optional findById(String id) { if (vehicles.size() > 1) { String message = "Expected at most one vehicle for ID: %s, but found %d".formatted( - id, + vehicleId, vehicles.size() ); @@ -64,27 +57,23 @@ default Optional findById(String id) { return Optional.of(vehicle); } - /** - * Retrieves all vehicles for the specified route IDs. - * - * @param routeIds a {@link Collection} of route IDs - * @return a {@link List} of {@link Vehicle}s associated with the route IDs, or an empty {@link List} if no - * vehicles are found for the route IDs - * @throws NullPointerException if {@code routeIds} is {@code null} or contains {@code null} elements - * @throws IllegalArgumentException if more than 10 route IDs are provided - * @throws Cta4jBusException if the API returns an error response or the response cannot be parsed - */ + /// Retrieves all vehicles for the specified route IDs. + /// + /// @param routeIds a [Collection] of route IDs + /// @return a [List] of [Vehicle]s associated with the route IDs, or an empty [List] if no vehicles are found for + /// the route IDs + /// @throws NullPointerException if `routeIds` is `null`, or if any element of `routeIds` is `null` + /// @throws IllegalArgumentException if more than 10 route IDs are provided + /// @throws Cta4jBusException if the API returns an error response or the response cannot be parsed List findByRouteIds(Collection routeIds); - /** - * Retrieves all vehicles for the specified route ID. - * - * @param routeId the route ID - * @return a {@link List} of {@link Vehicle}s associated with the route ID, or an empty {@link List} if no vehicles - * are found for the route ID - * @throws NullPointerException if {@code routeId} is {@code null} - * @throws Cta4jBusException if the API returns an error response or the response cannot be parsed - */ + /// Retrieves all vehicles for the specified route ID. + /// + /// @param routeId the route ID + /// @return a [List] of [Vehicle]s associated with the route ID, or an empty [List] if no vehicles are found for + /// the route ID + /// @throws NullPointerException if `routeId` is `null` + /// @throws Cta4jBusException if the API returns an error response or the response cannot be parsed default List findByRouteId(String routeId) { Objects.requireNonNull(routeId); 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 index 1ab6dc57..0b67b4e7 100644 --- a/src/main/java/com/cta4j/bus/vehicle/internal/impl/VehiclesApiImpl.java +++ b/src/main/java/com/cta4j/bus/vehicle/internal/impl/VehiclesApiImpl.java @@ -2,8 +2,8 @@ import com.cta4j.bus.common.exception.Cta4jBusException; import com.cta4j.bus.common.internal.config.BusApiConfig; -import com.cta4j.bus.common.internal.util.ApiUtils; import com.cta4j.bus.common.internal.util.BusApiConstants; +import com.cta4j.bus.common.internal.util.BusApiUtils; import com.cta4j.bus.common.internal.wire.CtaResponse; import com.cta4j.bus.vehicle.VehiclesApi; import com.cta4j.bus.vehicle.internal.mapper.VehicleMapper; @@ -37,18 +37,18 @@ public VehiclesApiImpl(BusApiConfig config) { } @Override - public List findByIds(Collection ids) { - Objects.requireNonNull(ids); + public List findByIds(Collection vehicleIds) { + Objects.requireNonNull(vehicleIds); - ids = List.copyOf(ids); + vehicleIds = List.copyOf(vehicleIds); - if (ids.isEmpty()) { + if (vehicleIds.isEmpty()) { return List.of(); } - ApiUtils.requireMaxIds(ids, "vehicle"); + BusApiUtils.requireMaxIds(vehicleIds, "vehicle"); - String idsString = String.join(",", ids); + String idsString = String.join(",", vehicleIds); String url = new URIBuilder() .setScheme(this.config.scheme()) @@ -74,7 +74,7 @@ public List findByRouteIds(Collection routeIds) { return List.of(); } - ApiUtils.requireMaxIds(routeIds, "route"); + BusApiUtils.requireMaxIds(routeIds, "route"); String routeIdsString = String.join(",", routeIds); @@ -126,7 +126,7 @@ private List makeRequest(String url) { .toList(); } - ApiUtils.checkErrors(errors, BusApiConstants.VEHICLES_ENDPOINT); + BusApiUtils.checkErrors(errors, BusApiConstants.VEHICLES_ENDPOINT); return List.of(); } diff --git a/src/main/java/com/cta4j/bus/vehicle/model/TransitMode.java b/src/main/java/com/cta4j/bus/vehicle/model/TransitMode.java index 8dede895..d0465550 100644 --- a/src/main/java/com/cta4j/bus/vehicle/model/TransitMode.java +++ b/src/main/java/com/cta4j/bus/vehicle/model/TransitMode.java @@ -2,34 +2,22 @@ import org.jspecify.annotations.NullMarked; -/** - * Represents the mode of transit for a vehicle. - */ +/// Represents the mode of transit for a vehicle. @NullMarked public enum TransitMode { - /** - * Indicates no specific transit mode. - */ + /// Indicates no specific transit mode. NONE(0), - /** - * Indicates a bus transit mode. - */ + /// Indicates a bus transit mode. BUS(1), - /** - * Indicates a ferry transit mode. - */ + /// Indicates a ferry transit mode. FERRY(2), - /** - * Indicates a rail transit mode. - */ + /// Indicates a rail transit mode. RAIL(3), - /** - * Indicates a people mover transit mode. - */ + /// Indicates a people mover transit mode. PEOPLE_MOVER(4); private final int code; @@ -38,22 +26,18 @@ public enum TransitMode { this.code = code; } - /** - * Gets the code associated with this transit mode. - * - * @return the transit mode code - */ + /// Returns the code associated with this transit mode. + /// + /// @return the transit mode code public int getCode() { return this.code; } - /** - * Returns the {@code TransitMode} corresponding to the given code. - * - * @param code the transit mode code - * @return the corresponding {@code TransitMode} - * @throws IllegalArgumentException if the code does not correspond to any known transit mode - */ + /// Returns the `TransitMode` corresponding to the given code. + /// + /// @param code the transit mode code + /// @return the corresponding `TransitMode` + /// @throws IllegalArgumentException if the code does not correspond to any known transit mode public static TransitMode fromCode(int code) { return switch (code) { case 0 -> NONE; diff --git a/src/main/java/com/cta4j/bus/vehicle/model/Vehicle.java b/src/main/java/com/cta4j/bus/vehicle/model/Vehicle.java index 191113cd..18e2f3f2 100644 --- a/src/main/java/com/cta4j/bus/vehicle/model/Vehicle.java +++ b/src/main/java/com/cta4j/bus/vehicle/model/Vehicle.java @@ -5,16 +5,14 @@ import java.util.Objects; -/** - * Represents a vehicle. - * - * @param id the unique identifier of this vehicle - * @param routeId the alphanumeric designator of the route that is currently being serviced by this vehicle - * @param destination the destination of the trip being serviced by this vehicle (e.g. "Austin") - * @param coordinates the current coordinates of this vehicle - * @param delayed whether this vehicle is currently delayed - * @param metadata the metadata associated with this vehicle - */ +/// Represents a vehicle. +/// +/// @param id the unique identifier of this vehicle +/// @param routeId the alphanumeric designator of the route that is currently being serviced by this vehicle +/// @param destination the destination of the trip being serviced by this vehicle (e.g., "Howard") +/// @param coordinates the current coordinates of this vehicle +/// @param delayed whether this vehicle is currently delayed +/// @param metadata the metadata associated with this vehicle @NullMarked public record Vehicle( String id, @@ -24,18 +22,15 @@ public record Vehicle( boolean delayed, VehicleMetadata metadata ) { - /** - * Constructs a {@code Vehicle}. - * - * @param id the unique identifier of the vehicle - * @param routeId the alphanumeric designator of the route that is currently being serviced by the vehicle - * @param destination the destination of the trip being serviced by the vehicle (e.g. "Austin") - * @param coordinates the current coordinates of the vehicle - * @param delayed whether the vehicle is currently delayed - * @param metadata the metadata associated with the vehicle - * @throws NullPointerException if {@code id}, {@code routeId}, {@code destination}, {@code coordinates}, or - * {@code metadata} is {@code null} - */ + /// Constructs a `Vehicle`. + /// + /// @param id the unique identifier of the vehicle + /// @param routeId the alphanumeric designator of the route that is currently being serviced by the vehicle + /// @param destination the destination of the trip being serviced by the vehicle (e.g., "Howard") + /// @param coordinates the current coordinates of the vehicle + /// @param delayed whether the vehicle is currently delayed + /// @param metadata the metadata associated with the vehicle + /// @throws NullPointerException if `id`, `routeId`, `destination`, `coordinates`, or `metadata` is `null` public Vehicle { Objects.requireNonNull(id); Objects.requireNonNull(routeId); diff --git a/src/main/java/com/cta4j/bus/vehicle/model/VehicleMetadata.java b/src/main/java/com/cta4j/bus/vehicle/model/VehicleMetadata.java index 6aef1612..82eb5ea0 100644 --- a/src/main/java/com/cta4j/bus/vehicle/model/VehicleMetadata.java +++ b/src/main/java/com/cta4j/bus/vehicle/model/VehicleMetadata.java @@ -8,37 +8,34 @@ import java.time.LocalDate; import java.util.Objects; -/** - * Represents metadata associated with a vehicle. - * - *

- * NOTE: {@code dataFeed}, {@code stopStatus}, {@code timepointId}, {@code stopId}, {@code sequence}, - * {@code gtfsSequence}, {@code serverTimestamp}, {@code speed}, and {@code block} are not well-documented by the - * CTA. As such, their presence here is primarily for completeness and may not be populated or described correctly. - *

- * - * @param dataFeed the data feed from which this vehicle information was obtained, if applicable - * @param lastUpdated the date and time (UTC) this vehicle information was last updated, if applicable - * @param patternId the pattern identifier for the trip this vehicle is servicing - * @param distanceToPatternPoint the number of feet this vehicle has traveled into the pattern currently being serviced - * @param stopStatus the stop status of this vehicle, if applicable - * @param timepointId the timepoint identifier associated with this vehicle, if applicable - * @param stopId the stop identifier associated with this vehicle, if applicable - * @param sequence the sequence number associated with this vehicle, if applicable - * @param gtfsSequence the GTFS sequence number associated with this vehicle, if applicable - * @param serverTimestamp the date and time (UTC) this vehicle information was received by the server, if applicable - * @param speed the current speed of this vehicle in miles per hour, if applicable - * @param block the block number for this vehicle, if applicable - * @param blockId the scheduled block identifier for this vehicle - * @param tripId the scheduled trip identifier for this vehicle - * @param originalTripNumber the trip identifier for this vehicle - * @param zone the zone name for this vehicle, otherwise blank - * @param mode the {@link TransitMode} of this vehicle - * @param passengerLoad the {@link PassengerLoad} of this vehicle - * @param scheduledStartSeconds the scheduled start time in seconds past midnight associated with this vehicle, if - * applicable - * @param scheduledStartDate the scheduled start date associated with this vehicle, if applicable - */ +/// Represents metadata associated with a vehicle. +/// +/// **NOTE:** `dataFeed`, `stopStatus`, `timepointId`, `stopId`, `sequence`, `gtfsSequence`, `serverTimestamp`, +/// `speed`, and `block` are not well-documented by the CTA. As such, their presence here is primarily for completeness +/// and may not be populated or described correctly. +/// +/// @param dataFeed the data feed from which this vehicle information was obtained, if applicable +/// @param lastUpdated the date and time (UTC) this vehicle information was last updated, if applicable +/// @param patternId the pattern identifier for the trip this vehicle is servicing +/// @param distanceToPatternPoint the number of feet this vehicle has traveled into the pattern currently being +/// serviced +/// @param stopStatus the stop status of this vehicle, if applicable +/// @param timepointId the timepoint identifier associated with this vehicle, if applicable +/// @param stopId the stop identifier associated with this vehicle, if applicable +/// @param sequence the sequence number associated with this vehicle, if applicable +/// @param gtfsSequence the GTFS sequence number associated with this vehicle, if applicable +/// @param serverTimestamp the date and time (UTC) this vehicle information was received by the server, if applicable +/// @param speed the current speed of this vehicle in miles per hour, if applicable +/// @param block the block number for this vehicle, if applicable +/// @param blockId the scheduled block identifier for this vehicle +/// @param tripId the scheduled trip identifier for this vehicle +/// @param originalTripNumber the trip identifier for this vehicle +/// @param zone the zone name for this vehicle, otherwise blank +/// @param mode the [TransitMode] of this vehicle +/// @param passengerLoad the [PassengerLoad] of this vehicle +/// @param scheduledStartSeconds the scheduled start time in seconds past midnight associated with this vehicle, if +/// applicable +/// @param scheduledStartDate the scheduled start date associated with this vehicle, if applicable @NullMarked public record VehicleMetadata( @Nullable String dataFeed, @@ -62,34 +59,33 @@ public record VehicleMetadata( @Nullable Integer scheduledStartSeconds, @Nullable LocalDate scheduledStartDate ) { - /** - * Constructs a {@code VehicleMetadata}. - * - * @param dataFeed the data feed from which the vehicle information was obtained, if applicable - * @param lastUpdated the date and time (UTC) the vehicle information was last updated, if applicable - * @param patternId the pattern identifier for the trip the vehicle is servicing - * @param distanceToPatternPoint the number of feet the vehicle has traveled into the pattern currently being - * serviced - * @param stopStatus the stop status of the vehicle, if applicable - * @param timepointId the timepoint identifier associated with the vehicle, if applicable - * @param stopId the stop identifier associated with the vehicle, if applicable - * @param sequence the sequence number associated with the vehicle, if applicable - * @param gtfsSequence the GTFS sequence number associated with the vehicle, if applicable - * @param serverTimestamp the date and time (UTC) the vehicle information was received by the server, if applicable - * @param speed the current speed of the vehicle in miles per hour, if applicable - * @param block the block number for the vehicle, if applicable - * @param blockId the scheduled block identifier for the vehicle - * @param tripId the scheduled trip identifier for the vehicle - * @param originalTripNumber the trip identifier for the vehicle - * @param zone the zone name for the vehicle, otherwise blank - * @param mode the {@link TransitMode} of the vehicle - * @param passengerLoad the {@link PassengerLoad} of the vehicle - * @param scheduledStartSeconds the scheduled start time in seconds past midnight associated with the vehicle, if - * applicable - * @param scheduledStartDate the scheduled start date associated with the vehicle, if applicable - * @throws NullPointerException if {@code patternId}, {@code blockId}, {@code tripId}, {@code originalTripNumber}, - * {@code zone}, {@code mode}, or {@code passengerLoad} is {@code null} - */ + /// Constructs a `VehicleMetadata`. + /// + /// @param dataFeed the data feed from which the vehicle information was obtained, if applicable + /// @param lastUpdated the date and time (UTC) the vehicle information was last updated, if applicable + /// @param patternId the pattern identifier for the trip the vehicle is servicing + /// @param distanceToPatternPoint the number of feet the vehicle has traveled into the pattern currently being + /// serviced + /// @param stopStatus the stop status of the vehicle, if applicable + /// @param timepointId the timepoint identifier associated with the vehicle, if applicable + /// @param stopId the stop identifier associated with the vehicle, if applicable + /// @param sequence the sequence number associated with the vehicle, if applicable + /// @param gtfsSequence the GTFS sequence number associated with the vehicle, if applicable + /// @param serverTimestamp the date and time (UTC) the vehicle information was received by the server, if + /// applicable + /// @param speed the current speed of the vehicle in miles per hour, if applicable + /// @param block the block number for the vehicle, if applicable + /// @param blockId the scheduled block identifier for the vehicle + /// @param tripId the scheduled trip identifier for the vehicle + /// @param originalTripNumber the trip identifier for the vehicle + /// @param zone the zone name for the vehicle, otherwise blank + /// @param mode the [TransitMode] of the vehicle + /// @param passengerLoad the [PassengerLoad] of the vehicle + /// @param scheduledStartSeconds the scheduled start time in seconds past midnight associated with the vehicle, if + /// applicable + /// @param scheduledStartDate the scheduled start date associated with the vehicle, if applicable + /// @throws NullPointerException if `patternId`, `blockId`, `tripId`, `originalTripNumber`, `zone`, `mode`, or + /// `passengerLoad` is `null` public VehicleMetadata { Objects.requireNonNull(patternId); Objects.requireNonNull(blockId); diff --git a/src/main/java/com/cta4j/bus/vehicle/model/package-info.java b/src/main/java/com/cta4j/bus/vehicle/model/package-info.java new file mode 100644 index 00000000..985d5c7e --- /dev/null +++ b/src/main/java/com/cta4j/bus/vehicle/model/package-info.java @@ -0,0 +1,3 @@ +/// Domain model types returned by the CTA Bus Tracker API's vehicles endpoint, representing a vehicle and its +/// metadata. +package com.cta4j.bus.vehicle.model; diff --git a/src/main/java/com/cta4j/bus/vehicle/package-info.java b/src/main/java/com/cta4j/bus/vehicle/package-info.java new file mode 100644 index 00000000..c277200c --- /dev/null +++ b/src/main/java/com/cta4j/bus/vehicle/package-info.java @@ -0,0 +1,2 @@ +/// Retrieval of real-time CTA bus vehicle locations and metadata. +package com.cta4j.bus.vehicle; diff --git a/src/main/java/com/cta4j/common/exception/Cta4jException.java b/src/main/java/com/cta4j/common/exception/Cta4jException.java index 3701dfe8..fd044cfa 100644 --- a/src/main/java/com/cta4j/common/exception/Cta4jException.java +++ b/src/main/java/com/cta4j/common/exception/Cta4jException.java @@ -4,48 +4,37 @@ import java.util.Objects; -/** - * A custom exception class for handling cta4j-specific errors. - */ +/// A custom exception type for handling SDK-specific errors. @NullMarked public class Cta4jException extends RuntimeException { - /** - * The endpoint associated with this exception. - */ private final String endpoint; - /** - * Constructs a {@code Cta4jException}. - * - * @param message the detail message - * @param endpoint the endpoint associated with the exception - * @throws NullPointerException if {@code endpoint} is {@code null} - */ + /// Constructs a `Cta4jException`. + /// + /// @param message the detail message + /// @param endpoint the API endpoint associated with the exception + /// @throws NullPointerException if `endpoint` is `null` public Cta4jException(String message, String endpoint) { super(message); this.endpoint = Objects.requireNonNull(endpoint); } - /** - * Constructs a {@code Cta4jException}. - * - * @param message the detail message - * @param endpoint the endpoint associated with the exception - * @param cause the cause of the exception - * @throws NullPointerException if {@code endpoint} is {@code null} - */ + /// Constructs a `Cta4jException`. + /// + /// @param message the detail message + /// @param endpoint the API endpoint associated with the exception + /// @param cause the cause of the exception + /// @throws NullPointerException if `endpoint` is `null` public Cta4jException(String message, String endpoint, Throwable cause) { super(message, cause); this.endpoint = Objects.requireNonNull(endpoint); } - /** - * Returns the endpoint associated with this exception. - * - * @return the endpoint - */ + /// Returns the API endpoint associated with this exception. + /// + /// @return the endpoint public String getEndpoint() { return this.endpoint; } diff --git a/src/main/java/com/cta4j/common/exception/package-info.java b/src/main/java/com/cta4j/common/exception/package-info.java new file mode 100644 index 00000000..10b06600 --- /dev/null +++ b/src/main/java/com/cta4j/common/exception/package-info.java @@ -0,0 +1,2 @@ +/// Base exception type for the SDK, extended by every transit-specific exception type. +package com.cta4j.common.exception; diff --git a/src/main/java/com/cta4j/common/geo/Coordinates.java b/src/main/java/com/cta4j/common/geo/Coordinates.java index 9d95443a..36475017 100644 --- a/src/main/java/com/cta4j/common/geo/Coordinates.java +++ b/src/main/java/com/cta4j/common/geo/Coordinates.java @@ -6,29 +6,25 @@ import java.math.BigDecimal; import java.util.Objects; -/** - * Represents geographic coordinates. - * - * @param latitude the latitude of these coordinates - * @param longitude the longitude of these coordinates - * @param heading the heading of these coordinates in degrees (0-359) - */ +/// Represents geographic coordinates. +/// +/// @param latitude the latitude of these coordinates, in degrees (-90-90) +/// @param longitude the longitude of these coordinates, in degrees (-180-180) +/// @param heading the heading of these coordinates in degrees (0-359) @NullMarked public record Coordinates( BigDecimal latitude, BigDecimal longitude, int heading ) { - /** - * Constructs a {@code Coordinates}. - * - * @param latitude the latitude of the coordinates - * @param longitude the longitude of the coordinates - * @param heading the heading of the coordinates in degrees (0-359) - * @throws NullPointerException if {@code latitude} or {@code longitude} is {@code null} - * @throws IllegalArgumentException if {@code latitude} is not between -90 and 90 (inclusive), {@code longitude} - * is not between -180 and 180 (inclusive), or {@code heading} is not between 0 and 359 (inclusive) - */ + /// Constructs a `Coordinates`. + /// + /// @param latitude the latitude of the coordinates, in degrees (-90-90) + /// @param longitude the longitude of the coordinates, in degrees (-180-180) + /// @param heading the heading of the coordinates in degrees (0-359) + /// @throws NullPointerException if `latitude` or `longitude` is `null` + /// @throws IllegalArgumentException if `latitude` is not between -90 and 90 (inclusive), `longitude` is not + /// between -180 and 180 (inclusive), or `heading` is not between 0 and 359 (inclusive) public Coordinates { Objects.requireNonNull(latitude); Objects.requireNonNull(longitude); diff --git a/src/main/java/com/cta4j/common/geo/package-info.java b/src/main/java/com/cta4j/common/geo/package-info.java new file mode 100644 index 00000000..000353fe --- /dev/null +++ b/src/main/java/com/cta4j/common/geo/package-info.java @@ -0,0 +1,2 @@ +/// Domain model type representing geographic coordinates, shared across the bus and train APIs. +package com.cta4j.common.geo; diff --git a/src/main/java/com/cta4j/common/internal/util/BooleanParser.java b/src/main/java/com/cta4j/common/internal/util/BooleanParser.java new file mode 100644 index 00000000..742534d5 --- /dev/null +++ b/src/main/java/com/cta4j/common/internal/util/BooleanParser.java @@ -0,0 +1,28 @@ +package com.cta4j.common.internal.util; + +import org.jetbrains.annotations.ApiStatus; +import org.jspecify.annotations.NullMarked; + +import java.util.Objects; + +@ApiStatus.Internal +@NullMarked +public final class BooleanParser { + private BooleanParser() { + throw new UnsupportedOperationException("This is a utility class and cannot be instantiated"); + } + + public static boolean parse01(String value) { + Objects.requireNonNull(value); + + return switch (value) { + case "0" -> false; + case "1" -> true; + default -> { + String message = "Invalid value: %s. Expected 0 or 1".formatted(value); + + throw new IllegalArgumentException(message); + } + }; + } +} diff --git a/src/main/java/com/cta4j/common/internal/util/TimestampParser.java b/src/main/java/com/cta4j/common/internal/util/TimestampParser.java new file mode 100644 index 00000000..c4ca5508 --- /dev/null +++ b/src/main/java/com/cta4j/common/internal/util/TimestampParser.java @@ -0,0 +1,51 @@ +package com.cta4j.common.internal.util; + +import org.jetbrains.annotations.ApiStatus; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.Objects; + +@ApiStatus.Internal +@NullMarked +public final class TimestampParser { + private TimestampParser() { + throw new UnsupportedOperationException("This is a utility class and cannot be instantiated"); + } + + public static Instant parse(String timestamp, DateTimeFormatter formatter, ZoneId zoneId) { + Objects.requireNonNull(timestamp); + Objects.requireNonNull(formatter); + Objects.requireNonNull(zoneId); + + try { + return LocalDateTime.parse(timestamp, formatter) + .atZone(zoneId) + .toInstant(); + } catch (DateTimeParseException e) { + String message = "Failed to parse timestamp: %s".formatted(timestamp); + + throw new IllegalArgumentException(message, e); + } + } + + public static @Nullable Instant parseNullable( + @Nullable String timestamp, + DateTimeFormatter formatter, + ZoneId zoneId + ) { + Objects.requireNonNull(formatter); + Objects.requireNonNull(zoneId); + + if (timestamp == null) { + return null; + } + + return parse(timestamp, formatter, zoneId); + } +} diff --git a/src/main/java/com/cta4j/common/package-info.java b/src/main/java/com/cta4j/common/package-info.java new file mode 100644 index 00000000..3090aa49 --- /dev/null +++ b/src/main/java/com/cta4j/common/package-info.java @@ -0,0 +1,3 @@ +/// Cross-cutting types shared across the bus and train APIs, such as geographic coordinates and the base SDK +/// exception type. +package com.cta4j.common; diff --git a/src/main/java/com/cta4j/train/TrainApi.java b/src/main/java/com/cta4j/train/TrainApi.java index fd8a1147..ad3ea682 100644 --- a/src/main/java/com/cta4j/train/TrainApi.java +++ b/src/main/java/com/cta4j/train/TrainApi.java @@ -9,86 +9,66 @@ import java.util.Objects; -/** - * Primary entry point for interacting with the CTA Train Tracker API. - *

- * This interface provides grouped sub-APIs for different aspects of the Train Tracker API, such as stations, arrivals, - * train following, and locations. - *

- * Instances of {@code TrainApi} are immutable and thread-safe once built. - * Use {@link #builder(String)} to construct a configured instance. - */ +/// Primary entry point for interacting with the CTA Train Tracker API. +/// +/// This interface provides grouped sub-APIs for different aspects of the Train Tracker API, such as stations, +/// arrivals, train following, and locations. +/// +/// Instances of `TrainApi` are immutable and thread-safe once built. Use [#builder(String)] to construct a configured +/// instance. @NullMarked public interface TrainApi { - /** - * Provides access to station-related endpoints. - * - * @return the {@link StationsApi} - */ + /// Provides access to station-related endpoints. + /// + /// @return the [StationsApi] StationsApi stations(); - /** - * Provides access to arrival-related endpoints. - * - * @return the {@link ArrivalsApi} - */ + /// Provides access to arrival-related endpoints. + /// + /// @return the [ArrivalsApi] ArrivalsApi arrivals(); - /** - * Provides access to train follow-related endpoints. - * - * @return the {@link FollowApi} - */ + /// Provides access to follow-related endpoints. + /// + /// @return the [FollowApi] FollowApi follow(); - /** - * Provides access to location-related endpoints. - * - * @return the {@link LocationsApi} - */ + /// Provides access to location-related endpoints. + /// + /// @return the [LocationsApi] LocationsApi locations(); - /** - * Builder for constructing {@link TrainApi} instances. - */ + /// Builder for constructing [TrainApi] instances. interface Builder { - /** - * Sets the API host to use for requests. - *

- * If not specified, the default CTA Train Tracker API host is used. - * - * @param host the API host - * @return this builder instance - * @throws NullPointerException if {@code host} is {@code null} - */ + /// Sets the API host to use for requests. + /// + /// If not specified, the default CTA Train Tracker API host is used. + /// + /// @param host the API host + /// @return this `Builder` instance + /// @throws NullPointerException if `host` is `null` Builder host(String host); - /** - * Sets the URL to fetch station data from. - *

- * If not specified, the default URL for station data is used. - * - * @param stationsUrl the URL for station data - * @return this builder instance - * @throws NullPointerException if {@code stationsUrl} is {@code null} - */ + /// Sets the URL to fetch station data from. + /// + /// If not specified, the default URL for station data is used. + /// + /// @param stationsUrl the URL for station data + /// @return this `Builder` instance + /// @throws NullPointerException if `stationsUrl` is `null` Builder stationsUrl(String stationsUrl); - /** - * Builds a configured {@link TrainApi} instance. - * - * @return a new {@link TrainApi} - */ + /// Builds a configured `TrainApi` instance. + /// + /// @return a new `TrainApi` TrainApi build(); } - /** - * Creates a new {@link Builder} for constructing a {@link TrainApi}. - * - * @param apiKey the CTA Train Tracker API key - * @return a new {@link Builder} - * @throws NullPointerException if {@code apiKey} is {@code null} - */ + /// Creates a new `Builder` for constructing a `TrainApi`. + /// + /// @param apiKey the CTA Train Tracker API key + /// @return a new `Builder` + /// @throws NullPointerException if `apiKey` is `null` static Builder builder(String apiKey) { Objects.requireNonNull(apiKey); diff --git a/src/main/java/com/cta4j/train/arrival/ArrivalsApi.java b/src/main/java/com/cta4j/train/arrival/ArrivalsApi.java index b4caf27d..5acd1655 100644 --- a/src/main/java/com/cta4j/train/arrival/ArrivalsApi.java +++ b/src/main/java/com/cta4j/train/arrival/ArrivalsApi.java @@ -1,71 +1,107 @@ package com.cta4j.train.arrival; import com.cta4j.train.arrival.exception.Cta4jArrivalsException; -import com.cta4j.train.arrival.query.MapArrivalQuery; -import com.cta4j.train.arrival.query.StopArrivalQuery; +import com.cta4j.train.arrival.query.MapArrivalsQuery; +import com.cta4j.train.arrival.query.StopArrivalsQuery; import com.cta4j.train.common.model.Arrival; import org.jspecify.annotations.NullMarked; +import java.util.Collection; import java.util.List; +import java.util.Objects; -/** - * Provides access to arrival-related endpoints of the CTA Train Tracker API. - *

- * This API allows retrieval of arrival information by map ID or stop ID. - */ +/// Provides access to arrival-related endpoints of the CTA Train Tracker API. +/// +/// This API allows retrieval of arrivals by map IDs or stop IDs. @NullMarked public interface ArrivalsApi { - /** - * Retrieves arrival information by map ID. - * - * @param query the query parameters for fetching arrival information by map ID - * @return a {@link List} of {@link Arrival}s corresponding to the provided map ID, or an empty {@link List} if no - * arrivals are found - * @throws NullPointerException if {@code query} is {@code null} - * @throws Cta4jArrivalsException if the API returns an error response or the response cannot be parsed - */ - List findByMapId(MapArrivalQuery query); - - /** - * Retrieves arrival information by stop ID. - * - * @param query the query parameters for fetching arrival information by stop ID - * @return a {@link List} of {@link Arrival}s corresponding to the provided stop ID, or an empty {@link List} if no - * arrivals are found - * @throws NullPointerException if {@code query} is {@code null} - * @throws Cta4jArrivalsException if the API returns an error response or the response cannot be parsed - */ - List findByStopId(StopArrivalQuery query); - - /** - * Retrieves arrival information by map ID. - * - * @param mapId the map ID - * @return a {@link List} of {@link Arrival}s corresponding to the provided map ID, or an empty {@link List} if no - * arrivals are found - * @throws NullPointerException if {@code mapId} is {@code null} - * @throws Cta4jArrivalsException if the API returns an error response or the response cannot be parsed - */ + /// Retrieves arrivals by map IDs. + /// + /// @param query the query parameters for fetching arrivals by map IDs + /// @return a [List] of [Arrival]s corresponding to the provided map IDs, or an empty [List] if no arrivals are + /// found + /// @throws NullPointerException if `query` is `null` + /// @throws Cta4jArrivalsException if the API returns an error response or the response cannot be parsed + List findByMapIds(MapArrivalsQuery query); + + /// Retrieves arrivals by map IDs. + /// + /// @param mapIds a [Collection] of map IDs + /// @return a [List] of [Arrival]s corresponding to the provided map IDs, or an empty [List] if no arrivals are + /// found + /// @throws NullPointerException if `mapIds` is `null`, or if any element of `mapIds` is `null` + /// @throws Cta4jArrivalsException if the API returns an error response or the response cannot be parsed + default List findByMapIds(Collection mapIds) { + Objects.requireNonNull(mapIds); + + List mapIdsList = List.copyOf(mapIds); + + MapArrivalsQuery query = MapArrivalsQuery.builder(mapIdsList) + .build(); + + return this.findByMapIds(query); + } + + /// Retrieves arrivals by map ID. + /// + /// @param mapId the map ID + /// @return a [List] of [Arrival]s corresponding to the provided map ID, or an empty [List] if no arrivals are + /// found + /// @throws NullPointerException if `mapId` is `null` + /// @throws Cta4jArrivalsException if the API returns an error response or the response cannot be parsed default List findByMapId(String mapId) { - MapArrivalQuery query = MapArrivalQuery.builder(mapId) - .build(); + Objects.requireNonNull(mapId); + + List mapIds = List.of(mapId); + + MapArrivalsQuery query = MapArrivalsQuery.builder(mapIds) + .build(); - return this.findByMapId(query); + return this.findByMapIds(query); } - /** - * Retrieves arrival information by stop ID. - * - * @param stopId the stop ID - * @return a {@link List} of {@link Arrival}s corresponding to the provided stop ID, or an empty {@link List} if no - * arrivals are found - * @throws NullPointerException if {@code stopId} is {@code null} - * @throws Cta4jArrivalsException if the API returns an error response or the response cannot be parsed - */ + /// Retrieves arrivals by stop IDs. + /// + /// @param query the query parameters for fetching arrivals by stop IDs + /// @return a [List] of [Arrival]s corresponding to the provided stop IDs, or an empty [List] if no arrivals are + /// found + /// @throws NullPointerException if `query` is `null` + /// @throws Cta4jArrivalsException if the API returns an error response or the response cannot be parsed + List findByStopIds(StopArrivalsQuery query); + + /// Retrieves arrivals by stop IDs. + /// + /// @param stopIds a [Collection] of stop IDs + /// @return a [List] of [Arrival]s corresponding to the provided stop IDs, or an empty [List] if no arrivals are + /// found + /// @throws NullPointerException if `stopIds` is `null`, or if any element of `stopIds` is `null` + /// @throws Cta4jArrivalsException if the API returns an error response or the response cannot be parsed + default List findByStopIds(Collection stopIds) { + Objects.requireNonNull(stopIds); + + List stopIdsList = List.copyOf(stopIds); + + StopArrivalsQuery query = StopArrivalsQuery.builder(stopIdsList) + .build(); + + return this.findByStopIds(query); + } + + /// Retrieves arrivals by stop ID. + /// + /// @param stopId the stop ID + /// @return a [List] of [Arrival]s corresponding to the provided stop ID, or an empty [List] if no arrivals are + /// found + /// @throws NullPointerException if `stopId` is `null` + /// @throws Cta4jArrivalsException if the API returns an error response or the response cannot be parsed default List findByStopId(String stopId) { - StopArrivalQuery query = StopArrivalQuery.builder(stopId) - .build(); + Objects.requireNonNull(stopId); + + List stopIds = List.of(stopId); + + StopArrivalsQuery query = StopArrivalsQuery.builder(stopIds) + .build(); - return this.findByStopId(query); + return this.findByStopIds(query); } } diff --git a/src/main/java/com/cta4j/train/arrival/exception/ArrivalsErrorCode.java b/src/main/java/com/cta4j/train/arrival/exception/ArrivalsErrorCode.java index 9e58dbb4..0251b87d 100644 --- a/src/main/java/com/cta4j/train/arrival/exception/ArrivalsErrorCode.java +++ b/src/main/java/com/cta4j/train/arrival/exception/ArrivalsErrorCode.java @@ -1,129 +1,78 @@ package com.cta4j.train.arrival.exception; import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; -/** - * Represents the error codes returned by the CTA Arrivals API. - */ +/// Represents the error codes returned by the CTA Arrivals API. @NullMarked public enum ArrivalsErrorCode { - /** - * Indicates that the request was successful and there were no errors. - */ + /// Indicates that the request was successful and there were no errors. OK(0), - /** - * Indicates that a required parameter is missing from the request. - */ + /// Indicates that a required parameter is missing from the request. MISSING_PARAMETER(100), - /** - * Indicates that the provided API key is invalid. - */ + /// Indicates that the provided API key is invalid. INVALID_API_KEY(101), - /** - * Indicates that the daily limit for API requests has been exceeded. - */ + /// Indicates that the daily limit for API requests has been exceeded. DAILY_LIMIT_EXCEEDED(102), - /** - * Indicates that the provided map ID is invalid. - */ + /// Indicates that the provided map ID is invalid. INVALID_MAPID(103), - /** - * Indicates that the provided map ID is not an integer. - */ + /// Indicates that the provided map ID is not an integer. MAPID_NOT_INTEGER(104), - /** - * Indicates that the number of map IDs provided exceeds the allowed limit (more than 4). - */ + /// Indicates that the number of map IDs provided exceeds the allowed limit (more than 4). TOO_MANY_MAPIDS(105), - /** - * Indicates that the provided route is invalid. - */ + /// Indicates that the provided route is invalid. INVALID_ROUTE(106), - /** - * Indicates that the number of routes provided exceeds the allowed limit (more than 4). - */ + /// Indicates that the number of routes provided exceeds the allowed limit (more than 4). TOO_MANY_ROUTES(107), - /** - * Indicates that the provided stop ID is invalid. - */ + /// Indicates that the provided stop ID is invalid. INVALID_STPID(108), - /** - * Indicates that the number of stop IDs provided exceeds the allowed limit (more than 4). - */ + /// Indicates that the number of stop IDs provided exceeds the allowed limit (more than 4). TOO_MANY_STPIDS(109), - /** - * Indicates that a non-integer value was specified for the maximum number of results. - */ + /// Indicates that a noninteger value was specified for the maximum number of results. INVALID_MAX(110), - /** - * Indicates that the provided maximum number of results is not a positive integer. - */ + /// Indicates that the provided maximum number of results is not a positive integer. MAX_NOT_POSITIVE(111), - /** - * Indicates that the provided stop ID is not an integer. - */ + /// Indicates that the provided stop ID is not an integer. STPID_NOT_INTEGER(112), - /** - * Indicates that the query string contains a parameter that is not recognized by the API. The supported API - * parameters are "mapid", "key", "rt", "stpid", and "max". - */ + /// Indicates that the query string contains a parameter that is not recognized by the API. The supported API + /// parameters are `mapid`, `key`, `rt`, `stpid`, and `max`. INVALID_PARAMETER(500), - /** - * Indicates that the server encountered an unexpected error that prevented it from fulfilling the request. - */ - SERVER_ERROR(900), + /// Indicates that the server encountered an unexpected error that prevented it from fulfilling the request. + SERVER_ERROR(900); - /** - * Indicates that an unknown error occurred that does not match any of the defined error codes. - */ - UNKNOWN(-1); - - /** - * The integer code associated with this error code. - */ private final int code; - /** - * Constructs an {@code ArrivalsErrorCode}. - * - * @param code the integer code associated with the error code - */ ArrivalsErrorCode(int code) { this.code = code; } - /** - * Returns the integer code associated with this error code. - * - * @return the integer code - */ + /// Returns the integer code associated with this error code. + /// + /// @return the integer code public int getCode() { return this.code; } - /** - * Returns the {@code ArrivalsErrorCode} corresponding to the given integer code. - * - * @param code the integer code to look up - * @return the corresponding {@code ArrivalsErrorCode}, or {@code UNKNOWN} if the code does not match any defined - * error code - */ - public static ArrivalsErrorCode fromCode(int code) { + /// Returns the `ArrivalsErrorCode` corresponding to the given integer code. + /// + /// @param code the integer code to look up + /// @return the corresponding `ArrivalsErrorCode`, or `null` if the code does not match any defined error code + public static @Nullable ArrivalsErrorCode fromCode(int code) { return switch (code) { case 0 -> OK; case 100 -> MISSING_PARAMETER; @@ -141,7 +90,7 @@ public static ArrivalsErrorCode fromCode(int code) { case 112 -> STPID_NOT_INTEGER; case 500 -> INVALID_PARAMETER; case 900 -> SERVER_ERROR; - default -> UNKNOWN; + default -> null; }; } } diff --git a/src/main/java/com/cta4j/train/arrival/exception/Cta4jArrivalsException.java b/src/main/java/com/cta4j/train/arrival/exception/Cta4jArrivalsException.java index bf4d72d7..b88c896d 100644 --- a/src/main/java/com/cta4j/train/arrival/exception/Cta4jArrivalsException.java +++ b/src/main/java/com/cta4j/train/arrival/exception/Cta4jArrivalsException.java @@ -5,46 +5,35 @@ import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; -/** - * A custom exception class for handling cta4j train arrival-specific errors. - */ +/// A custom exception class for handling cta4j train arrival-specific errors. @NullMarked public final class Cta4jArrivalsException extends Cta4jTrainException { - /** - * The error code associated with this exception, if available. - */ @Nullable private final ArrivalsErrorCode errorCode; - /** - * Constructs a {@code Cta4jArrivalsException}. - * - * @param message the detail message - * @param cause the cause of the exception - */ + /// Constructs a `Cta4jArrivalsException`. + /// + /// @param message the detail message + /// @param cause the cause of the exception public Cta4jArrivalsException(String message, Throwable cause) { super(message, TrainApiConstants.ARRIVALS_ENDPOINT, cause); this.errorCode = null; } - /** - * Constructs a {@code Cta4jArrivalsException}. - * - * @param message the detail message - * @param rawErrorCode the raw error code associated with the exception - */ + /// Constructs a `Cta4jArrivalsException`. + /// + /// @param message the detail message + /// @param rawErrorCode the raw error code associated with the exception public Cta4jArrivalsException(String message, int rawErrorCode) { super(message, TrainApiConstants.ARRIVALS_ENDPOINT, rawErrorCode); this.errorCode = ArrivalsErrorCode.fromCode(rawErrorCode); } - /** - * Returns the error code associated with this exception, if available. - * - * @return the error code, or {@code null} if not available - */ + /// Returns the error code associated with this exception, if available. + /// + /// @return the error code, or `null` if not available public @Nullable ArrivalsErrorCode getErrorCode() { return this.errorCode; } diff --git a/src/main/java/com/cta4j/train/arrival/exception/package-info.java b/src/main/java/com/cta4j/train/arrival/exception/package-info.java new file mode 100644 index 00000000..9a499fdd --- /dev/null +++ b/src/main/java/com/cta4j/train/arrival/exception/package-info.java @@ -0,0 +1,2 @@ +/// Exception type and error code enum thrown by the CTA Train Tracker API's arrivals endpoint. +package com.cta4j.train.arrival.exception; diff --git a/src/main/java/com/cta4j/train/arrival/internal/impl/ArrivalsApiImpl.java b/src/main/java/com/cta4j/train/arrival/internal/impl/ArrivalsApiImpl.java index f8267d73..abed5fdd 100644 --- a/src/main/java/com/cta4j/train/arrival/internal/impl/ArrivalsApiImpl.java +++ b/src/main/java/com/cta4j/train/arrival/internal/impl/ArrivalsApiImpl.java @@ -4,8 +4,8 @@ import com.cta4j.train.arrival.exception.ArrivalsErrorCode; import com.cta4j.train.arrival.exception.Cta4jArrivalsException; import com.cta4j.train.arrival.internal.wire.CtaArrivalsResponse; -import com.cta4j.train.arrival.query.MapArrivalQuery; -import com.cta4j.train.arrival.query.StopArrivalQuery; +import com.cta4j.train.arrival.query.MapArrivalsQuery; +import com.cta4j.train.arrival.query.StopArrivalsQuery; import com.cta4j.train.common.internal.config.TrainApiConfig; import com.cta4j.train.common.internal.mapper.ArrivalMapper; import com.cta4j.train.common.internal.util.TrainApiConstants; @@ -38,15 +38,23 @@ public ArrivalsApiImpl(TrainApiConfig config) { } @Override - public List findByMapId(MapArrivalQuery query) { + public List findByMapIds(MapArrivalsQuery query) { Objects.requireNonNull(query); + List mapIds = query.mapIds(); + + if (mapIds.isEmpty()) { + return List.of(); + } + + String mapIdsString = String.join(",", mapIds); + URIBuilder builder = new URIBuilder() .setScheme(this.config.scheme()) .setHost(this.config.host()) .setPort(this.config.port()) .setPath(TrainApiConstants.ARRIVALS_ENDPOINT) - .addParameter("mapid", query.mapId()) + .addParameter("mapid", mapIdsString) .addParameter("key", this.config.apiKey()) .addParameter("outputType", "JSON"); @@ -54,15 +62,23 @@ public List findByMapId(MapArrivalQuery query) { } @Override - public List findByStopId(StopArrivalQuery query) { + public List findByStopIds(StopArrivalsQuery query) { Objects.requireNonNull(query); + List stopIds = query.stopIds(); + + if (stopIds.isEmpty()) { + return List.of(); + } + + String stopIdsString = String.join(",", stopIds); + URIBuilder builder = new URIBuilder() .setScheme(this.config.scheme()) .setHost(this.config.host()) .setPort(this.config.port()) .setPath(TrainApiConstants.ARRIVALS_ENDPOINT) - .addParameter("stpid", query.stopId()) + .addParameter("stpid", stopIdsString) .addParameter("key", this.config.apiKey()) .addParameter("outputType", "JSON"); @@ -79,7 +95,9 @@ private List makeRequest( } if (maxResults != null) { - builder.addParameter("max", maxResults.toString()); + String maxResultString = String.valueOf(maxResults); + + builder.addParameter("max", maxResultString); } String url = builder.toString(); diff --git a/src/main/java/com/cta4j/train/arrival/package-info.java b/src/main/java/com/cta4j/train/arrival/package-info.java new file mode 100644 index 00000000..4d1a51a7 --- /dev/null +++ b/src/main/java/com/cta4j/train/arrival/package-info.java @@ -0,0 +1,2 @@ +/// Retrieval of real-time CTA train arrival predictions for stations and stops. +package com.cta4j.train.arrival; diff --git a/src/main/java/com/cta4j/train/arrival/query/MapArrivalQuery.java b/src/main/java/com/cta4j/train/arrival/query/MapArrivalQuery.java deleted file mode 100644 index 2ea87f7e..00000000 --- a/src/main/java/com/cta4j/train/arrival/query/MapArrivalQuery.java +++ /dev/null @@ -1,127 +0,0 @@ -package com.cta4j.train.arrival.query; - -import com.cta4j.train.common.model.TrainLine; -import org.jspecify.annotations.NullMarked; -import org.jspecify.annotations.Nullable; - -import java.util.Objects; - -/** - * Represents a query for train arrival information for a specific map. - * - * @param mapId the ID of the map to retrieve arrival information for - * @param line the optional train line to filter arrival information by - * @param maxResults the optional maximum number of arrival information to return - */ -@NullMarked -public record MapArrivalQuery( - String mapId, - @Nullable TrainLine line, - @Nullable Integer maxResults -) { - /** - * Constructs a {@code MapArrivalQuery}. - * - * @param mapId the ID of the map to retrieve arrival information for - * @param line the optional train line to filter arrival information by - * @param maxResults the optional maximum number of arrival information to return - * @throws NullPointerException if {@code mapId} is {@code null} - * @throws IllegalArgumentException if {@code maxResults} is non-{@code null} and not positive - */ - public MapArrivalQuery { - Objects.requireNonNull(mapId); - - if ((maxResults != null) && (maxResults <= 0)) { - throw new IllegalArgumentException("maxResults must be positive"); - } - } - - /** - * Creates a builder for {@code MapArrivalQuery}. - * - * @param mapId the ID of the map to retrieve arrival information for - * @return a new {@code Builder} instance - * @throws NullPointerException if {@code mapId} is {@code null} - */ - public static Builder builder(String mapId) { - return new Builder(mapId); - } - - /** - * A builder for {@code MapArrivalQuery}. - */ - public static final class Builder { - /** - * The ID of the map to retrieve arrival information for. - */ - private final String mapId; - - /** - * The optional train line to filter arrival information by. - */ - @Nullable - private TrainLine line; - - /** - * The optional maximum number of arrival information to return. - */ - @Nullable - private Integer maxResults; - - /** - * Constructs a {@code Builder}. - * - * @param mapId the ID of the map to retrieve arrival information for - * @throws NullPointerException if {@code mapId} is {@code null} - */ - public Builder(String mapId) { - this.mapId = Objects.requireNonNull(mapId); - } - - /** - * Sets the train line to filter arrival information by. - * - * @param line the train line - * @return this {@code Builder} instance - * @throws NullPointerException if {@code line} is {@code null} - */ - public Builder line(TrainLine line) { - this.line = Objects.requireNonNull(line); - - return this; - } - - /** - * Sets the maximum number of arrival information to return. - * - * @param maxResults the maximum number of arrival information - * @return this {@code Builder} instance - * @throws NullPointerException if {@code maxResults} is {@code null} - * @throws IllegalArgumentException if {@code maxResults} is not positive - */ - public Builder maxResults(Integer maxResults) { - Objects.requireNonNull(maxResults); - - if (maxResults <= 0) { - throw new IllegalArgumentException("maxResults must be positive"); - } - - this.maxResults = maxResults; - - return this; - } - - /** - * Builds the {@code MapArrivalQuery}. - * - * @return a new {@code MapArrivalQuery} instance - */ - public MapArrivalQuery build() { - return new MapArrivalQuery( - this.mapId, - this.line, - this.maxResults - ); - } - } -} diff --git a/src/main/java/com/cta4j/train/arrival/query/MapArrivalsQuery.java b/src/main/java/com/cta4j/train/arrival/query/MapArrivalsQuery.java new file mode 100644 index 00000000..7183c884 --- /dev/null +++ b/src/main/java/com/cta4j/train/arrival/query/MapArrivalsQuery.java @@ -0,0 +1,106 @@ +package com.cta4j.train.arrival.query; + +import com.cta4j.train.common.internal.util.TrainApiUtils; +import com.cta4j.train.common.model.TrainLine; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +import java.util.Collection; +import java.util.List; +import java.util.Objects; + +/// Represents a query for train arrivals at a specific map. +/// +/// @param mapIds the [List] of map IDs to retrieve arrivals for +/// @param line the optional train line to filter arrivals by +/// @param maxResults the optional maximum number of arrivals to return +@NullMarked +public record MapArrivalsQuery( + List mapIds, + @Nullable TrainLine line, + @Nullable Integer maxResults +) { + /// Constructs a `MapArrivalsQuery`. + /// + /// @param mapIds the [List] of map IDs to retrieve arrivals for + /// @param line the optional train line to filter arrivals by + /// @param maxResults the optional maximum number of arrivals to return + /// @throws NullPointerException if `mapIds` is `null`, or if any element of `mapIds` is `null` + /// @throws IllegalArgumentException if more than 4 map IDs are provided, or if `maxResults` is non-`null` and not + /// positive + public MapArrivalsQuery { + Objects.requireNonNull(mapIds); + + mapIds = List.copyOf(mapIds); + + TrainApiUtils.requireMaxIds(mapIds, "map"); + + if ((maxResults != null) && (maxResults <= 0)) { + throw new IllegalArgumentException("maxResults must be positive"); + } + } + + /// Creates a new `Builder` for constructing a `MapArrivalsQuery`. + /// + /// @param mapIds the [Collection] of map IDs to retrieve arrivals for + /// @return a new `Builder` + /// @throws NullPointerException if `mapIds` is `null`, or if any element of `mapIds` is `null` + public static Builder builder(Collection mapIds) { + return new Builder(mapIds); + } + + /// A builder for `MapArrivalsQuery`. + public static final class Builder { + private final List mapIds; + + @Nullable + private TrainLine line; + + @Nullable + private Integer maxResults; + + private Builder(Collection mapIds) { + Objects.requireNonNull(mapIds); + + this.mapIds = List.copyOf(mapIds); + } + + /// Sets the train line to filter arrivals by. + /// + /// @param line the train line + /// @return this `Builder` instance + /// @throws NullPointerException if `line` is `null` + public Builder line(TrainLine line) { + this.line = Objects.requireNonNull(line); + + return this; + } + + /// Sets the maximum number of arrivals to return. + /// + /// @param maxResults the maximum number of arrivals + /// @return this `Builder` instance + /// @throws IllegalArgumentException if `maxResults` is not positive + public Builder maxResults(int maxResults) { + if (maxResults <= 0) { + throw new IllegalArgumentException("maxResults must be positive"); + } + + this.maxResults = maxResults; + + return this; + } + + /// Builds a configured `MapArrivalsQuery` instance. + /// + /// @return a new `MapArrivalsQuery` + /// @throws IllegalArgumentException if more than 4 map IDs are provided + public MapArrivalsQuery build() { + return new MapArrivalsQuery( + this.mapIds, + this.line, + this.maxResults + ); + } + } +} diff --git a/src/main/java/com/cta4j/train/arrival/query/StopArrivalQuery.java b/src/main/java/com/cta4j/train/arrival/query/StopArrivalQuery.java deleted file mode 100644 index e0c9e917..00000000 --- a/src/main/java/com/cta4j/train/arrival/query/StopArrivalQuery.java +++ /dev/null @@ -1,127 +0,0 @@ -package com.cta4j.train.arrival.query; - -import com.cta4j.train.common.model.TrainLine; -import org.jspecify.annotations.NullMarked; -import org.jspecify.annotations.Nullable; - -import java.util.Objects; - -/** - * Represents a query for train arrival information for a specific stop. - * - * @param stopId the ID of the stop to retrieve arrival information for - * @param line the optional train line to filter arrival information by - * @param maxResults the optional maximum number of arrival information to return - */ -@NullMarked -public record StopArrivalQuery( - String stopId, - @Nullable TrainLine line, - @Nullable Integer maxResults -) { - /** - * Constructs a {@code StopArrivalQuery}. - * - * @param stopId the ID of the stop to retrieve arrival information for - * @param line the optional train line to filter arrival information by - * @param maxResults the optional maximum number of arrival information to return - * @throws NullPointerException if {@code stopId} is {@code null} - * @throws IllegalArgumentException if {@code maxResults} is non-{@code null} and not positive - */ - public StopArrivalQuery { - Objects.requireNonNull(stopId); - - if ((maxResults != null) && (maxResults <= 0)) { - throw new IllegalArgumentException("maxResults must be positive"); - } - } - - /** - * Creates a builder for {@code StopArrivalQuery}. - * - * @param stopId the ID of the stop to retrieve arrival information for - * @return a new {@code Builder} instance - * @throws NullPointerException if {@code stopId} is {@code null} - */ - public static Builder builder(String stopId) { - return new Builder(stopId); - } - - /** - * A builder for {@code StopArrivalQuery}. - */ - public static final class Builder { - /** - * The ID of the stop to retrieve arrival information for. - */ - private final String stopId; - - /** - * The optional train line to filter arrival information by. - */ - @Nullable - private TrainLine line; - - /** - * The optional maximum number of arrival information to return. - */ - @Nullable - private Integer maxResults; - - /** - * Constructs a {@code Builder}. - * - * @param stopId the ID of the stop to retrieve arrival information for - * @throws NullPointerException if {@code stopId} is {@code null} - */ - public Builder(String stopId) { - this.stopId = Objects.requireNonNull(stopId); - } - - /** - * Sets the train line to filter arrival information by. - * - * @param line the train line - * @return this {@code Builder} instance - * @throws NullPointerException if {@code line} is {@code null} - */ - public Builder line(TrainLine line) { - this.line = Objects.requireNonNull(line); - - return this; - } - - /** - * Sets the maximum number of arrival information to return. - * - * @param maxResults the maximum number of arrival information - * @return this {@code Builder} instance - * @throws NullPointerException if {@code maxResults} is {@code null} - * @throws IllegalArgumentException if {@code maxResults} is not positive - */ - public Builder maxResults(Integer maxResults) { - Objects.requireNonNull(maxResults); - - if (maxResults <= 0) { - throw new IllegalArgumentException("maxResults must be positive"); - } - - this.maxResults = maxResults; - - return this; - } - - /** - * Builds the {@code StopArrivalQuery}. - * - * @return a new {@code StopArrivalQuery} instance - */ - public StopArrivalQuery build() { - return new StopArrivalQuery( - this.stopId, - this.line, - this.maxResults - ); - } - } -} diff --git a/src/main/java/com/cta4j/train/arrival/query/StopArrivalsQuery.java b/src/main/java/com/cta4j/train/arrival/query/StopArrivalsQuery.java new file mode 100644 index 00000000..5054b8a7 --- /dev/null +++ b/src/main/java/com/cta4j/train/arrival/query/StopArrivalsQuery.java @@ -0,0 +1,106 @@ +package com.cta4j.train.arrival.query; + +import com.cta4j.train.common.internal.util.TrainApiUtils; +import com.cta4j.train.common.model.TrainLine; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +import java.util.Collection; +import java.util.List; +import java.util.Objects; + +/// Represents a query for train arrivals at a specific stop. +/// +/// @param stopIds the [List] of stop IDs to retrieve arrivals for +/// @param line the optional train line to filter arrivals by +/// @param maxResults the optional maximum number of arrivals to return +@NullMarked +public record StopArrivalsQuery( + List stopIds, + @Nullable TrainLine line, + @Nullable Integer maxResults +) { + /// Constructs a `StopArrivalsQuery`. + /// + /// @param stopIds the [List] of stop IDs to retrieve arrivals for + /// @param line the optional train line to filter arrivals by + /// @param maxResults the optional maximum number of arrivals to return + /// @throws NullPointerException if `stopIds` is `null`, or if any element of `stopIds` is `null` + /// @throws IllegalArgumentException if more than 4 stop IDs are provided, or if `maxResults` is non-`null` and not + /// positive + public StopArrivalsQuery { + Objects.requireNonNull(stopIds); + + stopIds = List.copyOf(stopIds); + + TrainApiUtils.requireMaxIds(stopIds, "stop"); + + if ((maxResults != null) && (maxResults <= 0)) { + throw new IllegalArgumentException("maxResults must be positive"); + } + } + + /// Creates a new `Builder` for constructing a `StopArrivalsQuery`. + /// + /// @param stopIds the [Collection] of stop IDs to retrieve arrivals for + /// @return a new `Builder` + /// @throws NullPointerException if `stopIds` is `null`, or if any element of `stopIds` is `null` + public static Builder builder(Collection stopIds) { + return new Builder(stopIds); + } + + /// A builder for `StopArrivalsQuery`. + public static final class Builder { + private final List stopIds; + + @Nullable + private TrainLine line; + + @Nullable + private Integer maxResults; + + private Builder(Collection stopIds) { + Objects.requireNonNull(stopIds); + + this.stopIds = List.copyOf(stopIds); + } + + /// Sets the train line to filter arrivals by. + /// + /// @param line the train line + /// @return this `Builder` instance + /// @throws NullPointerException if `line` is `null` + public Builder line(TrainLine line) { + this.line = Objects.requireNonNull(line); + + return this; + } + + /// Sets the maximum number of arrivals to return. + /// + /// @param maxResults the maximum number of arrivals + /// @return this `Builder` instance + /// @throws IllegalArgumentException if `maxResults` is not positive + public Builder maxResults(int maxResults) { + if (maxResults <= 0) { + throw new IllegalArgumentException("maxResults must be positive"); + } + + this.maxResults = maxResults; + + return this; + } + + /// Builds a configured `StopArrivalsQuery` instance. + /// + /// @return a new `StopArrivalsQuery` + /// @throws IllegalArgumentException if more than 4 stop IDs are provided + public StopArrivalsQuery build() { + return new StopArrivalsQuery( + this.stopIds, + this.line, + this.maxResults + ); + } + } +} diff --git a/src/main/java/com/cta4j/train/arrival/query/package-info.java b/src/main/java/com/cta4j/train/arrival/query/package-info.java new file mode 100644 index 00000000..c312a12f --- /dev/null +++ b/src/main/java/com/cta4j/train/arrival/query/package-info.java @@ -0,0 +1,3 @@ +/// Query builder types for filtering requests to the CTA Train Tracker API's arrivals endpoint by station or +/// stop. +package com.cta4j.train.arrival.query; diff --git a/src/main/java/com/cta4j/train/common/exception/Cta4jTrainException.java b/src/main/java/com/cta4j/train/common/exception/Cta4jTrainException.java index e2a21309..b5c1c93d 100644 --- a/src/main/java/com/cta4j/train/common/exception/Cta4jTrainException.java +++ b/src/main/java/com/cta4j/train/common/exception/Cta4jTrainException.java @@ -4,63 +4,50 @@ import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; -/** - * A custom exception class for handling cta4j train-specific errors. - */ +/// A custom exception class for handling cta4j train-specific errors. @NullMarked public class Cta4jTrainException extends Cta4jException { - /** - * The raw error code associated with this exception, if available. - */ @Nullable private final Integer rawErrorCode; - /** - * Constructs a {@code Cta4jTrainException}. - * - * @param message the detail message - * @param endpoint the endpoint associated with the exception - * @throws NullPointerException if {@code endpoint} is {@code null} - */ + /// Constructs a `Cta4jTrainException`. + /// + /// @param message the detail message + /// @param endpoint the endpoint associated with the exception + /// @throws NullPointerException if `endpoint` is `null` public Cta4jTrainException(String message, String endpoint) { super(message, endpoint); this.rawErrorCode = null; } - /** - * Constructs a {@code Cta4jTrainException}. - * - * @param message the detail message - * @param endpoint the endpoint associated with the exception - * @param cause the cause of the exception - * @throws NullPointerException if {@code endpoint} is {@code null} - */ + /// Constructs a `Cta4jTrainException`. + /// + /// @param message the detail message + /// @param endpoint the endpoint associated with the exception + /// @param cause the cause of the exception + /// @throws NullPointerException if `endpoint` is `null` public Cta4jTrainException(String message, String endpoint, Throwable cause) { super(message, endpoint, cause); this.rawErrorCode = null; } - /** - * Constructs a {@code Cta4jTrainException} with a raw error code. - * - * @param message the detail message - * @param endpoint the endpoint associated with the exception - * @param rawErrorCode the raw error code associated with the exception - * @throws NullPointerException if {@code endpoint} is {@code null} - */ + /// Constructs a `Cta4jTrainException` with a raw error code. + /// + /// @param message the detail message + /// @param endpoint the endpoint associated with the exception + /// @param rawErrorCode the raw error code associated with the exception + /// @throws NullPointerException if `endpoint` is `null` public Cta4jTrainException(String message, String endpoint, int rawErrorCode) { super(message, endpoint); this.rawErrorCode = rawErrorCode; } - /** - * Returns the raw error code associated with this exception, if available. - * - * @return the raw error code, or {@code null} if not available - */ + /// Returns the raw error code associated with this exception, if available. + /// + /// @return the raw error code, or `null` if not available public @Nullable Integer getRawErrorCode() { return this.rawErrorCode; } diff --git a/src/main/java/com/cta4j/train/common/exception/package-info.java b/src/main/java/com/cta4j/train/common/exception/package-info.java new file mode 100644 index 00000000..51341afa --- /dev/null +++ b/src/main/java/com/cta4j/train/common/exception/package-info.java @@ -0,0 +1,2 @@ +/// Base exception type thrown by the CTA Train Tracker API's sub-APIs. +package com.cta4j.train.common.exception; diff --git a/src/main/java/com/cta4j/train/common/internal/mapper/Qualifiers.java b/src/main/java/com/cta4j/train/common/internal/mapper/Qualifiers.java index 39c6a3b7..a4e01da6 100644 --- a/src/main/java/com/cta4j/train/common/internal/mapper/Qualifiers.java +++ b/src/main/java/com/cta4j/train/common/internal/mapper/Qualifiers.java @@ -1,6 +1,8 @@ package com.cta4j.train.common.internal.mapper; import com.cta4j.common.geo.Coordinates; +import com.cta4j.common.internal.util.BooleanParser; +import com.cta4j.common.internal.util.TimestampParser; import com.cta4j.train.common.internal.wire.CtaArrival; import com.cta4j.train.common.model.TrainDirection; import com.cta4j.train.common.model.TrainLine; @@ -12,15 +14,15 @@ import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; import org.mapstruct.Named; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import tools.jackson.core.JacksonException; import tools.jackson.databind.json.JsonMapper; import java.math.BigDecimal; import java.time.Instant; -import java.time.LocalDateTime; import java.time.ZoneId; import java.time.format.DateTimeFormatter; -import java.time.format.DateTimeParseException; import java.util.EnumSet; import java.util.Objects; import java.util.Set; @@ -28,6 +30,8 @@ @ApiStatus.Internal @NullMarked public final class Qualifiers { + private static final Logger log = LoggerFactory.getLogger(Qualifiers.class); + private static final DateTimeFormatter TIMESTAMP_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss"); private static final ZoneId CHICAGO_ZONE_ID = ZoneId.of("America/Chicago"); @@ -104,40 +108,30 @@ public static Set mapTrainLines(CtaStation station) { } @Named("mapLine") - public static TrainLine mapLine(String line) { + public static @Nullable TrainLine mapLine(String line) { Objects.requireNonNull(line); - return TrainLine.fromCode(line); + TrainLine trainLine = TrainLine.fromCode(line); + + if (trainLine == null) { + log.warn("Unknown train line code: {}", line); + } + + return trainLine; } @Named("mapTimestamp") public static Instant mapTimestamp(String timestamp) { Objects.requireNonNull(timestamp); - try { - return LocalDateTime.parse(timestamp, TIMESTAMP_FORMATTER) - .atZone(CHICAGO_ZONE_ID) - .toInstant(); - } catch (DateTimeParseException e) { - String message = "Failed to parse timestamp: %s".formatted(timestamp); - - throw new IllegalArgumentException(message, e); - } + return TimestampParser.parse(timestamp, TIMESTAMP_FORMATTER, CHICAGO_ZONE_ID); } @Named("map01ToBoolean") public static boolean map01ToBoolean(String value) { Objects.requireNonNull(value); - return switch (value) { - case "0" -> false; - case "1" -> true; - default -> { - String message = "Invalid boolean value: %s. Expected 0 or 1".formatted(value); - - throw new IllegalArgumentException(message); - } - }; + return BooleanParser.parse01(value); } @Named("map15ToTrainDirection") @@ -163,8 +157,8 @@ public static TrainDirection map15ToTrainDirection(String direction) { } } - @Named("parseCoordinate") - public static BigDecimal parseCoordinate(String value) { + @Named("mapCoordinate") + public static BigDecimal mapCoordinate(String value) { Objects.requireNonNull(value); try { @@ -176,8 +170,8 @@ public static BigDecimal parseCoordinate(String value) { } } - @Named("parseHeading") - public static int parseHeading(String value) { + @Named("mapHeading") + public static int mapHeading(String value) { Objects.requireNonNull(value); try { @@ -222,9 +216,9 @@ public static int parseHeading(String value) { return null; } - BigDecimal latitude = parseCoordinate(lat); - BigDecimal longitude = parseCoordinate(lon); - int headingValue = parseHeading(heading); + BigDecimal latitude = mapCoordinate(lat); + BigDecimal longitude = mapCoordinate(lon); + int headingValue = mapHeading(heading); return new Coordinates(latitude, longitude, headingValue); } diff --git a/src/main/java/com/cta4j/train/common/internal/util/TrainApiUtils.java b/src/main/java/com/cta4j/train/common/internal/util/TrainApiUtils.java new file mode 100644 index 00000000..0e51779a --- /dev/null +++ b/src/main/java/com/cta4j/train/common/internal/util/TrainApiUtils.java @@ -0,0 +1,32 @@ +package com.cta4j.train.common.internal.util; + +import org.jetbrains.annotations.ApiStatus; +import org.jspecify.annotations.NullMarked; + +import java.util.Collection; +import java.util.Objects; + +@ApiStatus.Internal +@NullMarked +public final class TrainApiUtils { + public static final int MAX_IDS_PER_REQUEST = 4; + + private TrainApiUtils() { + throw new UnsupportedOperationException("This is a utility class and cannot be instantiated"); + } + + public static void requireMaxIds(Collection ids, String label) { + Objects.requireNonNull(ids); + Objects.requireNonNull(label); + + if (ids.size() > MAX_IDS_PER_REQUEST) { + String message = "A maximum of %d %s IDs can be requested at once, but %d were provided".formatted( + MAX_IDS_PER_REQUEST, + label, + ids.size() + ); + + throw new IllegalArgumentException(message); + } + } +} diff --git a/src/main/java/com/cta4j/train/common/model/Arrival.java b/src/main/java/com/cta4j/train/common/model/Arrival.java index 3b4eab09..ff241463 100644 --- a/src/main/java/com/cta4j/train/common/model/Arrival.java +++ b/src/main/java/com/cta4j/train/common/model/Arrival.java @@ -1,36 +1,37 @@ package com.cta4j.train.common.model; import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; import java.time.Instant; import java.util.Objects; -/** - * Represents a train arrival. - * - * @param stationId the unique identifier of the station for which this arrival was generated - * @param stationName the display name of the station for which this arrival was generated - * @param stopId the unique identifier of the stop for which this arrival was generated - * @param stopDescription the display name of the stop for which this arrival was generated - * @param line the train line associated with this arrival - * @param destinationStationId the unique identifier of the destination station for this arrival - * @param destinationName the display name of the destination station for this arrival - * @param predictionTime the date and time (UTC) this arrival was generated - * @param arrivalTime the date and time (UTC) of a train’s arrival or departure to the stop associated with this - * arrival - * @param approaching whether the train associated with this arrival is currently approaching the stop - * @param scheduled whether this arrival is based on a scheduled time rather than a real-time prediction - * @param delayed whether the train associated with this arrival is currently delayed - * @param fault whether the train associated with this arrival is currently experiencing a fault - * @param metadata the metadata associated with this arrival - */ +/// Represents a train arrival. +/// +/// **NOTE:** The CTA will occasionally send a train line code that can't be resolved, in which case `line` is `null`. +/// +/// @param stationId the unique identifier of the station for which this arrival was generated +/// @param stationName the display name of the station for which this arrival was generated +/// @param stopId the unique identifier of the stop for which this arrival was generated +/// @param stopDescription the display name of the stop for which this arrival was generated +/// @param line the train line associated with this arrival, if applicable +/// @param destinationStationId the unique identifier of the destination station for this arrival +/// @param destinationName the display name of the destination station for this arrival +/// @param predictionTime the date and time (UTC) this arrival was generated +/// @param arrivalTime the date and time (UTC) of a train’s arrival or departure to the stop associated with this +/// arrival +/// @param approaching whether the train associated with this arrival is currently approaching the stop +/// @param scheduled whether this arrival is based on a scheduled time rather than a real-time prediction +/// @param delayed whether the train associated with this arrival is currently delayed +/// @param fault whether the train associated with this arrival is currently experiencing a fault +/// @param metadata the metadata associated with this arrival @NullMarked public record Arrival( String stationId, String stationName, String stopId, String stopDescription, - TrainLine line, + @Nullable TrainLine line, String destinationStationId, String destinationName, Instant predictionTime, @@ -41,34 +42,30 @@ public record Arrival( boolean fault, ArrivalMetadata metadata ) { - /** - * Constructs an {@code Arrival}. - * - * @param stationId the unique identifier of the station for which the arrival was generated - * @param stationName the display name of the station for which the arrival was generated - * @param stopId the unique identifier of the stop for which the arrival was generated - * @param stopDescription the display name of the stop for which the arrival was generated - * @param line the train line associated with the arrival - * @param destinationStationId the unique identifier of the destination station for the arrival - * @param destinationName the display name of the destination station for the arrival - * @param predictionTime the date and time (UTC) the arrival was generated - * @param arrivalTime the date and time (UTC) of a train’s arrival or departure to the stop associated with the - * arrival - * @param approaching whether the train associated with the arrival is currently approaching the stop - * @param scheduled whether the arrival is based on a scheduled time rather than a real-time prediction - * @param delayed whether the train associated with the arrival is currently delayed - * @param fault whether the train associated with the arrival is currently experiencing a fault - * @param metadata the metadata associated with the arrival - * @throws NullPointerException if {@code stationId}, {@code stationName}, {@code stopId}, {@code stopDescription}, - * {@code line}, {@code destinationStationId}, {@code destinationName}, {@code predictionTime}, - * {@code arrivalTime}, or {@code metadata} is {@code null} - */ + /// Constructs an `Arrival`. + /// + /// @param stationId the unique identifier of the station for which the arrival was generated + /// @param stationName the display name of the station for which the arrival was generated + /// @param stopId the unique identifier of the stop for which the arrival was generated + /// @param stopDescription the display name of the stop for which the arrival was generated + /// @param line the train line associated with the arrival, if applicable + /// @param destinationStationId the unique identifier of the destination station for the arrival + /// @param destinationName the display name of the destination station for the arrival + /// @param predictionTime the date and time (UTC) the arrival was generated + /// @param arrivalTime the date and time (UTC) of a train’s arrival or departure to the stop associated with the + /// arrival + /// @param approaching whether the train associated with the arrival is currently approaching the stop + /// @param scheduled whether the arrival is based on a scheduled time rather than a real-time prediction + /// @param delayed whether the train associated with the arrival is currently delayed + /// @param fault whether the train associated with the arrival is currently experiencing a fault + /// @param metadata the metadata associated with the arrival + /// @throws NullPointerException if `stationId`, `stationName`, `stopId`, `stopDescription`, + /// `destinationStationId`, `destinationName`, `predictionTime`, `arrivalTime`, or `metadata` is `null` public Arrival { Objects.requireNonNull(stationId); Objects.requireNonNull(stationName); Objects.requireNonNull(stopId); Objects.requireNonNull(stopDescription); - Objects.requireNonNull(line); Objects.requireNonNull(destinationStationId); Objects.requireNonNull(destinationName); Objects.requireNonNull(predictionTime); diff --git a/src/main/java/com/cta4j/train/common/model/ArrivalMetadata.java b/src/main/java/com/cta4j/train/common/model/ArrivalMetadata.java index a941023b..6f9c0920 100644 --- a/src/main/java/com/cta4j/train/common/model/ArrivalMetadata.java +++ b/src/main/java/com/cta4j/train/common/model/ArrivalMetadata.java @@ -6,14 +6,15 @@ import java.util.Objects; -/** - * Represents metadata associated with a train arrival. - * - * @param runNumber the run number of the train associated with this arrival - * @param direction the direction of travel of the train associated with this arrival - * @param coordinates the coordinates of the train associated with this arrival, if applicable - * @param flags the flags associated with this arrival, if applicable - */ +/// Represents metadata associated with a train arrival. +/// +/// **NOTE:** `flags` is not well-documented by the CTA. As such, its presence here is primarily for completeness and +/// may not be populated or described correctly. +/// +/// @param runNumber the run number of the train associated with this arrival +/// @param direction the direction of travel of the train associated with this arrival +/// @param coordinates the coordinates of the train associated with this arrival, if applicable +/// @param flags the flags associated with this arrival, if applicable @NullMarked public record ArrivalMetadata( String runNumber, @@ -21,15 +22,13 @@ public record ArrivalMetadata( @Nullable Coordinates coordinates, @Nullable String flags ) { - /** - * Constructs an {@code ArrivalMetadata}. - * - * @param runNumber the run number of the train associated with the arrival - * @param direction the direction of travel of the train associated with the arrival - * @param coordinates the coordinates of the train associated with the arrival, if applicable - * @param flags the flags associated with the arrival, if applicable - * @throws NullPointerException if {@code runNumber} or {@code direction} is {@code null} - */ + /// Constructs an `ArrivalMetadata`. + /// + /// @param runNumber the run number of the train associated with the arrival + /// @param direction the direction of travel of the train associated with the arrival + /// @param coordinates the coordinates of the train associated with the arrival, if applicable + /// @param flags the flags associated with the arrival, if applicable + /// @throws NullPointerException if `runNumber` or `direction` is `null` public ArrivalMetadata { Objects.requireNonNull(runNumber); Objects.requireNonNull(direction); diff --git a/src/main/java/com/cta4j/train/common/model/TrainDirection.java b/src/main/java/com/cta4j/train/common/model/TrainDirection.java index 270fb636..b34707ba 100644 --- a/src/main/java/com/cta4j/train/common/model/TrainDirection.java +++ b/src/main/java/com/cta4j/train/common/model/TrainDirection.java @@ -2,61 +2,37 @@ import org.jspecify.annotations.NullMarked; -/** - * Represents the operational direction of a train. - * - *

- * NOTE: This direction is operational in nature and does not necessarily reflect the physical direction of the - * train at its current location. It loosely translates to a northbound or southbound direction, though this may not be - * intuitive for all lines. - */ +/// Represents the operational direction of a train. +/// +/// **NOTE:** This direction is operational in nature and does not necessarily reflect the physical direction of the +/// train at its current location. It loosely translates to a northbound or southbound direction, though this may not +/// be intuitive for all lines. @NullMarked public enum TrainDirection { - /** - * Indicates a northbound operational direction (CTA direction code 1). - */ + /// Indicates a northbound operational direction (CTA direction code 1). NORTHBOUND(1), - /** - * Indicates a southbound operational direction (CTA direction code 5). - */ + /// Indicates a southbound operational direction (CTA direction code 5). SOUTHBOUND(5); - /** - * The CTA direction code associated with this train direction. - */ private final int code; - /** - * Constructs a {@code TrainDirection}. - * - * @param code the CTA direction code associated with this train direction - * @throws IllegalArgumentException if {@code code} is not 1 (northbound) or 5 (southbound) - */ TrainDirection(int code) { - if ((code != 1) && (code != 5)) { - throw new IllegalArgumentException("CTA direction code must be either 1 (northbound) or 5 (southbound)"); - } - this.code = code; } - /** - * Gets the CTA direction code associated with this direction. - * - * @return the CTA direction code - */ + /// Returns the CTA direction code associated with this direction. + /// + /// @return the CTA direction code public int getCode() { return this.code; } - /** - * Returns the {@code TrainDirection} corresponding to the given code. - * - * @param code the CTA direction code (1 for northbound, 5 for southbound) - * @return the corresponding {@code TrainDirection} - * @throws IllegalArgumentException if the code does not correspond to any known train direction - */ + /// Returns the `TrainDirection` corresponding to the given code. + /// + /// @param code the CTA direction code (1 for northbound, 5 for southbound) + /// @return the corresponding `TrainDirection` + /// @throws IllegalArgumentException if the code does not correspond to any known train direction public static TrainDirection fromCode(int code) { return switch (code) { case 1 -> NORTHBOUND; diff --git a/src/main/java/com/cta4j/train/common/model/TrainLine.java b/src/main/java/com/cta4j/train/common/model/TrainLine.java index 252e7687..4d107ac8 100644 --- a/src/main/java/com/cta4j/train/common/model/TrainLine.java +++ b/src/main/java/com/cta4j/train/common/model/TrainLine.java @@ -1,102 +1,64 @@ package com.cta4j.train.common.model; import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; import java.util.Objects; -/** - * Represents a train line. - */ +/// Represents a train line. @NullMarked public enum TrainLine { - /** - * The Red Line. - */ + /// Indicates the Red Line. RED("Red", "#C60C30"), - /** - * The Blue Line. - */ + /// Indicates the Blue Line. BLUE("Blue", "#00A1DE"), - /** - * The Brown Line. - */ + /// Indicates the Brown Line. BROWN("Brn", "#62361B"), - /** - * The Green Line. - */ + /// Indicates the Green Line. GREEN("G", "#009B3A"), - /** - * The Orange Line. - */ + /// Indicates the Orange Line. ORANGE("Org", "#F9461C"), - /** - * The Purple Line. - */ + /// Indicates the Purple Line. PURPLE("P", "#522398"), - /** - * The Pink Line. - */ + /// Indicates the Pink Line. PINK("Pink", "#E27EA6"), - /** - * The Yellow Line. - */ + /// Indicates the Yellow Line. YELLOW("Y", "#F9E300"); - /** - * The CTA code for this train line. - */ private final String code; - - /** - * The hex color code of this train line. - */ private final String colorHex; - /** - * Constructs a {@code TrainLine}. - * - * @param code the CTA code of the train line - * @param colorHex the hex color code of the train line - * @throws NullPointerException if {@code code} or {@code colorHex} is {@code null} - */ TrainLine(String code, String colorHex) { this.code = Objects.requireNonNull(code); this.colorHex = Objects.requireNonNull(colorHex); } - /** - * Gets the CTA code for this train line. - * - * @return the CTA code - */ + /// Returns the CTA code for this train line. + /// + /// @return the CTA code public String getCode() { return this.code; } - /** - * Gets the hex color code of this train line. - * - * @return the hex color code - */ + /// Returns the hex color code of this train line. + /// + /// @return the hex color code public String getColorHex() { return this.colorHex; } - /** - * Returns the {@code TrainLine} corresponding to the given code. - * - * @param code the CTA code of the train line (case-insensitive, may include "LINE" suffix) - * @return the corresponding {@code TrainLine} - * @throws IllegalArgumentException if the code does not correspond to any known train line - */ - public static TrainLine fromCode(String code) { + /// Returns the `TrainLine` corresponding to the given code. + /// + /// @param code the CTA code of the train line (case-insensitive, may include "LINE" suffix) + /// @return the corresponding `TrainLine`, or `null` if the code does not correspond to any known train line + public static @Nullable TrainLine fromCode(String code) { Objects.requireNonNull(code); return switch (code.toUpperCase()) { @@ -108,7 +70,7 @@ public static TrainLine fromCode(String code) { case "P", "PURPLE LINE" -> TrainLine.PURPLE; case "PINK", "PINK LINE" -> TrainLine.PINK; case "Y", "YELLOW LINE" -> TrainLine.YELLOW; - default -> throw new IllegalArgumentException("Invalid train line: %s".formatted(code)); + default -> null; }; } } diff --git a/src/main/java/com/cta4j/train/common/model/package-info.java b/src/main/java/com/cta4j/train/common/model/package-info.java new file mode 100644 index 00000000..c0baf256 --- /dev/null +++ b/src/main/java/com/cta4j/train/common/model/package-info.java @@ -0,0 +1,3 @@ +/// Domain model types shared across the CTA Train Tracker API's sub-APIs, such as arrivals and train line and +/// direction designators. +package com.cta4j.train.common.model; diff --git a/src/main/java/com/cta4j/train/common/package-info.java b/src/main/java/com/cta4j/train/common/package-info.java new file mode 100644 index 00000000..716d1f48 --- /dev/null +++ b/src/main/java/com/cta4j/train/common/package-info.java @@ -0,0 +1,2 @@ +/// Shared types, configuration, and internal plumbing used across the CTA Train Tracker API's sub-APIs. +package com.cta4j.train.common; diff --git a/src/main/java/com/cta4j/train/follow/FollowApi.java b/src/main/java/com/cta4j/train/follow/FollowApi.java index a841db2e..29cfc585 100644 --- a/src/main/java/com/cta4j/train/follow/FollowApi.java +++ b/src/main/java/com/cta4j/train/follow/FollowApi.java @@ -6,21 +6,17 @@ import java.util.Optional; -/** - * Provides access to follow-related endpoints of the CTA Train Tracker API. - *

- * This API allows retrieval of information about a specific train run. - */ +/// Provides access to follow-related endpoints of the CTA Train Tracker API. +/// +/// This API allows retrieval of upcoming predictions for a train by its run number. @NullMarked public interface FollowApi { - /** - * Retrieves a train by its run number. - * - * @param run the run number of the train - * @return an {@link Optional} containing the {@link FollowTrain} if found, or an empty {@link Optional} if no - * train is found for the given run number - * @throws NullPointerException if {@code run} is {@code null} - * @throws Cta4jFollowException if the API returns an error response or the response cannot be parsed - */ + /// Retrieves a train by its run number. + /// + /// @param run the run number of the train + /// @return an [Optional] containing the [FollowTrain] if found, or an empty [Optional] if no train is found for + /// the given run number + /// @throws NullPointerException if `run` is `null` + /// @throws Cta4jFollowException if the API returns an error response or the response cannot be parsed Optional findByRun(String run); } diff --git a/src/main/java/com/cta4j/train/follow/exception/Cta4jFollowException.java b/src/main/java/com/cta4j/train/follow/exception/Cta4jFollowException.java index d38fde70..fc09b101 100644 --- a/src/main/java/com/cta4j/train/follow/exception/Cta4jFollowException.java +++ b/src/main/java/com/cta4j/train/follow/exception/Cta4jFollowException.java @@ -5,46 +5,35 @@ import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; -/** - * A custom exception class for handling cta4j train follow-specific errors. - */ +/// A custom exception class for handling cta4j train follow-specific errors. @NullMarked public final class Cta4jFollowException extends Cta4jTrainException { - /** - * The error code associated with this exception, if available. - */ @Nullable private final FollowErrorCode errorCode; - /** - * Constructs a {@code Cta4jFollowException}. - * - * @param message the detail message - * @param cause the cause of the exception - */ + /// Constructs a `Cta4jFollowException`. + /// + /// @param message the detail message + /// @param cause the cause of the exception public Cta4jFollowException(String message, Throwable cause) { super(message, TrainApiConstants.FOLLOW_ENDPOINT, cause); this.errorCode = null; } - /** - * Constructs a {@code Cta4jFollowException}. - * - * @param message the detail message - * @param rawErrorCode the raw error code associated with the exception - */ + /// Constructs a `Cta4jFollowException`. + /// + /// @param message the detail message + /// @param rawErrorCode the raw error code associated with the exception public Cta4jFollowException(String message, int rawErrorCode) { super(message, TrainApiConstants.FOLLOW_ENDPOINT, rawErrorCode); this.errorCode = FollowErrorCode.fromCode(rawErrorCode); } - /** - * Returns the error code associated with this exception, if available. - * - * @return the error code, or {@code null} if not available - */ + /// Returns the error code associated with this exception, if available. + /// + /// @return the error code, or `null` if not available public @Nullable FollowErrorCode getErrorCode() { return this.errorCode; } diff --git a/src/main/java/com/cta4j/train/follow/exception/FollowErrorCode.java b/src/main/java/com/cta4j/train/follow/exception/FollowErrorCode.java index c4ee699d..16a5ebcd 100644 --- a/src/main/java/com/cta4j/train/follow/exception/FollowErrorCode.java +++ b/src/main/java/com/cta4j/train/follow/exception/FollowErrorCode.java @@ -1,90 +1,55 @@ package com.cta4j.train.follow.exception; import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; -/** - * Represents the error codes returned by the CTA Follow API. - */ +/// Represents the error codes returned by the CTA Follow API. @NullMarked public enum FollowErrorCode { - /** - * Indicates that the request was successful and there were no errors. - */ + /// Indicates that the request was successful and there were no errors. OK(0), - /** - * Indicates that a required parameter is missing from the request. - */ + /// Indicates that a required parameter is missing from the request. MISSING_PARAMETER(100), - /** - * Indicates that the provided API key is invalid. - */ + /// Indicates that the provided API key is invalid. INVALID_API_KEY(101), - /** - * Indicates that the daily limit for API requests has been exceeded. - */ + /// Indicates that the daily limit for API requests has been exceeded. DAILY_LIMIT_EXCEEDED(102), - /** - * Indicates that the query string contains a parameter that is not recognized by the API. The supported API - * parameters are "runnumber" and "key". - */ + /// Indicates that the query string contains a parameter that is not recognized by the API. The supported API + /// parameters are `runnumber` and `key`. INVALID_PARAMETER(500), - /** - * Indicates that the specified run number does not correspond to any known train run. - */ + /// Indicates that the specified run number does not correspond to any known train run. RUN_NOT_FOUND(501), - /** - * Indicates that the specified train run exists, but has an unexpected exit station ID that prevents the API from - * reliably determining which predictions to report. - */ + /// Indicates that the specified train run exists, but has an unexpected exit station ID that prevents the API from + /// reliably determining which predictions to report. UNABLE_TO_DETERMINE_STOPS(502), - /** - * Indicates that the specified train run exists, but none of its available predictions are for active stations. - */ - UNABLE_TO_FIND_PREDICTIONS(503), + /// Indicates that the specified train run exists, but none of its available predictions are for active stations. + UNABLE_TO_FIND_PREDICTIONS(503); - /** - * Indicates that an unknown error occurred that does not match any of the defined error codes. - */ - UNKNOWN(-1); - - /** - * The integer code associated with this error code. - */ private final int code; - /** - * Constructs a {@code FollowErrorCode}. - * - * @param code the integer code associated with the error code - */ FollowErrorCode(int code) { this.code = code; } - /** - * Returns the integer code associated with this error code. - * - * @return the integer code - */ + /// Returns the integer code associated with this error code. + /// + /// @return the integer code public int getCode() { return this.code; } - /** - * Returns the {@code FollowErrorCode} corresponding to the given integer code. - * - * @param code the integer code to look up - * @return the corresponding {@code FollowErrorCode}, or {@code UNKNOWN} if the code does not match any defined - * error code - */ - public static FollowErrorCode fromCode(int code) { + /// Returns the `FollowErrorCode` corresponding to the given integer code. + /// + /// @param code the integer code to look up + /// @return the corresponding `FollowErrorCode`, or `null` if the code does not match any defined error code + public static @Nullable FollowErrorCode fromCode(int code) { return switch (code) { case 0 -> OK; case 100 -> MISSING_PARAMETER; @@ -94,7 +59,7 @@ public static FollowErrorCode fromCode(int code) { case 501 -> RUN_NOT_FOUND; case 502 -> UNABLE_TO_DETERMINE_STOPS; case 503 -> UNABLE_TO_FIND_PREDICTIONS; - default -> UNKNOWN; + default -> null; }; } } diff --git a/src/main/java/com/cta4j/train/follow/exception/package-info.java b/src/main/java/com/cta4j/train/follow/exception/package-info.java new file mode 100644 index 00000000..a432f5a9 --- /dev/null +++ b/src/main/java/com/cta4j/train/follow/exception/package-info.java @@ -0,0 +1,2 @@ +/// Exception type and error code enum thrown by the CTA Train Tracker API's follow endpoint. +package com.cta4j.train.follow.exception; diff --git a/src/main/java/com/cta4j/train/follow/model/FollowTrain.java b/src/main/java/com/cta4j/train/follow/model/FollowTrain.java index 155a2b83..3dd3de00 100644 --- a/src/main/java/com/cta4j/train/follow/model/FollowTrain.java +++ b/src/main/java/com/cta4j/train/follow/model/FollowTrain.java @@ -8,25 +8,20 @@ import java.util.List; import java.util.Objects; -/** - * Represents a response from the "follow" endpoint of the CTA Train Tracker API. - * - * @param coordinates the current coordinates of this train being followed - * @param arrivals the {@link List} of {@link Arrival}s for this train being followed - */ +/// Represents a response from the "follow" endpoint of the CTA Train Tracker API. +/// +/// @param coordinates the current coordinates of this train being followed, if applicable +/// @param arrivals the [List] of [Arrival]s for this train being followed @NullMarked public record FollowTrain( @Nullable Coordinates coordinates, List arrivals ) { - /** - * Constructs a {@code FollowTrain}. - * - * @param coordinates the current coordinates of the train being followed - * @param arrivals the {@link List} of {@link Arrival}s for the train being followed - * @throws NullPointerException if {@code arrivals} is {@code null}, or if {@code arrivals} contains {@code null} - * elements - */ + /// Constructs a `FollowTrain`. + /// + /// @param coordinates the current coordinates of the train being followed, if applicable + /// @param arrivals the [List] of [Arrival]s for the train being followed + /// @throws NullPointerException if `arrivals` is `null`, or if any element of `arrivals` is `null` public FollowTrain { Objects.requireNonNull(arrivals); diff --git a/src/main/java/com/cta4j/train/follow/model/package-info.java b/src/main/java/com/cta4j/train/follow/model/package-info.java new file mode 100644 index 00000000..aaef2a65 --- /dev/null +++ b/src/main/java/com/cta4j/train/follow/model/package-info.java @@ -0,0 +1,3 @@ +/// Domain model type returned by the CTA Train Tracker API's follow endpoint, representing a train's upcoming +/// predictions. +package com.cta4j.train.follow.model; diff --git a/src/main/java/com/cta4j/train/follow/package-info.java b/src/main/java/com/cta4j/train/follow/package-info.java new file mode 100644 index 00000000..57b63ffd --- /dev/null +++ b/src/main/java/com/cta4j/train/follow/package-info.java @@ -0,0 +1,2 @@ +/// Retrieval of upcoming predictions for a single CTA train run as it continues along its route. +package com.cta4j.train.follow; diff --git a/src/main/java/com/cta4j/train/location/LocationsApi.java b/src/main/java/com/cta4j/train/location/LocationsApi.java index b24c1046..d778b994 100644 --- a/src/main/java/com/cta4j/train/location/LocationsApi.java +++ b/src/main/java/com/cta4j/train/location/LocationsApi.java @@ -6,46 +6,44 @@ import org.jspecify.annotations.NullMarked; import java.util.List; +import java.util.Objects; -/** - * Provides access to location-related endpoints of the CTA Train Tracker API. - *

- * This API allows retrieval of train locations by line. - */ +/// Provides access to location-related endpoints of the CTA Train Tracker API. +/// +/// This API allows retrieval of train locations by line. @NullMarked public interface LocationsApi { - /** - * Retrieves train locations for the specified lines. - * - * @param lines a {@link List} of {@link TrainLine}s to filter the train locations by - * @return a {@link List} of {@link TrainLocations} corresponding to the provided lines, or an empty {@link List} - * if no train locations are found for the specified lines - * @throws NullPointerException if {@code lines} is {@code null} or contains {@code null} elements - * @throws Cta4jLocationsException if the API returns an error response or the response cannot be parsed - */ + /// Retrieves train locations for all lines. + /// + /// @return a [List] of [TrainLocations] for all lines, or an empty [List] if no train locations are found + /// @throws Cta4jLocationsException if the API returns an error response or the response cannot be parsed + default List list() { + List lines = List.of(TrainLine.values()); + + return this.findByLines(lines); + } + + /// Retrieves train locations for the specified lines. + /// + /// @param lines a [List] of [TrainLine]s to filter the train locations by + /// @return a [List] of [TrainLocations] corresponding to the provided lines, or an empty [List] if no train + /// locations are found for the specified lines + /// @throws NullPointerException if `lines` is `null`, or if any element of `lines` is `null` + /// @throws Cta4jLocationsException if the API returns an error response or the response cannot be parsed List findByLines(List lines); - /** - * Retrieves train locations for the specified line. - * - * @param line the {@link TrainLine} to filter the train locations by - * @return a {@link List} of {@link TrainLocations} corresponding to the provided line, or an empty {@link List} if - * no train locations are found for the specified line - * @throws NullPointerException if {@code line} is {@code null} - * @throws Cta4jLocationsException if the API returns an error response or the response cannot be parsed - */ + /// Retrieves train locations for the specified line. + /// + /// @param line the [TrainLine] to filter the train locations by + /// @return a [List] of [TrainLocations] corresponding to the provided line, or an empty [List] if no train + /// locations are found for the specified line + /// @throws NullPointerException if `line` is `null` + /// @throws Cta4jLocationsException if the API returns an error response or the response cannot be parsed default List findByLine(TrainLine line) { - return findByLines(List.of(line)); - } + Objects.requireNonNull(line); + + List lines = List.of(line); - /** - * Retrieves train locations for all lines. - * - * @return a {@link List} of {@link TrainLocations} for all lines, or an empty {@link List} if no train locations - * are found - * @throws Cta4jLocationsException if the API returns an error response or the response cannot be parsed - */ - default List findAll() { - return findByLines(List.of(TrainLine.values())); + return this.findByLines(lines); } } diff --git a/src/main/java/com/cta4j/train/location/exception/Cta4jLocationsException.java b/src/main/java/com/cta4j/train/location/exception/Cta4jLocationsException.java index 448d0dd8..deb033d6 100644 --- a/src/main/java/com/cta4j/train/location/exception/Cta4jLocationsException.java +++ b/src/main/java/com/cta4j/train/location/exception/Cta4jLocationsException.java @@ -5,46 +5,35 @@ import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; -/** - * A custom exception class for handling cta4j train location-specific errors. - */ +/// A custom exception class for handling cta4j train location-specific errors. @NullMarked public final class Cta4jLocationsException extends Cta4jTrainException { - /** - * The error code associated with this exception, if available. - */ @Nullable private final LocationsErrorCode errorCode; - /** - * Constructs a {@code Cta4jLocationsException}. - * - * @param message the detail message - * @param cause the cause of the exception - */ + /// Constructs a `Cta4jLocationsException`. + /// + /// @param message the detail message + /// @param cause the cause of the exception public Cta4jLocationsException(String message, Throwable cause) { super(message, TrainApiConstants.POSITIONS_ENDPOINT, cause); this.errorCode = null; } - /** - * Constructs a {@code Cta4jLocationsException}. - * - * @param message the detail message - * @param rawErrorCode the raw error code associated with the exception - */ + /// Constructs a `Cta4jLocationsException`. + /// + /// @param message the detail message + /// @param rawErrorCode the raw error code associated with the exception public Cta4jLocationsException(String message, int rawErrorCode) { super(message, TrainApiConstants.POSITIONS_ENDPOINT, rawErrorCode); this.errorCode = LocationsErrorCode.fromCode(rawErrorCode); } - /** - * Returns the error code associated with this exception, if available. - * - * @return the error code, or {@code null} if not available - */ + /// Returns the error code associated with this exception, if available. + /// + /// @return the error code, or `null` if not available public @Nullable LocationsErrorCode getErrorCode() { return this.errorCode; } diff --git a/src/main/java/com/cta4j/train/location/exception/LocationsErrorCode.java b/src/main/java/com/cta4j/train/location/exception/LocationsErrorCode.java index 70c0518f..f699745e 100644 --- a/src/main/java/com/cta4j/train/location/exception/LocationsErrorCode.java +++ b/src/main/java/com/cta4j/train/location/exception/LocationsErrorCode.java @@ -1,85 +1,52 @@ package com.cta4j.train.location.exception; import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; -/** - * Represents the error codes returned by the CTA Location API. - */ +/// Represents the error codes returned by the CTA Location API. @NullMarked public enum LocationsErrorCode { - /** - * Indicates that the request was successful and there were no errors. - */ + /// Indicates that the request was successful and there were no errors. OK(0), - /** - * Indicates that a required parameter is missing from the request. - */ + /// Indicates that a required parameter is missing from the request. MISSING_PARAMETER(100), - /** - * Indicates that the provided API key is invalid. - */ + /// Indicates that the provided API key is invalid. INVALID_API_KEY(101), - /** - * Indicates that the daily limit for API requests has been exceeded. - */ + /// Indicates that the daily limit for API requests has been exceeded. DAILY_LIMIT_EXCEEDED(102), - /** - * Indicates that the specified route is not valid or does not exist in the CTA system. Valid route identifiers are - * red, blue, brn, g, org, p, pink, and y. The route identifiers are case-insensitive. - */ + /// Indicates that the specified route is not valid or does not exist in the CTA system. Valid route identifiers + /// are red, blue, brn, g, org, p, pink, and y. The route identifiers are case-insensitive. INVALID_ROUTE(106), - /** - * Indicates that the number of routes provided exceeds the allowed limit (more than 8). - */ + /// Indicates that the number of routes provided exceeds the allowed limit (more than 8). TOO_MANY_ROUTES(107), - /** - * Indicates that the query string contains a parameter that is not recognized by the API. The supported API - * parameters are "rt" and "key". - */ - INVALID_PARAMETER(500), + /// Indicates that the query string contains a parameter that is not recognized by the API. The supported API + /// parameters are `rt` and `key`. + INVALID_PARAMETER(500); - /** - * Indicates that an unknown error occurred that does not match any of the defined error codes. - */ - UNKNOWN(-1); - - /** - * The integer code associated with this error code. - */ private final int code; - /** - * Constructs a {@code LocationsErrorCode}. - * - * @param code the integer code associated with the error code - */ LocationsErrorCode(int code) { this.code = code; } - /** - * Returns the integer code associated with this error code. - * - * @return the integer code - */ + /// Returns the integer code associated with this error code. + /// + /// @return the integer code public int getCode() { return this.code; } - /** - * Returns the {@code LocationsErrorCode} corresponding to the given integer code. - * - * @param code the integer code to look up - * @return the corresponding {@code LocationsErrorCode}, or {@code UNKNOWN} if the code does not match any defined - * error code - */ - public static LocationsErrorCode fromCode(int code) { + /// Returns the `LocationsErrorCode` corresponding to the given integer code. + /// + /// @param code the integer code to look up + /// @return the corresponding `LocationsErrorCode`, or `null` if the code does not match any defined error code + public static @Nullable LocationsErrorCode fromCode(int code) { return switch (code) { case 0 -> OK; case 100 -> MISSING_PARAMETER; @@ -88,7 +55,7 @@ public static LocationsErrorCode fromCode(int code) { case 106 -> INVALID_ROUTE; case 107 -> TOO_MANY_ROUTES; case 500 -> INVALID_PARAMETER; - default -> UNKNOWN; + default -> null; }; } } diff --git a/src/main/java/com/cta4j/train/location/exception/package-info.java b/src/main/java/com/cta4j/train/location/exception/package-info.java new file mode 100644 index 00000000..429a10f6 --- /dev/null +++ b/src/main/java/com/cta4j/train/location/exception/package-info.java @@ -0,0 +1,2 @@ +/// Exception type and error code enum thrown by the CTA Train Tracker API's locations endpoint. +package com.cta4j.train.location.exception; diff --git a/src/main/java/com/cta4j/train/location/internal/mapper/LocationTrainMapper.java b/src/main/java/com/cta4j/train/location/internal/mapper/LocationTrainMapper.java deleted file mode 100644 index ed58c768..00000000 --- a/src/main/java/com/cta4j/train/location/internal/mapper/LocationTrainMapper.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.cta4j.train.location.internal.mapper; - -import com.cta4j.train.common.internal.mapper.Qualifiers; -import com.cta4j.train.location.internal.wire.CtaLocationTrain; -import com.cta4j.train.location.model.LocationTrain; -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 LocationTrainMapper { - LocationTrainMapper INSTANCE = Mappers.getMapper(LocationTrainMapper.class); - - @Mapping(target = "run", source = "rn") - @Mapping(target = "destinationStationId", source = "destSt") - @Mapping(target = "destinationName", source = "destNm") - @Mapping(target = "direction", source = "trDr", qualifiedByName = "map15ToTrainDirection") - @Mapping(target = "nextStationId", source = "nextStaId") - @Mapping(target = "nextStopId", source = "nextStpId") - @Mapping(target = "nextStationName", source = "nextStaNm") - @Mapping(target = "predictionTime", source = "prdt", qualifiedByName = "mapTimestamp") - @Mapping(target = "arrivalTime", source = "arrT", qualifiedByName = "mapTimestamp") - @Mapping(target = "approaching", source = "isApp", qualifiedByName = "map01ToBoolean") - @Mapping(target = "delayed", source = "isDly", qualifiedByName = "map01ToBoolean") - @Mapping(target = "flags", source = "flags") - @Mapping(target = "coordinates.latitude", source = "lat", qualifiedByName = "parseCoordinate") - @Mapping(target = "coordinates.longitude", source = "lon", qualifiedByName = "parseCoordinate") - @Mapping(target = "coordinates.heading", source = "heading", qualifiedByName = "parseHeading") - LocationTrain toDomain(CtaLocationTrain train); -} diff --git a/src/main/java/com/cta4j/train/location/internal/mapper/TrainLocationsMapper.java b/src/main/java/com/cta4j/train/location/internal/mapper/TrainLocationsMapper.java index cf88b5e4..135b94d4 100644 --- a/src/main/java/com/cta4j/train/location/internal/mapper/TrainLocationsMapper.java +++ b/src/main/java/com/cta4j/train/location/internal/mapper/TrainLocationsMapper.java @@ -1,7 +1,9 @@ package com.cta4j.train.location.internal.mapper; import com.cta4j.train.common.internal.mapper.Qualifiers; +import com.cta4j.train.location.internal.wire.CtaLocationTrain; import com.cta4j.train.location.internal.wire.CtaRoute; +import com.cta4j.train.location.model.LocationTrain; import com.cta4j.train.location.model.TrainLocations; import org.jetbrains.annotations.ApiStatus; import org.mapstruct.Mapper; @@ -10,7 +12,7 @@ import org.mapstruct.factory.Mappers; @Mapper( - uses = {Qualifiers.class, LocationTrainMapper.class}, + uses = Qualifiers.class, nullValueIterableMappingStrategy = NullValueMappingStrategy.RETURN_DEFAULT ) @ApiStatus.Internal @@ -20,4 +22,21 @@ public interface TrainLocationsMapper { @Mapping(target = "line", source = "name", qualifiedByName = "mapLine") @Mapping(target = "trains", source = "train") TrainLocations toDomain(CtaRoute route); + + @Mapping(target = "run", source = "rn") + @Mapping(target = "destinationStationId", source = "destSt") + @Mapping(target = "destinationName", source = "destNm") + @Mapping(target = "direction", source = "trDr", qualifiedByName = "map15ToTrainDirection") + @Mapping(target = "nextStationId", source = "nextStaId") + @Mapping(target = "nextStopId", source = "nextStpId") + @Mapping(target = "nextStationName", source = "nextStaNm") + @Mapping(target = "predictionTime", source = "prdt", qualifiedByName = "mapTimestamp") + @Mapping(target = "arrivalTime", source = "arrT", qualifiedByName = "mapTimestamp") + @Mapping(target = "approaching", source = "isApp", qualifiedByName = "map01ToBoolean") + @Mapping(target = "delayed", source = "isDly", qualifiedByName = "map01ToBoolean") + @Mapping(target = "flags", source = "flags") + @Mapping(target = "coordinates.latitude", source = "lat", qualifiedByName = "mapCoordinate") + @Mapping(target = "coordinates.longitude", source = "lon", qualifiedByName = "mapCoordinate") + @Mapping(target = "coordinates.heading", source = "heading", qualifiedByName = "mapHeading") + LocationTrain toDomain(CtaLocationTrain train); } diff --git a/src/main/java/com/cta4j/train/location/model/LocationTrain.java b/src/main/java/com/cta4j/train/location/model/LocationTrain.java index a98344bd..79fec147 100644 --- a/src/main/java/com/cta4j/train/location/model/LocationTrain.java +++ b/src/main/java/com/cta4j/train/location/model/LocationTrain.java @@ -8,23 +8,24 @@ import java.time.Instant; import java.util.Objects; -/** - * Represents the location of a train on a route. - * - * @param run the run number of this train - * @param destinationStationId the unique identifier of the destination station for this train - * @param destinationName the display name of the destination station for this train - * @param direction the direction of travel of this train - * @param nextStationId the unique identifier of the next station for this train - * @param nextStopId the unique identifier of the next stop for this train - * @param nextStationName the display name of the next station for this train - * @param predictionTime the date and time (UTC) this location was generated - * @param arrivalTime the date and time (UTC) of this train's arrival at the next stop - * @param approaching whether this train is currently approaching the next stop - * @param delayed whether this train is currently delayed - * @param flags the flags associated with this train, if applicable - * @param coordinates the current coordinates of this train - */ +/// Represents the location of a train on a route. +/// +/// **NOTE:** `flags` is not well-documented by the CTA. As such, its presence here is primarily for completeness and +/// may not be populated or described correctly. +/// +/// @param run the run number of this train +/// @param destinationStationId the unique identifier of the destination station for this train +/// @param destinationName the display name of the destination station for this train +/// @param direction the direction of travel of this train +/// @param nextStationId the unique identifier of the next station for this train +/// @param nextStopId the unique identifier of the next stop for this train +/// @param nextStationName the display name of the next station for this train +/// @param predictionTime the date and time (UTC) this location was generated +/// @param arrivalTime the date and time (UTC) of this train's arrival at the next stop +/// @param approaching whether this train is currently approaching the next stop +/// @param delayed whether this train is currently delayed +/// @param flags the flags associated with this train, if applicable +/// @param coordinates the current coordinates of this train @NullMarked public record LocationTrain( String run, @@ -41,26 +42,23 @@ public record LocationTrain( @Nullable String flags, Coordinates coordinates ) { - /** - * Constructs a {@code LocationTrain}. - * - * @param run the run number of the train - * @param destinationStationId the unique identifier of the destination station for the train - * @param destinationName the display name of the destination station for the train - * @param direction the direction of travel of the train - * @param nextStationId the unique identifier of the next station for the train - * @param nextStopId the unique identifier of the next stop for the train - * @param nextStationName the display name of the next station for the train - * @param predictionTime the date and time (UTC) the location was generated - * @param arrivalTime the date and time (UTC) of the train's arrival at the next stop - * @param approaching whether the train is currently approaching the next stop - * @param delayed whether the train is currently delayed - * @param flags the flags associated with the train, if applicable - * @param coordinates the current coordinates of the train - * @throws NullPointerException if {@code run}, {@code destinationStationId}, {@code destinationName}, - * {@code direction}, {@code nextStationId}, {@code nextStopId}, {@code nextStationName}, {@code predictionTime}, - * {@code arrivalTime}, or {@code coordinates} is {@code null} - */ + /// Constructs a `LocationTrain`. + /// + /// @param run the run number of the train + /// @param destinationStationId the unique identifier of the destination station for the train + /// @param destinationName the display name of the destination station for the train + /// @param direction the direction of travel of the train + /// @param nextStationId the unique identifier of the next station for the train + /// @param nextStopId the unique identifier of the next stop for the train + /// @param nextStationName the display name of the next station for the train + /// @param predictionTime the date and time (UTC) the location was generated + /// @param arrivalTime the date and time (UTC) of the train's arrival at the next stop + /// @param approaching whether the train is currently approaching the next stop + /// @param delayed whether the train is currently delayed + /// @param flags the flags associated with the train, if applicable + /// @param coordinates the current coordinates of the train + /// @throws NullPointerException if `run`, `destinationStationId`, `destinationName`, `direction`, `nextStationId`, + /// `nextStopId`, `nextStationName`, `predictionTime`, `arrivalTime`, or `coordinates` is `null` public LocationTrain { Objects.requireNonNull(run); Objects.requireNonNull(destinationStationId); diff --git a/src/main/java/com/cta4j/train/location/model/TrainLocations.java b/src/main/java/com/cta4j/train/location/model/TrainLocations.java index 5629cf27..8f7a66ac 100644 --- a/src/main/java/com/cta4j/train/location/model/TrainLocations.java +++ b/src/main/java/com/cta4j/train/location/model/TrainLocations.java @@ -2,31 +2,26 @@ import com.cta4j.train.common.model.TrainLine; import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; import java.util.List; import java.util.Objects; -/** - * Represents the locations of all trains on a route. - * - * @param line the train line associated with these locations - * @param trains the {@link List} of {@link LocationTrain}s for this train line - */ +/// Represents the locations of all trains on a route. +/// +/// @param line the train line associated with these locations, if applicable +/// @param trains the [List] of [LocationTrain]s for this train line @NullMarked public record TrainLocations( - TrainLine line, + @Nullable TrainLine line, List trains ) { - /** - * Constructs a {@code TrainLocations}. - * - * @param line the train line associated with the locations - * @param trains the {@link List} of {@link LocationTrain}s for the train line - * @throws NullPointerException if {@code line} or {@code trains} is {@code null}, or if {@code trains} contains - * {@code null} elements - */ + /// Constructs a `TrainLocations`. + /// + /// @param line the train line associated with the locations, if applicable + /// @param trains the [List] of [LocationTrain]s for the train line + /// @throws NullPointerException if `trains` is `null`, or if any element of `trains` is `null` public TrainLocations { - Objects.requireNonNull(line); Objects.requireNonNull(trains); trains = List.copyOf(trains); diff --git a/src/main/java/com/cta4j/train/location/model/package-info.java b/src/main/java/com/cta4j/train/location/model/package-info.java new file mode 100644 index 00000000..e4f1445e --- /dev/null +++ b/src/main/java/com/cta4j/train/location/model/package-info.java @@ -0,0 +1,3 @@ +/// Domain model types returned by the CTA Train Tracker API's locations endpoint, representing a train's +/// location and its containing collection. +package com.cta4j.train.location.model; diff --git a/src/main/java/com/cta4j/train/location/package-info.java b/src/main/java/com/cta4j/train/location/package-info.java new file mode 100644 index 00000000..b3821847 --- /dev/null +++ b/src/main/java/com/cta4j/train/location/package-info.java @@ -0,0 +1,2 @@ +/// Retrieval of real-time locations of CTA trains on a given route. +package com.cta4j.train.location; diff --git a/src/main/java/com/cta4j/train/package-info.java b/src/main/java/com/cta4j/train/package-info.java new file mode 100644 index 00000000..77be0018 --- /dev/null +++ b/src/main/java/com/cta4j/train/package-info.java @@ -0,0 +1,3 @@ +/// Entry point for the CTA Train Tracker API, exposing sub-APIs for stations, arrivals, train following, and +/// locations. +package com.cta4j.train; diff --git a/src/main/java/com/cta4j/train/station/StationsApi.java b/src/main/java/com/cta4j/train/station/StationsApi.java index 03f4f981..dc2bec39 100644 --- a/src/main/java/com/cta4j/train/station/StationsApi.java +++ b/src/main/java/com/cta4j/train/station/StationsApi.java @@ -6,22 +6,18 @@ import java.util.List; -/** - * Provides access to station-related endpoints. - *

- * This API allows retrieval of station information, including station names, IDs, and other details. - *

- * NOTE: The CTA Train Tracker API does not provide an endpoint for retrieving station information. This API - * uses the City of Chicago's Data Portal as its data source by default. The URL used to retrieve station information - * is configurable to accommodate changes to the data source. - */ +/// Provides access to station-related endpoints of the CTA Train Tracker API. +/// +/// This API allows retrieval of station information, including station names, IDs, and other details. +/// +/// **NOTE:** The CTA Train Tracker API does not provide an endpoint for retrieving station information. This API uses +/// the City of Chicago's Data Portal as its data source by default. The URL used to retrieve station information is +/// configurable to accommodate changes to the data source. @NullMarked public interface StationsApi { - /** - * Retrieves all available stations. - * - * @return a {@link List} of all available {@link Station}s, or an empty {@link List} if no stations are found - * @throws Cta4jTrainException if the API returns an error response or the response cannot be parsed - */ + /// Retrieves all available stations. + /// + /// @return a [List] of all available [Station]s, or an empty [List] if no stations are found + /// @throws Cta4jTrainException if the API returns an error response or the response cannot be parsed List list(); } diff --git a/src/main/java/com/cta4j/train/station/internal/wire/CtaLocation.java b/src/main/java/com/cta4j/train/station/internal/wire/CtaLocation.java index 316ae6c5..9420e76b 100644 --- a/src/main/java/com/cta4j/train/station/internal/wire/CtaLocation.java +++ b/src/main/java/com/cta4j/train/station/internal/wire/CtaLocation.java @@ -13,9 +13,11 @@ @NullMarked public record CtaLocation( String latitude, + String longitude, - @Nullable + @JsonProperty("human_address") + @Nullable String humanAddress ) { public CtaLocation { diff --git a/src/main/java/com/cta4j/train/station/model/CardinalDirection.java b/src/main/java/com/cta4j/train/station/model/CardinalDirection.java index 19302aa4..367c007f 100644 --- a/src/main/java/com/cta4j/train/station/model/CardinalDirection.java +++ b/src/main/java/com/cta4j/train/station/model/CardinalDirection.java @@ -4,38 +4,26 @@ import java.util.Objects; -/** - * Represents the four cardinal directions. - */ +/// Represents the four cardinal directions. @NullMarked public enum CardinalDirection { - /** - * North direction. - */ + /// Indicates the north direction. NORTH, - /** - * East direction. - */ + /// Indicates the east direction. EAST, - /** - * South direction. - */ + /// Indicates the south direction. SOUTH, - /** - * West direction. - */ + /// Indicates the west direction. WEST; - /** - * Returns the {@code CardinalDirection} corresponding to the given code. - * - * @param code the code representing the cardinal direction (e.g., "N", "E", "S", "W" or their full names) - * @return the corresponding {@code CardinalDirection} - * @throws IllegalArgumentException if the code does not correspond to any known cardinal direction - */ + /// Returns the `CardinalDirection` corresponding to the given code. + /// + /// @param code the code representing the cardinal direction (e.g., "N", "E", "S", "W" or their full names) + /// @return the corresponding `CardinalDirection` + /// @throws IllegalArgumentException if the code does not correspond to any known cardinal direction public static CardinalDirection fromCode(String code) { Objects.requireNonNull(code); diff --git a/src/main/java/com/cta4j/train/station/model/HumanAddress.java b/src/main/java/com/cta4j/train/station/model/HumanAddress.java index 4d1531be..c0d19579 100644 --- a/src/main/java/com/cta4j/train/station/model/HumanAddress.java +++ b/src/main/java/com/cta4j/train/station/model/HumanAddress.java @@ -4,14 +4,12 @@ import java.util.Objects; -/** - * Represents a human-readable address. - * - * @param address the street address - * @param city the city - * @param state the state - * @param zip the ZIP code - */ +/// Represents a human-readable address. +/// +/// @param address the street address +/// @param city the city +/// @param state the state +/// @param zip the ZIP code @NullMarked public record HumanAddress( String address, @@ -19,15 +17,13 @@ public record HumanAddress( String state, String zip ) { - /** - * Constructs a {@code HumanAddress}. - * - * @param address the street address - * @param city the city - * @param state the state - * @param zip the ZIP code - * @throws NullPointerException if {@code address}, {@code city}, {@code state}, or {@code zip} is {@code null} - */ + /// Constructs a `HumanAddress`. + /// + /// @param address the street address + /// @param city the city + /// @param state the state + /// @param zip the ZIP code + /// @throws NullPointerException if `address`, `city`, `state`, or `zip` is `null` public HumanAddress { Objects.requireNonNull(address); Objects.requireNonNull(city); diff --git a/src/main/java/com/cta4j/train/station/model/Location.java b/src/main/java/com/cta4j/train/station/model/Location.java index dc03a1fd..26f9c746 100644 --- a/src/main/java/com/cta4j/train/station/model/Location.java +++ b/src/main/java/com/cta4j/train/station/model/Location.java @@ -6,27 +6,23 @@ import java.math.BigDecimal; import java.util.Objects; -/** - * Represents a geographical location with latitude and longitude, optionally including a human-readable address. - * - * @param latitude the latitude - * @param longitude the longitude - * @param humanAddress the human-readable address, or {@code null} if not available - */ +/// Represents a geographical location with latitude and longitude, optionally including a human-readable address. +/// +/// @param latitude the latitude +/// @param longitude the longitude +/// @param humanAddress the human-readable address, if applicable @NullMarked public record Location( BigDecimal latitude, BigDecimal longitude, @Nullable HumanAddress humanAddress ) { - /** - * Constructs a {@code Location}. - * - * @param latitude the latitude - * @param longitude the longitude - * @param humanAddress the human-readable address, or {@code null} if not available - * @throws NullPointerException if {@code latitude} or {@code longitude} is {@code null} - */ + /// Constructs a `Location`. + /// + /// @param latitude the latitude + /// @param longitude the longitude + /// @param humanAddress the human-readable address, if applicable + /// @throws NullPointerException if `latitude` or `longitude` is `null` public Location { Objects.requireNonNull(latitude); Objects.requireNonNull(longitude); diff --git a/src/main/java/com/cta4j/train/station/model/Station.java b/src/main/java/com/cta4j/train/station/model/Station.java index a8396cc4..410ded3c 100644 --- a/src/main/java/com/cta4j/train/station/model/Station.java +++ b/src/main/java/com/cta4j/train/station/model/Station.java @@ -6,19 +6,17 @@ import java.util.Objects; import java.util.Set; -/** - * Represents a train station. - * - * @param stopId the unique stop identifier of this station - * @param direction the {@link CardinalDirection} of this station - * @param stopName the stop name of this station - * @param name the name of this station - * @param descriptiveName the descriptive name of this station - * @param mapId the map identifier of this station - * @param adaAccessible whether this station is ADA accessible - * @param lines the {@link Set} of {@link TrainLine}s that serve this station - * @param location the {@link Location} of this station - */ +/// Represents a train station. +/// +/// @param stopId the unique stop identifier of this station +/// @param direction the [CardinalDirection] of this station +/// @param stopName the stop name of this station +/// @param name the name of this station +/// @param descriptiveName the descriptive name of this station +/// @param mapId the unique map identifier of this station +/// @param adaAccessible whether this station is ADA accessible +/// @param lines the [Set] of [TrainLine]s that serve this station +/// @param location the [Location] of this station @NullMarked public record Station( String stopId, @@ -31,22 +29,19 @@ public record Station( Set lines, Location location ) { - /** - * Constructs a {@code Station}. - * - * @param stopId the unique stop identifier of the station - * @param direction the {@link CardinalDirection} of the station - * @param stopName the stop name of the station - * @param name the name of the station - * @param descriptiveName the descriptive name of the station - * @param mapId the map identifier of the station - * @param adaAccessible whether the station is ADA accessible - * @param lines the {@link Set} of {@link TrainLine}s that serve the station - * @param location the {@link Location} of the station - * @throws NullPointerException if {@code stopId}, {@code direction}, {@code stopName}, {@code name}, - * {@code descriptiveName}, {@code mapId}, {@code lines}, or {@code location} is {@code null}, or if any element of - * {@code lines} is {@code null} - */ + /// Constructs a `Station`. + /// + /// @param stopId the unique stop identifier of the station + /// @param direction the [CardinalDirection] of the station + /// @param stopName the stop name of the station + /// @param name the name of the station + /// @param descriptiveName the descriptive name of the station + /// @param mapId the unique map identifier of the station + /// @param adaAccessible whether the station is ADA accessible + /// @param lines the [Set] of [TrainLine]s that serve the station + /// @param location the [Location] of the station + /// @throws NullPointerException if `stopId`, `direction`, `stopName`, `name`, `descriptiveName`, `mapId`, `lines`, + /// or `location` is `null`, or if any element of `lines` is `null` public Station { Objects.requireNonNull(stopId); Objects.requireNonNull(direction); diff --git a/src/main/java/com/cta4j/train/station/model/package-info.java b/src/main/java/com/cta4j/train/station/model/package-info.java new file mode 100644 index 00000000..6eb8617c --- /dev/null +++ b/src/main/java/com/cta4j/train/station/model/package-info.java @@ -0,0 +1,3 @@ +/// Domain model types returned by the CTA Train Tracker API's stations endpoint, representing a station, its +/// stops, and their addresses. +package com.cta4j.train.station.model; diff --git a/src/main/java/com/cta4j/train/station/package-info.java b/src/main/java/com/cta4j/train/station/package-info.java new file mode 100644 index 00000000..57d7014d --- /dev/null +++ b/src/main/java/com/cta4j/train/station/package-info.java @@ -0,0 +1,2 @@ +/// Retrieval of CTA train station and stop information. +package com.cta4j.train.station; diff --git a/src/test/java/com/cta4j/alert/AlertApiTest.java b/src/test/java/com/cta4j/alert/AlertApiTest.java new file mode 100644 index 00000000..b0ba8f9c --- /dev/null +++ b/src/test/java/com/cta4j/alert/AlertApiTest.java @@ -0,0 +1,15 @@ +package com.cta4j.alert; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.*; + +class AlertApiTest { + @Test + void builder_returnsWorkingInstance() { + AlertApi api = AlertApi.builder().build(); + + assertThat(api).isNotNull(); + assertThat(api.routeStatus()).isNotNull(); + } +} diff --git a/src/test/java/com/cta4j/alert/common/AlertQualifiersTest.java b/src/test/java/com/cta4j/alert/common/AlertQualifiersTest.java new file mode 100644 index 00000000..53f3fc95 --- /dev/null +++ b/src/test/java/com/cta4j/alert/common/AlertQualifiersTest.java @@ -0,0 +1,101 @@ +package com.cta4j.alert.common; + +import com.cta4j.alert.common.internal.mapper.Qualifiers; +import com.cta4j.alert.common.model.ServiceType; +import org.junit.jupiter.api.Test; + +import java.net.URI; +import java.time.Instant; + +import static org.assertj.core.api.Assertions.*; + +class AlertQualifiersTest { + @Test + void mapUri_returnsUri_whenValueIsValid() { + URI result = Qualifiers.mapUri("http://www.transitchicago.com/redline/"); + + assertThat(result).hasToString("http://www.transitchicago.com/redline/"); + } + + @Test + void mapUri_throwsIllegalArgumentException_whenValueIsInvalid() { + assertThatIllegalArgumentException().isThrownBy(() -> Qualifiers.mapUri("not a uri")) + .withMessageContaining("Failed to parse URI") + .withCauseInstanceOf(java.net.URISyntaxException.class); + } + + @Test + void mapTimestamp_returnsInstant_atChicagoZone_whenTimeOfDayIsPresent() { + Instant result = Qualifiers.mapTimestamp("2026-07-01T05:00:00"); + + // America/Chicago is UTC-5 (CDT) in July. + assertThat(result).isEqualTo(Instant.parse("2026-07-01T10:00:00Z")); + } + + @Test + void mapTimestamp_defaultsToMidnight_whenTimeOfDayIsAbsent() { + // Confirmed against the live Detailed Alerts API: EventStart/EventEnd are sometimes returned as a + // bare date (e.g., "2027-09-30") with no time-of-day component. + Instant result = Qualifiers.mapTimestamp("2027-09-30"); + + assertThat(result).isEqualTo(Instant.parse("2027-09-30T05:00:00Z")); + } + + @Test + void mapTimestamp_appliesStandardTimeOffset_whenDateIsOutsideDaylightSavingTime() { + Instant result = Qualifiers.mapTimestamp("2025-11-07"); + + assertThat(result).isEqualTo(Instant.parse("2025-11-07T06:00:00Z")); + } + + @Test + void mapTimestamp_returnsNull_whenValueIsNull() { + assertThat(Qualifiers.mapTimestamp(null)).isNull(); + } + + @Test + void mapTimestamp_throwsIllegalArgumentException_whenValueIsInvalid() { + assertThatIllegalArgumentException().isThrownBy(() -> Qualifiers.mapTimestamp("not-a-timestamp")); + } + + @Test + void map01ToBoolean_returnsFalse_whenValueIsZero() { + assertThat(Qualifiers.map01ToBoolean("0")).isFalse(); + } + + @Test + void map01ToBoolean_returnsTrue_whenValueIsOne() { + assertThat(Qualifiers.map01ToBoolean("1")).isTrue(); + } + + @Test + void map01ToBoolean_throwsIllegalArgumentException_whenValueIsInvalid() { + assertThatIllegalArgumentException().isThrownBy(() -> Qualifiers.map01ToBoolean("2")); + } + + @Test + void mapScore_returnsParsedInt() { + assertThat(Qualifiers.mapScore("37")).isEqualTo(37); + } + + @Test + void mapScore_throwsIllegalArgumentException_whenValueIsNotNumeric() { + assertThatIllegalArgumentException().isThrownBy(() -> Qualifiers.mapScore("not-a-number")) + .withMessageContaining("Failed to parse score: not-a-number") + .withCauseInstanceOf(NumberFormatException.class); + } + + @Test + void mapServiceType_returnsCorrectValue_forEachKnownCode() { + assertThat(Qualifiers.mapServiceType("B")).isEqualTo(ServiceType.BUS); + assertThat(Qualifiers.mapServiceType("R")).isEqualTo(ServiceType.RAIL); + assertThat(Qualifiers.mapServiceType("T")).isEqualTo(ServiceType.STATION); + assertThat(Qualifiers.mapServiceType("X")).isEqualTo(ServiceType.SYSTEMWIDE); + } + + @Test + void mapServiceType_throwsIllegalArgumentException_whenCodeIsUnknown() { + assertThatIllegalArgumentException().isThrownBy(() -> Qualifiers.mapServiceType("Z")) + .withMessageContaining("Unknown service type: Z"); + } +} diff --git a/src/test/java/com/cta4j/alert/common/exception/Cta4jAlertExceptionTest.java b/src/test/java/com/cta4j/alert/common/exception/Cta4jAlertExceptionTest.java new file mode 100644 index 00000000..a04d9bae --- /dev/null +++ b/src/test/java/com/cta4j/alert/common/exception/Cta4jAlertExceptionTest.java @@ -0,0 +1,39 @@ +package com.cta4j.alert.common.exception; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.*; + +class Cta4jAlertExceptionTest { + @Test + void constructor_setsMessageAndEndpoint() { + Cta4jAlertException exception = new Cta4jAlertException("Failed to parse response", "/routes.aspx"); + + assertThat(exception.getMessage()).isEqualTo("Failed to parse response"); + assertThat(exception.getEndpoint()).isEqualTo("/routes.aspx"); + assertThat(exception.getCause()).isNull(); + assertThat(exception.getRawErrorCode()).isNull(); + } + + @Test + void constructor_setsMessageEndpointAndCause() { + Throwable cause = new RuntimeException("root cause"); + + Cta4jAlertException exception = new Cta4jAlertException("Failed to parse response", "/routes.aspx", cause); + + assertThat(exception.getMessage()).isEqualTo("Failed to parse response"); + assertThat(exception.getEndpoint()).isEqualTo("/routes.aspx"); + assertThat(exception.getCause()).isSameAs(cause); + assertThat(exception.getRawErrorCode()).isNull(); + } + + @Test + void constructor_setsMessageEndpointAndRawErrorCode() { + Cta4jAlertException exception = new Cta4jAlertException("Invalid parameter", "/routes.aspx", 500); + + assertThat(exception.getMessage()).isEqualTo("Invalid parameter"); + assertThat(exception.getEndpoint()).isEqualTo("/routes.aspx"); + assertThat(exception.getCause()).isNull(); + assertThat(exception.getRawErrorCode()).isEqualTo(500); + } +} diff --git a/src/test/java/com/cta4j/alert/common/internal/impl/AlertApiImplTest.java b/src/test/java/com/cta4j/alert/common/internal/impl/AlertApiImplTest.java new file mode 100644 index 00000000..441fe0a7 --- /dev/null +++ b/src/test/java/com/cta4j/alert/common/internal/impl/AlertApiImplTest.java @@ -0,0 +1,76 @@ +package com.cta4j.alert.common.internal.impl; + +import com.cta4j.alert.AlertApi; +import com.cta4j.alert.common.internal.config.AlertApiConfig; +import com.cta4j.alert.detailedalert.DetailedAlertsApi; +import com.cta4j.alert.routestatus.RouteStatusApi; +import com.github.tomakehurst.wiremock.WireMockServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig; +import static org.assertj.core.api.Assertions.*; + +class AlertApiImplTest { + private WireMockServer server; + private AlertApiImpl api; + + @BeforeEach + void setUp() { + this.server = new WireMockServer(wireMockConfig().dynamicPort()); + this.server.start(); + AlertApiConfig config = new AlertApiConfig("http", "localhost", this.server.port()); + this.api = new AlertApiImpl(config); + } + + @AfterEach + void tearDown() { + this.server.stop(); + } + + @Test + void constructor_throwsNullPointerException_whenConfigIsNull() { + assertThatNullPointerException().isThrownBy(() -> new AlertApiImpl(null)); + } + + @Test + void routeStatus_returnsNonNull() { + RouteStatusApi result = this.api.routeStatus(); + + assertThat(result).isNotNull(); + } + + @Test + void detailedAlerts_returnsNonNull() { + DetailedAlertsApi result = this.api.detailedAlerts(); + + assertThat(result).isNotNull(); + } + + @Test + void builderImpl_host_throwsNullPointerException_whenHostIsNull() { + AlertApiImpl.BuilderImpl builder = new AlertApiImpl.BuilderImpl(); + + assertThatNullPointerException().isThrownBy(() -> builder.host(null)); + } + + @Test + void builderImpl_build_returnsInstance_withDefaultHost() { + AlertApiImpl.BuilderImpl builder = new AlertApiImpl.BuilderImpl(); + + AlertApi result = builder.build(); + + assertThat(result).isNotNull(); + } + + @Test + void builderImpl_build_returnsInstance_withCustomHost() { + AlertApiImpl.BuilderImpl builder = new AlertApiImpl.BuilderImpl(); + builder.host("example.com"); + + AlertApi result = builder.build(); + + assertThat(result).isNotNull(); + } +} diff --git a/src/test/java/com/cta4j/alert/common/model/AlertTrainLineTest.java b/src/test/java/com/cta4j/alert/common/model/AlertTrainLineTest.java new file mode 100644 index 00000000..cea3e5d6 --- /dev/null +++ b/src/test/java/com/cta4j/alert/common/model/AlertTrainLineTest.java @@ -0,0 +1,51 @@ +package com.cta4j.alert.common.model; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.*; + +class AlertTrainLineTest { + @Test + void fromCode_returnsCorrectValue_forEveryDefinedCode() { + for (AlertTrainLine line : AlertTrainLine.values()) { + assertThat(AlertTrainLine.fromCode(line.getCode())).isEqualTo(line); + } + } + + @Test + void fromCode_isCaseInsensitive() { + assertThat(AlertTrainLine.fromCode("red")).isEqualTo(AlertTrainLine.RED); + assertThat(AlertTrainLine.fromCode("PEXP")).isEqualTo(AlertTrainLine.PURPLE_EXPRESS); + assertThat(AlertTrainLine.fromCode("pexp")).isEqualTo(AlertTrainLine.PURPLE_EXPRESS); + } + + @Test + void fromCode_distinguishesPurpleFromPurpleExpress() { + assertThat(AlertTrainLine.fromCode("P")).isEqualTo(AlertTrainLine.PURPLE); + assertThat(AlertTrainLine.fromCode("Pexp")).isEqualTo(AlertTrainLine.PURPLE_EXPRESS); + assertThat(AlertTrainLine.PURPLE).isNotEqualTo(AlertTrainLine.PURPLE_EXPRESS); + } + + @Test + void fromCode_throwsIllegalArgumentException_whenCodeIsUnknown() { + assertThatIllegalArgumentException().isThrownBy(() -> AlertTrainLine.fromCode("Unknown")) + .withMessageContaining("Invalid alert train line: Unknown"); + } + + @Test + void fromCode_throwsNullPointerException_whenCodeIsNull() { + assertThatNullPointerException().isThrownBy(() -> AlertTrainLine.fromCode(null)); + } + + @Test + void getCode_returnsCode() { + assertThat(AlertTrainLine.RED.getCode()).isEqualTo("Red"); + assertThat(AlertTrainLine.BROWN.getCode()).isEqualTo("Brn"); + assertThat(AlertTrainLine.GREEN.getCode()).isEqualTo("G"); + assertThat(AlertTrainLine.ORANGE.getCode()).isEqualTo("Org"); + assertThat(AlertTrainLine.PURPLE.getCode()).isEqualTo("P"); + assertThat(AlertTrainLine.PURPLE_EXPRESS.getCode()).isEqualTo("Pexp"); + assertThat(AlertTrainLine.PINK.getCode()).isEqualTo("Pink"); + assertThat(AlertTrainLine.YELLOW.getCode()).isEqualTo("Y"); + } +} diff --git a/src/test/java/com/cta4j/alert/detailedalert/AlertMapperTest.java b/src/test/java/com/cta4j/alert/detailedalert/AlertMapperTest.java new file mode 100644 index 00000000..c68f0082 --- /dev/null +++ b/src/test/java/com/cta4j/alert/detailedalert/AlertMapperTest.java @@ -0,0 +1,181 @@ +package com.cta4j.alert.detailedalert; + +import com.cta4j.alert.common.internal.wire.CtaCdata; +import com.cta4j.alert.common.model.ServiceType; +import com.cta4j.alert.detailedalert.internal.mapper.AlertMapper; +import com.cta4j.alert.detailedalert.internal.wire.CtaAlert; +import com.cta4j.alert.detailedalert.internal.wire.CtaImpactedService; +import com.cta4j.alert.detailedalert.internal.wire.CtaImpactedServices; +import com.cta4j.alert.detailedalert.model.Alert; +import com.cta4j.alert.detailedalert.model.ImpactedService; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.List; + +import static org.assertj.core.api.Assertions.*; + +class AlertMapperTest { + @Test + void toDomain_mapsAllFields_whenOptionalFieldsArePresent() { + CtaImpactedService wireService = new CtaImpactedService( + "B", "Bus Route", "Clark", "22", "565a5c", "ffffff", + new CtaCdata("http://www.transitchicago.com/bus/22/") + ); + + CtaAlert wire = new CtaAlert( + "115070", + "Route 22 Rerouted", + "Route 22 is rerouted due to construction", + new CtaCdata("Route 22 buses are being rerouted due to construction on Clark St."), + "37", + "06c", + "planned", + "Planned Reroute", + "2026-07-01T05:00:00", + "2026-08-01T05:00:00", + "0", + "0", + new CtaCdata("http://www.transitchicago.com/alerts/115070"), + new CtaImpactedServices(List.of(wireService)), + "0", + "664b81c1-197b-450b-a00c-090483b90bb9" + ); + + Alert alert = AlertMapper.INSTANCE.toDomain(wire); + + assertThat(alert.id()).isEqualTo("115070"); + assertThat(alert.headline()).isEqualTo("Route 22 Rerouted"); + assertThat(alert.shortDescription()).isEqualTo("Route 22 is rerouted due to construction"); + assertThat(alert.fullDescription()) + .isEqualTo("Route 22 buses are being rerouted due to construction on Clark St."); + assertThat(alert.severity().score()).isEqualTo(37); + assertThat(alert.severity().color()).isEqualTo("06c"); + assertThat(alert.severity().css()).isEqualTo("planned"); + assertThat(alert.impact()).isEqualTo("Planned Reroute"); + assertThat(alert.startTime()).isEqualTo(Instant.parse("2026-07-01T10:00:00Z")); + assertThat(alert.endTime()).isEqualTo(Instant.parse("2026-08-01T10:00:00Z")); + assertThat(alert.openEnded()).isFalse(); + assertThat(alert.major()).isFalse(); + assertThat(alert.url()).hasToString("http://www.transitchicago.com/alerts/115070"); + assertThat(alert.impactedServices()).hasSize(1); + + ImpactedService impactedService = alert.impactedServices().getFirst(); + assertThat(impactedService.type()).isEqualTo(ServiceType.BUS); + assertThat(impactedService.typeDescription()).isEqualTo("Bus Route"); + assertThat(impactedService.name()).isEqualTo("Clark"); + assertThat(impactedService.serviceId()).isEqualTo("22"); + assertThat(impactedService.color()).isEqualTo("565a5c"); + assertThat(impactedService.textColor()).isEqualTo("ffffff"); + assertThat(impactedService.url()).hasToString("http://www.transitchicago.com/bus/22/"); + + assertThat(alert.ttim()).isEqualTo("0"); + assertThat(alert.guid()).isEqualTo("664b81c1-197b-450b-a00c-090483b90bb9"); + } + + @Test + void toDomain_mapsNullEndTimeTtimAndGuid_whenAbsent() { + CtaImpactedService wireService = new CtaImpactedService( + "T", "Train Station", "Austin", "41260", "009b3a", "FFFFFF", + new CtaCdata("http://www.transitchicago.com/travel_information/station.aspx?StopId=24") + ); + + CtaAlert wire = new CtaAlert( + "115080", + "Austin Main Stationhouse Temporarily Closed", + "Elevator out of service", + new CtaCdata("The elevator at Austin is out of service."), + "9", + "000000", + "minor", + "Elevator Status", + "2026-07-15T00:00:00", + null, + "1", + "1", + new CtaCdata("http://www.transitchicago.com/alerts/115080"), + new CtaImpactedServices(List.of(wireService)), + null, + null + ); + + Alert alert = AlertMapper.INSTANCE.toDomain(wire); + + assertThat(alert.endTime()).isNull(); + assertThat(alert.openEnded()).isTrue(); + assertThat(alert.major()).isTrue(); + assertThat(alert.ttim()).isNull(); + assertThat(alert.guid()).isNull(); + } + + @Test + void toDomain_mapsDateOnlyEventStartAndEventEnd_toMidnightChicagoTime() { + // Confirmed against the live Detailed Alerts API: EventStart/EventEnd are sometimes a bare + // "yyyy-MM-dd" date with no time-of-day component (e.g., long-running planned service changes). + CtaImpactedService wireService = new CtaImpactedService( + "B", "Bus Route", "South Pulaski", "53A", "059", "ffffff", + new CtaCdata("http://www.transitchicago.com/riding_cta/bus_route.aspx?RouteId=207") + ); + + CtaAlert wire = new CtaAlert( + "115090", + "Later, More Frequent Weekend Service", + "Service is being increased on the South Pulaski corridor.", + new CtaCdata("Later evening and more frequent weekend service."), + "11", + "000000", + "normal", + "Added Service", + "2025-11-07", + "2027-09-30", + "0", + "0", + new CtaCdata("http://www.transitchicago.com/alerts/115090"), + new CtaImpactedServices(List.of(wireService)), + "0", + "9979cd0c-a29d-4b52-805d-4baa0b32322b" + ); + + Alert alert = AlertMapper.INSTANCE.toDomain(wire); + + assertThat(alert.startTime()).isEqualTo(Instant.parse("2025-11-07T06:00:00Z")); + assertThat(alert.endTime()).isEqualTo(Instant.parse("2027-09-30T05:00:00Z")); + } + + @Test + void toDomain_mapsMultipleImpactedServices() { + CtaImpactedService station = new CtaImpactedService( + "T", "Train Station", "Austin", "41260", "009b3a", "FFFFFF", + new CtaCdata("http://www.transitchicago.com/travel_information/station.aspx?StopId=24") + ); + CtaImpactedService route = new CtaImpactedService( + "R", "Train Route", "Green Line", "G", "009b3a", "FFFFFF", + new CtaCdata("http://www.transitchicago.com/greenline/") + ); + + CtaAlert wire = new CtaAlert( + "115080", + "Austin Main Stationhouse Temporarily Closed", + "Elevator out of service", + new CtaCdata("The elevator at Austin is out of service."), + "9", + "000000", + "minor", + "Elevator Status", + "2026-07-15T00:00:00", + null, + "1", + "0", + new CtaCdata("http://www.transitchicago.com/alerts/115080"), + new CtaImpactedServices(List.of(station, route)), + "1", + "d41b2532-09ca-4827-b9c6-f4299cc86fb6" + ); + + Alert alert = AlertMapper.INSTANCE.toDomain(wire); + + assertThat(alert.impactedServices()).hasSize(2); + assertThat(alert.impactedServices().get(0).type()).isEqualTo(ServiceType.STATION); + assertThat(alert.impactedServices().get(1).type()).isEqualTo(ServiceType.RAIL); + } +} diff --git a/src/test/java/com/cta4j/alert/detailedalert/DetailedAlertsApiImplTest.java b/src/test/java/com/cta4j/alert/detailedalert/DetailedAlertsApiImplTest.java new file mode 100644 index 00000000..3926a24a --- /dev/null +++ b/src/test/java/com/cta4j/alert/detailedalert/DetailedAlertsApiImplTest.java @@ -0,0 +1,438 @@ +package com.cta4j.alert.detailedalert; + +import com.cta4j.TestFixtures; +import com.cta4j.alert.common.internal.config.AlertApiConfig; +import com.cta4j.alert.common.internal.util.AlertApiConstants; +import com.cta4j.alert.common.model.AlertTrainLine; +import com.cta4j.alert.detailedalert.exception.Cta4jDetailedAlertsException; +import com.cta4j.alert.detailedalert.exception.DetailedAlertsErrorCode; +import com.cta4j.alert.detailedalert.internal.impl.DetailedAlertsApiImpl; +import com.cta4j.alert.detailedalert.model.Alert; +import com.cta4j.alert.detailedalert.query.AlertsQuery; +import com.github.tomakehurst.wiremock.WireMockServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import tools.jackson.core.JacksonException; + +import java.time.LocalDate; +import java.util.Arrays; +import java.util.List; + +import static com.github.tomakehurst.wiremock.client.WireMock.*; +import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig; +import static org.assertj.core.api.Assertions.*; + +class DetailedAlertsApiImplTest { + private WireMockServer server; + private DetailedAlertsApiImpl api; + + @BeforeEach + void setUp() { + this.server = new WireMockServer(wireMockConfig().dynamicPort()); + this.server.start(); + AlertApiConfig config = new AlertApiConfig("http", "localhost", this.server.port()); + this.api = new DetailedAlertsApiImpl(config); + } + + @AfterEach + void tearDown() { + this.server.stop(); + } + + @Test + void list_returnsAlerts_whenResponseContainsData() { + this.server.stubFor(get(urlPathEqualTo("/api/1.0/alerts.aspx")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(TestFixtures.read("alert/detailedalert/list_success.json")))); + + List alerts = this.api.list(); + + assertThat(alerts).hasSize(3); + + Alert busReroute = alerts.getFirst(); + assertThat(busReroute.id()).isEqualTo("115070"); + assertThat(busReroute.headline()).isEqualTo("Route 22 Rerouted"); + assertThat(busReroute.severity().score()).isEqualTo(37); + assertThat(busReroute.endTime()).isNotNull(); + assertThat(busReroute.openEnded()).isFalse(); + assertThat(busReroute.major()).isFalse(); + assertThat(busReroute.impactedServices()).hasSize(1); + assertThat(busReroute.ttim()).isEqualTo("0"); + assertThat(busReroute.guid()).isEqualTo("664b81c1-197b-450b-a00c-090483b90bb9"); + + Alert stationClosure = alerts.get(1); + assertThat(stationClosure.id()).isEqualTo("115080"); + assertThat(stationClosure.endTime()).isNull(); + assertThat(stationClosure.openEnded()).isTrue(); + assertThat(stationClosure.major()).isFalse(); + assertThat(stationClosure.impactedServices()).hasSize(2); + assertThat(stationClosure.ttim()).isEqualTo("1"); + assertThat(stationClosure.guid()).isEqualTo("d41b2532-09ca-4827-b9c6-f4299cc86fb6"); + + // Confirmed against the live API: EventStart/EventEnd are sometimes bare dates with no time-of-day. + Alert dateOnlyAlert = alerts.get(2); + assertThat(dateOnlyAlert.id()).isEqualTo("115090"); + assertThat(dateOnlyAlert.startTime()).isNotNull(); + assertThat(dateOnlyAlert.endTime()).isNotNull(); + } + + @Test + void list_sendsDefaultQueryParameters() { + this.server.stubFor(get(urlPathEqualTo("/api/1.0/alerts.aspx")) + .withQueryParam("activeonly", equalTo("false")) + .withQueryParam("accessibility", equalTo("true")) + .withQueryParam("planned", equalTo("true")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(TestFixtures.read("alert/detailedalert/list_success.json")))); + + List alerts = this.api.list(); + + assertThat(alerts).hasSize(3); + this.server.verify(getRequestedFor(urlPathEqualTo("/api/1.0/alerts.aspx")) + .withoutQueryParam("bystartdate") + .withoutQueryParam("recentdays")); + } + + @Test + void list_sendsByStartDateParameter_whenProvided() { + this.server.stubFor(get(urlPathEqualTo("/api/1.0/alerts.aspx")) + .withQueryParam("bystartdate", equalTo("20260701")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(TestFixtures.read("alert/detailedalert/list_success.json")))); + + AlertsQuery query = AlertsQuery.builder() + .byStartDate(LocalDate.of(2026, 7, 1)) + .build(); + + List alerts = this.api.list(query); + + assertThat(alerts).hasSize(3); + } + + @Test + void list_sendsRecentDaysParameter_whenProvided() { + this.server.stubFor(get(urlPathEqualTo("/api/1.0/alerts.aspx")) + .withQueryParam("recentdays", equalTo("7")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(TestFixtures.read("alert/detailedalert/list_success.json")))); + + AlertsQuery query = AlertsQuery.builder() + .recentDays(7) + .build(); + + List alerts = this.api.list(query); + + assertThat(alerts).hasSize(3); + } + + @Test + void list_returnsEmpty_whenErrorCodeIsNoActiveAlerts() { + this.server.stubFor(get(urlPathEqualTo("/api/1.0/alerts.aspx")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(TestFixtures.read("alert/detailedalert/no_active_alerts.json")))); + + List alerts = this.api.list(); + + assertThat(alerts).isEmpty(); + } + + @Test + void list_returnsEmpty_whenErrorCodeIsNoActiveAlertsForFilter() { + this.server.stubFor(get(urlPathEqualTo("/api/1.0/alerts.aspx")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(TestFixtures.read("alert/detailedalert/no_active_alerts_for_filter.json")))); + + List alerts = this.api.list(); + + assertThat(alerts).isEmpty(); + } + + @Test + void list_returnsEmpty_whenAlertIsExplicitlyEmptyArray() { + this.server.stubFor(get(urlPathEqualTo("/api/1.0/alerts.aspx")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(TestFixtures.read("alert/detailedalert/empty_alert_array.json")))); + + List alerts = this.api.list(); + + assertThat(alerts).isEmpty(); + } + + @Test + void list_returnsEmpty_whenErrorCodeIsOk_andNoAlertData() { + this.server.stubFor(get(urlPathEqualTo("/api/1.0/alerts.aspx")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(TestFixtures.read("alert/detailedalert/ok_no_alerts.json")))); + + List alerts = this.api.list(); + + assertThat(alerts).isEmpty(); + } + + @Test + void list_throwsCta4jDetailedAlertsException_whenResponseContainsFatalError() { + this.server.stubFor(get(urlPathEqualTo("/api/1.0/alerts.aspx")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(TestFixtures.read("alert/detailedalert/fatal_error.json")))); + + assertThatThrownBy(() -> this.api.list()) + .isInstanceOf(Cta4jDetailedAlertsException.class) + .hasMessage("Invalid option for parameter 'activeonly': Valid options are 'true', 'false'") + .satisfies(e -> assertThat(((Cta4jDetailedAlertsException) e).getErrorCode()) + .isEqualTo(DetailedAlertsErrorCode.INVALID_ACTIVEONLY)) + .satisfies(e -> assertThat(((Cta4jDetailedAlertsException) e).getRawErrorCode()).isEqualTo(100)) + .satisfies(e -> assertThat(((Cta4jDetailedAlertsException) e).getEndpoint()) + .isEqualTo(AlertApiConstants.DETAILED_ALERTS_ENDPOINT)); + } + + @Test + void list_throwsCta4jDetailedAlertsException_withDefaultMessage_whenErrorMessageIsBlank() { + this.server.stubFor(get(urlPathEqualTo("/api/1.0/alerts.aspx")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(TestFixtures.read("alert/detailedalert/error_message_blank.json")))); + + assertThatThrownBy(() -> this.api.list()) + .isInstanceOf(Cta4jDetailedAlertsException.class) + .hasMessage("An unknown error occurred.") + .satisfies(e -> assertThat(((Cta4jDetailedAlertsException) e).getErrorCode()) + .isEqualTo(DetailedAlertsErrorCode.SERVER_ERROR)); + } + + @Test + void list_throwsCta4jDetailedAlertsException_withDefaultMessage_whenErrorMessageIsAbsent() { + this.server.stubFor(get(urlPathEqualTo("/api/1.0/alerts.aspx")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(TestFixtures.read("alert/detailedalert/error_message_absent.json")))); + + assertThatThrownBy(() -> this.api.list()) + .isInstanceOf(Cta4jDetailedAlertsException.class) + .hasMessage("An unknown error occurred.") + .satisfies(e -> assertThat(((Cta4jDetailedAlertsException) e).getErrorCode()) + .isEqualTo(DetailedAlertsErrorCode.INVALID_PARAMETER)); + } + + @Test + void list_throwsCta4jDetailedAlertsException_whenErrorCodeIsNotNumeric() { + this.server.stubFor(get(urlPathEqualTo("/api/1.0/alerts.aspx")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(TestFixtures.read("alert/detailedalert/bad_error_code.json")))); + + assertThatThrownBy(() -> this.api.list()) + .isInstanceOf(Cta4jDetailedAlertsException.class) + .hasMessage("Failed to parse error code") + .satisfies(e -> assertThat(e.getCause()).isInstanceOf(NumberFormatException.class)); + } + + @Test + void list_throwsCta4jDetailedAlertsException_whenResponseIsNotJson() { + this.server.stubFor(get(urlPathEqualTo("/api/1.0/alerts.aspx")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody("not-json"))); + + assertThatThrownBy(() -> this.api.list()) + .isInstanceOf(Cta4jDetailedAlertsException.class) + .hasMessage("Failed to parse response") + .satisfies(e -> assertThat(((Cta4jDetailedAlertsException) e).getEndpoint()) + .isEqualTo(AlertApiConstants.DETAILED_ALERTS_ENDPOINT)) + .satisfies(e -> assertThat(e.getCause()).isInstanceOf(JacksonException.class)); + } + + @Test + void list_throwsCta4jDetailedAlertsException_whenServerReturnsErrorStatus() { + this.server.stubFor(get(urlPathEqualTo("/api/1.0/alerts.aspx")) + .willReturn(aResponse() + .withStatus(500))); + + assertThatThrownBy(() -> this.api.list()) + .isInstanceOf(Cta4jDetailedAlertsException.class) + .hasMessageContaining("500") + .satisfies(e -> assertThat(e.getCause()).isNotNull()); + } + + @Test + void findByBusRouteIds_returnsEmpty_whenInputIsEmpty() { + List alerts = this.api.findByBusRouteIds(List.of()); + + assertThat(alerts).isEmpty(); + this.server.verify(0, anyRequestedFor(anyUrl())); + } + + @Test + void findByBusRouteIds_sendsRouteidParameter_asCommaJoined() { + this.server.stubFor(get(urlPathEqualTo("/api/1.0/alerts.aspx")) + .withQueryParam("routeid", equalTo("22,53")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(TestFixtures.read("alert/detailedalert/list_success.json")))); + + List alerts = this.api.findByBusRouteIds(List.of("22", "53")); + + assertThat(alerts).hasSize(3); + } + + @Test + void findByBusRouteIds_throwsIllegalArgumentException_whenRouteIdIsTrainLine() { + assertThatIllegalArgumentException() + .isThrownBy(() -> this.api.findByBusRouteIds(List.of("Red"))) + .withMessageContaining("Red is a train line, not a bus route"); + + this.server.verify(0, anyRequestedFor(anyUrl())); + } + + @Test + void findByBusRouteIds_throwsIllegalArgumentException_whenRouteIdIsTrainLine_caseInsensitive() { + assertThatIllegalArgumentException() + .isThrownBy(() -> this.api.findByBusRouteIds(List.of("red"))); + + this.server.verify(0, anyRequestedFor(anyUrl())); + } + + @Test + void findByBusRouteIds_throwsNullPointerException_whenRouteIdsContainsNull() { + List withNull = Arrays.asList("22", null); + + assertThatNullPointerException().isThrownBy(() -> this.api.findByBusRouteIds(withNull)); + } + + @Test + void findByBusRouteId_delegatesToFindByBusRouteIds() { + this.server.stubFor(get(urlPathEqualTo("/api/1.0/alerts.aspx")) + .withQueryParam("routeid", equalTo("22")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(TestFixtures.read("alert/detailedalert/list_success.json")))); + + List alerts = this.api.findByBusRouteId("22"); + + assertThat(alerts).hasSize(3); + } + + @Test + void findByLines_returnsEmpty_whenInputIsEmpty() { + List alerts = this.api.findByLines(List.of()); + + assertThat(alerts).isEmpty(); + this.server.verify(0, anyRequestedFor(anyUrl())); + } + + @Test + void findByLines_sendsRouteidParameter_asCommaJoinedCodes() { + this.server.stubFor(get(urlPathEqualTo("/api/1.0/alerts.aspx")) + .withQueryParam("routeid", equalTo("Red,Blue")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(TestFixtures.read("alert/detailedalert/list_success.json")))); + + List alerts = this.api.findByLines(List.of(AlertTrainLine.RED, AlertTrainLine.BLUE)); + + assertThat(alerts).hasSize(3); + } + + @Test + void findByLines_sendsRouteidParameter_usingPurpleExpressCode() { + this.server.stubFor(get(urlPathEqualTo("/api/1.0/alerts.aspx")) + .withQueryParam("routeid", equalTo("Pexp")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(TestFixtures.read("alert/detailedalert/list_success.json")))); + + List alerts = this.api.findByLine(AlertTrainLine.PURPLE_EXPRESS); + + assertThat(alerts).hasSize(3); + } + + @Test + void findByLines_throwsNullPointerException_whenLinesContainsNull() { + List withNull = Arrays.asList(AlertTrainLine.RED, null); + + assertThatNullPointerException().isThrownBy(() -> this.api.findByLines(withNull)); + } + + @Test + void findByLine_delegatesToFindByLines() { + this.server.stubFor(get(urlPathEqualTo("/api/1.0/alerts.aspx")) + .withQueryParam("routeid", equalTo("Red")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(TestFixtures.read("alert/detailedalert/list_success.json")))); + + List alerts = this.api.findByLine(AlertTrainLine.RED); + + assertThat(alerts).hasSize(3); + } + + @Test + void findByStationIds_returnsEmpty_whenInputIsEmpty() { + List alerts = this.api.findByStationIds(List.of()); + + assertThat(alerts).isEmpty(); + this.server.verify(0, anyRequestedFor(anyUrl())); + } + + @Test + void findByStationIds_sendsStationidParameter_asCommaJoined() { + this.server.stubFor(get(urlPathEqualTo("/api/1.0/alerts.aspx")) + .withQueryParam("stationid", equalTo("40380,41260")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(TestFixtures.read("alert/detailedalert/list_success.json")))); + + List alerts = this.api.findByStationIds(List.of("40380", "41260")); + + assertThat(alerts).hasSize(3); + } + + @Test + void findByStationIds_throwsNullPointerException_whenStationIdsContainsNull() { + List withNull = Arrays.asList("40380", null); + + assertThatNullPointerException().isThrownBy(() -> this.api.findByStationIds(withNull)); + } + + @Test + void findByStationId_delegatesToFindByStationIds() { + this.server.stubFor(get(urlPathEqualTo("/api/1.0/alerts.aspx")) + .withQueryParam("stationid", equalTo("41260")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(TestFixtures.read("alert/detailedalert/list_success.json")))); + + List alerts = this.api.findByStationId("41260"); + + assertThat(alerts).hasSize(3); + } +} diff --git a/src/test/java/com/cta4j/alert/detailedalert/exception/Cta4jDetailedAlertsExceptionTest.java b/src/test/java/com/cta4j/alert/detailedalert/exception/Cta4jDetailedAlertsExceptionTest.java new file mode 100644 index 00000000..195f7fb5 --- /dev/null +++ b/src/test/java/com/cta4j/alert/detailedalert/exception/Cta4jDetailedAlertsExceptionTest.java @@ -0,0 +1,43 @@ +package com.cta4j.alert.detailedalert.exception; + +import com.cta4j.alert.common.internal.util.AlertApiConstants; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.*; + +class Cta4jDetailedAlertsExceptionTest { + @Test + void constructor_setsMessageEndpointAndCause_andLeavesErrorCodeNull() { + Throwable cause = new RuntimeException("root cause"); + + Cta4jDetailedAlertsException exception = new Cta4jDetailedAlertsException("Failed to parse response", cause); + + assertThat(exception.getMessage()).isEqualTo("Failed to parse response"); + assertThat(exception.getEndpoint()).isEqualTo(AlertApiConstants.DETAILED_ALERTS_ENDPOINT); + assertThat(exception.getCause()).isSameAs(cause); + assertThat(exception.getRawErrorCode()).isNull(); + assertThat(exception.getErrorCode()).isNull(); + } + + @Test + void constructor_setsMessageEndpointAndErrorCode() { + Cta4jDetailedAlertsException exception = new Cta4jDetailedAlertsException( + "Invalid option for parameter 'activeonly': Valid options are 'true', 'false'", 100 + ); + + assertThat(exception.getMessage()) + .isEqualTo("Invalid option for parameter 'activeonly': Valid options are 'true', 'false'"); + assertThat(exception.getEndpoint()).isEqualTo(AlertApiConstants.DETAILED_ALERTS_ENDPOINT); + assertThat(exception.getCause()).isNull(); + assertThat(exception.getRawErrorCode()).isEqualTo(100); + assertThat(exception.getErrorCode()).isEqualTo(DetailedAlertsErrorCode.INVALID_ACTIVEONLY); + } + + @Test + void constructor_setsNullErrorCode_whenRawErrorCodeIsUnrecognized() { + Cta4jDetailedAlertsException exception = new Cta4jDetailedAlertsException("Something odd happened", 999); + + assertThat(exception.getRawErrorCode()).isEqualTo(999); + assertThat(exception.getErrorCode()).isNull(); + } +} diff --git a/src/test/java/com/cta4j/alert/detailedalert/exception/DetailedAlertsErrorCodeTest.java b/src/test/java/com/cta4j/alert/detailedalert/exception/DetailedAlertsErrorCodeTest.java new file mode 100644 index 00000000..c1d4f45a --- /dev/null +++ b/src/test/java/com/cta4j/alert/detailedalert/exception/DetailedAlertsErrorCodeTest.java @@ -0,0 +1,25 @@ +package com.cta4j.alert.detailedalert.exception; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.*; + +class DetailedAlertsErrorCodeTest { + @Test + void fromCode_returnsCorrectValue_forEveryDefinedCode() { + for (DetailedAlertsErrorCode code : DetailedAlertsErrorCode.values()) { + assertThat(DetailedAlertsErrorCode.fromCode(code.getCode())).isEqualTo(code); + } + } + + @Test + void fromCode_returnsNull_whenCodeIsUnrecognized() { + assertThat(DetailedAlertsErrorCode.fromCode(12345)).isNull(); + } + + @Test + void getCode_returnsCode() { + assertThat(DetailedAlertsErrorCode.NO_ACTIVE_ALERTS.getCode()).isEqualTo(25); + assertThat(DetailedAlertsErrorCode.NO_ACTIVE_ALERTS_FOR_FILTER.getCode()).isEqualTo(50); + } +} diff --git a/src/test/java/com/cta4j/alert/detailedalert/internal/wire/CtaAlertsTest.java b/src/test/java/com/cta4j/alert/detailedalert/internal/wire/CtaAlertsTest.java new file mode 100644 index 00000000..7f060ca6 --- /dev/null +++ b/src/test/java/com/cta4j/alert/detailedalert/internal/wire/CtaAlertsTest.java @@ -0,0 +1,59 @@ +package com.cta4j.alert.detailedalert.internal.wire; + +import com.cta4j.alert.common.internal.wire.CtaCdata; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.*; + +class CtaAlertsTest { + @Test + void constructor_copiesAlert_whenNonNull() { + List alert = new ArrayList<>(List.of(newAlert("115070"))); + + CtaAlerts alerts = new CtaAlerts("2026-07-28T12:00:00", "0", null, alert); + alert.add(newAlert("115080")); + + assertThat(alerts.alert()).hasSize(1); + } + + @Test + void constructor_allowsNullAlert() { + CtaAlerts alerts = new CtaAlerts("2026-07-28T12:00:00", "25", "There are no active alerts", null); + + assertThat(alerts.alert()).isNull(); + } + + @Test + void constructor_throwsNullPointerException_whenTimestampIsNull() { + assertThatNullPointerException().isThrownBy(() -> new CtaAlerts(null, "0", null, null)); + } + + @Test + void constructor_throwsNullPointerException_whenErrorCodeIsNull() { + assertThatNullPointerException().isThrownBy(() -> new CtaAlerts("2026-07-28T12:00:00", null, null, null)); + } + + private static CtaAlert newAlert(String alertId) { + return new CtaAlert( + alertId, + "Headline", + "Short description", + new CtaCdata("Full description"), + "9", + "000000", + "minor", + "Impact", + "2026-07-01T05:00:00", + null, + "0", + "0", + new CtaCdata("http://www.transitchicago.com/alerts/" + alertId), + new CtaImpactedServices(List.of()), + null, + null + ); + } +} diff --git a/src/test/java/com/cta4j/alert/detailedalert/internal/wire/CtaImpactedServicesTest.java b/src/test/java/com/cta4j/alert/detailedalert/internal/wire/CtaImpactedServicesTest.java new file mode 100644 index 00000000..714c1e0c --- /dev/null +++ b/src/test/java/com/cta4j/alert/detailedalert/internal/wire/CtaImpactedServicesTest.java @@ -0,0 +1,30 @@ +package com.cta4j.alert.detailedalert.internal.wire; + +import com.cta4j.alert.common.internal.wire.CtaCdata; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.*; + +class CtaImpactedServicesTest { + @Test + void constructor_copiesService() { + CtaImpactedService service = new CtaImpactedService( + "B", "Bus Route", "Clark", "22", "565a5c", "ffffff", + new CtaCdata("http://www.transitchicago.com/bus/22/") + ); + List services = new ArrayList<>(List.of(service)); + + CtaImpactedServices impactedServices = new CtaImpactedServices(services); + services.add(service); + + assertThat(impactedServices.service()).hasSize(1); + } + + @Test + void constructor_throwsNullPointerException_whenServiceIsNull() { + assertThatNullPointerException().isThrownBy(() -> new CtaImpactedServices(null)); + } +} diff --git a/src/test/java/com/cta4j/alert/detailedalert/model/SeverityTest.java b/src/test/java/com/cta4j/alert/detailedalert/model/SeverityTest.java new file mode 100644 index 00000000..99f48ac6 --- /dev/null +++ b/src/test/java/com/cta4j/alert/detailedalert/model/SeverityTest.java @@ -0,0 +1,33 @@ +package com.cta4j.alert.detailedalert.model; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.*; + +class SeverityTest { + @Test + void constructor_succeeds_whenScoreIsValid() { + assertThatNoException().isThrownBy(() -> new Severity(0, "000000", "normal")); + assertThatNoException().isThrownBy(() -> new Severity(99, "000000", "major")); + } + + @Test + void constructor_throwsIllegalArgumentException_whenScoreTooLow() { + assertThatIllegalArgumentException().isThrownBy(() -> new Severity(-1, "000000", "normal")); + } + + @Test + void constructor_throwsIllegalArgumentException_whenScoreTooHigh() { + assertThatIllegalArgumentException().isThrownBy(() -> new Severity(100, "000000", "normal")); + } + + @Test + void constructor_throwsNullPointerException_whenColorIsNull() { + assertThatNullPointerException().isThrownBy(() -> new Severity(50, null, "normal")); + } + + @Test + void constructor_throwsNullPointerException_whenCssIsNull() { + assertThatNullPointerException().isThrownBy(() -> new Severity(50, "000000", null)); + } +} diff --git a/src/test/java/com/cta4j/alert/detailedalert/query/AlertsQueryTest.java b/src/test/java/com/cta4j/alert/detailedalert/query/AlertsQueryTest.java new file mode 100644 index 00000000..cdfeca7e --- /dev/null +++ b/src/test/java/com/cta4j/alert/detailedalert/query/AlertsQueryTest.java @@ -0,0 +1,73 @@ +package com.cta4j.alert.detailedalert.query; + +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.*; + +class AlertsQueryTest { + @Test + void builder_hasCtaDefaults() { + AlertsQuery query = AlertsQuery.builder().build(); + + assertThat(query.activeOnly()).isFalse(); + assertThat(query.accessibility()).isTrue(); + assertThat(query.planned()).isTrue(); + assertThat(query.byStartDate()).isNull(); + assertThat(query.recentDays()).isNull(); + } + + @Test + void builder_buildsQueryWithOptionalParams() { + LocalDate date = LocalDate.of(2026, 7, 1); + + AlertsQuery query = AlertsQuery.builder() + .activeOnly(true) + .accessibility(false) + .planned(false) + .byStartDate(date) + .build(); + + assertThat(query.activeOnly()).isTrue(); + assertThat(query.accessibility()).isFalse(); + assertThat(query.planned()).isFalse(); + assertThat(query.byStartDate()).isEqualTo(date); + assertThat(query.recentDays()).isNull(); + } + + @Test + void builder_buildsQueryWithRecentDays() { + AlertsQuery query = AlertsQuery.builder().recentDays(7).build(); + + assertThat(query.recentDays()).isEqualTo(7); + assertThat(query.byStartDate()).isNull(); + } + + @Test + void builder_recentDays_throwsIllegalArgumentException_whenZero() { + assertThatIllegalArgumentException().isThrownBy(() -> AlertsQuery.builder().recentDays(0)); + } + + @Test + void builder_recentDays_throwsIllegalArgumentException_whenNegative() { + assertThatIllegalArgumentException().isThrownBy(() -> AlertsQuery.builder().recentDays(-1)); + } + + @Test + void builder_byStartDate_throwsNullPointerException_whenNull() { + assertThatNullPointerException().isThrownBy(() -> AlertsQuery.builder().byStartDate(null)); + } + + @Test + void constructor_throwsIllegalArgumentException_whenByStartDateAndRecentDaysBothSpecified() { + assertThatIllegalArgumentException().isThrownBy(() -> + new AlertsQuery(false, true, true, LocalDate.of(2026, 7, 1), 7)); + } + + @Test + void constructor_throwsIllegalArgumentException_whenRecentDaysIsNotPositive() { + assertThatIllegalArgumentException().isThrownBy(() -> + new AlertsQuery(false, true, true, null, 0)); + } +} diff --git a/src/test/java/com/cta4j/alert/detailedalert/query/BusRouteAlertsQueryTest.java b/src/test/java/com/cta4j/alert/detailedalert/query/BusRouteAlertsQueryTest.java new file mode 100644 index 00000000..13765f9d --- /dev/null +++ b/src/test/java/com/cta4j/alert/detailedalert/query/BusRouteAlertsQueryTest.java @@ -0,0 +1,79 @@ +package com.cta4j.alert.detailedalert.query; + +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.*; + +class BusRouteAlertsQueryTest { + @Test + void builder_copiesRouteIds_andHasCtaDefaults() { + List routeIds = new ArrayList<>(List.of("22", "53")); + + BusRouteAlertsQuery query = BusRouteAlertsQuery.builder(routeIds).build(); + routeIds.add("9"); + + assertThat(query.routeIds()).containsExactly("22", "53"); + assertThat(query.activeOnly()).isFalse(); + assertThat(query.accessibility()).isTrue(); + assertThat(query.planned()).isTrue(); + } + + @Test + void builder_buildsQueryWithOptionalParams() { + BusRouteAlertsQuery query = BusRouteAlertsQuery.builder(List.of("22")) + .activeOnly(true) + .accessibility(false) + .planned(false) + .recentDays(3) + .build(); + + assertThat(query.activeOnly()).isTrue(); + assertThat(query.accessibility()).isFalse(); + assertThat(query.planned()).isFalse(); + assertThat(query.recentDays()).isEqualTo(3); + } + + @Test + void builder_setsByStartDate() { + LocalDate date = LocalDate.of(2026, 7, 1); + + BusRouteAlertsQuery query = BusRouteAlertsQuery.builder(List.of("22")) + .byStartDate(date) + .build(); + + assertThat(query.byStartDate()).isEqualTo(date); + assertThat(query.recentDays()).isNull(); + } + + @Test + void builder_throwsNullPointerException_whenRouteIdsIsNull() { + assertThatNullPointerException().isThrownBy(() -> BusRouteAlertsQuery.builder(null)); + } + + @Test + void builder_recentDays_throwsIllegalArgumentException_whenNotPositive() { + assertThatIllegalArgumentException().isThrownBy(() -> BusRouteAlertsQuery.builder(List.of("22")).recentDays(0)); + } + + @Test + void constructor_throwsNullPointerException_whenRouteIdsIsNull() { + assertThatNullPointerException().isThrownBy(() -> + new BusRouteAlertsQuery(null, false, true, true, null, null)); + } + + @Test + void constructor_throwsIllegalArgumentException_whenByStartDateAndRecentDaysBothSpecified() { + assertThatIllegalArgumentException().isThrownBy(() -> + new BusRouteAlertsQuery(List.of("22"), false, true, true, LocalDate.of(2026, 7, 1), 7)); + } + + @Test + void constructor_throwsIllegalArgumentException_whenRecentDaysIsNotPositive() { + assertThatIllegalArgumentException().isThrownBy(() -> + new BusRouteAlertsQuery(List.of("22"), false, true, true, null, -1)); + } +} diff --git a/src/test/java/com/cta4j/alert/detailedalert/query/LineAlertsQueryTest.java b/src/test/java/com/cta4j/alert/detailedalert/query/LineAlertsQueryTest.java new file mode 100644 index 00000000..920d3c97 --- /dev/null +++ b/src/test/java/com/cta4j/alert/detailedalert/query/LineAlertsQueryTest.java @@ -0,0 +1,81 @@ +package com.cta4j.alert.detailedalert.query; + +import com.cta4j.alert.common.model.AlertTrainLine; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.*; + +class LineAlertsQueryTest { + @Test + void builder_copiesLines_andHasCtaDefaults() { + List lines = new ArrayList<>(List.of(AlertTrainLine.RED, AlertTrainLine.BLUE)); + + LineAlertsQuery query = LineAlertsQuery.builder(lines).build(); + lines.add(AlertTrainLine.GREEN); + + assertThat(query.lines()).containsExactly(AlertTrainLine.RED, AlertTrainLine.BLUE); + assertThat(query.activeOnly()).isFalse(); + assertThat(query.accessibility()).isTrue(); + assertThat(query.planned()).isTrue(); + } + + @Test + void builder_buildsQueryWithOptionalParams() { + LineAlertsQuery query = LineAlertsQuery.builder(List.of(AlertTrainLine.RED)) + .activeOnly(true) + .accessibility(false) + .planned(false) + .recentDays(3) + .build(); + + assertThat(query.activeOnly()).isTrue(); + assertThat(query.accessibility()).isFalse(); + assertThat(query.planned()).isFalse(); + assertThat(query.recentDays()).isEqualTo(3); + } + + @Test + void builder_setsByStartDate() { + LocalDate date = LocalDate.of(2026, 7, 1); + + LineAlertsQuery query = LineAlertsQuery.builder(List.of(AlertTrainLine.RED)) + .byStartDate(date) + .build(); + + assertThat(query.byStartDate()).isEqualTo(date); + assertThat(query.recentDays()).isNull(); + } + + @Test + void builder_recentDays_throwsIllegalArgumentException_whenNotPositive() { + assertThatIllegalArgumentException().isThrownBy(() -> + LineAlertsQuery.builder(List.of(AlertTrainLine.RED)).recentDays(0)); + } + + @Test + void builder_throwsNullPointerException_whenLinesIsNull() { + assertThatNullPointerException().isThrownBy(() -> LineAlertsQuery.builder(null)); + } + + @Test + void constructor_throwsNullPointerException_whenLinesIsNull() { + assertThatNullPointerException().isThrownBy(() -> + new LineAlertsQuery(null, false, true, true, null, null)); + } + + @Test + void constructor_throwsIllegalArgumentException_whenByStartDateAndRecentDaysBothSpecified() { + assertThatIllegalArgumentException().isThrownBy(() -> + new LineAlertsQuery(List.of(AlertTrainLine.RED), false, true, true, LocalDate.of(2026, 7, 1), 7)); + } + + @Test + void constructor_throwsIllegalArgumentException_whenRecentDaysIsNotPositive() { + assertThatIllegalArgumentException().isThrownBy(() -> + new LineAlertsQuery(List.of(AlertTrainLine.RED), false, true, true, null, -1)); + } +} diff --git a/src/test/java/com/cta4j/alert/detailedalert/query/StationAlertsQueryTest.java b/src/test/java/com/cta4j/alert/detailedalert/query/StationAlertsQueryTest.java new file mode 100644 index 00000000..5519d0ef --- /dev/null +++ b/src/test/java/com/cta4j/alert/detailedalert/query/StationAlertsQueryTest.java @@ -0,0 +1,78 @@ +package com.cta4j.alert.detailedalert.query; + +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.*; + +class StationAlertsQueryTest { + @Test + void builder_copiesStationIds_andHasCtaDefaults() { + List stationIds = new ArrayList<>(List.of("40380", "41260")); + + StationAlertsQuery query = StationAlertsQuery.builder(stationIds).build(); + stationIds.add("40560"); + + assertThat(query.stationIds()).containsExactly("40380", "41260"); + assertThat(query.activeOnly()).isFalse(); + assertThat(query.accessibility()).isTrue(); + assertThat(query.planned()).isTrue(); + } + + @Test + void builder_buildsQueryWithOptionalParams() { + StationAlertsQuery query = StationAlertsQuery.builder(List.of("40380")) + .activeOnly(true) + .accessibility(false) + .planned(false) + .byStartDate(LocalDate.of(2026, 7, 1)) + .build(); + + assertThat(query.activeOnly()).isTrue(); + assertThat(query.accessibility()).isFalse(); + assertThat(query.planned()).isFalse(); + assertThat(query.byStartDate()).isEqualTo(LocalDate.of(2026, 7, 1)); + } + + @Test + void builder_setsRecentDays() { + StationAlertsQuery query = StationAlertsQuery.builder(List.of("40380")) + .recentDays(7) + .build(); + + assertThat(query.recentDays()).isEqualTo(7); + assertThat(query.byStartDate()).isNull(); + } + + @Test + void builder_recentDays_throwsIllegalArgumentException_whenNotPositive() { + assertThatIllegalArgumentException().isThrownBy(() -> + StationAlertsQuery.builder(List.of("40380")).recentDays(0)); + } + + @Test + void builder_throwsNullPointerException_whenStationIdsIsNull() { + assertThatNullPointerException().isThrownBy(() -> StationAlertsQuery.builder(null)); + } + + @Test + void constructor_throwsNullPointerException_whenStationIdsIsNull() { + assertThatNullPointerException().isThrownBy(() -> + new StationAlertsQuery(null, false, true, true, null, null)); + } + + @Test + void constructor_throwsIllegalArgumentException_whenByStartDateAndRecentDaysBothSpecified() { + assertThatIllegalArgumentException().isThrownBy(() -> + new StationAlertsQuery(List.of("40380"), false, true, true, LocalDate.of(2026, 7, 1), 7)); + } + + @Test + void constructor_throwsIllegalArgumentException_whenRecentDaysIsNotPositive() { + assertThatIllegalArgumentException().isThrownBy(() -> + new StationAlertsQuery(List.of("40380"), false, true, true, null, -1)); + } +} diff --git a/src/test/java/com/cta4j/alert/routestatus/RouteStatusApiImplTest.java b/src/test/java/com/cta4j/alert/routestatus/RouteStatusApiImplTest.java new file mode 100644 index 00000000..cd3df713 --- /dev/null +++ b/src/test/java/com/cta4j/alert/routestatus/RouteStatusApiImplTest.java @@ -0,0 +1,454 @@ +package com.cta4j.alert.routestatus; + +import com.cta4j.TestFixtures; +import com.cta4j.alert.common.internal.config.AlertApiConfig; +import com.cta4j.alert.common.internal.util.AlertApiConstants; +import com.cta4j.alert.common.model.AlertTrainLine; +import com.cta4j.alert.common.model.ServiceType; +import com.cta4j.alert.routestatus.exception.Cta4jRouteStatusException; +import com.cta4j.alert.routestatus.exception.RouteStatusErrorCode; +import com.cta4j.alert.routestatus.internal.impl.RouteStatusApiImpl; +import com.cta4j.alert.routestatus.model.RouteStatus; +import com.github.tomakehurst.wiremock.WireMockServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import tools.jackson.core.JacksonException; + +import java.util.Arrays; +import java.util.List; + +import static com.github.tomakehurst.wiremock.client.WireMock.*; +import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig; +import static org.assertj.core.api.Assertions.*; + +class RouteStatusApiImplTest { + private WireMockServer server; + private RouteStatusApiImpl api; + + @BeforeEach + void setUp() { + this.server = new WireMockServer(wireMockConfig().dynamicPort()); + this.server.start(); + AlertApiConfig config = new AlertApiConfig("http", "localhost", this.server.port()); + this.api = new RouteStatusApiImpl(config); + } + + @AfterEach + void tearDown() { + this.server.stop(); + } + + @Test + void list_returnsRouteStatuses_whenResponseContainsData() { + this.server.stubFor(get(urlPathEqualTo("/api/1.0/routes.aspx")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(TestFixtures.read("alert/routestatus/list_success.json")))); + + List statuses = this.api.list(); + + assertThat(statuses).hasSize(3); + RouteStatus redLine = statuses.getFirst(); + assertThat(redLine.route()).isEqualTo("Red Line"); + assertThat(redLine.color()).isEqualTo("c60c30"); + assertThat(redLine.textColor()).isEqualTo("ffffff"); + assertThat(redLine.serviceId()).isEqualTo("Red"); + assertThat(redLine.url()).hasToString("http://www.transitchicago.com/redline/"); + assertThat(redLine.status()).isEqualTo("Normal Service"); + assertThat(redLine.statusColor()).isEqualTo("404040"); + } + + @Test + void list_returnsEmpty_whenErrorCodeIsExplicitlyEmptyArray() { + this.server.stubFor(get(urlPathEqualTo("/api/1.0/routes.aspx")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(TestFixtures.read("alert/routestatus/error_code_empty_array.json")))); + + List statuses = this.api.list(); + + assertThat(statuses).isEmpty(); + } + + @Test + void list_returnsEmpty_whenNoRouteInfoAndNoErrorCode() { + this.server.stubFor(get(urlPathEqualTo("/api/1.0/routes.aspx")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(TestFixtures.read("alert/routestatus/no_data_no_error.json")))); + + List statuses = this.api.list(); + + assertThat(statuses).isEmpty(); + } + + @Test + void list_returnsEmpty_whenRouteInfoIsExplicitlyEmptyArray() { + this.server.stubFor(get(urlPathEqualTo("/api/1.0/routes.aspx")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(TestFixtures.read("alert/routestatus/empty_route_info_array.json")))); + + List statuses = this.api.list(); + + assertThat(statuses).isEmpty(); + } + + @Test + void list_returnsEmpty_whenErrorCodeIsNoResults() { + this.server.stubFor(get(urlPathEqualTo("/api/1.0/routes.aspx")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(TestFixtures.read("alert/routestatus/no_results.json")))); + + List statuses = this.api.list(); + + assertThat(statuses).isEmpty(); + } + + @Test + void list_returnsEmpty_whenErrorCodesAreDistinct_usingFirstValue() { + this.server.stubFor(get(urlPathEqualTo("/api/1.0/routes.aspx")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(TestFixtures.read("alert/routestatus/distinct_error_codes.json")))); + + List statuses = this.api.list(); + + assertThat(statuses).isEmpty(); + } + + @Test + void list_throwsCta4jRouteStatusException_whenResponseContainsFatalError() { + this.server.stubFor(get(urlPathEqualTo("/api/1.0/routes.aspx")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(TestFixtures.read("alert/routestatus/invalid_type_error.json")))); + + assertThatThrownBy(() -> this.api.list()) + .isInstanceOf(Cta4jRouteStatusException.class) + .hasMessage("Invalid option for parameter 'type': Valid options are 'bus', 'rail', 'station' or 'systemwide'") + .satisfies(e -> assertThat(((Cta4jRouteStatusException) e).getErrorCode()) + .isEqualTo(RouteStatusErrorCode.INVALID_TYPE)) + .satisfies(e -> assertThat(((Cta4jRouteStatusException) e).getRawErrorCode()).isEqualTo(101)) + .satisfies(e -> assertThat(((Cta4jRouteStatusException) e).getEndpoint()) + .isEqualTo(AlertApiConstants.ROUTE_STATUS_ENDPOINT)); + } + + @Test + void list_throwsCta4jRouteStatusException_withDefaultMessage_whenErrorMessageIsExplicitlyEmptyArray() { + this.server.stubFor(get(urlPathEqualTo("/api/1.0/routes.aspx")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(TestFixtures.read("alert/routestatus/error_message_empty_array.json")))); + + assertThatThrownBy(() -> this.api.list()) + .isInstanceOf(Cta4jRouteStatusException.class) + .hasMessage("An unknown error occurred.") + .satisfies(e -> assertThat(((Cta4jRouteStatusException) e).getErrorCode()) + .isEqualTo(RouteStatusErrorCode.INVALID_TYPE)); + } + + @Test + void list_throwsCta4jRouteStatusException_withDefaultMessage_whenErrorMessageIsBlank() { + this.server.stubFor(get(urlPathEqualTo("/api/1.0/routes.aspx")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(TestFixtures.read("alert/routestatus/blank_error_message.json")))); + + assertThatThrownBy(() -> this.api.list()) + .isInstanceOf(Cta4jRouteStatusException.class) + .hasMessage("An unknown error occurred.") + .satisfies(e -> assertThat(((Cta4jRouteStatusException) e).getErrorCode()) + .isEqualTo(RouteStatusErrorCode.SERVER_ERROR)); + } + + @Test + void list_throwsCta4jRouteStatusException_withDefaultMessage_whenErrorMessageElementIsNull() { + this.server.stubFor(get(urlPathEqualTo("/api/1.0/routes.aspx")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(TestFixtures.read("alert/routestatus/error_null_message_element.json")))); + + assertThatThrownBy(() -> this.api.list()) + .isInstanceOf(Cta4jRouteStatusException.class) + .hasMessage("An unknown error occurred.") + .satisfies(e -> assertThat(((Cta4jRouteStatusException) e).getErrorCode()) + .isEqualTo(RouteStatusErrorCode.INVALID_TYPE)); + } + + @Test + void list_throwsCta4jRouteStatusException_withDefaultMessage_whenErrorMessageIsAbsent() { + this.server.stubFor(get(urlPathEqualTo("/api/1.0/routes.aspx")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(TestFixtures.read("alert/routestatus/error_no_message.json")))); + + assertThatThrownBy(() -> this.api.list()) + .isInstanceOf(Cta4jRouteStatusException.class) + .hasMessage("An unknown error occurred.") + .satisfies(e -> assertThat(((Cta4jRouteStatusException) e).getErrorCode()) + .isEqualTo(RouteStatusErrorCode.INVALID_TYPE)); + } + + @Test + void list_throwsCta4jRouteStatusException_whenErrorCodeIsNotNumeric() { + this.server.stubFor(get(urlPathEqualTo("/api/1.0/routes.aspx")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(TestFixtures.read("alert/routestatus/bad_error_code.json")))); + + assertThatThrownBy(() -> this.api.list()) + .isInstanceOf(Cta4jRouteStatusException.class) + .hasMessage("Failed to parse error code") + .satisfies(e -> assertThat(e.getCause()).isInstanceOf(NumberFormatException.class)); + } + + @Test + void list_throwsCta4jRouteStatusException_whenResponseIsNotJson() { + this.server.stubFor(get(urlPathEqualTo("/api/1.0/routes.aspx")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody("not-json"))); + + assertThatThrownBy(() -> this.api.list()) + .isInstanceOf(Cta4jRouteStatusException.class) + .hasMessage("Failed to parse response") + .satisfies(e -> assertThat(((Cta4jRouteStatusException) e).getEndpoint()) + .isEqualTo(AlertApiConstants.ROUTE_STATUS_ENDPOINT)) + .satisfies(e -> assertThat(e.getCause()).isInstanceOf(JacksonException.class)); + } + + @Test + void list_throwsCta4jRouteStatusException_whenServerReturnsErrorStatus() { + this.server.stubFor(get(urlPathEqualTo("/api/1.0/routes.aspx")) + .willReturn(aResponse() + .withStatus(500))); + + assertThatThrownBy(() -> this.api.list()) + .isInstanceOf(Cta4jRouteStatusException.class) + .hasMessageContaining("500") + .satisfies(e -> assertThat(e.getCause()).isNotNull()); + } + + @Test + void findByTypes_returnsEmpty_whenInputIsEmpty() { + List statuses = this.api.findByTypes(List.of()); + + assertThat(statuses).isEmpty(); + this.server.verify(0, anyRequestedFor(anyUrl())); + } + + @Test + void findByTypes_sendsTypeParameter_asCommaJoinedLowercase() { + this.server.stubFor(get(urlPathEqualTo("/api/1.0/routes.aspx")) + .withQueryParam("type", equalTo("bus,rail")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(TestFixtures.read("alert/routestatus/list_success.json")))); + + List statuses = this.api.findByTypes(List.of(ServiceType.BUS, ServiceType.RAIL)); + + assertThat(statuses).hasSize(3); + } + + @Test + void findByTypes_throwsNullPointerException_whenTypesContainsNull() { + List withNull = Arrays.asList(ServiceType.BUS, null); + + assertThatNullPointerException().isThrownBy(() -> this.api.findByTypes(withNull)); + } + + @Test + void findByType_delegatesToFindByTypes() { + this.server.stubFor(get(urlPathEqualTo("/api/1.0/routes.aspx")) + .withQueryParam("type", equalTo("rail")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(TestFixtures.read("alert/routestatus/rail_success.json")))); + + List statuses = this.api.findByType(ServiceType.RAIL); + + assertThat(statuses).hasSize(2); + } + + @Test + void findByBusRouteIds_returnsEmpty_whenInputIsEmpty() { + List statuses = this.api.findByBusRouteIds(List.of()); + + assertThat(statuses).isEmpty(); + this.server.verify(0, anyRequestedFor(anyUrl())); + } + + @Test + void findByBusRouteIds_sendsRouteidParameter_asCommaJoined() { + this.server.stubFor(get(urlPathEqualTo("/api/1.0/routes.aspx")) + .withQueryParam("routeid", equalTo("22,53")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(TestFixtures.read("alert/routestatus/bus_success.json")))); + + List statuses = this.api.findByBusRouteIds(List.of("22", "53")); + + assertThat(statuses).hasSize(1); + } + + @Test + void findByBusRouteIds_returnsRouteStatuses_whenResponseOmitsErrorEnvelope() { + this.server.stubFor(get(urlPathEqualTo("/api/1.0/routes.aspx")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(TestFixtures.read("alert/routestatus/bus_success.json")))); + + List statuses = this.api.findByBusRouteIds(List.of("22")); + + assertThat(statuses).hasSize(1); + RouteStatus status = statuses.getFirst(); + assertThat(status.serviceId()).isEqualTo("22"); + assertThat(status.status()).isEqualTo("Bus Stop Note"); + } + + @Test + void findByBusRouteIds_throwsNullPointerException_whenRouteIdsContainsNull() { + List withNull = Arrays.asList("22", null); + + assertThatNullPointerException().isThrownBy(() -> this.api.findByBusRouteIds(withNull)); + } + + @Test + void findByBusRouteIds_throwsIllegalArgumentException_whenRouteIdIsTrainLine() { + assertThatIllegalArgumentException() + .isThrownBy(() -> this.api.findByBusRouteIds(List.of("Red"))) + .withMessageContaining("Red is a train line, not a bus route"); + + this.server.verify(0, anyRequestedFor(anyUrl())); + } + + @Test + void findByBusRouteIds_throwsIllegalArgumentException_whenRouteIdIsTrainLine_caseInsensitive() { + assertThatIllegalArgumentException() + .isThrownBy(() -> this.api.findByBusRouteIds(List.of("red"))); + + this.server.verify(0, anyRequestedFor(anyUrl())); + } + + @Test + void findByBusRouteId_delegatesToFindByBusRouteIds() { + this.server.stubFor(get(urlPathEqualTo("/api/1.0/routes.aspx")) + .withQueryParam("routeid", equalTo("22")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(TestFixtures.read("alert/routestatus/bus_success.json")))); + + List statuses = this.api.findByBusRouteId("22"); + + assertThat(statuses).hasSize(1); + } + + @Test + void findByLines_returnsEmpty_whenInputIsEmpty() { + List statuses = this.api.findByLines(List.of()); + + assertThat(statuses).isEmpty(); + this.server.verify(0, anyRequestedFor(anyUrl())); + } + + @Test + void findByLines_throwsNullPointerException_whenLinesContainsNull() { + List withNull = Arrays.asList(AlertTrainLine.RED, null); + + assertThatNullPointerException().isThrownBy(() -> this.api.findByLines(withNull)); + } + + @Test + void findByLines_sendsRouteidParameter_asCommaJoinedCodes() { + this.server.stubFor(get(urlPathEqualTo("/api/1.0/routes.aspx")) + .withQueryParam("routeid", equalTo("Red,Blue")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(TestFixtures.read("alert/routestatus/rail_success.json")))); + + List statuses = this.api.findByLines(List.of(AlertTrainLine.RED, AlertTrainLine.BLUE)); + + assertThat(statuses).hasSize(2); + } + + @Test + void findByLines_returnsRouteStatuses_whenResponseContainsData() { + this.server.stubFor(get(urlPathEqualTo("/api/1.0/routes.aspx")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(TestFixtures.read("alert/routestatus/rail_success.json")))); + + List statuses = this.api.findByLines(List.of(AlertTrainLine.RED, AlertTrainLine.BLUE)); + + assertThat(statuses).hasSize(2); + RouteStatus redLine = statuses.getFirst(); + assertThat(redLine.serviceId()).isEqualTo("Red"); + assertThat(redLine.route()).isEqualTo("Red Line"); + assertThat(redLine.status()).isEqualTo("Normal Service"); + } + + @Test + void findByLine_delegatesToFindByLines() { + this.server.stubFor(get(urlPathEqualTo("/api/1.0/routes.aspx")) + .withQueryParam("routeid", equalTo("Red")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(TestFixtures.read("alert/routestatus/rail_success.json")))); + + List statuses = this.api.findByLine(AlertTrainLine.RED); + + assertThat(statuses).hasSize(2); + } + + @Test + void findByStationId_sendsStationidParameter() { + this.server.stubFor(get(urlPathEqualTo("/api/1.0/routes.aspx")) + .withQueryParam("stationid", equalTo("40380")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(TestFixtures.read("alert/routestatus/rail_success.json")))); + + List statuses = this.api.findByStationId("40380"); + + assertThat(statuses).hasSize(2); + } + + @Test + void findByStationId_returnsRouteStatuses_whenResponseContainsData() { + this.server.stubFor(get(urlPathEqualTo("/api/1.0/routes.aspx")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(TestFixtures.read("alert/routestatus/bus_success.json")))); + + List statuses = this.api.findByStationId("40380"); + + assertThat(statuses).hasSize(1); + assertThat(statuses.getFirst().serviceId()).isEqualTo("22"); + } +} diff --git a/src/test/java/com/cta4j/alert/routestatus/RouteStatusMapperTest.java b/src/test/java/com/cta4j/alert/routestatus/RouteStatusMapperTest.java new file mode 100644 index 00000000..6257dc83 --- /dev/null +++ b/src/test/java/com/cta4j/alert/routestatus/RouteStatusMapperTest.java @@ -0,0 +1,30 @@ +package com.cta4j.alert.routestatus; + +import com.cta4j.alert.common.internal.wire.CtaCdata; +import com.cta4j.alert.routestatus.internal.mapper.RouteStatusMapper; +import com.cta4j.alert.routestatus.internal.wire.CtaRouteInfo; +import com.cta4j.alert.routestatus.model.RouteStatus; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.*; + +class RouteStatusMapperTest { + @Test + void toDomain_mapsAllFields() { + CtaRouteInfo wire = new CtaRouteInfo( + "Clark", "565a5c", "ffffff", "22", + new CtaCdata("http://www.transitchicago.com/bus/22/"), + "Bus Stop Note", "000000" + ); + + RouteStatus status = RouteStatusMapper.INSTANCE.toDomain(wire); + + assertThat(status.route()).isEqualTo("Clark"); + assertThat(status.color()).isEqualTo("565a5c"); + assertThat(status.textColor()).isEqualTo("ffffff"); + assertThat(status.serviceId()).isEqualTo("22"); + assertThat(status.url()).hasToString("http://www.transitchicago.com/bus/22/"); + assertThat(status.status()).isEqualTo("Bus Stop Note"); + assertThat(status.statusColor()).isEqualTo("000000"); + } +} diff --git a/src/test/java/com/cta4j/alert/routestatus/exception/Cta4jRouteStatusExceptionTest.java b/src/test/java/com/cta4j/alert/routestatus/exception/Cta4jRouteStatusExceptionTest.java new file mode 100644 index 00000000..4edfd425 --- /dev/null +++ b/src/test/java/com/cta4j/alert/routestatus/exception/Cta4jRouteStatusExceptionTest.java @@ -0,0 +1,41 @@ +package com.cta4j.alert.routestatus.exception; + +import com.cta4j.alert.common.internal.util.AlertApiConstants; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.*; + +class Cta4jRouteStatusExceptionTest { + @Test + void constructor_setsMessageEndpointAndCause_andLeavesErrorCodeNull() { + Throwable cause = new RuntimeException("root cause"); + + Cta4jRouteStatusException exception = new Cta4jRouteStatusException("Failed to parse response", cause); + + assertThat(exception.getMessage()).isEqualTo("Failed to parse response"); + assertThat(exception.getEndpoint()).isEqualTo(AlertApiConstants.ROUTE_STATUS_ENDPOINT); + assertThat(exception.getCause()).isSameAs(cause); + assertThat(exception.getRawErrorCode()).isNull(); + assertThat(exception.getErrorCode()).isNull(); + } + + @Test + void constructor_setsMessageEndpointAndErrorCode() { + Cta4jRouteStatusException exception = + new Cta4jRouteStatusException("Invalid option for parameter 'type'", 101); + + assertThat(exception.getMessage()).isEqualTo("Invalid option for parameter 'type'"); + assertThat(exception.getEndpoint()).isEqualTo(AlertApiConstants.ROUTE_STATUS_ENDPOINT); + assertThat(exception.getCause()).isNull(); + assertThat(exception.getRawErrorCode()).isEqualTo(101); + assertThat(exception.getErrorCode()).isEqualTo(RouteStatusErrorCode.INVALID_TYPE); + } + + @Test + void constructor_setsNullErrorCode_whenRawErrorCodeIsUnrecognized() { + Cta4jRouteStatusException exception = new Cta4jRouteStatusException("Something odd happened", 999); + + assertThat(exception.getRawErrorCode()).isEqualTo(999); + assertThat(exception.getErrorCode()).isNull(); + } +} diff --git a/src/test/java/com/cta4j/alert/routestatus/exception/RouteStatusErrorCodeTest.java b/src/test/java/com/cta4j/alert/routestatus/exception/RouteStatusErrorCodeTest.java new file mode 100644 index 00000000..be20ccbc --- /dev/null +++ b/src/test/java/com/cta4j/alert/routestatus/exception/RouteStatusErrorCodeTest.java @@ -0,0 +1,24 @@ +package com.cta4j.alert.routestatus.exception; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.*; + +class RouteStatusErrorCodeTest { + @Test + void fromCode_returnsCorrectValue_forEveryDefinedCode() { + for (RouteStatusErrorCode code : RouteStatusErrorCode.values()) { + assertThat(RouteStatusErrorCode.fromCode(code.getCode())).isEqualTo(code); + } + } + + @Test + void fromCode_returnsNull_whenCodeIsUnrecognized() { + assertThat(RouteStatusErrorCode.fromCode(12345)).isNull(); + } + + @Test + void getCode_returnsCode() { + assertThat(RouteStatusErrorCode.NO_RESULTS.getCode()).isEqualTo(50); + } +} diff --git a/src/test/java/com/cta4j/alert/routestatus/internal/wire/CtaRoutesTest.java b/src/test/java/com/cta4j/alert/routestatus/internal/wire/CtaRoutesTest.java new file mode 100644 index 00000000..94aa780d --- /dev/null +++ b/src/test/java/com/cta4j/alert/routestatus/internal/wire/CtaRoutesTest.java @@ -0,0 +1,44 @@ +package com.cta4j.alert.routestatus.internal.wire; + +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import static org.assertj.core.api.Assertions.*; + +class CtaRoutesTest { + @Test + void constructor_copiesErrorCode_whenNonNull() { + List errorCode = new ArrayList<>(List.of("0", "0")); + + CtaRoutes routes = new CtaRoutes("2026-07-17T14:06:58", errorCode, null, null); + errorCode.add("50"); + + assertThat(routes.errorCode()).containsExactly("0", "0"); + } + + @Test + void constructor_copiesErrorMessage_whenNonNull_andAllowsNullElements() { + List errorMessage = new ArrayList<>(Arrays.asList(null, null)); + + CtaRoutes routes = new CtaRoutes("2026-07-17T14:06:58", List.of("0", "0"), errorMessage, null); + errorMessage.add("late addition"); + + assertThat(routes.errorMessage()).containsExactly(null, null); + } + + @Test + void constructor_allowsNullErrorCodeAndErrorMessage() { + CtaRoutes routes = new CtaRoutes("2026-07-17T14:06:58", null, null, null); + + assertThat(routes.errorCode()).isNull(); + assertThat(routes.errorMessage()).isNull(); + } + + @Test + void constructor_throwsNullPointerException_whenTimestampIsNull() { + assertThatNullPointerException().isThrownBy(() -> new CtaRoutes(null, null, null, null)); + } +} diff --git a/src/test/java/com/cta4j/bus/BusApiTest.java b/src/test/java/com/cta4j/bus/BusApiTest.java index 267430a0..a5f78985 100644 --- a/src/test/java/com/cta4j/bus/BusApiTest.java +++ b/src/test/java/com/cta4j/bus/BusApiTest.java @@ -17,4 +17,4 @@ void builder_returnsWorkingInstance() { assertThat(api).isNotNull(); assertThat(api.vehicles()).isNotNull(); } -} \ No newline at end of file +} diff --git a/src/test/java/com/cta4j/bus/common/exception/Cta4jBusExceptionTest.java b/src/test/java/com/cta4j/bus/common/exception/Cta4jBusExceptionTest.java index acc2eec7..58355419 100644 --- a/src/test/java/com/cta4j/bus/common/exception/Cta4jBusExceptionTest.java +++ b/src/test/java/com/cta4j/bus/common/exception/Cta4jBusExceptionTest.java @@ -61,4 +61,4 @@ void constructor_setsMessageAndEndpoint_withNoErrorsOrCause() { assertThat(exception.getEndpoint()).isEqualTo("/bustime/api/v3/getstops"); assertThat(exception.getCause()).isNull(); } -} \ No newline at end of file +} diff --git a/src/test/java/com/cta4j/bus/common/internal/impl/SystemTimeApiImplTest.java b/src/test/java/com/cta4j/bus/common/internal/impl/SystemTimeApiImplTest.java index de47ddd4..6432ed67 100644 --- a/src/test/java/com/cta4j/bus/common/internal/impl/SystemTimeApiImplTest.java +++ b/src/test/java/com/cta4j/bus/common/internal/impl/SystemTimeApiImplTest.java @@ -76,6 +76,21 @@ void systemTime_throwsCta4jBusException_whenResponseHasNoTimeAndNoErrors() { .isEqualTo(BusApiConstants.SYSTEM_TIME_ENDPOINT)); } + @Test + void systemTime_throwsCta4jBusException_whenErrorsIsExplicitlyEmptyArray() { + this.server.stubFor(get(urlPathEqualTo("/bustime/api/v3/gettime")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(TestFixtures.read("bus/time/empty-error-array.json")))); + + assertThatThrownBy(() -> this.api.systemTime()) + .isInstanceOf(Cta4jBusException.class) + .hasMessage("No system time data returned") + .satisfies(e -> assertThat(((Cta4jBusException) e).getEndpoint()) + .isEqualTo(BusApiConstants.SYSTEM_TIME_ENDPOINT)); + } + @Test void systemTime_throwsCta4jBusException_whenResponseIsNotJson() { this.server.stubFor(get(urlPathEqualTo("/bustime/api/v3/gettime")) @@ -105,4 +120,4 @@ void systemTime_throwsCta4jBusException_whenServerReturnsErrorStatus() { .isEqualTo(BusApiConstants.SYSTEM_TIME_ENDPOINT)) .satisfies(e -> assertThat(e.getCause()).isNotNull()); } -} \ No newline at end of file +} diff --git a/src/test/java/com/cta4j/bus/common/internal/util/ApiUtilsTest.java b/src/test/java/com/cta4j/bus/common/internal/util/BusApiUtilsTest.java similarity index 67% rename from src/test/java/com/cta4j/bus/common/internal/util/ApiUtilsTest.java rename to src/test/java/com/cta4j/bus/common/internal/util/BusApiUtilsTest.java index 78728f13..92b314d7 100644 --- a/src/test/java/com/cta4j/bus/common/internal/util/ApiUtilsTest.java +++ b/src/test/java/com/cta4j/bus/common/internal/util/BusApiUtilsTest.java @@ -8,7 +8,7 @@ import static org.assertj.core.api.Assertions.*; -class ApiUtilsTest { +class BusApiUtilsTest { private record TestError(String msg, boolean notFound) implements CtaError { } @@ -16,49 +16,49 @@ private record TestError(String msg, boolean notFound) implements CtaError { void requireMaxIds_doesNotThrow_whenIdsIsAtMax() { List ids = List.of("1", "2", "3", "4", "5", "6", "7", "8", "9", "10"); - assertThatCode(() -> ApiUtils.requireMaxIds(ids, "stop")).doesNotThrowAnyException(); + assertThatCode(() -> BusApiUtils.requireMaxIds(ids, "stop")).doesNotThrowAnyException(); } @Test void requireMaxIds_throwsIllegalArgumentException_whenIdsExceedsMax() { List ids = List.of("1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11"); - assertThatIllegalArgumentException().isThrownBy(() -> ApiUtils.requireMaxIds(ids, "stop")) + assertThatIllegalArgumentException().isThrownBy(() -> BusApiUtils.requireMaxIds(ids, "stop")) .withMessage("A maximum of 10 stop IDs can be requested at once, but 11 were provided"); } @Test void requireMaxIds_throwsNullPointerException_whenIdsIsNull() { - assertThatNullPointerException().isThrownBy(() -> ApiUtils.requireMaxIds(null, "stop")); + assertThatNullPointerException().isThrownBy(() -> BusApiUtils.requireMaxIds(null, "stop")); } @Test void requireMaxIds_throwsNullPointerException_whenLabelIsNull() { - assertThatNullPointerException().isThrownBy(() -> ApiUtils.requireMaxIds(List.of(), null)); + assertThatNullPointerException().isThrownBy(() -> BusApiUtils.requireMaxIds(List.of(), null)); } @Test void checkErrors_doesNotThrow_whenErrorsIsNull() { - assertThatCode(() -> ApiUtils.checkErrors(null, "/test")).doesNotThrowAnyException(); + assertThatCode(() -> BusApiUtils.checkErrors(null, "/test")).doesNotThrowAnyException(); } @Test void checkErrors_doesNotThrow_whenErrorsIsEmpty() { - assertThatCode(() -> ApiUtils.checkErrors(List.of(), "/test")).doesNotThrowAnyException(); + assertThatCode(() -> BusApiUtils.checkErrors(List.of(), "/test")).doesNotThrowAnyException(); } @Test void checkErrors_doesNotThrow_whenAllErrorsAreNotFound() { List errors = List.of(new TestError("not found", true)); - assertThatCode(() -> ApiUtils.checkErrors(errors, "/test")).doesNotThrowAnyException(); + assertThatCode(() -> BusApiUtils.checkErrors(errors, "/test")).doesNotThrowAnyException(); } @Test void checkErrors_throwsCta4jBusException_whenAnyErrorIsNotResourceSpecific() { List errors = List.of(new TestError("fatal error", false)); - assertThatThrownBy(() -> ApiUtils.checkErrors(errors, "/test")) + assertThatThrownBy(() -> BusApiUtils.checkErrors(errors, "/test")) .isInstanceOf(Cta4jBusException.class) .hasMessage("fatal error") .satisfies(e -> assertThat(((Cta4jBusException) e).getEndpoint()).isEqualTo("/test")); @@ -66,6 +66,6 @@ void checkErrors_throwsCta4jBusException_whenAnyErrorIsNotResourceSpecific() { @Test void checkErrors_throwsNullPointerException_whenEndpointIsNull() { - assertThatNullPointerException().isThrownBy(() -> ApiUtils.checkErrors(List.of(), null)); + assertThatNullPointerException().isThrownBy(() -> BusApiUtils.checkErrors(List.of(), null)); } -} \ No newline at end of file +} diff --git a/src/test/java/com/cta4j/bus/detour/DetoursApiImplTest.java b/src/test/java/com/cta4j/bus/detour/DetoursApiImplTest.java index e46c498b..29b2cf95 100644 --- a/src/test/java/com/cta4j/bus/detour/DetoursApiImplTest.java +++ b/src/test/java/com/cta4j/bus/detour/DetoursApiImplTest.java @@ -66,6 +66,19 @@ void list_returnsEmpty_whenResponseHasNoDataAndNoErrors() { assertThat(detours).isEmpty(); } + @Test + void list_returnsEmpty_whenDtrsIsExplicitlyEmptyArray() { + this.server.stubFor(get(urlPathEqualTo("/bustime/api/v3/getdetours")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(TestFixtures.read("bus/detour/empty_dtrs_array.json")))); + + List detours = this.api.list(); + + assertThat(detours).isEmpty(); + } + @Test void list_throwsCta4jBusException_whenResponseContainsFatalErrors() { this.server.stubFor(get(urlPathEqualTo("/bustime/api/v3/getdetours")) diff --git a/src/test/java/com/cta4j/bus/detour/internal/wire/CtaDetourErrorTest.java b/src/test/java/com/cta4j/bus/detour/internal/wire/CtaDetourErrorTest.java index 0902ac6f..24856478 100644 --- a/src/test/java/com/cta4j/bus/detour/internal/wire/CtaDetourErrorTest.java +++ b/src/test/java/com/cta4j/bus/detour/internal/wire/CtaDetourErrorTest.java @@ -18,4 +18,4 @@ void notFound_returnsFalse_whenRtAbsent() { assertThat(error.notFound()).isFalse(); } -} \ No newline at end of file +} diff --git a/src/test/java/com/cta4j/bus/direction/DirectionsApiImplTest.java b/src/test/java/com/cta4j/bus/direction/DirectionsApiImplTest.java index de5c6057..86a754c8 100644 --- a/src/test/java/com/cta4j/bus/direction/DirectionsApiImplTest.java +++ b/src/test/java/com/cta4j/bus/direction/DirectionsApiImplTest.java @@ -60,6 +60,19 @@ void findByRouteId_returnsEmpty_whenResponseHasNoDataAndNoErrors() { assertThat(directions).isEmpty(); } + @Test + void findByRouteId_returnsEmpty_whenDirectionsIsExplicitlyEmptyArray() { + this.server.stubFor(get(urlPathEqualTo("/bustime/api/v3/getdirections")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(TestFixtures.read("bus/direction/empty_directions_array.json")))); + + List directions = this.api.findByRouteId("22"); + + assertThat(directions).isEmpty(); + } + @Test void findByRouteId_returnsEmpty_whenAllErrorsAreResourceSpecific() { this.server.stubFor(get(urlPathEqualTo("/bustime/api/v3/getdirections")) diff --git a/src/test/java/com/cta4j/bus/direction/internal/wire/CtaDirectionErrorTest.java b/src/test/java/com/cta4j/bus/direction/internal/wire/CtaDirectionErrorTest.java index 8130b4b8..0d77b484 100644 --- a/src/test/java/com/cta4j/bus/direction/internal/wire/CtaDirectionErrorTest.java +++ b/src/test/java/com/cta4j/bus/direction/internal/wire/CtaDirectionErrorTest.java @@ -18,4 +18,4 @@ void notFound_returnsFalse_whenRtAbsent() { assertThat(error.notFound()).isFalse(); } -} \ No newline at end of file +} diff --git a/src/test/java/com/cta4j/bus/locale/LocalesApiImplTest.java b/src/test/java/com/cta4j/bus/locale/LocalesApiImplTest.java index 9f7803c8..9a7c09f4 100644 --- a/src/test/java/com/cta4j/bus/locale/LocalesApiImplTest.java +++ b/src/test/java/com/cta4j/bus/locale/LocalesApiImplTest.java @@ -65,6 +65,19 @@ void list_returnsEmpty_whenResponseHasNoDataAndNoErrors() { assertThat(locales).isEmpty(); } + @Test + void list_returnsEmpty_whenLocaleIsExplicitlyEmptyArray() { + this.server.stubFor(get(urlPathEqualTo("/bustime/api/v3/getlocalelist")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(TestFixtures.read("bus/locale/empty_locale_array.json")))); + + List locales = this.api.list(); + + assertThat(locales).isEmpty(); + } + @Test void list_throwsCta4jBusException_whenResponseContainsFatalErrors() { this.server.stubFor(get(urlPathEqualTo("/bustime/api/v3/getlocalelist")) diff --git a/src/test/java/com/cta4j/bus/pattern/PatternsApiImplTest.java b/src/test/java/com/cta4j/bus/pattern/PatternsApiImplTest.java index 15450ec4..f50ff7b8 100644 --- a/src/test/java/com/cta4j/bus/pattern/PatternsApiImplTest.java +++ b/src/test/java/com/cta4j/bus/pattern/PatternsApiImplTest.java @@ -75,6 +75,19 @@ void findByIds_returnsEmpty_whenResponseHasNoDataAndNoErrors() { assertThat(patterns).isEmpty(); } + @Test + void findByIds_returnsEmpty_whenPtrIsExplicitlyEmptyArray() { + this.server.stubFor(get(urlPathEqualTo("/bustime/api/v3/getpatterns")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(TestFixtures.read("bus/pattern/empty_ptr_array.json")))); + + List patterns = this.api.findByIds(List.of("3630")); + + assertThat(patterns).isEmpty(); + } + @Test void findByIds_returnsEmpty_whenAllErrorsAreResourceSpecific() { this.server.stubFor(get(urlPathEqualTo("/bustime/api/v3/getpatterns")) diff --git a/src/test/java/com/cta4j/bus/pattern/internal/wire/CtaPatternErrorTest.java b/src/test/java/com/cta4j/bus/pattern/internal/wire/CtaPatternErrorTest.java index 2fef2ce6..3fa9094d 100644 --- a/src/test/java/com/cta4j/bus/pattern/internal/wire/CtaPatternErrorTest.java +++ b/src/test/java/com/cta4j/bus/pattern/internal/wire/CtaPatternErrorTest.java @@ -25,4 +25,4 @@ void notFound_returnsFalse_whenNeitherPidNorRtPresent() { assertThat(error.notFound()).isFalse(); } -} \ No newline at end of file +} diff --git a/src/test/java/com/cta4j/bus/pattern/internal/wire/CtaPatternTest.java b/src/test/java/com/cta4j/bus/pattern/internal/wire/CtaPatternTest.java index a47e5c34..1de9205d 100644 --- a/src/test/java/com/cta4j/bus/pattern/internal/wire/CtaPatternTest.java +++ b/src/test/java/com/cta4j/bus/pattern/internal/wire/CtaPatternTest.java @@ -18,4 +18,4 @@ void constructor_copiesDetourPoints_whenNonNull() { assertThat(pattern.dtrpt()).containsExactly(point); } -} \ No newline at end of file +} diff --git a/src/test/java/com/cta4j/bus/pattern/model/RoutePatternTest.java b/src/test/java/com/cta4j/bus/pattern/model/RoutePatternTest.java index 02687749..e23be6b4 100644 --- a/src/test/java/com/cta4j/bus/pattern/model/RoutePatternTest.java +++ b/src/test/java/com/cta4j/bus/pattern/model/RoutePatternTest.java @@ -22,4 +22,4 @@ void constructor_copiesDetourPoints_whenNonNull() { assertThat(pattern.detourPoints()).containsExactly(point); } -} \ No newline at end of file +} diff --git a/src/test/java/com/cta4j/bus/prediction/PredictionsApiImplTest.java b/src/test/java/com/cta4j/bus/prediction/PredictionsApiImplTest.java index 0de8407d..ea2cfac6 100644 --- a/src/test/java/com/cta4j/bus/prediction/PredictionsApiImplTest.java +++ b/src/test/java/com/cta4j/bus/prediction/PredictionsApiImplTest.java @@ -6,14 +6,15 @@ import com.cta4j.bus.common.internal.util.BusApiConstants; import com.cta4j.bus.prediction.internal.impl.PredictionsApiImpl; 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.prediction.query.StopPredictionsQuery; +import com.cta4j.bus.prediction.query.VehiclePredictionsQuery; import com.github.tomakehurst.wiremock.WireMockServer; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import tools.jackson.core.JacksonException; +import java.util.Arrays; import java.util.List; import static com.github.tomakehurst.wiremock.client.WireMock.*; @@ -45,7 +46,7 @@ void findByStopIds_returnsPredictions_whenResponseContainsPredictions() { .withHeader("Content-Type", "application/json") .withBody(TestFixtures.read("bus/prediction/success.json")))); - StopsPredictionsQuery query = StopsPredictionsQuery.builder(List.of("456")).build(); + StopPredictionsQuery query = StopPredictionsQuery.builder(List.of("456")).build(); List predictions = this.api.findByStopIds(query); assertThat(predictions).hasSize(1); @@ -57,7 +58,7 @@ void findByStopIds_returnsPredictions_whenResponseContainsPredictions() { @Test void findByStopIds_returnsEmpty_whenInputIsEmpty() { - StopsPredictionsQuery query = StopsPredictionsQuery.builder(List.of()).build(); + StopPredictionsQuery query = StopPredictionsQuery.builder(List.of()).build(); List predictions = this.api.findByStopIds(query); assertThat(predictions).isEmpty(); @@ -72,7 +73,21 @@ void findByStopIds_returnsEmpty_whenResponseHasNoDataAndNoErrors() { .withHeader("Content-Type", "application/json") .withBody(TestFixtures.read("bus/prediction/empty.json")))); - StopsPredictionsQuery query = StopsPredictionsQuery.builder(List.of("456")).build(); + StopPredictionsQuery query = StopPredictionsQuery.builder(List.of("456")).build(); + List predictions = this.api.findByStopIds(query); + + assertThat(predictions).isEmpty(); + } + + @Test + void findByStopIds_returnsEmpty_whenPrdIsExplicitlyEmptyArray() { + this.server.stubFor(get(urlPathEqualTo("/bustime/api/v3/getpredictions")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(TestFixtures.read("bus/prediction/empty_prd_array.json")))); + + StopPredictionsQuery query = StopPredictionsQuery.builder(List.of("456")).build(); List predictions = this.api.findByStopIds(query); assertThat(predictions).isEmpty(); @@ -86,7 +101,7 @@ void findByStopIds_returnsEmpty_whenAllErrorsAreResourceSpecific() { .withHeader("Content-Type", "application/json") .withBody(TestFixtures.read("bus/prediction/not_found.json")))); - StopsPredictionsQuery query = StopsPredictionsQuery.builder(List.of("99999")).build(); + StopPredictionsQuery query = StopPredictionsQuery.builder(List.of("99999")).build(); List predictions = this.api.findByStopIds(query); assertThat(predictions).isEmpty(); @@ -100,7 +115,7 @@ void findByStopIds_throwsCta4jBusException_whenResponseContainsFatalErrors() { .withHeader("Content-Type", "application/json") .withBody(TestFixtures.read("bus/prediction/error.json")))); - StopsPredictionsQuery query = StopsPredictionsQuery.builder(List.of("456")).build(); + StopPredictionsQuery query = StopPredictionsQuery.builder(List.of("456")).build(); assertThatThrownBy(() -> this.api.findByStopIds(query)) .isInstanceOf(Cta4jBusException.class) @@ -117,7 +132,7 @@ void findByStopIds_throwsCta4jBusException_whenResponseIsNotJson() { .withHeader("Content-Type", "application/json") .withBody("not-json"))); - StopsPredictionsQuery query = StopsPredictionsQuery.builder(List.of("456")).build(); + StopPredictionsQuery query = StopPredictionsQuery.builder(List.of("456")).build(); assertThatThrownBy(() -> this.api.findByStopIds(query)) .isInstanceOf(Cta4jBusException.class) @@ -133,7 +148,7 @@ void findByStopIds_throwsCta4jBusException_whenServerReturnsErrorStatus() { .willReturn(aResponse() .withStatus(500))); - StopsPredictionsQuery query = StopsPredictionsQuery.builder(List.of("456")).build(); + StopPredictionsQuery query = StopPredictionsQuery.builder(List.of("456")).build(); assertThatThrownBy(() -> this.api.findByStopIds(query)) .isInstanceOf(Cta4jBusException.class) @@ -145,7 +160,7 @@ void findByStopIds_throwsCta4jBusException_whenServerReturnsErrorStatus() { @Test void findByVehicleIds_returnsEmpty_whenInputIsEmpty() { - VehiclesPredictionsQuery query = VehiclesPredictionsQuery.builder(List.of()).build(); + VehiclePredictionsQuery query = VehiclePredictionsQuery.builder(List.of()).build(); List predictions = this.api.findByVehicleIds(query); assertThat(predictions).isEmpty(); @@ -161,7 +176,7 @@ void findByVehicleIds_sendsVidParameter() { .withHeader("Content-Type", "application/json") .withBody(TestFixtures.read("bus/prediction/success.json")))); - VehiclesPredictionsQuery query = VehiclesPredictionsQuery.builder(List.of("509")).build(); + VehiclePredictionsQuery query = VehiclePredictionsQuery.builder(List.of("509")).build(); List predictions = this.api.findByVehicleIds(query); assertThat(predictions).hasSize(1); @@ -176,9 +191,9 @@ void findByStopIds_sendsRtParameter_whenRouteIdsProvided() { .withHeader("Content-Type", "application/json") .withBody(TestFixtures.read("bus/prediction/success.json")))); - StopsPredictionsQuery query = StopsPredictionsQuery.builder(List.of("456")) - .routeIds(List.of("8")) - .build(); + StopPredictionsQuery query = StopPredictionsQuery.builder(List.of("456")) + .routeIds(List.of("8")) + .build(); List predictions = this.api.findByStopIds(query); assertThat(predictions).hasSize(1); @@ -193,9 +208,9 @@ void findByStopIds_sendsTopParameter_whenMaxResultsProvided() { .withHeader("Content-Type", "application/json") .withBody(TestFixtures.read("bus/prediction/success.json")))); - StopsPredictionsQuery query = StopsPredictionsQuery.builder(List.of("456")) - .maxResults(5) - .build(); + StopPredictionsQuery query = StopPredictionsQuery.builder(List.of("456")) + .maxResults(5) + .build(); List predictions = this.api.findByStopIds(query); assertThat(predictions).hasSize(1); @@ -253,11 +268,53 @@ void findByVehicleIds_sendsTopParameter_whenMaxResultsProvided() { .withHeader("Content-Type", "application/json") .withBody(TestFixtures.read("bus/prediction/success.json")))); - VehiclesPredictionsQuery query = VehiclesPredictionsQuery.builder(List.of("509")) - .maxResults(3) - .build(); + VehiclePredictionsQuery query = VehiclePredictionsQuery.builder(List.of("509")) + .maxResults(3) + .build(); List predictions = this.api.findByVehicleIds(query); assertThat(predictions).hasSize(1); } + + @Test + void findByStopIds_collectionOverload_returnsPredictions_whenResponseContainsPredictions() { + this.server.stubFor(get(urlPathEqualTo("/bustime/api/v3/getpredictions")) + .withQueryParam("stpid", equalTo("456,789")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(TestFixtures.read("bus/prediction/success.json")))); + + List predictions = this.api.findByStopIds(List.of("456", "789")); + + assertThat(predictions).hasSize(1); + } + + @Test + void findByVehicleIds_collectionOverload_returnsPredictions_whenResponseContainsPredictions() { + this.server.stubFor(get(urlPathEqualTo("/bustime/api/v3/getpredictions")) + .withQueryParam("vid", equalTo("509,510")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(TestFixtures.read("bus/prediction/success.json")))); + + List predictions = this.api.findByVehicleIds(List.of("509", "510")); + + assertThat(predictions).hasSize(1); + } + + @Test + void findByStopIds_collectionOverload_throwsNullPointerException_whenStopIdsContainsNull() { + List withNull = Arrays.asList("456", null); + + assertThatNullPointerException().isThrownBy(() -> this.api.findByStopIds(withNull)); + } + + @Test + void findByVehicleIds_collectionOverload_throwsNullPointerException_whenVehicleIdsContainsNull() { + List withNull = Arrays.asList("509", null); + + assertThatNullPointerException().isThrownBy(() -> this.api.findByVehicleIds(withNull)); + } } diff --git a/src/test/java/com/cta4j/bus/prediction/internal/wire/CtaPredictionErrorTest.java b/src/test/java/com/cta4j/bus/prediction/internal/wire/CtaPredictionErrorTest.java index 28326b30..2ed1eba7 100644 --- a/src/test/java/com/cta4j/bus/prediction/internal/wire/CtaPredictionErrorTest.java +++ b/src/test/java/com/cta4j/bus/prediction/internal/wire/CtaPredictionErrorTest.java @@ -25,4 +25,4 @@ void notFound_returnsFalse_whenNeitherStpidNorVidPresent() { assertThat(error.notFound()).isFalse(); } -} \ No newline at end of file +} diff --git a/src/test/java/com/cta4j/bus/prediction/model/DynamicActionTest.java b/src/test/java/com/cta4j/bus/prediction/model/DynamicActionTest.java index 7da01f3d..753093d6 100644 --- a/src/test/java/com/cta4j/bus/prediction/model/DynamicActionTest.java +++ b/src/test/java/com/cta4j/bus/prediction/model/DynamicActionTest.java @@ -36,4 +36,4 @@ void getCode_returnsCode() { assertThat(DynamicAction.NONE.getCode()).isEqualTo(0); assertThat(DynamicAction.CANCELLED.getCode()).isEqualTo(1); } -} \ No newline at end of file +} diff --git a/src/test/java/com/cta4j/bus/prediction/model/FlagStopTest.java b/src/test/java/com/cta4j/bus/prediction/model/FlagStopTest.java index 512f88ec..d4fadc57 100644 --- a/src/test/java/com/cta4j/bus/prediction/model/FlagStopTest.java +++ b/src/test/java/com/cta4j/bus/prediction/model/FlagStopTest.java @@ -23,4 +23,4 @@ void getCode_returnsCode() { assertThat(FlagStop.UNDEFINED.getCode()).isEqualTo(-1); assertThat(FlagStop.NORMAL.getCode()).isEqualTo(0); } -} \ No newline at end of file +} diff --git a/src/test/java/com/cta4j/bus/prediction/model/PredictionTest.java b/src/test/java/com/cta4j/bus/prediction/model/PredictionTest.java index 41d58965..ce970117 100644 --- a/src/test/java/com/cta4j/bus/prediction/model/PredictionTest.java +++ b/src/test/java/com/cta4j/bus/prediction/model/PredictionTest.java @@ -38,4 +38,4 @@ void etaMinutes_returnsZero_whenArrivalTimeIsInPast() { assertThat(eta).isZero(); } -} \ No newline at end of file +} diff --git a/src/test/java/com/cta4j/bus/prediction/query/StopsPredictionsQueryTest.java b/src/test/java/com/cta4j/bus/prediction/query/StopPredictionsQueryTest.java similarity index 65% rename from src/test/java/com/cta4j/bus/prediction/query/StopsPredictionsQueryTest.java rename to src/test/java/com/cta4j/bus/prediction/query/StopPredictionsQueryTest.java index 8964ad06..0f555758 100644 --- a/src/test/java/com/cta4j/bus/prediction/query/StopsPredictionsQueryTest.java +++ b/src/test/java/com/cta4j/bus/prediction/query/StopPredictionsQueryTest.java @@ -7,13 +7,13 @@ import static org.assertj.core.api.Assertions.*; -class StopsPredictionsQueryTest { +class StopPredictionsQueryTest { @Test void builder_buildsQueryWithOptionalParams() { - StopsPredictionsQuery query = StopsPredictionsQuery.builder(List.of("1001")) - .routeIds(List.of("22", "36")) - .maxResults(5) - .build(); + StopPredictionsQuery query = StopPredictionsQuery.builder(List.of("1001")) + .routeIds(List.of("22", "36")) + .maxResults(5) + .build(); assertThat(query.stopIds()).containsExactly("1001"); assertThat(query.routeIds()).containsExactly("22", "36"); @@ -22,7 +22,7 @@ void builder_buildsQueryWithOptionalParams() { @Test void builder_buildsQueryWithNoOptionalParams() { - StopsPredictionsQuery query = StopsPredictionsQuery.builder(List.of("1001")).build(); + StopPredictionsQuery query = StopPredictionsQuery.builder(List.of("1001")).build(); assertThat(query.stopIds()).containsExactly("1001"); assertThat(query.routeIds()).isNull(); @@ -32,13 +32,13 @@ void builder_buildsQueryWithNoOptionalParams() { @Test void builder_throwsIllegalArgumentException_whenMaxResultsIsZero() { assertThatIllegalArgumentException().isThrownBy(() -> - StopsPredictionsQuery.builder(List.of("1001")).maxResults(0)); + StopPredictionsQuery.builder(List.of("1001")).maxResults(0)); } @Test void builder_throwsIllegalArgumentException_whenMaxResultsIsNegative() { assertThatIllegalArgumentException().isThrownBy(() -> - StopsPredictionsQuery.builder(List.of("1001")).maxResults(-1)); + StopPredictionsQuery.builder(List.of("1001")).maxResults(-1)); } @Test @@ -46,12 +46,12 @@ void constructor_throwsIllegalArgumentException_whenTooManyStopIds() { List ids = Collections.nCopies(11, "1001"); assertThatIllegalArgumentException().isThrownBy(() -> - new StopsPredictionsQuery(ids, null, null)); + new StopPredictionsQuery(ids, null, null)); } @Test void constructor_throwsIllegalArgumentException_whenMaxResultsIsNotPositive() { assertThatIllegalArgumentException().isThrownBy(() -> - new StopsPredictionsQuery(List.of("1001"), null, 0)); + new StopPredictionsQuery(List.of("1001"), null, 0)); } -} \ No newline at end of file +} diff --git a/src/test/java/com/cta4j/bus/prediction/query/VehiclesPredictionsQueryTest.java b/src/test/java/com/cta4j/bus/prediction/query/VehiclePredictionsQueryTest.java similarity index 65% rename from src/test/java/com/cta4j/bus/prediction/query/VehiclesPredictionsQueryTest.java rename to src/test/java/com/cta4j/bus/prediction/query/VehiclePredictionsQueryTest.java index a72f78f1..bf3ea790 100644 --- a/src/test/java/com/cta4j/bus/prediction/query/VehiclesPredictionsQueryTest.java +++ b/src/test/java/com/cta4j/bus/prediction/query/VehiclePredictionsQueryTest.java @@ -7,12 +7,12 @@ import static org.assertj.core.api.Assertions.*; -class VehiclesPredictionsQueryTest { +class VehiclePredictionsQueryTest { @Test void builder_buildsQueryWithMaxResults() { - VehiclesPredictionsQuery query = VehiclesPredictionsQuery.builder(List.of("509")) - .maxResults(3) - .build(); + VehiclePredictionsQuery query = VehiclePredictionsQuery.builder(List.of("509")) + .maxResults(3) + .build(); assertThat(query.vehicleIds()).containsExactly("509"); assertThat(query.maxResults()).isEqualTo(3); @@ -20,7 +20,7 @@ void builder_buildsQueryWithMaxResults() { @Test void builder_buildsQueryWithNoOptionalParams() { - VehiclesPredictionsQuery query = VehiclesPredictionsQuery.builder(List.of("509")).build(); + VehiclePredictionsQuery query = VehiclePredictionsQuery.builder(List.of("509")).build(); assertThat(query.vehicleIds()).containsExactly("509"); assertThat(query.maxResults()).isNull(); @@ -29,13 +29,13 @@ void builder_buildsQueryWithNoOptionalParams() { @Test void builder_throwsIllegalArgumentException_whenMaxResultsIsZero() { assertThatIllegalArgumentException().isThrownBy(() -> - VehiclesPredictionsQuery.builder(List.of("509")).maxResults(0)); + VehiclePredictionsQuery.builder(List.of("509")).maxResults(0)); } @Test void builder_throwsIllegalArgumentException_whenMaxResultsIsNegative() { assertThatIllegalArgumentException().isThrownBy(() -> - VehiclesPredictionsQuery.builder(List.of("509")).maxResults(-1)); + VehiclePredictionsQuery.builder(List.of("509")).maxResults(-1)); } @Test @@ -43,12 +43,12 @@ void constructor_throwsIllegalArgumentException_whenTooManyVehicleIds() { List ids = Collections.nCopies(11, "509"); assertThatIllegalArgumentException().isThrownBy(() -> - new VehiclesPredictionsQuery(ids, null)); + new VehiclePredictionsQuery(ids, null)); } @Test void constructor_throwsIllegalArgumentException_whenMaxResultsIsNotPositive() { assertThatIllegalArgumentException().isThrownBy(() -> - new VehiclesPredictionsQuery(List.of("509"), 0)); + new VehiclePredictionsQuery(List.of("509"), 0)); } -} \ No newline at end of file +} diff --git a/src/test/java/com/cta4j/bus/route/RoutesApiImplTest.java b/src/test/java/com/cta4j/bus/route/RoutesApiImplTest.java index ff7b5c50..60e5751e 100644 --- a/src/test/java/com/cta4j/bus/route/RoutesApiImplTest.java +++ b/src/test/java/com/cta4j/bus/route/RoutesApiImplTest.java @@ -67,6 +67,19 @@ void list_returnsEmpty_whenResponseHasNoDataAndNoErrors() { assertThat(routes).isEmpty(); } + @Test + void list_returnsEmpty_whenRoutesIsExplicitlyEmptyArray() { + this.server.stubFor(get(urlPathEqualTo("/bustime/api/v3/getroutes")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(TestFixtures.read("bus/route/empty_routes_array.json")))); + + List routes = this.api.list(); + + assertThat(routes).isEmpty(); + } + @Test void list_throwsCta4jBusException_whenResponseContainsFatalErrors() { this.server.stubFor(get(urlPathEqualTo("/bustime/api/v3/getroutes")) diff --git a/src/test/java/com/cta4j/bus/stop/StopMapperTest.java b/src/test/java/com/cta4j/bus/stop/StopMapperTest.java index f595e996..e97688d6 100644 --- a/src/test/java/com/cta4j/bus/stop/StopMapperTest.java +++ b/src/test/java/com/cta4j/bus/stop/StopMapperTest.java @@ -5,6 +5,8 @@ import com.cta4j.bus.stop.model.Stop; import org.junit.jupiter.api.Test; +import java.util.List; + import static org.assertj.core.api.Assertions.*; class StopMapperTest { @@ -37,4 +39,31 @@ void toDomain_mapsAdaAccessibleTrue() { assertThat(stop.adaAccessible()).isTrue(); } + + @Test + void toDomain_mapsDetoursAddedAndRemoved_whenNonNull() { + CtaStop wire = new CtaStop( + "456", + "Ashland & Division", + 41.9, + -87.67, + List.of(1, 2), + List.of(3), + 5, + null + ); + + Stop stop = StopMapper.INSTANCE.toDomain(wire); + + assertThat(stop.detoursAdded()).containsExactly(1, 2); + assertThat(stop.detoursRemoved()).containsExactly(3); + assertThat(stop.gtfsSequence()).isEqualTo(5); + } + + @Test + void toDomain_returnsNull_whenStopIsNull() { + Stop stop = StopMapper.INSTANCE.toDomain(null); + + assertThat(stop).isNull(); + } } diff --git a/src/test/java/com/cta4j/bus/stop/internal/wire/CtaStopErrorTest.java b/src/test/java/com/cta4j/bus/stop/internal/wire/CtaStopErrorTest.java index d9b9b85f..7a30eb33 100644 --- a/src/test/java/com/cta4j/bus/stop/internal/wire/CtaStopErrorTest.java +++ b/src/test/java/com/cta4j/bus/stop/internal/wire/CtaStopErrorTest.java @@ -32,4 +32,4 @@ void notFound_returnsFalse_whenNoTypedFieldsPresent() { assertThat(error.notFound()).isFalse(); } -} \ No newline at end of file +} diff --git a/src/test/java/com/cta4j/bus/stop/internal/wire/CtaStopTest.java b/src/test/java/com/cta4j/bus/stop/internal/wire/CtaStopTest.java index 83271b8a..8122f416 100644 --- a/src/test/java/com/cta4j/bus/stop/internal/wire/CtaStopTest.java +++ b/src/test/java/com/cta4j/bus/stop/internal/wire/CtaStopTest.java @@ -27,4 +27,4 @@ void constructor_copiesDetoursRemoved_whenNonNull() { assertThat(stop.dtrrem()).containsExactly(321, 654); } -} \ No newline at end of file +} diff --git a/src/test/java/com/cta4j/bus/stop/model/StopTest.java b/src/test/java/com/cta4j/bus/stop/model/StopTest.java index ad366b0e..2b9e87d1 100644 --- a/src/test/java/com/cta4j/bus/stop/model/StopTest.java +++ b/src/test/java/com/cta4j/bus/stop/model/StopTest.java @@ -34,4 +34,4 @@ void constructor_copiesDetoursRemoved_whenNonNull() { assertThat(stop.detoursRemoved()).containsExactly(321, 654); } -} \ No newline at end of file +} diff --git a/src/test/java/com/cta4j/bus/vehicle/VehiclesApiImplTest.java b/src/test/java/com/cta4j/bus/vehicle/VehiclesApiImplTest.java index 91b300d5..4226e20a 100644 --- a/src/test/java/com/cta4j/bus/vehicle/VehiclesApiImplTest.java +++ b/src/test/java/com/cta4j/bus/vehicle/VehiclesApiImplTest.java @@ -12,6 +12,7 @@ import org.junit.jupiter.api.Test; import tools.jackson.core.JacksonException; +import java.util.Arrays; import java.util.List; import java.util.Optional; @@ -75,6 +76,19 @@ void findByIds_returnsEmpty_whenResponseHasNoDataAndNoErrors() { assertThat(vehicles).isEmpty(); } + @Test + void findByIds_returnsEmpty_whenVehicleIsExplicitlyEmptyArray() { + this.server.stubFor(get(urlPathEqualTo("/bustime/api/v3/getvehicles")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(TestFixtures.read("bus/vehicle/empty_vehicle_array.json")))); + + List vehicles = this.api.findByIds(List.of("509")); + + assertThat(vehicles).isEmpty(); + } + @Test void findByIds_returnsEmpty_whenAllErrorsAreResourceSpecific() { this.server.stubFor(get(urlPathEqualTo("/bustime/api/v3/getvehicles")) @@ -155,6 +169,20 @@ void findByRouteIds_sendsRtParameter() { assertThat(vehicles).hasSize(1); } + @Test + void findByIds_throwsNullPointerException_whenIdsContainsNull() { + List withNull = Arrays.asList("509", null); + + assertThatNullPointerException().isThrownBy(() -> this.api.findByIds(withNull)); + } + + @Test + void findByRouteIds_throwsNullPointerException_whenRouteIdsContainsNull() { + List withNull = Arrays.asList("8", null); + + assertThatNullPointerException().isThrownBy(() -> this.api.findByRouteIds(withNull)); + } + @Test void findByIds_throwsIllegalArgumentException_whenTooManyVehicleIds() { List tooMany = List.of("1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11"); diff --git a/src/test/java/com/cta4j/bus/vehicle/internal/wire/CtaVehicleErrorTest.java b/src/test/java/com/cta4j/bus/vehicle/internal/wire/CtaVehicleErrorTest.java index c096c442..64257b1c 100644 --- a/src/test/java/com/cta4j/bus/vehicle/internal/wire/CtaVehicleErrorTest.java +++ b/src/test/java/com/cta4j/bus/vehicle/internal/wire/CtaVehicleErrorTest.java @@ -25,4 +25,4 @@ void notFound_returnsFalse_whenNeitherVidNorRtPresent() { assertThat(error.notFound()).isFalse(); } -} \ No newline at end of file +} diff --git a/src/test/java/com/cta4j/bus/vehicle/model/TransitModeTest.java b/src/test/java/com/cta4j/bus/vehicle/model/TransitModeTest.java index 48051ca1..82fca855 100644 --- a/src/test/java/com/cta4j/bus/vehicle/model/TransitModeTest.java +++ b/src/test/java/com/cta4j/bus/vehicle/model/TransitModeTest.java @@ -24,4 +24,4 @@ void getCode_returnsCode() { assertThat(TransitMode.NONE.getCode()).isEqualTo(0); assertThat(TransitMode.BUS.getCode()).isEqualTo(1); } -} \ No newline at end of file +} diff --git a/src/test/java/com/cta4j/common/geo/CoordinatesTest.java b/src/test/java/com/cta4j/common/geo/CoordinatesTest.java index 6af5e468..becdbff5 100644 --- a/src/test/java/com/cta4j/common/geo/CoordinatesTest.java +++ b/src/test/java/com/cta4j/common/geo/CoordinatesTest.java @@ -48,4 +48,4 @@ void constructor_throwsIllegalArgumentException_whenHeadingTooHigh() { assertThatIllegalArgumentException().isThrownBy(() -> new Coordinates(new BigDecimal("41.8827"), new BigDecimal("-87.6233"), 360)); } -} \ No newline at end of file +} diff --git a/src/test/java/com/cta4j/common/internal/util/BooleanParserTest.java b/src/test/java/com/cta4j/common/internal/util/BooleanParserTest.java new file mode 100644 index 00000000..61ea9c53 --- /dev/null +++ b/src/test/java/com/cta4j/common/internal/util/BooleanParserTest.java @@ -0,0 +1,38 @@ +package com.cta4j.common.internal.util; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.*; + +class BooleanParserTest { + @Test + void parse01_returnsFalse_whenValueIsZero() { + assertThat(BooleanParser.parse01("0")).isFalse(); + } + + @Test + void parse01_returnsTrue_whenValueIsOne() { + assertThat(BooleanParser.parse01("1")).isTrue(); + } + + @Test + void parse01_throwsIllegalArgumentException_whenValueIsInvalid() { + assertThatIllegalArgumentException().isThrownBy(() -> BooleanParser.parse01("2")) + .withMessage("Invalid value: 2. Expected 0 or 1"); + } + + @Test + void parse01_throwsIllegalArgumentException_whenValueIsBlank() { + assertThatIllegalArgumentException().isThrownBy(() -> BooleanParser.parse01("")); + } + + @Test + void parse01_throwsIllegalArgumentException_whenValueIsTrueOrFalseString() { + assertThatIllegalArgumentException().isThrownBy(() -> BooleanParser.parse01("true")); + } + + @Test + void parse01_throwsNullPointerException_whenValueIsNull() { + assertThatNullPointerException().isThrownBy(() -> BooleanParser.parse01(null)); + } +} diff --git a/src/test/java/com/cta4j/common/internal/util/TimestampParserTest.java b/src/test/java/com/cta4j/common/internal/util/TimestampParserTest.java new file mode 100644 index 00000000..3f206056 --- /dev/null +++ b/src/test/java/com/cta4j/common/internal/util/TimestampParserTest.java @@ -0,0 +1,73 @@ +package com.cta4j.common.internal.util; + +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; + +import static org.assertj.core.api.Assertions.*; + +class TimestampParserTest { + private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss"); + + @Test + void parse_returnsInstant_atProvidedZone() { + Instant result = TimestampParser.parse("2026-07-28T12:00:00", FORMATTER, ZoneOffset.UTC); + + assertThat(result).isEqualTo(Instant.parse("2026-07-28T12:00:00Z")); + } + + @Test + void parse_appliesZoneId_whenConvertingToInstant() { + Instant utc = TimestampParser.parse("2026-07-28T12:00:00", FORMATTER, ZoneOffset.UTC); + Instant chicago = TimestampParser.parse("2026-07-28T12:00:00", FORMATTER, ZoneId.of("America/Chicago")); + + assertThat(chicago).isAfter(utc); + } + + @Test + void parse_throwsIllegalArgumentException_whenTimestampDoesNotMatchFormatter() { + assertThatIllegalArgumentException() + .isThrownBy(() -> TimestampParser.parse("not-a-timestamp", FORMATTER, ZoneOffset.UTC)) + .withMessageContaining("Failed to parse timestamp: not-a-timestamp") + .withCauseInstanceOf(DateTimeParseException.class); + } + + @Test + void parse_throwsNullPointerException_whenTimestampIsNull() { + assertThatNullPointerException().isThrownBy(() -> TimestampParser.parse(null, FORMATTER, ZoneOffset.UTC)); + } + + @Test + void parse_throwsNullPointerException_whenFormatterIsNull() { + assertThatNullPointerException() + .isThrownBy(() -> TimestampParser.parse("2026-07-28T12:00:00", null, ZoneOffset.UTC)); + } + + @Test + void parse_throwsNullPointerException_whenZoneIdIsNull() { + assertThatNullPointerException() + .isThrownBy(() -> TimestampParser.parse("2026-07-28T12:00:00", FORMATTER, null)); + } + + @Test + void parseNullable_returnsNull_whenTimestampIsNull() { + assertThat(TimestampParser.parseNullable(null, FORMATTER, ZoneOffset.UTC)).isNull(); + } + + @Test + void parseNullable_returnsInstant_whenTimestampIsNonNull() { + Instant result = TimestampParser.parseNullable("2026-07-28T12:00:00", FORMATTER, ZoneOffset.UTC); + + assertThat(result).isEqualTo(Instant.parse("2026-07-28T12:00:00Z")); + } + + @Test + void parseNullable_throwsIllegalArgumentException_whenTimestampDoesNotMatchFormatter() { + assertThatIllegalArgumentException() + .isThrownBy(() -> TimestampParser.parseNullable("bad", FORMATTER, ZoneOffset.UTC)); + } +} diff --git a/src/test/java/com/cta4j/train/TrainApiTest.java b/src/test/java/com/cta4j/train/TrainApiTest.java index c54d6e37..50f5f480 100644 --- a/src/test/java/com/cta4j/train/TrainApiTest.java +++ b/src/test/java/com/cta4j/train/TrainApiTest.java @@ -17,4 +17,4 @@ void builder_returnsWorkingInstance() { assertThat(api).isNotNull(); assertThat(api.stations()).isNotNull(); } -} \ No newline at end of file +} diff --git a/src/test/java/com/cta4j/train/arrival/ArrivalsApiImplTest.java b/src/test/java/com/cta4j/train/arrival/ArrivalsApiImplTest.java index 020b481d..4374e98a 100644 --- a/src/test/java/com/cta4j/train/arrival/ArrivalsApiImplTest.java +++ b/src/test/java/com/cta4j/train/arrival/ArrivalsApiImplTest.java @@ -1,11 +1,10 @@ package com.cta4j.train.arrival; import com.cta4j.TestFixtures; -import com.cta4j.train.arrival.exception.ArrivalsErrorCode; import com.cta4j.train.arrival.exception.Cta4jArrivalsException; import com.cta4j.train.arrival.internal.impl.ArrivalsApiImpl; -import com.cta4j.train.arrival.query.MapArrivalQuery; -import com.cta4j.train.arrival.query.StopArrivalQuery; +import com.cta4j.train.arrival.query.MapArrivalsQuery; +import com.cta4j.train.arrival.query.StopArrivalsQuery; import com.cta4j.train.common.internal.config.TrainApiConfig; import com.cta4j.train.common.model.Arrival; import com.cta4j.train.common.model.TrainLine; @@ -15,6 +14,7 @@ import org.junit.jupiter.api.Test; import tools.jackson.core.JacksonException; +import java.util.Arrays; import java.util.List; import static com.github.tomakehurst.wiremock.client.WireMock.*; @@ -45,15 +45,15 @@ void tearDown() { } @Test - void findByMapId_returnsArrivals_whenResponseContainsArrivals() { + void findByMapIds_returnsArrivals_whenResponseContainsArrivals() { this.server.stubFor(get(urlPathEqualTo("/api/1.0/ttarrivals.aspx")) .willReturn(aResponse() .withStatus(200) .withHeader("Content-Type", "application/json") .withBody(TestFixtures.read("train/arrival/success.json")))); - MapArrivalQuery query = MapArrivalQuery.builder("40900").build(); - List arrivals = this.api.findByMapId(query); + MapArrivalsQuery query = MapArrivalsQuery.builder(List.of("40900")).build(); + List arrivals = this.api.findByMapIds(query); assertThat(arrivals).hasSize(1); Arrival arrival = arrivals.getFirst(); @@ -65,76 +65,100 @@ void findByMapId_returnsArrivals_whenResponseContainsArrivals() { } @Test - void findByMapId_returnsEmpty_whenResponseHasNoEta() { + void findByMapIds_sendsCommaDelimitedMapIds_whenMultipleProvided() { + this.server.stubFor(get(urlPathEqualTo("/api/1.0/ttarrivals.aspx")) + .withQueryParam("mapid", equalTo("40900,40380,40360")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(TestFixtures.read("train/arrival/success.json")))); + + MapArrivalsQuery query = MapArrivalsQuery.builder(List.of("40900", "40380", "40360")).build(); + List arrivals = this.api.findByMapIds(query); + + assertThat(arrivals).hasSize(1); + } + + @Test + void findByMapIds_returnsEmpty_whenMapIdsIsEmpty() { + MapArrivalsQuery query = MapArrivalsQuery.builder(List.of()).build(); + List arrivals = this.api.findByMapIds(query); + + assertThat(arrivals).isEmpty(); + this.server.verify(0, getRequestedFor(urlPathEqualTo("/api/1.0/ttarrivals.aspx"))); + } + + @Test + void findByMapIds_returnsEmpty_whenResponseHasNoEta() { this.server.stubFor(get(urlPathEqualTo("/api/1.0/ttarrivals.aspx")) .willReturn(aResponse() .withStatus(200) .withHeader("Content-Type", "application/json") .withBody(TestFixtures.read("train/arrival/empty.json")))); - MapArrivalQuery query = MapArrivalQuery.builder("40900").build(); - List arrivals = this.api.findByMapId(query); + MapArrivalsQuery query = MapArrivalsQuery.builder(List.of("40900")).build(); + List arrivals = this.api.findByMapIds(query); assertThat(arrivals).isEmpty(); } @Test - void findByMapId_returnsEmpty_whenEtaIsEmptyArray() { + void findByMapIds_returnsEmpty_whenEtaIsEmptyArray() { this.server.stubFor(get(urlPathEqualTo("/api/1.0/ttarrivals.aspx")) .willReturn(aResponse() .withStatus(200) .withHeader("Content-Type", "application/json") .withBody("{\"ctatt\":{\"tmst\":\"2015-04-30T20:23:53\",\"errCd\":\"0\",\"errNm\":null,\"eta\":[]}}"))); - MapArrivalQuery query = MapArrivalQuery.builder("40900").build(); - List arrivals = this.api.findByMapId(query); + MapArrivalsQuery query = MapArrivalsQuery.builder(List.of("40900")).build(); + List arrivals = this.api.findByMapIds(query); assertThat(arrivals).isEmpty(); } @Test - void findByMapId_throwsCta4jArrivalsException_whenResponseContainsError() { + void findByMapIds_throwsCta4jArrivalsException_whenResponseContainsError() { this.server.stubFor(get(urlPathEqualTo("/api/1.0/ttarrivals.aspx")) .willReturn(aResponse() .withStatus(200) .withHeader("Content-Type", "application/json") .withBody(TestFixtures.read("train/arrival/error.json")))); - MapArrivalQuery query = MapArrivalQuery.builder("40900").build(); + MapArrivalsQuery query = MapArrivalsQuery.builder(List.of("40900")).build(); - assertThatThrownBy(() -> this.api.findByMapId(query)) + assertThatThrownBy(() -> this.api.findByMapIds(query)) .isInstanceOf(Cta4jArrivalsException.class) .hasMessage("Invalid API key") .satisfies(e -> assertThat(((Cta4jArrivalsException) e).getErrorCode()) - .isEqualTo(ArrivalsErrorCode.UNKNOWN)) + .isNull()) .satisfies(e -> assertThat(((Cta4jArrivalsException) e).getRawErrorCode()).isEqualTo(1)); } @Test - void findByMapId_returnsEmpty_whenResponseContainsInvalidMapIdError() { + void findByMapIds_returnsEmpty_whenResponseContainsInvalidMapIdError() { this.server.stubFor(get(urlPathEqualTo("/api/1.0/ttarrivals.aspx")) .willReturn(aResponse() .withStatus(200) .withHeader("Content-Type", "application/json") .withBody(TestFixtures.read("train/arrival/not_found_mapid.json")))); - MapArrivalQuery query = MapArrivalQuery.builder("99999").build(); - List arrivals = this.api.findByMapId(query); + MapArrivalsQuery query = MapArrivalsQuery.builder(List.of("99999")).build(); + List arrivals = this.api.findByMapIds(query); assertThat(arrivals).isEmpty(); } @Test - void findByMapId_throwsCta4jArrivalsException_whenResponseIsNotJson() { + void findByMapIds_throwsCta4jArrivalsException_whenResponseIsNotJson() { this.server.stubFor(get(urlPathEqualTo("/api/1.0/ttarrivals.aspx")) .willReturn(aResponse() .withStatus(200) .withHeader("Content-Type", "application/json") .withBody("not-json"))); - MapArrivalQuery query = MapArrivalQuery.builder("40900").build(); + MapArrivalsQuery query = MapArrivalsQuery.builder(List.of("40900")).build(); - assertThatThrownBy(() -> this.api.findByMapId(query)) + assertThatThrownBy(() -> this.api.findByMapIds(query)) .isInstanceOf(Cta4jArrivalsException.class) .hasMessage("Failed to parse response") .satisfies(e -> assertThat(((Cta4jArrivalsException) e).getErrorCode()).isNull()) @@ -142,7 +166,7 @@ void findByMapId_throwsCta4jArrivalsException_whenResponseIsNotJson() { } @Test - void findByStopId_returnsArrivals_whenResponseContainsArrivals() { + void findByStopIds_returnsArrivals_whenResponseContainsArrivals() { this.server.stubFor(get(urlPathEqualTo("/api/1.0/ttarrivals.aspx")) .withQueryParam("stpid", equalTo("30070")) .willReturn(aResponse() @@ -150,60 +174,84 @@ void findByStopId_returnsArrivals_whenResponseContainsArrivals() { .withHeader("Content-Type", "application/json") .withBody(TestFixtures.read("train/arrival/success.json")))); - StopArrivalQuery query = StopArrivalQuery.builder("30070").build(); - List arrivals = this.api.findByStopId(query); + StopArrivalsQuery query = StopArrivalsQuery.builder(List.of("30070")).build(); + List arrivals = this.api.findByStopIds(query); + + assertThat(arrivals).hasSize(1); + } + + @Test + void findByStopIds_sendsCommaDelimitedStopIds_whenMultipleProvided() { + this.server.stubFor(get(urlPathEqualTo("/api/1.0/ttarrivals.aspx")) + .withQueryParam("stpid", equalTo("30070,30071,30375")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(TestFixtures.read("train/arrival/success.json")))); + + StopArrivalsQuery query = StopArrivalsQuery.builder(List.of("30070", "30071", "30375")).build(); + List arrivals = this.api.findByStopIds(query); assertThat(arrivals).hasSize(1); } @Test - void findByStopId_returnsEmpty_whenResponseHasNoEta() { + void findByStopIds_returnsEmpty_whenStopIdsIsEmpty() { + StopArrivalsQuery query = StopArrivalsQuery.builder(List.of()).build(); + List arrivals = this.api.findByStopIds(query); + + assertThat(arrivals).isEmpty(); + this.server.verify(0, getRequestedFor(urlPathEqualTo("/api/1.0/ttarrivals.aspx"))); + } + + @Test + void findByStopIds_returnsEmpty_whenResponseHasNoEta() { this.server.stubFor(get(urlPathEqualTo("/api/1.0/ttarrivals.aspx")) .willReturn(aResponse() .withStatus(200) .withHeader("Content-Type", "application/json") .withBody(TestFixtures.read("train/arrival/empty.json")))); - StopArrivalQuery query = StopArrivalQuery.builder("30070").build(); - List arrivals = this.api.findByStopId(query); + StopArrivalsQuery query = StopArrivalsQuery.builder(List.of("30070")).build(); + List arrivals = this.api.findByStopIds(query); assertThat(arrivals).isEmpty(); } @Test - void findByStopId_throwsCta4jArrivalsException_whenResponseContainsError() { + void findByStopIds_throwsCta4jArrivalsException_whenResponseContainsError() { this.server.stubFor(get(urlPathEqualTo("/api/1.0/ttarrivals.aspx")) .willReturn(aResponse() .withStatus(200) .withHeader("Content-Type", "application/json") .withBody(TestFixtures.read("train/arrival/error.json")))); - StopArrivalQuery query = StopArrivalQuery.builder("30070").build(); + StopArrivalsQuery query = StopArrivalsQuery.builder(List.of("30070")).build(); - assertThatThrownBy(() -> this.api.findByStopId(query)) + assertThatThrownBy(() -> this.api.findByStopIds(query)) .isInstanceOf(Cta4jArrivalsException.class) .hasMessage("Invalid API key") .satisfies(e -> assertThat(((Cta4jArrivalsException) e).getErrorCode()) - .isEqualTo(ArrivalsErrorCode.UNKNOWN)) + .isNull()) .satisfies(e -> assertThat(((Cta4jArrivalsException) e).getRawErrorCode()).isEqualTo(1)); } @Test - void findByStopId_returnsEmpty_whenResponseContainsInvalidStopIdError() { + void findByStopIds_returnsEmpty_whenResponseContainsInvalidStopIdError() { this.server.stubFor(get(urlPathEqualTo("/api/1.0/ttarrivals.aspx")) .willReturn(aResponse() .withStatus(200) .withHeader("Content-Type", "application/json") .withBody(TestFixtures.read("train/arrival/not_found_stpid.json")))); - StopArrivalQuery query = StopArrivalQuery.builder("99999").build(); - List arrivals = this.api.findByStopId(query); + StopArrivalsQuery query = StopArrivalsQuery.builder(List.of("99999")).build(); + List arrivals = this.api.findByStopIds(query); assertThat(arrivals).isEmpty(); } @Test - void findByMapId_sendsLineAndMaxResultsQueryParams_whenSet() { + void findByMapIds_sendsLineAndMaxResultsQueryParams_whenSet() { this.server.stubFor(get(urlPathEqualTo("/api/1.0/ttarrivals.aspx")) .withQueryParam("rt", equalTo("Red")) .withQueryParam("max", equalTo("5")) @@ -212,18 +260,18 @@ void findByMapId_sendsLineAndMaxResultsQueryParams_whenSet() { .withHeader("Content-Type", "application/json") .withBody(TestFixtures.read("train/arrival/success.json")))); - MapArrivalQuery query = MapArrivalQuery.builder("40900") - .line(TrainLine.RED) - .maxResults(5) - .build(); + MapArrivalsQuery query = MapArrivalsQuery.builder(List.of("40900")) + .line(TrainLine.RED) + .maxResults(5) + .build(); - List arrivals = this.api.findByMapId(query); + List arrivals = this.api.findByMapIds(query); assertThat(arrivals).hasSize(1); } @Test - void findByStopId_sendsLineAndMaxResultsQueryParams_whenSet() { + void findByStopIds_sendsLineAndMaxResultsQueryParams_whenSet() { this.server.stubFor(get(urlPathEqualTo("/api/1.0/ttarrivals.aspx")) .withQueryParam("stpid", equalTo("30070")) .withQueryParam("rt", equalTo("Red")) @@ -233,16 +281,58 @@ void findByStopId_sendsLineAndMaxResultsQueryParams_whenSet() { .withHeader("Content-Type", "application/json") .withBody(TestFixtures.read("train/arrival/success.json")))); - StopArrivalQuery query = StopArrivalQuery.builder("30070") - .line(TrainLine.RED) - .maxResults(5) - .build(); + StopArrivalsQuery query = StopArrivalsQuery.builder(List.of("30070")) + .line(TrainLine.RED) + .maxResults(5) + .build(); - List arrivals = this.api.findByStopId(query); + List arrivals = this.api.findByStopIds(query); assertThat(arrivals).hasSize(1); } + @Test + void findByMapIds_collectionOverload_returnsArrivals_whenResponseContainsArrivals() { + this.server.stubFor(get(urlPathEqualTo("/api/1.0/ttarrivals.aspx")) + .withQueryParam("mapid", equalTo("40900,40380")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(TestFixtures.read("train/arrival/success.json")))); + + List arrivals = this.api.findByMapIds(List.of("40900", "40380")); + + assertThat(arrivals).hasSize(1); + } + + @Test + void findByMapIds_collectionOverload_throwsNullPointerException_whenMapIdsContainsNull() { + List withNull = Arrays.asList("40900", null); + + assertThatNullPointerException().isThrownBy(() -> this.api.findByMapIds(withNull)); + } + + @Test + void findByStopIds_collectionOverload_returnsArrivals_whenResponseContainsArrivals() { + this.server.stubFor(get(urlPathEqualTo("/api/1.0/ttarrivals.aspx")) + .withQueryParam("stpid", equalTo("30070,30071")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(TestFixtures.read("train/arrival/success.json")))); + + List arrivals = this.api.findByStopIds(List.of("30070", "30071")); + + assertThat(arrivals).hasSize(1); + } + + @Test + void findByStopIds_collectionOverload_throwsNullPointerException_whenStopIdsContainsNull() { + List withNull = Arrays.asList("30070", null); + + assertThatNullPointerException().isThrownBy(() -> this.api.findByStopIds(withNull)); + } + @Test void findByMapId_stringOverload_returnsArrivals_whenResponseContainsArrivals() { this.server.stubFor(get(urlPathEqualTo("/api/1.0/ttarrivals.aspx")) @@ -271,16 +361,16 @@ void findByStopId_stringOverload_returnsArrivals_whenResponseContainsArrivals() } @Test - void findByMapId_throwsCta4jArrivalsException_whenErrCdIsNotNumeric() { + void findByMapIds_throwsCta4jArrivalsException_whenErrCdIsNotNumeric() { this.server.stubFor(get(urlPathEqualTo("/api/1.0/ttarrivals.aspx")) .willReturn(aResponse() .withStatus(200) .withHeader("Content-Type", "application/json") .withBody(TestFixtures.read("train/arrival/invalid_err_cd.json")))); - MapArrivalQuery query = MapArrivalQuery.builder("40900").build(); + MapArrivalsQuery query = MapArrivalsQuery.builder(List.of("40900")).build(); - assertThatThrownBy(() -> this.api.findByMapId(query)) + assertThatThrownBy(() -> this.api.findByMapIds(query)) .isInstanceOf(Cta4jArrivalsException.class) .hasMessage("Failed to parse error code") .satisfies(e -> assertThat(((Cta4jArrivalsException) e).getErrorCode()).isNull()) @@ -288,50 +378,68 @@ void findByMapId_throwsCta4jArrivalsException_whenErrCdIsNotNumeric() { } @Test - void findByMapId_throwsCta4jArrivalsException_whenErrCdIsNegative() { + void findByMapIds_throwsCta4jArrivalsException_whenErrCdIsNegative() { this.server.stubFor(get(urlPathEqualTo("/api/1.0/ttarrivals.aspx")) .willReturn(aResponse() .withStatus(200) .withHeader("Content-Type", "application/json") .withBody("{\"ctatt\":{\"tmst\":\"2015-04-30T20:23:53\",\"errCd\":\"-1\",\"errNm\":\"Unexpected error\"}}"))); - MapArrivalQuery query = MapArrivalQuery.builder("40900").build(); + MapArrivalsQuery query = MapArrivalsQuery.builder(List.of("40900")).build(); - assertThatThrownBy(() -> this.api.findByMapId(query)) + assertThatThrownBy(() -> this.api.findByMapIds(query)) .isInstanceOf(Cta4jArrivalsException.class) .hasMessage("Unknown error code") .satisfies(e -> assertThat(((Cta4jArrivalsException) e).getErrorCode()) - .isEqualTo(ArrivalsErrorCode.UNKNOWN)) + .isNull()) .satisfies(e -> assertThat(((Cta4jArrivalsException) e).getRawErrorCode()).isEqualTo(-1)); } @Test - void findByMapId_throwsCta4jArrivalsException_withDefaultMessage_whenErrNmIsBlank() { + void findByMapIds_throwsCta4jArrivalsException_withDefaultMessage_whenErrNmIsBlank() { this.server.stubFor(get(urlPathEqualTo("/api/1.0/ttarrivals.aspx")) .willReturn(aResponse() .withStatus(200) .withHeader("Content-Type", "application/json") .withBody("{\"ctatt\":{\"tmst\":\"2015-04-30T20:23:53\",\"errCd\":\"1\",\"errNm\":\"\"}}"))); - MapArrivalQuery query = MapArrivalQuery.builder("40900").build(); + MapArrivalsQuery query = MapArrivalsQuery.builder(List.of("40900")).build(); + + assertThatThrownBy(() -> this.api.findByMapIds(query)) + .isInstanceOf(Cta4jArrivalsException.class) + .hasMessage("An unknown error occurred.") + .satisfies(e -> assertThat(((Cta4jArrivalsException) e).getErrorCode()) + .isNull()) + .satisfies(e -> assertThat(((Cta4jArrivalsException) e).getRawErrorCode()).isEqualTo(1)); + } + + @Test + void findByMapIds_throwsCta4jArrivalsException_withDefaultMessage_whenErrNmIsAbsent() { + this.server.stubFor(get(urlPathEqualTo("/api/1.0/ttarrivals.aspx")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody("{\"ctatt\":{\"tmst\":\"2015-04-30T20:23:53\",\"errCd\":\"1\"}}"))); + + MapArrivalsQuery query = MapArrivalsQuery.builder(List.of("40900")).build(); - assertThatThrownBy(() -> this.api.findByMapId(query)) + assertThatThrownBy(() -> this.api.findByMapIds(query)) .isInstanceOf(Cta4jArrivalsException.class) .hasMessage("An unknown error occurred.") .satisfies(e -> assertThat(((Cta4jArrivalsException) e).getErrorCode()) - .isEqualTo(ArrivalsErrorCode.UNKNOWN)) + .isNull()) .satisfies(e -> assertThat(((Cta4jArrivalsException) e).getRawErrorCode()).isEqualTo(1)); } @Test - void findByMapId_throwsCta4jArrivalsException_whenServerReturnsErrorStatus() { + void findByMapIds_throwsCta4jArrivalsException_whenServerReturnsErrorStatus() { this.server.stubFor(get(urlPathEqualTo("/api/1.0/ttarrivals.aspx")) .willReturn(aResponse() .withStatus(500))); - MapArrivalQuery query = MapArrivalQuery.builder("40900").build(); + MapArrivalsQuery query = MapArrivalsQuery.builder(List.of("40900")).build(); - assertThatThrownBy(() -> this.api.findByMapId(query)) + assertThatThrownBy(() -> this.api.findByMapIds(query)) .isInstanceOf(Cta4jArrivalsException.class) .hasMessageContaining("status code: 500") .satisfies(e -> assertThat(e.getCause()).isNotNull()); diff --git a/src/test/java/com/cta4j/train/arrival/exception/ArrivalsErrorCodeTest.java b/src/test/java/com/cta4j/train/arrival/exception/ArrivalsErrorCodeTest.java index f958ba9a..a4d04d8b 100644 --- a/src/test/java/com/cta4j/train/arrival/exception/ArrivalsErrorCodeTest.java +++ b/src/test/java/com/cta4j/train/arrival/exception/ArrivalsErrorCodeTest.java @@ -13,13 +13,12 @@ void fromCode_returnsCorrectValue_forEveryDefinedCode() { } @Test - void fromCode_returnsUnknown_whenCodeIsUnrecognized() { - assertThat(ArrivalsErrorCode.fromCode(999)).isEqualTo(ArrivalsErrorCode.UNKNOWN); + void fromCode_returnsNull_whenCodeIsUnrecognized() { + assertThat(ArrivalsErrorCode.fromCode(999)).isNull(); } @Test void getCode_returnsCode() { assertThat(ArrivalsErrorCode.INVALID_API_KEY.getCode()).isEqualTo(101); - assertThat(ArrivalsErrorCode.UNKNOWN.getCode()).isEqualTo(-1); } -} \ No newline at end of file +} diff --git a/src/test/java/com/cta4j/train/arrival/exception/Cta4jArrivalsExceptionTest.java b/src/test/java/com/cta4j/train/arrival/exception/Cta4jArrivalsExceptionTest.java index d4f7c056..8631e4be 100644 --- a/src/test/java/com/cta4j/train/arrival/exception/Cta4jArrivalsExceptionTest.java +++ b/src/test/java/com/cta4j/train/arrival/exception/Cta4jArrivalsExceptionTest.java @@ -31,10 +31,10 @@ void constructor_setsMessageEndpointAndErrorCode() { } @Test - void constructor_setsUnknownErrorCode_whenRawErrorCodeIsUnrecognized() { + void constructor_setsNullErrorCode_whenRawErrorCodeIsUnrecognized() { Cta4jArrivalsException exception = new Cta4jArrivalsException("Something odd happened", 999); assertThat(exception.getRawErrorCode()).isEqualTo(999); - assertThat(exception.getErrorCode()).isEqualTo(ArrivalsErrorCode.UNKNOWN); + assertThat(exception.getErrorCode()).isNull(); } -} \ No newline at end of file +} diff --git a/src/test/java/com/cta4j/train/arrival/query/MapArrivalQueryTest.java b/src/test/java/com/cta4j/train/arrival/query/MapArrivalQueryTest.java deleted file mode 100644 index 11204314..00000000 --- a/src/test/java/com/cta4j/train/arrival/query/MapArrivalQueryTest.java +++ /dev/null @@ -1,47 +0,0 @@ -package com.cta4j.train.arrival.query; - -import com.cta4j.train.common.model.TrainLine; -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.*; - -class MapArrivalQueryTest { - @Test - void builder_buildsQueryWithOptionalParams() { - MapArrivalQuery query = MapArrivalQuery.builder("40900") - .line(TrainLine.RED) - .maxResults(5) - .build(); - - assertThat(query.mapId()).isEqualTo("40900"); - assertThat(query.line()).isEqualTo(TrainLine.RED); - assertThat(query.maxResults()).isEqualTo(5); - } - - @Test - void builder_buildsQueryWithNoOptionalParams() { - MapArrivalQuery query = MapArrivalQuery.builder("40900").build(); - - assertThat(query.mapId()).isEqualTo("40900"); - assertThat(query.line()).isNull(); - assertThat(query.maxResults()).isNull(); - } - - @Test - void builder_throwsIllegalArgumentException_whenMaxResultsIsZero() { - assertThatIllegalArgumentException().isThrownBy(() -> - MapArrivalQuery.builder("40900").maxResults(0)); - } - - @Test - void builder_throwsIllegalArgumentException_whenMaxResultsIsNegative() { - assertThatIllegalArgumentException().isThrownBy(() -> - MapArrivalQuery.builder("40900").maxResults(-1)); - } - - @Test - void constructor_throwsIllegalArgumentException_whenMaxResultsIsNotPositive() { - assertThatIllegalArgumentException().isThrownBy(() -> - new MapArrivalQuery("40900", null, 0)); - } -} \ No newline at end of file diff --git a/src/test/java/com/cta4j/train/arrival/query/MapArrivalsQueryTest.java b/src/test/java/com/cta4j/train/arrival/query/MapArrivalsQueryTest.java new file mode 100644 index 00000000..88c6d03c --- /dev/null +++ b/src/test/java/com/cta4j/train/arrival/query/MapArrivalsQueryTest.java @@ -0,0 +1,107 @@ +package com.cta4j.train.arrival.query; + +import com.cta4j.train.common.model.TrainLine; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static org.assertj.core.api.Assertions.*; + +class MapArrivalsQueryTest { + @Test + void builder_buildsQueryWithOptionalParams() { + MapArrivalsQuery query = MapArrivalsQuery.builder(List.of("40900")) + .line(TrainLine.RED) + .maxResults(5) + .build(); + + assertThat(query.mapIds()).containsExactly("40900"); + assertThat(query.line()).isEqualTo(TrainLine.RED); + assertThat(query.maxResults()).isEqualTo(5); + } + + @Test + void builder_buildsQueryWithMultipleMapIds() { + MapArrivalsQuery query = MapArrivalsQuery.builder(List.of("40900", "40380", "40360")).build(); + + assertThat(query.mapIds()).containsExactly("40900", "40380", "40360"); + } + + @Test + void builder_buildsQueryWithNoOptionalParams() { + MapArrivalsQuery query = MapArrivalsQuery.builder(List.of("40900")).build(); + + assertThat(query.mapIds()).containsExactly("40900"); + assertThat(query.line()).isNull(); + assertThat(query.maxResults()).isNull(); + } + + @Test + void builder_throwsNullPointerException_whenMapIdsIsNull() { + assertThatNullPointerException().isThrownBy(() -> MapArrivalsQuery.builder(null)); + } + + @Test + void builder_throwsNullPointerException_whenMapIdsContainsNull() { + List withNull = Arrays.asList("40900", null); + + assertThatNullPointerException().isThrownBy(() -> MapArrivalsQuery.builder(withNull)); + } + + @Test + void builder_throwsIllegalArgumentException_whenMaxResultsIsZero() { + assertThatIllegalArgumentException().isThrownBy(() -> + MapArrivalsQuery.builder(List.of("40900")).maxResults(0)); + } + + @Test + void builder_throwsIllegalArgumentException_whenMaxResultsIsNegative() { + assertThatIllegalArgumentException().isThrownBy(() -> + MapArrivalsQuery.builder(List.of("40900")).maxResults(-1)); + } + + @Test + void builder_throwsIllegalArgumentException_whenMoreThanFourMapIds() { + List mapIds = Collections.nCopies(5, "40900"); + + assertThatIllegalArgumentException().isThrownBy(() -> + MapArrivalsQuery.builder(mapIds).build()); + } + + @Test + void constructor_throwsNullPointerException_whenMapIdsIsNull() { + assertThatNullPointerException().isThrownBy(() -> new MapArrivalsQuery(null, null, null)); + } + + @Test + void constructor_throwsNullPointerException_whenMapIdsContainsNull() { + List withNull = Arrays.asList("40900", null); + + assertThatNullPointerException().isThrownBy(() -> new MapArrivalsQuery(withNull, null, null)); + } + + @Test + void constructor_throwsIllegalArgumentException_whenMaxResultsIsNotPositive() { + assertThatIllegalArgumentException().isThrownBy(() -> + new MapArrivalsQuery(List.of("40900"), null, 0)); + } + + @Test + void constructor_throwsIllegalArgumentException_whenMoreThanFourMapIds() { + List mapIds = Collections.nCopies(5, "40900"); + + assertThatIllegalArgumentException().isThrownBy(() -> + new MapArrivalsQuery(mapIds, null, null)); + } + + @Test + void constructor_allowsExactlyFourMapIds() { + List mapIds = Collections.nCopies(4, "40900"); + + MapArrivalsQuery query = new MapArrivalsQuery(mapIds, null, null); + + assertThat(query.mapIds()).hasSize(4); + } +} diff --git a/src/test/java/com/cta4j/train/arrival/query/StopArrivalQueryTest.java b/src/test/java/com/cta4j/train/arrival/query/StopArrivalQueryTest.java deleted file mode 100644 index 0df441aa..00000000 --- a/src/test/java/com/cta4j/train/arrival/query/StopArrivalQueryTest.java +++ /dev/null @@ -1,47 +0,0 @@ -package com.cta4j.train.arrival.query; - -import com.cta4j.train.common.model.TrainLine; -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.*; - -class StopArrivalQueryTest { - @Test - void builder_buildsQueryWithOptionalParams() { - StopArrivalQuery query = StopArrivalQuery.builder("30070") - .line(TrainLine.RED) - .maxResults(5) - .build(); - - assertThat(query.stopId()).isEqualTo("30070"); - assertThat(query.line()).isEqualTo(TrainLine.RED); - assertThat(query.maxResults()).isEqualTo(5); - } - - @Test - void builder_buildsQueryWithNoOptionalParams() { - StopArrivalQuery query = StopArrivalQuery.builder("30070").build(); - - assertThat(query.stopId()).isEqualTo("30070"); - assertThat(query.line()).isNull(); - assertThat(query.maxResults()).isNull(); - } - - @Test - void builder_throwsIllegalArgumentException_whenMaxResultsIsZero() { - assertThatIllegalArgumentException().isThrownBy(() -> - StopArrivalQuery.builder("30070").maxResults(0)); - } - - @Test - void builder_throwsIllegalArgumentException_whenMaxResultsIsNegative() { - assertThatIllegalArgumentException().isThrownBy(() -> - StopArrivalQuery.builder("30070").maxResults(-1)); - } - - @Test - void constructor_throwsIllegalArgumentException_whenMaxResultsIsNotPositive() { - assertThatIllegalArgumentException().isThrownBy(() -> - new StopArrivalQuery("30070", null, 0)); - } -} \ No newline at end of file diff --git a/src/test/java/com/cta4j/train/arrival/query/StopArrivalsQueryTest.java b/src/test/java/com/cta4j/train/arrival/query/StopArrivalsQueryTest.java new file mode 100644 index 00000000..1c3d7e5f --- /dev/null +++ b/src/test/java/com/cta4j/train/arrival/query/StopArrivalsQueryTest.java @@ -0,0 +1,107 @@ +package com.cta4j.train.arrival.query; + +import com.cta4j.train.common.model.TrainLine; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static org.assertj.core.api.Assertions.*; + +class StopArrivalsQueryTest { + @Test + void builder_buildsQueryWithOptionalParams() { + StopArrivalsQuery query = StopArrivalsQuery.builder(List.of("30070")) + .line(TrainLine.RED) + .maxResults(5) + .build(); + + assertThat(query.stopIds()).containsExactly("30070"); + assertThat(query.line()).isEqualTo(TrainLine.RED); + assertThat(query.maxResults()).isEqualTo(5); + } + + @Test + void builder_buildsQueryWithMultipleStopIds() { + StopArrivalsQuery query = StopArrivalsQuery.builder(List.of("30070", "30071", "30375")).build(); + + assertThat(query.stopIds()).containsExactly("30070", "30071", "30375"); + } + + @Test + void builder_buildsQueryWithNoOptionalParams() { + StopArrivalsQuery query = StopArrivalsQuery.builder(List.of("30070")).build(); + + assertThat(query.stopIds()).containsExactly("30070"); + assertThat(query.line()).isNull(); + assertThat(query.maxResults()).isNull(); + } + + @Test + void builder_throwsNullPointerException_whenStopIdsIsNull() { + assertThatNullPointerException().isThrownBy(() -> StopArrivalsQuery.builder(null)); + } + + @Test + void builder_throwsNullPointerException_whenStopIdsContainsNull() { + List withNull = Arrays.asList("30070", null); + + assertThatNullPointerException().isThrownBy(() -> StopArrivalsQuery.builder(withNull)); + } + + @Test + void builder_throwsIllegalArgumentException_whenMaxResultsIsZero() { + assertThatIllegalArgumentException().isThrownBy(() -> + StopArrivalsQuery.builder(List.of("30070")).maxResults(0)); + } + + @Test + void builder_throwsIllegalArgumentException_whenMaxResultsIsNegative() { + assertThatIllegalArgumentException().isThrownBy(() -> + StopArrivalsQuery.builder(List.of("30070")).maxResults(-1)); + } + + @Test + void builder_throwsIllegalArgumentException_whenMoreThanFourStopIds() { + List stopIds = Collections.nCopies(5, "30070"); + + assertThatIllegalArgumentException().isThrownBy(() -> + StopArrivalsQuery.builder(stopIds).build()); + } + + @Test + void constructor_throwsNullPointerException_whenStopIdsIsNull() { + assertThatNullPointerException().isThrownBy(() -> new StopArrivalsQuery(null, null, null)); + } + + @Test + void constructor_throwsNullPointerException_whenStopIdsContainsNull() { + List withNull = Arrays.asList("30070", null); + + assertThatNullPointerException().isThrownBy(() -> new StopArrivalsQuery(withNull, null, null)); + } + + @Test + void constructor_throwsIllegalArgumentException_whenMaxResultsIsNotPositive() { + assertThatIllegalArgumentException().isThrownBy(() -> + new StopArrivalsQuery(List.of("30070"), null, 0)); + } + + @Test + void constructor_throwsIllegalArgumentException_whenMoreThanFourStopIds() { + List stopIds = Collections.nCopies(5, "30070"); + + assertThatIllegalArgumentException().isThrownBy(() -> + new StopArrivalsQuery(stopIds, null, null)); + } + + @Test + void constructor_allowsExactlyFourStopIds() { + List stopIds = Collections.nCopies(4, "30070"); + + StopArrivalsQuery query = new StopArrivalsQuery(stopIds, null, null); + + assertThat(query.stopIds()).hasSize(4); + } +} diff --git a/src/test/java/com/cta4j/train/arrival/ArrivalMapperTest.java b/src/test/java/com/cta4j/train/common/ArrivalMapperTest.java similarity index 81% rename from src/test/java/com/cta4j/train/arrival/ArrivalMapperTest.java rename to src/test/java/com/cta4j/train/common/ArrivalMapperTest.java index 71ab7c35..287a9175 100644 --- a/src/test/java/com/cta4j/train/arrival/ArrivalMapperTest.java +++ b/src/test/java/com/cta4j/train/common/ArrivalMapperTest.java @@ -1,4 +1,4 @@ -package com.cta4j.train.arrival; +package com.cta4j.train.common; import com.cta4j.train.common.internal.mapper.ArrivalMapper; import com.cta4j.train.common.internal.wire.CtaArrival; @@ -75,4 +75,23 @@ void toDomain_mapsApproachingTrue() { assertThat(arrival.approaching()).isTrue(); } + + @Test + void toDomain_mapsNullLine_whenRtIsNotApplicable() { + CtaArrival wire = new CtaArrival( + "40100", "30070", + "Howard", "Service toward O'Hare", + "123", "N/A", + "30077", "O'Hare", + "1", + "2015-04-30T20:23:53", + "2015-04-30T20:25:00", + "0", "0", "0", "0", + null, null, null, null + ); + + Arrival arrival = ArrivalMapper.INSTANCE.toDomain(wire); + + assertThat(arrival.line()).isNull(); + } } diff --git a/src/test/java/com/cta4j/train/common/TrainQualifiersTest.java b/src/test/java/com/cta4j/train/common/TrainQualifiersTest.java index 3bafa7d1..85177b2c 100644 --- a/src/test/java/com/cta4j/train/common/TrainQualifiersTest.java +++ b/src/test/java/com/cta4j/train/common/TrainQualifiersTest.java @@ -135,6 +135,11 @@ void mapLine_returnsRed_whenLineIsRed() { assertThat(Qualifiers.mapLine("RED")).isEqualTo(TrainLine.RED); } + @Test + void mapLine_returnsNull_whenLineIsNotApplicable() { + assertThat(Qualifiers.mapLine("N/A")).isNull(); + } + @Test void mapTimestamp_returnsInstant_whenTimestampIsValid() { Instant instant = Qualifiers.mapTimestamp("2015-04-30T20:23:53"); @@ -176,25 +181,25 @@ void map15ToTrainDirection_throwsIllegalArgumentException_whenDirectionCodeIsUnr } @Test - void parseCoordinate_returnsBigDecimal_whenValueIsValid() { - assertThat(Qualifiers.parseCoordinate("42.019063")).isEqualByComparingTo(new BigDecimal("42.019063")); + void mapCoordinate_returnsBigDecimal_whenValueIsValid() { + assertThat(Qualifiers.mapCoordinate("42.019063")).isEqualByComparingTo(new BigDecimal("42.019063")); } @Test - void parseCoordinate_throwsIllegalArgumentException_whenValueIsNotNumeric() { + void mapCoordinate_throwsIllegalArgumentException_whenValueIsNotNumeric() { assertThatIllegalArgumentException().isThrownBy(() -> - Qualifiers.parseCoordinate("not-a-number")); + Qualifiers.mapCoordinate("not-a-number")); } @Test - void parseHeading_returnsInt_whenValueIsValid() { - assertThat(Qualifiers.parseHeading("180")).isEqualTo(180); + void mapHeading_returnsInt_whenValueIsValid() { + assertThat(Qualifiers.mapHeading("180")).isEqualTo(180); } @Test - void parseHeading_throwsIllegalArgumentException_whenValueIsNotNumeric() { + void mapHeading_throwsIllegalArgumentException_whenValueIsNotNumeric() { assertThatIllegalArgumentException().isThrownBy(() -> - Qualifiers.parseHeading("not-a-number")); + Qualifiers.mapHeading("not-a-number")); } @Test diff --git a/src/test/java/com/cta4j/train/common/exception/Cta4jTrainExceptionTest.java b/src/test/java/com/cta4j/train/common/exception/Cta4jTrainExceptionTest.java index 10693b55..8c528842 100644 --- a/src/test/java/com/cta4j/train/common/exception/Cta4jTrainExceptionTest.java +++ b/src/test/java/com/cta4j/train/common/exception/Cta4jTrainExceptionTest.java @@ -36,4 +36,4 @@ void constructor_setsMessageEndpointAndRawErrorCode() { assertThat(exception.getCause()).isNull(); assertThat(exception.getRawErrorCode()).isEqualTo(101); } -} \ No newline at end of file +} diff --git a/src/test/java/com/cta4j/train/common/internal/util/TrainApiUtilsTest.java b/src/test/java/com/cta4j/train/common/internal/util/TrainApiUtilsTest.java new file mode 100644 index 00000000..3b6312a9 --- /dev/null +++ b/src/test/java/com/cta4j/train/common/internal/util/TrainApiUtilsTest.java @@ -0,0 +1,34 @@ +package com.cta4j.train.common.internal.util; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.*; + +class TrainApiUtilsTest { + @Test + void requireMaxIds_doesNotThrow_whenIdsIsAtMax() { + List ids = List.of("1", "2", "3", "4"); + + assertThatCode(() -> TrainApiUtils.requireMaxIds(ids, "map")).doesNotThrowAnyException(); + } + + @Test + void requireMaxIds_throwsIllegalArgumentException_whenIdsExceedsMax() { + List ids = List.of("1", "2", "3", "4", "5"); + + assertThatIllegalArgumentException().isThrownBy(() -> TrainApiUtils.requireMaxIds(ids, "map")) + .withMessage("A maximum of 4 map IDs can be requested at once, but 5 were provided"); + } + + @Test + void requireMaxIds_throwsNullPointerException_whenIdsIsNull() { + assertThatNullPointerException().isThrownBy(() -> TrainApiUtils.requireMaxIds(null, "map")); + } + + @Test + void requireMaxIds_throwsNullPointerException_whenLabelIsNull() { + assertThatNullPointerException().isThrownBy(() -> TrainApiUtils.requireMaxIds(List.of(), null)); + } +} diff --git a/src/test/java/com/cta4j/train/common/model/TrainDirectionTest.java b/src/test/java/com/cta4j/train/common/model/TrainDirectionTest.java index 4a4faf96..c28a3a2b 100644 --- a/src/test/java/com/cta4j/train/common/model/TrainDirectionTest.java +++ b/src/test/java/com/cta4j/train/common/model/TrainDirectionTest.java @@ -21,4 +21,4 @@ void getCode_returnsCode() { assertThat(TrainDirection.NORTHBOUND.getCode()).isEqualTo(1); assertThat(TrainDirection.SOUTHBOUND.getCode()).isEqualTo(5); } -} \ No newline at end of file +} diff --git a/src/test/java/com/cta4j/train/common/model/TrainLineTest.java b/src/test/java/com/cta4j/train/common/model/TrainLineTest.java index 3c33a3ff..38cabd24 100644 --- a/src/test/java/com/cta4j/train/common/model/TrainLineTest.java +++ b/src/test/java/com/cta4j/train/common/model/TrainLineTest.java @@ -26,8 +26,9 @@ void fromCode_returnsCorrectValues() { } @Test - void fromCode_throwsIllegalArgumentException_whenCodeIsUnknown() { - assertThatIllegalArgumentException().isThrownBy(() -> TrainLine.fromCode("Unknown")); + void fromCode_returnsNull_whenCodeIsUnrecognized() { + assertThat(TrainLine.fromCode("N/A")).isNull(); + assertThat(TrainLine.fromCode("Unknown")).isNull(); } @Test @@ -35,4 +36,4 @@ void getCode_andGetColorHex_returnValues() { assertThat(TrainLine.RED.getCode()).isEqualTo("Red"); assertThat(TrainLine.RED.getColorHex()).isEqualTo("#C60C30"); } -} \ No newline at end of file +} diff --git a/src/test/java/com/cta4j/train/follow/FollowApiImplTest.java b/src/test/java/com/cta4j/train/follow/FollowApiImplTest.java index 17fa05c6..0ce228a6 100644 --- a/src/test/java/com/cta4j/train/follow/FollowApiImplTest.java +++ b/src/test/java/com/cta4j/train/follow/FollowApiImplTest.java @@ -3,7 +3,6 @@ import com.cta4j.TestFixtures; import com.cta4j.train.common.internal.config.TrainApiConfig; import com.cta4j.train.follow.exception.Cta4jFollowException; -import com.cta4j.train.follow.exception.FollowErrorCode; import com.cta4j.train.follow.internal.impl.FollowApiImpl; import com.cta4j.train.follow.model.FollowTrain; import com.github.tomakehurst.wiremock.WireMockServer; @@ -98,7 +97,7 @@ void findByRun_throwsCta4jFollowException_whenResponseContainsError() { .isInstanceOf(Cta4jFollowException.class) .hasMessage("Invalid API key") .satisfies(e -> assertThat(((Cta4jFollowException) e).getErrorCode()) - .isEqualTo(FollowErrorCode.UNKNOWN)) + .isNull()) .satisfies(e -> assertThat(((Cta4jFollowException) e).getRawErrorCode()).isEqualTo(1)); } @@ -158,7 +157,7 @@ void findByRun_throwsCta4jFollowException_whenErrCdIsNegative() { .isInstanceOf(Cta4jFollowException.class) .hasMessage("Unknown error code") .satisfies(e -> assertThat(((Cta4jFollowException) e).getErrorCode()) - .isEqualTo(FollowErrorCode.UNKNOWN)) + .isNull()) .satisfies(e -> assertThat(((Cta4jFollowException) e).getRawErrorCode()).isEqualTo(-1)); } @@ -174,7 +173,23 @@ void findByRun_throwsCta4jFollowException_withDefaultMessage_whenErrNmIsBlank() .isInstanceOf(Cta4jFollowException.class) .hasMessage("An unknown error occurred.") .satisfies(e -> assertThat(((Cta4jFollowException) e).getErrorCode()) - .isEqualTo(FollowErrorCode.UNKNOWN)) + .isNull()) + .satisfies(e -> assertThat(((Cta4jFollowException) e).getRawErrorCode()).isEqualTo(1)); + } + + @Test + void findByRun_throwsCta4jFollowException_withDefaultMessage_whenErrNmIsAbsent() { + this.server.stubFor(get(urlPathEqualTo("/api/1.0/ttfollow.aspx")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody("{\"ctatt\":{\"tmst\":\"2015-04-30T20:23:53\",\"errCd\":\"1\"}}"))); + + assertThatThrownBy(() -> this.api.findByRun("123")) + .isInstanceOf(Cta4jFollowException.class) + .hasMessage("An unknown error occurred.") + .satisfies(e -> assertThat(((Cta4jFollowException) e).getErrorCode()) + .isNull()) .satisfies(e -> assertThat(((Cta4jFollowException) e).getRawErrorCode()).isEqualTo(1)); } diff --git a/src/test/java/com/cta4j/train/follow/exception/Cta4jFollowExceptionTest.java b/src/test/java/com/cta4j/train/follow/exception/Cta4jFollowExceptionTest.java index 4f1b1f21..ba4a3101 100644 --- a/src/test/java/com/cta4j/train/follow/exception/Cta4jFollowExceptionTest.java +++ b/src/test/java/com/cta4j/train/follow/exception/Cta4jFollowExceptionTest.java @@ -31,10 +31,10 @@ void constructor_setsMessageEndpointAndErrorCode() { } @Test - void constructor_setsUnknownErrorCode_whenRawErrorCodeIsUnrecognized() { + void constructor_setsNullErrorCode_whenRawErrorCodeIsUnrecognized() { Cta4jFollowException exception = new Cta4jFollowException("Something odd happened", 999); assertThat(exception.getRawErrorCode()).isEqualTo(999); - assertThat(exception.getErrorCode()).isEqualTo(FollowErrorCode.UNKNOWN); + assertThat(exception.getErrorCode()).isNull(); } -} \ No newline at end of file +} diff --git a/src/test/java/com/cta4j/train/follow/exception/FollowErrorCodeTest.java b/src/test/java/com/cta4j/train/follow/exception/FollowErrorCodeTest.java index 431e69ba..99559d4d 100644 --- a/src/test/java/com/cta4j/train/follow/exception/FollowErrorCodeTest.java +++ b/src/test/java/com/cta4j/train/follow/exception/FollowErrorCodeTest.java @@ -13,13 +13,12 @@ void fromCode_returnsCorrectValue_forEveryDefinedCode() { } @Test - void fromCode_returnsUnknown_whenCodeIsUnrecognized() { - assertThat(FollowErrorCode.fromCode(999)).isEqualTo(FollowErrorCode.UNKNOWN); + void fromCode_returnsNull_whenCodeIsUnrecognized() { + assertThat(FollowErrorCode.fromCode(999)).isNull(); } @Test void getCode_returnsCode() { assertThat(FollowErrorCode.RUN_NOT_FOUND.getCode()).isEqualTo(501); - assertThat(FollowErrorCode.UNKNOWN.getCode()).isEqualTo(-1); } -} \ No newline at end of file +} diff --git a/src/test/java/com/cta4j/train/location/LocationsApiImplTest.java b/src/test/java/com/cta4j/train/location/LocationsApiImplTest.java index a8c4ffdb..b6ee1701 100644 --- a/src/test/java/com/cta4j/train/location/LocationsApiImplTest.java +++ b/src/test/java/com/cta4j/train/location/LocationsApiImplTest.java @@ -4,7 +4,6 @@ import com.cta4j.train.common.internal.config.TrainApiConfig; import com.cta4j.train.common.model.TrainLine; import com.cta4j.train.location.exception.Cta4jLocationsException; -import com.cta4j.train.location.exception.LocationsErrorCode; import com.cta4j.train.location.internal.impl.LocationsApiImpl; import com.cta4j.train.location.model.TrainLocations; import com.github.tomakehurst.wiremock.WireMockServer; @@ -91,7 +90,7 @@ void findByLines_throwsCta4jLocationsException_whenResponseContainsError() { .isInstanceOf(Cta4jLocationsException.class) .hasMessage("Invalid API key") .satisfies(e -> assertThat(((Cta4jLocationsException) e).getErrorCode()) - .isEqualTo(LocationsErrorCode.UNKNOWN)) + .isNull()) .satisfies(e -> assertThat(((Cta4jLocationsException) e).getRawErrorCode()).isEqualTo(1)); } @@ -139,14 +138,14 @@ void findByLine_sendsRtParameter() { } @Test - void findAll_returnsLocations_whenResponseContainsData() { + void list_returnsLocations_whenResponseContainsData() { this.server.stubFor(get(urlPathEqualTo("/api/1.0/ttpositions.aspx")) .willReturn(aResponse() .withStatus(200) .withHeader("Content-Type", "application/json") .withBody(TestFixtures.read("train/location/success.json")))); - List locations = this.api.findAll(); + List locations = this.api.list(); assertThat(locations).hasSize(1); } @@ -178,7 +177,7 @@ void findByLines_throwsCta4jLocationsException_whenErrCdIsNegative() { .isInstanceOf(Cta4jLocationsException.class) .hasMessage("Unknown error code") .satisfies(e -> assertThat(((Cta4jLocationsException) e).getErrorCode()) - .isEqualTo(LocationsErrorCode.UNKNOWN)) + .isNull()) .satisfies(e -> assertThat(((Cta4jLocationsException) e).getRawErrorCode()).isEqualTo(-1)); } @@ -194,7 +193,23 @@ void findByLines_throwsCta4jLocationsException_withDefaultMessage_whenErrNmIsBla .isInstanceOf(Cta4jLocationsException.class) .hasMessage("An unknown error occurred.") .satisfies(e -> assertThat(((Cta4jLocationsException) e).getErrorCode()) - .isEqualTo(LocationsErrorCode.UNKNOWN)) + .isNull()) + .satisfies(e -> assertThat(((Cta4jLocationsException) e).getRawErrorCode()).isEqualTo(1)); + } + + @Test + void findByLines_throwsCta4jLocationsException_withDefaultMessage_whenErrNmIsAbsent() { + this.server.stubFor(get(urlPathEqualTo("/api/1.0/ttpositions.aspx")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody("{\"ctatt\":{\"tmst\":\"2015-04-30T20:23:53\",\"errCd\":\"1\"}}"))); + + assertThatThrownBy(() -> this.api.findByLines(List.of(TrainLine.RED))) + .isInstanceOf(Cta4jLocationsException.class) + .hasMessage("An unknown error occurred.") + .satisfies(e -> assertThat(((Cta4jLocationsException) e).getErrorCode()) + .isNull()) .satisfies(e -> assertThat(((Cta4jLocationsException) e).getRawErrorCode()).isEqualTo(1)); } diff --git a/src/test/java/com/cta4j/train/location/TrainLocationsMapperTest.java b/src/test/java/com/cta4j/train/location/TrainLocationsMapperTest.java index edb5ba14..8cb28e8c 100644 --- a/src/test/java/com/cta4j/train/location/TrainLocationsMapperTest.java +++ b/src/test/java/com/cta4j/train/location/TrainLocationsMapperTest.java @@ -1,9 +1,11 @@ package com.cta4j.train.location; +import com.cta4j.train.common.model.TrainDirection; import com.cta4j.train.common.model.TrainLine; import com.cta4j.train.location.internal.mapper.TrainLocationsMapper; import com.cta4j.train.location.internal.wire.CtaLocationTrain; import com.cta4j.train.location.internal.wire.CtaRoute; +import com.cta4j.train.location.model.LocationTrain; import com.cta4j.train.location.model.TrainLocations; import org.junit.jupiter.api.Test; @@ -40,4 +42,82 @@ void toDomain_mapsNullTrainListAsEmpty() { assertThat(locations.trains()).isEmpty(); } + + @Test + void toDomain_train_mapsAllFields() { + CtaLocationTrain wire = new CtaLocationTrain( + "123", "30077", "O'Hare", "1", + "40100", "30070", "Howard", + "2015-04-30T20:23:53", + "2015-04-30T20:25:00", + "0", "0", "some-flag", + "41.88", "-87.63", "180" + ); + + LocationTrain train = TrainLocationsMapper.INSTANCE.toDomain(wire); + + assertThat(train.run()).isEqualTo("123"); + assertThat(train.destinationStationId()).isEqualTo("30077"); + assertThat(train.destinationName()).isEqualTo("O'Hare"); + assertThat(train.direction()).isEqualTo(TrainDirection.NORTHBOUND); + assertThat(train.nextStationId()).isEqualTo("40100"); + assertThat(train.nextStopId()).isEqualTo("30070"); + assertThat(train.nextStationName()).isEqualTo("Howard"); + assertThat(train.approaching()).isFalse(); + assertThat(train.delayed()).isFalse(); + assertThat(train.flags()).isEqualTo("some-flag"); + assertThat(train.coordinates()).isNotNull(); + assertThat(train.coordinates().latitude()).isEqualByComparingTo("41.88"); + assertThat(train.coordinates().longitude()).isEqualByComparingTo("-87.63"); + assertThat(train.coordinates().heading()).isEqualTo(180); + } + + @Test + void toDomain_train_mapsSouthboundDirection() { + CtaLocationTrain wire = new CtaLocationTrain( + "123", "30077", "O'Hare", "5", + "40100", "30070", "Howard", + "2015-04-30T20:23:53", + "2015-04-30T20:25:00", + "0", "0", null, + "41.88", "-87.63", "180" + ); + + LocationTrain train = TrainLocationsMapper.INSTANCE.toDomain(wire); + + assertThat(train.direction()).isEqualTo(TrainDirection.SOUTHBOUND); + } + + @Test + void toDomain_train_mapsApproachingAndDelayedTrue() { + CtaLocationTrain wire = new CtaLocationTrain( + "123", "30077", "O'Hare", "1", + "40100", "30070", "Howard", + "2015-04-30T20:23:53", + "2015-04-30T20:25:00", + "1", "1", null, + "41.88", "-87.63", "180" + ); + + LocationTrain train = TrainLocationsMapper.INSTANCE.toDomain(wire); + + assertThat(train.approaching()).isTrue(); + assertThat(train.delayed()).isTrue(); + } + + @Test + void toDomain_train_mapsNullFlagsAsNull() { + CtaLocationTrain wire = new CtaLocationTrain( + "123", "30077", "O'Hare", "1", + "40100", "30070", "Howard", + "2015-04-30T20:23:53", + "2015-04-30T20:25:00", + "0", "0", null, + "41.88", "-87.63", "180" + ); + + LocationTrain train = TrainLocationsMapper.INSTANCE.toDomain(wire); + + assertThat(train.flags()).isNull(); + } } diff --git a/src/test/java/com/cta4j/train/location/exception/Cta4jLocationsExceptionTest.java b/src/test/java/com/cta4j/train/location/exception/Cta4jLocationsExceptionTest.java index 3e59f433..d59ef868 100644 --- a/src/test/java/com/cta4j/train/location/exception/Cta4jLocationsExceptionTest.java +++ b/src/test/java/com/cta4j/train/location/exception/Cta4jLocationsExceptionTest.java @@ -31,10 +31,10 @@ void constructor_setsMessageEndpointAndErrorCode() { } @Test - void constructor_setsUnknownErrorCode_whenRawErrorCodeIsUnrecognized() { + void constructor_setsNullErrorCode_whenRawErrorCodeIsUnrecognized() { Cta4jLocationsException exception = new Cta4jLocationsException("Something odd happened", 999); assertThat(exception.getRawErrorCode()).isEqualTo(999); - assertThat(exception.getErrorCode()).isEqualTo(LocationsErrorCode.UNKNOWN); + assertThat(exception.getErrorCode()).isNull(); } -} \ No newline at end of file +} diff --git a/src/test/java/com/cta4j/train/location/exception/LocationsErrorCodeTest.java b/src/test/java/com/cta4j/train/location/exception/LocationsErrorCodeTest.java index e0325e7e..b08db97b 100644 --- a/src/test/java/com/cta4j/train/location/exception/LocationsErrorCodeTest.java +++ b/src/test/java/com/cta4j/train/location/exception/LocationsErrorCodeTest.java @@ -13,13 +13,12 @@ void fromCode_returnsCorrectValue_forEveryDefinedCode() { } @Test - void fromCode_returnsUnknown_whenCodeIsUnrecognized() { - assertThat(LocationsErrorCode.fromCode(999)).isEqualTo(LocationsErrorCode.UNKNOWN); + void fromCode_returnsNull_whenCodeIsUnrecognized() { + assertThat(LocationsErrorCode.fromCode(999)).isNull(); } @Test void getCode_returnsCode() { assertThat(LocationsErrorCode.INVALID_ROUTE.getCode()).isEqualTo(106); - assertThat(LocationsErrorCode.UNKNOWN.getCode()).isEqualTo(-1); } -} \ No newline at end of file +} diff --git a/src/test/java/com/cta4j/train/station/model/CardinalDirectionTest.java b/src/test/java/com/cta4j/train/station/model/CardinalDirectionTest.java index 7b25db0f..bb429f64 100644 --- a/src/test/java/com/cta4j/train/station/model/CardinalDirectionTest.java +++ b/src/test/java/com/cta4j/train/station/model/CardinalDirectionTest.java @@ -21,4 +21,4 @@ void fromCode_returnsCorrectValues() { void fromCode_throwsIllegalArgumentException_whenCodeIsUnknown() { assertThatIllegalArgumentException().isThrownBy(() -> CardinalDirection.fromCode("X")); } -} \ No newline at end of file +} diff --git a/src/test/resources/alert/detailedalert/bad_error_code.json b/src/test/resources/alert/detailedalert/bad_error_code.json new file mode 100644 index 00000000..24b39b75 --- /dev/null +++ b/src/test/resources/alert/detailedalert/bad_error_code.json @@ -0,0 +1,7 @@ +{ + "CTAAlerts": { + "TimeStamp": "2026-07-28T12:00:00", + "ErrorCode": "abc", + "ErrorMessage": "Something odd happened" + } +} diff --git a/src/test/resources/alert/detailedalert/empty_alert_array.json b/src/test/resources/alert/detailedalert/empty_alert_array.json new file mode 100644 index 00000000..841eef0f --- /dev/null +++ b/src/test/resources/alert/detailedalert/empty_alert_array.json @@ -0,0 +1,8 @@ +{ + "CTAAlerts": { + "TimeStamp": "2026-07-28T12:00:00", + "ErrorCode": "0", + "ErrorMessage": null, + "Alert": [] + } +} diff --git a/src/test/resources/alert/detailedalert/error_message_absent.json b/src/test/resources/alert/detailedalert/error_message_absent.json new file mode 100644 index 00000000..92365167 --- /dev/null +++ b/src/test/resources/alert/detailedalert/error_message_absent.json @@ -0,0 +1,6 @@ +{ + "CTAAlerts": { + "TimeStamp": "2026-07-28T12:00:00", + "ErrorCode": "500" + } +} diff --git a/src/test/resources/alert/detailedalert/error_message_blank.json b/src/test/resources/alert/detailedalert/error_message_blank.json new file mode 100644 index 00000000..d7da5ead --- /dev/null +++ b/src/test/resources/alert/detailedalert/error_message_blank.json @@ -0,0 +1,7 @@ +{ + "CTAAlerts": { + "TimeStamp": "2026-07-28T12:00:00", + "ErrorCode": "900", + "ErrorMessage": "" + } +} diff --git a/src/test/resources/alert/detailedalert/fatal_error.json b/src/test/resources/alert/detailedalert/fatal_error.json new file mode 100644 index 00000000..b19daa12 --- /dev/null +++ b/src/test/resources/alert/detailedalert/fatal_error.json @@ -0,0 +1,7 @@ +{ + "CTAAlerts": { + "TimeStamp": "2026-07-28T12:00:00", + "ErrorCode": "100", + "ErrorMessage": "Invalid option for parameter 'activeonly': Valid options are 'true', 'false'" + } +} diff --git a/src/test/resources/alert/detailedalert/list_success.json b/src/test/resources/alert/detailedalert/list_success.json new file mode 100644 index 00000000..408e9d24 --- /dev/null +++ b/src/test/resources/alert/detailedalert/list_success.json @@ -0,0 +1,104 @@ +{ + "CTAAlerts": { + "TimeStamp": "2026-07-28T12:00:00", + "ErrorCode": "0", + "ErrorMessage": null, + "Alert": [ + { + "AlertId": "115070", + "Headline": "Route 22 Rerouted", + "ShortDescription": "Route 22 is rerouted due to construction", + "FullDescription": {"#cdata-section": "Route 22 buses are being rerouted due to construction on Clark St."}, + "SeverityScore": "37", + "SeverityColor": "06c", + "SeverityCSS": "planned", + "Impact": "Planned Reroute", + "EventStart": "2026-07-01T05:00:00", + "EventEnd": "2026-08-01T05:00:00", + "TBD": "0", + "MajorAlert": "0", + "AlertURL": {"#cdata-section": "http://www.transitchicago.com/alerts/115070"}, + "ImpactedService": { + "Service": { + "ServiceType": "B", + "ServiceTypeDescription": "Bus Route", + "ServiceName": "Clark", + "ServiceId": "22", + "ServiceBackColor": "565a5c", + "ServiceTextColor": "ffffff", + "ServiceURL": {"#cdata-section": "http://www.transitchicago.com/bus/22/"} + } + }, + "ttim": "0", + "GUID": "664b81c1-197b-450b-a00c-090483b90bb9" + }, + { + "AlertId": "115080", + "Headline": "Austin Main Stationhouse Temporarily Closed", + "ShortDescription": "Elevator out of service", + "FullDescription": {"#cdata-section": "The elevator at Austin is out of service."}, + "SeverityScore": "9", + "SeverityColor": "000000", + "SeverityCSS": "minor", + "Impact": "Elevator Status", + "EventStart": "2026-07-15T00:00:00", + "EventEnd": null, + "TBD": "1", + "MajorAlert": "0", + "AlertURL": {"#cdata-section": "http://www.transitchicago.com/alerts/115080"}, + "ImpactedService": { + "Service": [ + { + "ServiceType": "T", + "ServiceTypeDescription": "Train Station", + "ServiceName": "Austin", + "ServiceId": "41260", + "ServiceBackColor": "009b3a", + "ServiceTextColor": "FFFFFF", + "ServiceURL": {"#cdata-section": "http://www.transitchicago.com/travel_information/station.aspx?StopId=24"} + }, + { + "ServiceType": "R", + "ServiceTypeDescription": "Train Route", + "ServiceName": "Green Line", + "ServiceId": "G", + "ServiceBackColor": "009b3a", + "ServiceTextColor": "FFFFFF", + "ServiceURL": {"#cdata-section": "http://www.transitchicago.com/greenline/"} + } + ] + }, + "ttim": "1", + "GUID": "d41b2532-09ca-4827-b9c6-f4299cc86fb6" + }, + { + "AlertId": "115090", + "Headline": "Later, More Frequent Weekend Service", + "ShortDescription": "Service is being increased on the South Pulaski corridor.", + "FullDescription": {"#cdata-section": "Later evening and more frequent weekend service will operate on the South Pulaski corridor."}, + "SeverityScore": "11", + "SeverityColor": "000000", + "SeverityCSS": "normal", + "Impact": "Added Service", + "EventStart": "2025-11-07", + "EventEnd": "2027-09-30", + "TBD": "0", + "MajorAlert": "0", + "AlertURL": {"#cdata-section": "http://www.transitchicago.com/alerts/115090"}, + "ImpactedService": { + "Service": { + "ServiceType": "B", + "ServiceTypeDescription": "Bus Route", + "ServiceName": "South Pulaski", + "ServiceId": "53A", + "ServiceBackColor": "059", + "ServiceTextColor": "ffffff", + "ServiceURL": {"#cdata-section": "http://www.transitchicago.com/riding_cta/bus_route.aspx?RouteId=207"} + } + }, + "ttim": "0", + "GUID": "9979cd0c-a29d-4b52-805d-4baa0b32322b" + } + ] + } +} diff --git a/src/test/resources/alert/detailedalert/no_active_alerts.json b/src/test/resources/alert/detailedalert/no_active_alerts.json new file mode 100644 index 00000000..69deaf3a --- /dev/null +++ b/src/test/resources/alert/detailedalert/no_active_alerts.json @@ -0,0 +1,7 @@ +{ + "CTAAlerts": { + "TimeStamp": "2026-07-28T12:00:00", + "ErrorCode": "25", + "ErrorMessage": "There are no active alerts" + } +} diff --git a/src/test/resources/alert/detailedalert/no_active_alerts_for_filter.json b/src/test/resources/alert/detailedalert/no_active_alerts_for_filter.json new file mode 100644 index 00000000..efe328d6 --- /dev/null +++ b/src/test/resources/alert/detailedalert/no_active_alerts_for_filter.json @@ -0,0 +1,7 @@ +{ + "CTAAlerts": { + "TimeStamp": "2026-07-28T12:00:00", + "ErrorCode": "50", + "ErrorMessage": "There are no active alerts based on your filter criteria" + } +} diff --git a/src/test/resources/alert/detailedalert/ok_no_alerts.json b/src/test/resources/alert/detailedalert/ok_no_alerts.json new file mode 100644 index 00000000..88f7d98c --- /dev/null +++ b/src/test/resources/alert/detailedalert/ok_no_alerts.json @@ -0,0 +1,6 @@ +{ + "CTAAlerts": { + "TimeStamp": "2026-07-28T12:00:00", + "ErrorCode": "0" + } +} diff --git a/src/test/resources/alert/routestatus/bad_error_code.json b/src/test/resources/alert/routestatus/bad_error_code.json new file mode 100644 index 00000000..88b52794 --- /dev/null +++ b/src/test/resources/alert/routestatus/bad_error_code.json @@ -0,0 +1,6 @@ +{ + "CTARoutes": { + "TimeStamp": "2026-07-17T14:40:15", + "ErrorCode": "notanumber" + } +} diff --git a/src/test/resources/alert/routestatus/blank_error_message.json b/src/test/resources/alert/routestatus/blank_error_message.json new file mode 100644 index 00000000..490c9d11 --- /dev/null +++ b/src/test/resources/alert/routestatus/blank_error_message.json @@ -0,0 +1,7 @@ +{ + "CTARoutes": { + "TimeStamp": "2026-07-17T14:40:15", + "ErrorCode": "900", + "ErrorMessage": "" + } +} diff --git a/src/test/resources/alert/routestatus/bus_success.json b/src/test/resources/alert/routestatus/bus_success.json new file mode 100644 index 00000000..8ccdd310 --- /dev/null +++ b/src/test/resources/alert/routestatus/bus_success.json @@ -0,0 +1,14 @@ +{ + "CTARoutes": { + "TimeStamp": "2026-07-17T14:10:43", + "RouteInfo": { + "Route": "Clark", + "RouteColorCode": "565a5c", + "RouteTextColor": "ffffff", + "ServiceId": "22", + "RouteURL": {"#cdata-section": "http://www.transitchicago.com/bus/22/"}, + "RouteStatus": "Bus Stop Note", + "RouteStatusColor": "000000" + } + } +} diff --git a/src/test/resources/alert/routestatus/distinct_error_codes.json b/src/test/resources/alert/routestatus/distinct_error_codes.json new file mode 100644 index 00000000..e5d589fe --- /dev/null +++ b/src/test/resources/alert/routestatus/distinct_error_codes.json @@ -0,0 +1,6 @@ +{ + "CTARoutes": { + "TimeStamp": "2026-07-17T14:40:15", + "ErrorCode": ["0", "50"] + } +} diff --git a/src/test/resources/alert/routestatus/empty_route_info_array.json b/src/test/resources/alert/routestatus/empty_route_info_array.json new file mode 100644 index 00000000..41969af9 --- /dev/null +++ b/src/test/resources/alert/routestatus/empty_route_info_array.json @@ -0,0 +1,8 @@ +{ + "CTARoutes": { + "TimeStamp": "2026-07-17T14:40:15", + "ErrorCode": "0", + "ErrorMessage": null, + "RouteInfo": [] + } +} diff --git a/src/test/resources/alert/routestatus/error_code_empty_array.json b/src/test/resources/alert/routestatus/error_code_empty_array.json new file mode 100644 index 00000000..46808a3e --- /dev/null +++ b/src/test/resources/alert/routestatus/error_code_empty_array.json @@ -0,0 +1,6 @@ +{ + "CTARoutes": { + "TimeStamp": "2026-07-17T14:40:15", + "ErrorCode": [] + } +} diff --git a/src/test/resources/alert/routestatus/error_message_empty_array.json b/src/test/resources/alert/routestatus/error_message_empty_array.json new file mode 100644 index 00000000..b04725c5 --- /dev/null +++ b/src/test/resources/alert/routestatus/error_message_empty_array.json @@ -0,0 +1,7 @@ +{ + "CTARoutes": { + "TimeStamp": "2026-07-17T14:40:15", + "ErrorCode": "101", + "ErrorMessage": [] + } +} diff --git a/src/test/resources/alert/routestatus/error_no_message.json b/src/test/resources/alert/routestatus/error_no_message.json new file mode 100644 index 00000000..0a017d3e --- /dev/null +++ b/src/test/resources/alert/routestatus/error_no_message.json @@ -0,0 +1,6 @@ +{ + "CTARoutes": { + "TimeStamp": "2026-07-17T14:40:15", + "ErrorCode": "101" + } +} diff --git a/src/test/resources/alert/routestatus/error_null_message_element.json b/src/test/resources/alert/routestatus/error_null_message_element.json new file mode 100644 index 00000000..05c602b7 --- /dev/null +++ b/src/test/resources/alert/routestatus/error_null_message_element.json @@ -0,0 +1,7 @@ +{ + "CTARoutes": { + "TimeStamp": "2026-07-17T14:40:15", + "ErrorCode": "101", + "ErrorMessage": [null] + } +} diff --git a/src/test/resources/alert/routestatus/invalid_type_error.json b/src/test/resources/alert/routestatus/invalid_type_error.json new file mode 100644 index 00000000..1dfb91af --- /dev/null +++ b/src/test/resources/alert/routestatus/invalid_type_error.json @@ -0,0 +1,7 @@ +{ + "CTARoutes": { + "TimeStamp": "2026-07-17T14:14:52", + "ErrorCode": "101", + "ErrorMessage": "Invalid option for parameter 'type': Valid options are 'bus', 'rail', 'station' or 'systemwide'" + } +} diff --git a/src/test/resources/alert/routestatus/list_success.json b/src/test/resources/alert/routestatus/list_success.json new file mode 100644 index 00000000..9b47acf0 --- /dev/null +++ b/src/test/resources/alert/routestatus/list_success.json @@ -0,0 +1,36 @@ +{ + "CTARoutes": { + "TimeStamp": "2026-07-17T14:06:58", + "ErrorCode": ["0", "0"], + "ErrorMessage": [null, null], + "RouteInfo": [ + { + "Route": "Red Line", + "RouteColorCode": "c60c30", + "RouteTextColor": "ffffff", + "ServiceId": "Red", + "RouteURL": {"#cdata-section": "http://www.transitchicago.com/redline/"}, + "RouteStatus": "Normal Service", + "RouteStatusColor": "404040" + }, + { + "Route": "Clark", + "RouteColorCode": "565a5c", + "RouteTextColor": "ffffff", + "ServiceId": "22", + "RouteURL": {"#cdata-section": "http://www.transitchicago.com/bus/22/"}, + "RouteStatus": "Bus Stop Note", + "RouteStatusColor": "000000" + }, + { + "Route": "All Routes", + "RouteColorCode": "000000", + "RouteTextColor": "ffffff", + "ServiceId": "Systemwide", + "RouteURL": {"#cdata-section": "http://www.transitchicago.com/travel_information/systemalerts.aspx"}, + "RouteStatus": "Normal Service", + "RouteStatusColor": "404040" + } + ] + } +} diff --git a/src/test/resources/alert/routestatus/no_data_no_error.json b/src/test/resources/alert/routestatus/no_data_no_error.json new file mode 100644 index 00000000..4ee45370 --- /dev/null +++ b/src/test/resources/alert/routestatus/no_data_no_error.json @@ -0,0 +1,5 @@ +{ + "CTARoutes": { + "TimeStamp": "2026-07-17T14:40:15" + } +} diff --git a/src/test/resources/alert/routestatus/no_results.json b/src/test/resources/alert/routestatus/no_results.json new file mode 100644 index 00000000..40dd851b --- /dev/null +++ b/src/test/resources/alert/routestatus/no_results.json @@ -0,0 +1,7 @@ +{ + "CTARoutes": { + "TimeStamp": "2026-07-17T14:40:15", + "ErrorCode": "50", + "ErrorMessage": "There are no routes based on your filter criteria" + } +} diff --git a/src/test/resources/alert/routestatus/rail_success.json b/src/test/resources/alert/routestatus/rail_success.json new file mode 100644 index 00000000..4aa296b7 --- /dev/null +++ b/src/test/resources/alert/routestatus/rail_success.json @@ -0,0 +1,27 @@ +{ + "CTARoutes": { + "TimeStamp": "2026-07-17T14:14:52", + "ErrorCode": "0", + "ErrorMessage": null, + "RouteInfo": [ + { + "Route": "Red Line", + "RouteColorCode": "c60c30", + "RouteTextColor": "ffffff", + "ServiceId": "Red", + "RouteURL": {"#cdata-section": "http://www.transitchicago.com/redline/"}, + "RouteStatus": "Normal Service", + "RouteStatusColor": "404040" + }, + { + "Route": "Blue Line", + "RouteColorCode": "00a1de", + "RouteTextColor": "FFFFFF", + "ServiceId": "Blue", + "RouteURL": {"#cdata-section": "http://www.transitchicago.com/blueline/"}, + "RouteStatus": "Normal Service", + "RouteStatusColor": "404040" + } + ] + } +} diff --git a/src/test/resources/bus/detour/empty_dtrs_array.json b/src/test/resources/bus/detour/empty_dtrs_array.json new file mode 100644 index 00000000..37586a1a --- /dev/null +++ b/src/test/resources/bus/detour/empty_dtrs_array.json @@ -0,0 +1,5 @@ +{ + "bustime-response": { + "dtrs": [] + } +} diff --git a/src/test/resources/bus/direction/empty_directions_array.json b/src/test/resources/bus/direction/empty_directions_array.json new file mode 100644 index 00000000..afb106f7 --- /dev/null +++ b/src/test/resources/bus/direction/empty_directions_array.json @@ -0,0 +1,5 @@ +{ + "bustime-response": { + "directions": [] + } +} diff --git a/src/test/resources/bus/locale/empty_locale_array.json b/src/test/resources/bus/locale/empty_locale_array.json new file mode 100644 index 00000000..b94eccc0 --- /dev/null +++ b/src/test/resources/bus/locale/empty_locale_array.json @@ -0,0 +1,5 @@ +{ + "bustime-response": { + "locale": [] + } +} diff --git a/src/test/resources/bus/pattern/empty_ptr_array.json b/src/test/resources/bus/pattern/empty_ptr_array.json new file mode 100644 index 00000000..a424747f --- /dev/null +++ b/src/test/resources/bus/pattern/empty_ptr_array.json @@ -0,0 +1,5 @@ +{ + "bustime-response": { + "ptr": [] + } +} diff --git a/src/test/resources/bus/prediction/empty_prd_array.json b/src/test/resources/bus/prediction/empty_prd_array.json new file mode 100644 index 00000000..f2922acc --- /dev/null +++ b/src/test/resources/bus/prediction/empty_prd_array.json @@ -0,0 +1,5 @@ +{ + "bustime-response": { + "prd": [] + } +} diff --git a/src/test/resources/bus/route/empty_routes_array.json b/src/test/resources/bus/route/empty_routes_array.json new file mode 100644 index 00000000..adcbadc0 --- /dev/null +++ b/src/test/resources/bus/route/empty_routes_array.json @@ -0,0 +1,5 @@ +{ + "bustime-response": { + "routes": [] + } +} diff --git a/src/test/resources/bus/time/empty-error-array.json b/src/test/resources/bus/time/empty-error-array.json new file mode 100644 index 00000000..d69e6eed --- /dev/null +++ b/src/test/resources/bus/time/empty-error-array.json @@ -0,0 +1,5 @@ +{ + "bustime-response": { + "error": [] + } +} diff --git a/src/test/resources/bus/vehicle/empty_vehicle_array.json b/src/test/resources/bus/vehicle/empty_vehicle_array.json new file mode 100644 index 00000000..d80b5740 --- /dev/null +++ b/src/test/resources/bus/vehicle/empty_vehicle_array.json @@ -0,0 +1,5 @@ +{ + "bustime-response": { + "vehicle": [] + } +}