diff --git a/.github/workflows/CI.yaml b/.github/workflows/CI.yaml index 0f91e86..c4b7061 100644 --- a/.github/workflows/CI.yaml +++ b/.github/workflows/CI.yaml @@ -29,6 +29,13 @@ jobs: - name: Build and test run: ./gradlew build + - name: Extract snapshot version + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + id: snapshot + run: | + VERSION=$(grep '^VERSION_NAME=' gradle.properties | cut -d'=' -f2) + echo "VERSION=${VERSION}-SNAPSHOT" >> $GITHUB_OUTPUT + - name: Publish SNAPSHOT (main only) if: github.event_name == 'push' && github.ref == 'refs/heads/main' env: @@ -36,7 +43,7 @@ jobs: SONATYPE_PASSWORD: ${{ secrets.SONATYPE_PASSWORD }} GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }} GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} - run: ./gradlew publishAllPublicationsToCentralPortalSnapshots -x test + run: ./gradlew publishAllPublicationsToCentralPortalSnapshots -Pversion=${{ steps.snapshot.outputs.VERSION }} -x test - name: Update dependency graph uses: gradle/actions/dependency-submission@v5 diff --git a/Makefile b/Makefile index f5096ed..50f70e4 100644 --- a/Makefile +++ b/Makefile @@ -15,3 +15,7 @@ test-detail: .PHONY: clean clean: ./gradlew clean --no-configuration-cache + +.PHONY: lint +lint: + ./gradlew check --rerun-tasks --no-configuration-cache diff --git a/build.gradle.kts b/build.gradle.kts index da64af4..e42ed5d 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -17,7 +17,8 @@ plugins { `java-library` `maven-publish` signing - id("com.gradleup.nmcp") version "1.2.1" + id("com.gradleup.nmcp") version "1.4.3" + id("com.diffplug.spotless") version "6.25.0" } // Load .env file if it exists @@ -68,13 +69,26 @@ val javaLauncherForTargetVersion = javaToolchainService.launcherFor { } dependencies { - implementation(platform("tools.jackson:jackson-bom:3.0.3")) + implementation(platform("tools.jackson:jackson-bom:3.0.4")) implementation("tools.jackson.core:jackson-databind") implementation("com.fasterxml.jackson.core:jackson-annotations") testImplementation("org.junit.jupiter:junit-jupiter:6.0.2") testRuntimeOnly("org.junit.platform:junit-platform-launcher:6.0.2") } +spotless { + java { + target("src/**/*.java") + trimTrailingWhitespace() + endWithNewline() + } + format("misc") { + target("*.gradle.kts", "*.md", ".gitignore", "Makefile") + trimTrailingWhitespace() + endWithNewline() + } +} + tasks.withType().configureEach { options.release.set(javaTargetVersion) options.compilerArgs.add("-Xlint:unchecked") @@ -136,6 +150,10 @@ tasks.withType().configureEach { javaLauncher.set(javaLauncherForTargetVersion) } +tasks.named("check") { + dependsOn("spotlessCheck") +} + tasks.register("cli") { description = "Runs the Apple Maps CLI against the live API." group = "application" diff --git a/src/main/java/com/williamcallahan/applemaps/AppleMaps.java b/src/main/java/com/williamcallahan/applemaps/AppleMaps.java index b0f5b14..9e72132 100644 --- a/src/main/java/com/williamcallahan/applemaps/AppleMaps.java +++ b/src/main/java/com/williamcallahan/applemaps/AppleMaps.java @@ -147,7 +147,7 @@ public List resolveCompletionUrls(List resul if (results.isEmpty()) { return List.of(); } - + List> futures = results.stream() .map(result -> CompletableFuture.supplyAsync(() -> gateway.resolveCompletionUrl(result.completionUrl()))) .toList(); diff --git a/src/main/java/com/williamcallahan/applemaps/adapters/mapsserver/AppleMapsAuthorizationService.java b/src/main/java/com/williamcallahan/applemaps/adapters/mapsserver/AppleMapsAuthorizationService.java index deb4ece..e8c9a71 100644 --- a/src/main/java/com/williamcallahan/applemaps/adapters/mapsserver/AppleMapsAuthorizationService.java +++ b/src/main/java/com/williamcallahan/applemaps/adapters/mapsserver/AppleMapsAuthorizationService.java @@ -9,6 +9,7 @@ import java.time.Instant; import java.util.Base64; import java.util.Objects; +import java.util.Optional; import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.locks.ReentrantLock; @@ -29,7 +30,7 @@ public final class AppleMapsAuthorizationService { private final URI tokenUri; private final Duration timeout; private final String authToken; - private final String origin; + private final Optional origin; private final Clock clock; private final ReentrantLock refreshLock = new ReentrantLock(); private final AtomicReference accessToken = new AtomicReference<>(); @@ -39,6 +40,7 @@ public final class AppleMapsAuthorizationService { * * @param authToken the Apple Maps Server API authorization token * @param timeout request timeout for token exchange + * @param origin optional Origin header value for token requests */ public AppleMapsAuthorizationService(String authToken, Duration timeout, String origin) { this(new Dependencies(authToken, timeout, origin)); @@ -50,11 +52,17 @@ public AppleMapsAuthorizationService(String authToken, Duration timeout, String this.tokenUri = dependencies.tokenUri(); this.timeout = dependencies.timeout(); this.authToken = dependencies.authToken(); - this.origin = dependencies.origin(); + this.origin = Optional.ofNullable(dependencies.origin()) + .filter(value -> !value.isBlank()); this.clock = dependencies.clock(); } - - public String getOrigin() { + + /** + * Returns the configured Origin header value, if any. + * + * @return the Origin header value, or empty when not set + */ + public Optional getOrigin() { return origin; } @@ -87,10 +95,8 @@ private AccessToken refreshAccessToken() { .timeout(timeout) .uri(tokenUri) .setHeader("Authorization", "Bearer " + authToken); - - if (origin != null) { - builder.setHeader("Origin", origin); - } + + origin.ifPresent(value -> builder.setHeader("Origin", value)); HttpRequest httpRequest = builder.build(); try { diff --git a/src/main/java/com/williamcallahan/applemaps/adapters/mapsserver/HttpAppleMapsGateway.java b/src/main/java/com/williamcallahan/applemaps/adapters/mapsserver/HttpAppleMapsGateway.java index d421b85..f39c097 100644 --- a/src/main/java/com/williamcallahan/applemaps/adapters/mapsserver/HttpAppleMapsGateway.java +++ b/src/main/java/com/williamcallahan/applemaps/adapters/mapsserver/HttpAppleMapsGateway.java @@ -68,6 +68,13 @@ public HttpAppleMapsGateway(String authToken, Duration timeout) { this(new Dependencies(authToken, timeout, null)); } + /** + * Creates an HTTP gateway that calls the Apple Maps Server API with an optional Origin header. + * + * @param authToken the Apple Maps Server API authorization token + * @param timeout request timeout + * @param origin optional Origin header value to include in requests + */ public HttpAppleMapsGateway(String authToken, Duration timeout, String origin) { this(new Dependencies(authToken, timeout, origin)); } @@ -170,17 +177,15 @@ private URI buildUri(String path, String queryString) { } private T invokeApi(String operation, URI uri, Class responseType) { - HttpRequest.Builder builder = HttpRequest.newBuilder() .GET() .uri(uri) .timeout(timeout) .setHeader("Authorization", "Bearer " + authorizationService.getAccessToken()); - if (authorizationService.getOrigin() != null) { - builder.setHeader("Origin", authorizationService.getOrigin()); - } - + authorizationService.getOrigin() + .ifPresent(value -> builder.setHeader("Origin", value)); + HttpRequest httpRequest = builder.build(); try { HttpResponse response = httpClient.send(httpRequest, HttpResponse.BodyHandlers.ofByteArray()); diff --git a/src/main/java/com/williamcallahan/applemaps/domain/model/AlternateIdsEntry.java b/src/main/java/com/williamcallahan/applemaps/domain/model/AlternateIdsEntry.java index 13332bf..79bd8fc 100644 --- a/src/main/java/com/williamcallahan/applemaps/domain/model/AlternateIdsEntry.java +++ b/src/main/java/com/williamcallahan/applemaps/domain/model/AlternateIdsEntry.java @@ -24,6 +24,11 @@ private static Optional normalizeOptional(Optional optionalInput } private static List normalizeList(List rawList) { - return List.copyOf(Objects.requireNonNullElse(rawList, List.of())); + if (rawList == null) { + return List.of(); + } + return rawList.stream() + .filter(Objects::nonNull) + .toList(); } } diff --git a/src/main/java/com/williamcallahan/applemaps/domain/model/AlternateIdsResponse.java b/src/main/java/com/williamcallahan/applemaps/domain/model/AlternateIdsResponse.java index 00af8b4..e8db6b4 100644 --- a/src/main/java/com/williamcallahan/applemaps/domain/model/AlternateIdsResponse.java +++ b/src/main/java/com/williamcallahan/applemaps/domain/model/AlternateIdsResponse.java @@ -19,6 +19,11 @@ public record AlternateIdsResponse(List results, List List normalizeList(List rawList) { - return List.copyOf(Objects.requireNonNullElse(rawList, List.of())); + if (rawList == null) { + return List.of(); + } + return rawList.stream() + .filter(Objects::nonNull) + .toList(); } } diff --git a/src/main/java/com/williamcallahan/applemaps/domain/model/AutocompleteResult.java b/src/main/java/com/williamcallahan/applemaps/domain/model/AutocompleteResult.java index 5286e44..f89316a 100644 --- a/src/main/java/com/williamcallahan/applemaps/domain/model/AutocompleteResult.java +++ b/src/main/java/com/williamcallahan/applemaps/domain/model/AutocompleteResult.java @@ -33,6 +33,11 @@ private static Optional normalizeOptional(Optional optionalInput) { } private static List normalizeList(List rawList) { - return List.copyOf(Objects.requireNonNullElse(rawList, List.of())); + if (rawList == null) { + return List.of(); + } + return rawList.stream() + .filter(Objects::nonNull) + .toList(); } } diff --git a/src/main/java/com/williamcallahan/applemaps/domain/model/DirectionsEndpoint.java b/src/main/java/com/williamcallahan/applemaps/domain/model/DirectionsEndpoint.java index e6fcac5..0502fdf 100644 --- a/src/main/java/com/williamcallahan/applemaps/domain/model/DirectionsEndpoint.java +++ b/src/main/java/com/williamcallahan/applemaps/domain/model/DirectionsEndpoint.java @@ -35,6 +35,7 @@ public static DirectionsEndpoint fromAddress(String address) { * @return a directions endpoint */ public static DirectionsEndpoint fromLatitudeLongitude(double latitude, double longitude) { + Location.validateLatitudeLongitude(latitude, longitude); return new DirectionsEndpoint(formatCoordinatePair(latitude, longitude)); } diff --git a/src/main/java/com/williamcallahan/applemaps/domain/model/DirectionsResponse.java b/src/main/java/com/williamcallahan/applemaps/domain/model/DirectionsResponse.java index 936dcf4..fd7a401 100644 --- a/src/main/java/com/williamcallahan/applemaps/domain/model/DirectionsResponse.java +++ b/src/main/java/com/williamcallahan/applemaps/domain/model/DirectionsResponse.java @@ -36,13 +36,30 @@ private static Optional normalizeOptional(Optional optionalInput) { } private static List normalizeList(List rawList) { - return List.copyOf(Objects.requireNonNullElse(rawList, List.of())); + if (rawList == null) { + return List.of(); + } + return rawList.stream() + .filter(Objects::nonNull) + .toList(); } private static List> normalizeStepPaths(List> rawList) { - List> normalizedPaths = Objects.requireNonNullElse(rawList, List.>of()).stream() - .map(path -> List.copyOf(Objects.requireNonNullElse(path, List.of()))) + if (rawList == null) { + return List.of(); + } + return rawList.stream() + .map(DirectionsResponse::normalizeStepPath) .toList(); - return List.copyOf(normalizedPaths); } + + private static List normalizeStepPath(List rawPath) { + if (rawPath == null) { + return List.of(); + } + return rawPath.stream() + .filter(Objects::nonNull) + .toList(); + } + } diff --git a/src/main/java/com/williamcallahan/applemaps/domain/model/DirectionsRoute.java b/src/main/java/com/williamcallahan/applemaps/domain/model/DirectionsRoute.java index a47ae5b..b2e97a5 100644 --- a/src/main/java/com/williamcallahan/applemaps/domain/model/DirectionsRoute.java +++ b/src/main/java/com/williamcallahan/applemaps/domain/model/DirectionsRoute.java @@ -39,6 +39,11 @@ private static Optional normalizeOptional(Optional optionalInput) { } private static List normalizeList(List rawList) { - return List.copyOf(Objects.requireNonNullElse(rawList, List.of())); + if (rawList == null) { + return List.of(); + } + return rawList.stream() + .filter(Objects::nonNull) + .toList(); } } diff --git a/src/main/java/com/williamcallahan/applemaps/domain/model/ErrorResponse.java b/src/main/java/com/williamcallahan/applemaps/domain/model/ErrorResponse.java index 88a2cd9..6ee577e 100644 --- a/src/main/java/com/williamcallahan/applemaps/domain/model/ErrorResponse.java +++ b/src/main/java/com/williamcallahan/applemaps/domain/model/ErrorResponse.java @@ -15,6 +15,15 @@ public record ErrorResponse(String message, List details) { */ public ErrorResponse { message = Objects.requireNonNull(message, "message"); - details = List.copyOf(Objects.requireNonNullElse(details, List.of())); + details = normalizeList(details); + } + + private static List normalizeList(List rawList) { + if (rawList == null) { + return List.of(); + } + return rawList.stream() + .filter(Objects::nonNull) + .toList(); } } diff --git a/src/main/java/com/williamcallahan/applemaps/domain/model/EtaResponse.java b/src/main/java/com/williamcallahan/applemaps/domain/model/EtaResponse.java index 7312fd8..1cc0f2e 100644 --- a/src/main/java/com/williamcallahan/applemaps/domain/model/EtaResponse.java +++ b/src/main/java/com/williamcallahan/applemaps/domain/model/EtaResponse.java @@ -17,6 +17,11 @@ public record EtaResponse(List etas) { } private static List normalizeList(List rawList) { - return List.copyOf(Objects.requireNonNullElse(rawList, List.of())); + if (rawList == null) { + return List.of(); + } + return rawList.stream() + .filter(Objects::nonNull) + .toList(); } } diff --git a/src/main/java/com/williamcallahan/applemaps/domain/model/Location.java b/src/main/java/com/williamcallahan/applemaps/domain/model/Location.java index 6835727..73b182a 100644 --- a/src/main/java/com/williamcallahan/applemaps/domain/model/Location.java +++ b/src/main/java/com/williamcallahan/applemaps/domain/model/Location.java @@ -3,4 +3,44 @@ /** * An object that describes a location in terms of its latitude and longitude. */ -public record Location(double latitude, double longitude) {} +public record Location(double latitude, double longitude) { + private static final double MIN_LATITUDE = -90.0; + private static final double MAX_LATITUDE = 90.0; + private static final double MIN_LONGITUDE = -180.0; + private static final double MAX_LONGITUDE = 180.0; + private static final String LATITUDE_LABEL = "latitude"; + private static final String LONGITUDE_LABEL = "longitude"; + private static final String FINITE_MESSAGE_TEMPLATE = "%s must be a finite value."; + private static final String RANGE_MESSAGE_TEMPLATE = "%s must be between %s and %s."; + + /** + * Canonical constructor that validates coordinate bounds and finiteness. + * + * @param latitude latitude in decimal degrees + * @param longitude longitude in decimal degrees + */ + public Location { + validateLatitudeLongitude(latitude, longitude); + } + + static void validateLatitudeLongitude(double latitude, double longitude) { + validateCoordinate(latitude, LATITUDE_LABEL, MIN_LATITUDE, MAX_LATITUDE); + validateCoordinate(longitude, LONGITUDE_LABEL, MIN_LONGITUDE, MAX_LONGITUDE); + } + + private static void validateCoordinate( + double coordinate, + String coordinateLabel, + double minimum, + double maximum + ) { + if (!Double.isFinite(coordinate)) { + throw new IllegalArgumentException(FINITE_MESSAGE_TEMPLATE.formatted(coordinateLabel)); + } + if (coordinate < minimum || coordinate > maximum) { + throw new IllegalArgumentException( + RANGE_MESSAGE_TEMPLATE.formatted(coordinateLabel, minimum, maximum) + ); + } + } +} diff --git a/src/main/java/com/williamcallahan/applemaps/domain/model/Place.java b/src/main/java/com/williamcallahan/applemaps/domain/model/Place.java index 7402ef6..d654743 100644 --- a/src/main/java/com/williamcallahan/applemaps/domain/model/Place.java +++ b/src/main/java/com/williamcallahan/applemaps/domain/model/Place.java @@ -48,6 +48,11 @@ private static Optional normalizeOptional(Optional optionalInput) { } private static List normalizeList(List rawList) { - return List.copyOf(Objects.requireNonNullElse(rawList, List.of())); + if (rawList == null) { + return List.of(); + } + return rawList.stream() + .filter(Objects::nonNull) + .toList(); } } diff --git a/src/main/java/com/williamcallahan/applemaps/domain/model/PlaceLookupError.java b/src/main/java/com/williamcallahan/applemaps/domain/model/PlaceLookupError.java index ad3ae28..b793571 100644 --- a/src/main/java/com/williamcallahan/applemaps/domain/model/PlaceLookupError.java +++ b/src/main/java/com/williamcallahan/applemaps/domain/model/PlaceLookupError.java @@ -1,19 +1,33 @@ package com.williamcallahan.applemaps.domain.model; import java.util.Objects; +import java.util.Optional; + +import com.fasterxml.jackson.annotation.JsonProperty; /** * An error associated with a place lookup request. */ -public record PlaceLookupError(PlaceLookupErrorCode errorCode, String id) { +public record PlaceLookupError( + PlaceLookupErrorCode errorCode, + @JsonProperty("id") String rawId +) { /** * Canonical constructor that validates required fields. * * @param errorCode error code returned by the API - * @param id place identifier associated with the error + * @param rawId place identifier associated with the error, may be null */ public PlaceLookupError { Objects.requireNonNull(errorCode, "errorCode"); - Objects.requireNonNull(id, "id"); + } + + /** + * Returns the place identifier associated with this error, if available. + * + * @return the place identifier, or empty if not associated with a specific place + */ + public Optional id() { + return Optional.ofNullable(rawId); } } diff --git a/src/main/java/com/williamcallahan/applemaps/domain/model/PlaceResults.java b/src/main/java/com/williamcallahan/applemaps/domain/model/PlaceResults.java index 8a53dc6..269e7b8 100644 --- a/src/main/java/com/williamcallahan/applemaps/domain/model/PlaceResults.java +++ b/src/main/java/com/williamcallahan/applemaps/domain/model/PlaceResults.java @@ -17,6 +17,11 @@ public record PlaceResults(List results) { } private static List normalizeList(List rawList) { - return List.copyOf(Objects.requireNonNullElse(rawList, List.of())); + if (rawList == null) { + return List.of(); + } + return rawList.stream() + .filter(Objects::nonNull) + .toList(); } } diff --git a/src/main/java/com/williamcallahan/applemaps/domain/model/PlacesResponse.java b/src/main/java/com/williamcallahan/applemaps/domain/model/PlacesResponse.java index d8ed36d..b14446c 100644 --- a/src/main/java/com/williamcallahan/applemaps/domain/model/PlacesResponse.java +++ b/src/main/java/com/williamcallahan/applemaps/domain/model/PlacesResponse.java @@ -19,6 +19,11 @@ public record PlacesResponse(List results, List errors) } private static List normalizeList(List rawList) { - return List.copyOf(Objects.requireNonNullElse(rawList, List.of())); + if (rawList == null) { + return List.of(); + } + return rawList.stream() + .filter(Objects::nonNull) + .toList(); } } diff --git a/src/main/java/com/williamcallahan/applemaps/domain/model/SearchAutocompleteResponse.java b/src/main/java/com/williamcallahan/applemaps/domain/model/SearchAutocompleteResponse.java index 43ec162..6003614 100644 --- a/src/main/java/com/williamcallahan/applemaps/domain/model/SearchAutocompleteResponse.java +++ b/src/main/java/com/williamcallahan/applemaps/domain/model/SearchAutocompleteResponse.java @@ -17,6 +17,11 @@ public record SearchAutocompleteResponse(List results) { } private static List normalizeList(List rawList) { - return List.copyOf(Objects.requireNonNullElse(rawList, List.of())); + if (rawList == null) { + return List.of(); + } + return rawList.stream() + .filter(Objects::nonNull) + .toList(); } } diff --git a/src/main/java/com/williamcallahan/applemaps/domain/model/SearchResponse.java b/src/main/java/com/williamcallahan/applemaps/domain/model/SearchResponse.java index 0643ab8..f5934fb 100644 --- a/src/main/java/com/williamcallahan/applemaps/domain/model/SearchResponse.java +++ b/src/main/java/com/williamcallahan/applemaps/domain/model/SearchResponse.java @@ -8,28 +8,33 @@ * An object that contains the search region and an array of place descriptions. */ public record SearchResponse( - SearchMapRegion displayMapRegion, + Optional displayMapRegion, Optional paginationInfo, List results ) { /** * Canonical constructor that normalizes potentially-null optionals and lists. * - * @param displayMapRegion display map region returned by the API + * @param displayMapRegion display map region returned by the API, if available * @param paginationInfo pagination information, if available * @param results search results returned by the API */ public SearchResponse { - displayMapRegion = Objects.requireNonNull(displayMapRegion, "displayMapRegion"); + displayMapRegion = normalizeOptional(displayMapRegion); paginationInfo = normalizeOptional(paginationInfo); results = normalizeList(results); } - private static Optional normalizeOptional(Optional optionalInput) { + private static Optional normalizeOptional(Optional optionalInput) { return Objects.requireNonNullElse(optionalInput, Optional.empty()); } private static List normalizeList(List rawList) { - return List.copyOf(Objects.requireNonNullElse(rawList, List.of())); + if (rawList == null) { + return List.of(); + } + return rawList.stream() + .filter(Objects::nonNull) + .toList(); } } diff --git a/src/main/java/com/williamcallahan/applemaps/domain/model/SearchResponsePlace.java b/src/main/java/com/williamcallahan/applemaps/domain/model/SearchResponsePlace.java index a8faed0..08e70d6 100644 --- a/src/main/java/com/williamcallahan/applemaps/domain/model/SearchResponsePlace.java +++ b/src/main/java/com/williamcallahan/applemaps/domain/model/SearchResponsePlace.java @@ -51,6 +51,11 @@ private static Optional normalizeOptional(Optional optionalInput) { } private static List normalizeList(List rawList) { - return List.copyOf(Objects.requireNonNullElse(rawList, List.of())); + if (rawList == null) { + return List.of(); + } + return rawList.stream() + .filter(Objects::nonNull) + .toList(); } } diff --git a/src/main/java/com/williamcallahan/applemaps/domain/model/StructuredAddress.java b/src/main/java/com/williamcallahan/applemaps/domain/model/StructuredAddress.java index 3cda3c6..2fc93bf 100644 --- a/src/main/java/com/williamcallahan/applemaps/domain/model/StructuredAddress.java +++ b/src/main/java/com/williamcallahan/applemaps/domain/model/StructuredAddress.java @@ -54,6 +54,11 @@ private static Optional normalizeOptional(Optional optionalInput } private static List normalizeList(List rawList) { - return List.copyOf(Objects.requireNonNullElse(rawList, List.of())); + if (rawList == null) { + return List.of(); + } + return rawList.stream() + .filter(Objects::nonNull) + .toList(); } } diff --git a/src/main/java/com/williamcallahan/applemaps/domain/request/EtaInput.java b/src/main/java/com/williamcallahan/applemaps/domain/request/EtaInput.java index f941235..73dc758 100644 --- a/src/main/java/com/williamcallahan/applemaps/domain/request/EtaInput.java +++ b/src/main/java/com/williamcallahan/applemaps/domain/request/EtaInput.java @@ -54,8 +54,8 @@ public record EtaInput( */ public String toQueryString() { List parameters = new ArrayList<>(); - parameters.add(formatParameter(PARAMETER_ORIGIN, origin.toQueryString())); - parameters.add(formatParameter(PARAMETER_DESTINATIONS, joinDestinations(destinations))); + parameters.add(formatParameter(PARAMETER_ORIGIN, encode(origin.toQueryString()))); + parameters.add(formatParameter(PARAMETER_DESTINATIONS, encode(joinDestinations(destinations)))); transportType.ifPresent(transportMode -> parameters.add(formatParameter(PARAMETER_TRANSPORT_TYPE, transportMode.apiValue()))); departureDate.ifPresent(departureDateText -> parameters.add(formatParameter(PARAMETER_DEPARTURE_DATE, encode(departureDateText)))); arrivalDate.ifPresent(arrivalDateText -> parameters.add(formatParameter(PARAMETER_ARRIVAL_DATE, encode(arrivalDateText)))); diff --git a/src/test/java/com/williamcallahan/applemaps/domain/model/DirectionsEndpointValidationTest.java b/src/test/java/com/williamcallahan/applemaps/domain/model/DirectionsEndpointValidationTest.java new file mode 100644 index 0000000..123dd10 --- /dev/null +++ b/src/test/java/com/williamcallahan/applemaps/domain/model/DirectionsEndpointValidationTest.java @@ -0,0 +1,27 @@ +package com.williamcallahan.applemaps.domain.model; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class DirectionsEndpointValidationTest { + private static final double VALID_LATITUDE = 37.7; + private static final double VALID_LONGITUDE = -122.4; + private static final double INVALID_LATITUDE_TOO_LOW = -90.1; + + @Test + void rejectsInvalidLatitude() { + assertThrows( + IllegalArgumentException.class, + () -> DirectionsEndpoint.fromLatitudeLongitude(INVALID_LATITUDE_TOO_LOW, VALID_LONGITUDE) + ); + } + + @Test + void acceptsValidCoordinates() { + assertDoesNotThrow( + () -> DirectionsEndpoint.fromLatitudeLongitude(VALID_LATITUDE, VALID_LONGITUDE) + ); + } +} diff --git a/src/test/java/com/williamcallahan/applemaps/domain/model/LocationValidationTest.java b/src/test/java/com/williamcallahan/applemaps/domain/model/LocationValidationTest.java new file mode 100644 index 0000000..0b776f3 --- /dev/null +++ b/src/test/java/com/williamcallahan/applemaps/domain/model/LocationValidationTest.java @@ -0,0 +1,40 @@ +package com.williamcallahan.applemaps.domain.model; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class LocationValidationTest { + private static final double VALID_LATITUDE = 37.7; + private static final double VALID_LONGITUDE = -122.4; + private static final double INVALID_LATITUDE_TOO_HIGH = 90.1; + private static final double INVALID_LONGITUDE_TOO_LOW = -180.1; + private static final double NOT_A_NUMBER = Double.NaN; + private static final double POSITIVE_INFINITY = Double.POSITIVE_INFINITY; + + @Test + void acceptsValidCoordinates() { + assertDoesNotThrow(() -> new Location(VALID_LATITUDE, VALID_LONGITUDE)); + } + + @Test + void rejectsNaNLatitude() { + assertThrows(IllegalArgumentException.class, () -> new Location(NOT_A_NUMBER, VALID_LONGITUDE)); + } + + @Test + void rejectsInfiniteLongitude() { + assertThrows(IllegalArgumentException.class, () -> new Location(VALID_LATITUDE, POSITIVE_INFINITY)); + } + + @Test + void rejectsLatitudeAboveRange() { + assertThrows(IllegalArgumentException.class, () -> new Location(INVALID_LATITUDE_TOO_HIGH, VALID_LONGITUDE)); + } + + @Test + void rejectsLongitudeBelowRange() { + assertThrows(IllegalArgumentException.class, () -> new Location(VALID_LATITUDE, INVALID_LONGITUDE_TOO_LOW)); + } +} diff --git a/src/test/java/com/williamcallahan/applemaps/domain/model/NullElementListNormalizationTest.java b/src/test/java/com/williamcallahan/applemaps/domain/model/NullElementListNormalizationTest.java new file mode 100644 index 0000000..1a2ba36 --- /dev/null +++ b/src/test/java/com/williamcallahan/applemaps/domain/model/NullElementListNormalizationTest.java @@ -0,0 +1,356 @@ +package com.williamcallahan.applemaps.domain.model; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class NullElementListNormalizationTest { + private static final String PRIMARY_ID = "primary-id"; + private static final String ALTERNATE_ID = "alternate-id"; + private static final String COMPLETION_URL = "/search?q=test"; + private static final String ADDRESS_LINE = "Line 1"; + private static final String PLACE_NAME = "Test Place"; + private static final String COUNTRY_NAME = "United States"; + private static final String COUNTRY_CODE = "US"; + private static final String AREA_OF_INTEREST = "Area 1"; + private static final double LOCATION_LATITUDE = 37.7; + private static final double LOCATION_LONGITUDE = -122.4; + private static final double MAP_NORTH_LATITUDE = 38.0; + private static final double MAP_EAST_LONGITUDE = -122.0; + private static final double MAP_SOUTH_LATITUDE = 37.0; + private static final double MAP_WEST_LONGITUDE = -123.0; + private static final int STEP_INDEX = 1; + + @Test + void alternateIdsEntryFiltersNullAlternateIds() { + List alternateIdsWithNull = new ArrayList<>(List.of(ALTERNATE_ID)); + alternateIdsWithNull.add(null); + + AlternateIdsEntry alternateIdsEntry = + new AlternateIdsEntry(Optional.of(PRIMARY_ID), alternateIdsWithNull); + + assertEquals(List.of(ALTERNATE_ID), alternateIdsEntry.alternateIds()); + } + + @Test + void alternateIdsResponseFiltersNullElements() { + List alternateIdEntriesWithNull = + new ArrayList<>(List.of(buildAlternateIdsEntry())); + alternateIdEntriesWithNull.add(null); + List placeLookupErrorsWithNull = + new ArrayList<>(List.of(buildPlaceLookupError())); + placeLookupErrorsWithNull.add(null); + + AlternateIdsResponse alternateIdsResponse = + new AlternateIdsResponse(alternateIdEntriesWithNull, placeLookupErrorsWithNull); + + assertEquals(List.of(buildAlternateIdsEntry()), alternateIdsResponse.results()); + assertEquals(List.of(buildPlaceLookupError()), alternateIdsResponse.errors()); + } + + @Test + void autocompleteResultFiltersNullDisplayLines() { + List displayLinesWithNull = new ArrayList<>(List.of(ADDRESS_LINE)); + displayLinesWithNull.add(null); + + AutocompleteResult autocompleteResult = new AutocompleteResult( + COMPLETION_URL, + displayLinesWithNull, + Optional.empty(), + Optional.empty() + ); + + assertEquals(List.of(ADDRESS_LINE), autocompleteResult.displayLines()); + } + + @Test + void searchAutocompleteResponseFiltersNullResults() { + List autocompleteResultsWithNull = + new ArrayList<>(List.of(buildAutocompleteResult())); + autocompleteResultsWithNull.add(null); + + SearchAutocompleteResponse searchAutocompleteResponse = + new SearchAutocompleteResponse(autocompleteResultsWithNull); + + assertEquals(List.of(buildAutocompleteResult()), searchAutocompleteResponse.results()); + } + + @Test + void searchResponseFiltersNullResults() { + List searchPlacesWithNull = + new ArrayList<>(List.of(buildSearchResponsePlace())); + searchPlacesWithNull.add(null); + + SearchResponse searchResponse = + new SearchResponse(Optional.empty(), Optional.empty(), searchPlacesWithNull); + + assertEquals(List.of(buildSearchResponsePlace()), searchResponse.results()); + } + + @Test + void searchResponsePlaceFiltersNullLists() { + List alternateIdsWithNull = new ArrayList<>(List.of(ALTERNATE_ID)); + alternateIdsWithNull.add(null); + List addressLinesWithNull = new ArrayList<>(List.of(ADDRESS_LINE)); + addressLinesWithNull.add(null); + + SearchResponsePlace searchResponsePlace = new SearchResponsePlace( + Optional.of(PRIMARY_ID), + alternateIdsWithNull, + PLACE_NAME, + buildLocation(), + Optional.of(buildMapRegion()), + addressLinesWithNull, + Optional.empty(), + COUNTRY_NAME, + COUNTRY_CODE, + Optional.empty() + ); + + assertEquals(List.of(ALTERNATE_ID), searchResponsePlace.alternateIds()); + assertEquals(List.of(ADDRESS_LINE), searchResponsePlace.formattedAddressLines()); + } + + @Test + void structuredAddressFiltersNullLists() { + List areasWithNull = new ArrayList<>(List.of(AREA_OF_INTEREST)); + areasWithNull.add(null); + List dependentLocalitiesWithNull = new ArrayList<>(List.of(AREA_OF_INTEREST)); + dependentLocalitiesWithNull.add(null); + + StructuredAddress structuredAddress = new StructuredAddress( + Optional.empty(), + Optional.empty(), + Optional.empty(), + areasWithNull, + dependentLocalitiesWithNull, + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty() + ); + + assertEquals(List.of(AREA_OF_INTEREST), structuredAddress.areasOfInterest()); + assertEquals(List.of(AREA_OF_INTEREST), structuredAddress.dependentLocalities()); + } + + @Test + void placeFiltersNullLists() { + List alternateIdsWithNull = new ArrayList<>(List.of(ALTERNATE_ID)); + alternateIdsWithNull.add(null); + List addressLinesWithNull = new ArrayList<>(List.of(ADDRESS_LINE)); + addressLinesWithNull.add(null); + + Place place = new Place( + Optional.of(PRIMARY_ID), + alternateIdsWithNull, + PLACE_NAME, + buildLocation(), + Optional.of(buildMapRegion()), + addressLinesWithNull, + Optional.empty(), + COUNTRY_NAME, + COUNTRY_CODE + ); + + assertEquals(List.of(ALTERNATE_ID), place.alternateIds()); + assertEquals(List.of(ADDRESS_LINE), place.formattedAddressLines()); + } + + @Test + void placeResultsFiltersNullResults() { + List placesWithNull = new ArrayList<>(List.of(buildPlace())); + placesWithNull.add(null); + + PlaceResults placeResults = new PlaceResults(placesWithNull); + + assertEquals(List.of(buildPlace()), placeResults.results()); + } + + @Test + void placesResponseFiltersNullLists() { + List placesWithNull = new ArrayList<>(List.of(buildPlace())); + placesWithNull.add(null); + List placeLookupErrorsWithNull = + new ArrayList<>(List.of(buildPlaceLookupError())); + placeLookupErrorsWithNull.add(null); + + PlacesResponse placesResponse = new PlacesResponse(placesWithNull, placeLookupErrorsWithNull); + + assertEquals(List.of(buildPlace()), placesResponse.results()); + assertEquals(List.of(buildPlaceLookupError()), placesResponse.errors()); + } + + @Test + void directionsRouteFiltersNullStepIndexes() { + List stepIndexesWithNull = new ArrayList<>(List.of(STEP_INDEX)); + stepIndexesWithNull.add(null); + + DirectionsRoute directionsRoute = new DirectionsRoute( + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty(), + stepIndexesWithNull, + Optional.empty() + ); + + assertEquals(List.of(STEP_INDEX), directionsRoute.stepIndexes()); + } + + @Test + void directionsResponseFiltersNullRoutesAndSteps() { + List routesWithNull = new ArrayList<>(List.of(buildDirectionsRoute())); + routesWithNull.add(null); + List stepsWithNull = new ArrayList<>(List.of(buildDirectionsStep())); + stepsWithNull.add(null); + + DirectionsResponse directionsResponse = new DirectionsResponse( + Optional.empty(), + Optional.empty(), + routesWithNull, + stepsWithNull, + List.of() + ); + + assertEquals(List.of(buildDirectionsRoute()), directionsResponse.routes()); + assertEquals(List.of(buildDirectionsStep()), directionsResponse.steps()); + } + + @Test + void directionsResponsePreservesStepPathsIndexAlignment() { + // stepPaths preserves null entries as empty lists to maintain index alignment with steps + List stepPath = new ArrayList<>(List.of(buildLocation())); + stepPath.add(null); // null locations within a path ARE filtered + List> stepPathsWithNull = new ArrayList<>(List.of(stepPath)); + stepPathsWithNull.add(null); // null paths are converted to empty lists (not filtered) + + DirectionsResponse directionsResponse = new DirectionsResponse( + Optional.empty(), + Optional.empty(), + List.of(), + List.of(), + stepPathsWithNull + ); + + // Expect 2 entries: [validLocation] and [] (empty list for null path) + assertEquals(2, directionsResponse.stepPaths().size()); + assertEquals(List.of(buildLocation()), directionsResponse.stepPaths().get(0)); + assertEquals(List.of(), directionsResponse.stepPaths().get(1)); + } + + @Test + void errorResponseFiltersNullDetails() { + List detailsWithNull = new ArrayList<>(List.of(ADDRESS_LINE)); + detailsWithNull.add(null); + + ErrorResponse errorResponse = new ErrorResponse(PLACE_NAME, detailsWithNull); + + assertEquals(List.of(ADDRESS_LINE), errorResponse.details()); + } + + @Test + void etaResponseFiltersNullEtas() { + List etaEstimatesWithNull = new ArrayList<>(List.of(buildEtaEstimate())); + etaEstimatesWithNull.add(null); + + EtaResponse etaResponse = new EtaResponse(etaEstimatesWithNull); + + assertEquals(List.of(buildEtaEstimate()), etaResponse.etas()); + } + + private static AlternateIdsEntry buildAlternateIdsEntry() { + return new AlternateIdsEntry(Optional.of(PRIMARY_ID), List.of(ALTERNATE_ID)); + } + + private static PlaceLookupError buildPlaceLookupError() { + return new PlaceLookupError(PlaceLookupErrorCode.FAILED_INTERNAL_ERROR, null); + } + + private static AutocompleteResult buildAutocompleteResult() { + return new AutocompleteResult( + COMPLETION_URL, + List.of(ADDRESS_LINE), + Optional.empty(), + Optional.empty() + ); + } + + private static SearchResponsePlace buildSearchResponsePlace() { + return new SearchResponsePlace( + Optional.of(PRIMARY_ID), + List.of(ALTERNATE_ID), + PLACE_NAME, + buildLocation(), + Optional.of(buildMapRegion()), + List.of(ADDRESS_LINE), + Optional.empty(), + COUNTRY_NAME, + COUNTRY_CODE, + Optional.empty() + ); + } + + private static Place buildPlace() { + return new Place( + Optional.of(PRIMARY_ID), + List.of(ALTERNATE_ID), + PLACE_NAME, + buildLocation(), + Optional.of(buildMapRegion()), + List.of(ADDRESS_LINE), + Optional.empty(), + COUNTRY_NAME, + COUNTRY_CODE + ); + } + + private static DirectionsRoute buildDirectionsRoute() { + return new DirectionsRoute( + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty(), + List.of(STEP_INDEX), + Optional.empty() + ); + } + + private static DirectionsStep buildDirectionsStep() { + return new DirectionsStep( + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty() + ); + } + + private static EtaEstimate buildEtaEstimate() { + return new EtaEstimate( + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty() + ); + } + + private static Location buildLocation() { + return new Location(LOCATION_LATITUDE, LOCATION_LONGITUDE); + } + + private static MapRegion buildMapRegion() { + return new MapRegion( + MAP_NORTH_LATITUDE, + MAP_EAST_LONGITUDE, + MAP_SOUTH_LATITUDE, + MAP_WEST_LONGITUDE + ); + } +} diff --git a/src/test/java/com/williamcallahan/applemaps/domain/model/PlaceLookupErrorTest.java b/src/test/java/com/williamcallahan/applemaps/domain/model/PlaceLookupErrorTest.java new file mode 100644 index 0000000..2f6b9ad --- /dev/null +++ b/src/test/java/com/williamcallahan/applemaps/domain/model/PlaceLookupErrorTest.java @@ -0,0 +1,61 @@ +package com.williamcallahan.applemaps.domain.model; + +import com.williamcallahan.applemaps.adapters.jackson.AppleMapsObjectMapperFactory; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import tools.jackson.databind.ObjectMapper; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class PlaceLookupErrorTest { + private static final String JSON_WITH_ID = """ + { + "errorCode": "FAILED_INVALID_ID", + "id": "invalid-place-id" + } + """; + private static final String JSON_WITHOUT_ID = """ + { + "errorCode": "FAILED_INTERNAL_ERROR" + } + """; + private static final String JSON_WITH_NULL_ID = """ + { + "errorCode": "FAILED_INTERNAL_ERROR", + "id": null + } + """; + + @Test + void deserializesErrorWithId() throws Exception { + ObjectMapper objectMapper = AppleMapsObjectMapperFactory.create(); + + PlaceLookupError placeLookupError = + objectMapper.readValue(JSON_WITH_ID, PlaceLookupError.class); + + assertEquals(PlaceLookupErrorCode.FAILED_INVALID_ID, placeLookupError.errorCode()); + assertEquals(Optional.of("invalid-place-id"), placeLookupError.id()); + } + + @Test + void deserializesErrorWithoutId() throws Exception { + ObjectMapper objectMapper = AppleMapsObjectMapperFactory.create(); + + PlaceLookupError placeLookupError = + objectMapper.readValue(JSON_WITHOUT_ID, PlaceLookupError.class); + + assertEquals(PlaceLookupErrorCode.FAILED_INTERNAL_ERROR, placeLookupError.errorCode()); + assertEquals(Optional.empty(), placeLookupError.id()); + } + + @Test + void deserializesErrorWithNullId() throws Exception { + ObjectMapper objectMapper = AppleMapsObjectMapperFactory.create(); + + PlaceLookupError placeLookupError = + objectMapper.readValue(JSON_WITH_NULL_ID, PlaceLookupError.class); + + assertEquals(PlaceLookupErrorCode.FAILED_INTERNAL_ERROR, placeLookupError.errorCode()); + assertEquals(Optional.empty(), placeLookupError.id()); + } +} diff --git a/src/test/java/com/williamcallahan/applemaps/domain/model/SearchResponseDisplayMapRegionTest.java b/src/test/java/com/williamcallahan/applemaps/domain/model/SearchResponseDisplayMapRegionTest.java new file mode 100644 index 0000000..a1327ff --- /dev/null +++ b/src/test/java/com/williamcallahan/applemaps/domain/model/SearchResponseDisplayMapRegionTest.java @@ -0,0 +1,42 @@ +package com.williamcallahan.applemaps.domain.model; + +import com.williamcallahan.applemaps.adapters.jackson.AppleMapsObjectMapperFactory; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import tools.jackson.databind.ObjectMapper; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class SearchResponseDisplayMapRegionTest { + private static final String JSON_WITHOUT_DISPLAY_MAP_REGION = """ + { + "results": [] + } + """; + private static final String JSON_WITH_NULL_DISPLAY_MAP_REGION = """ + { + "displayMapRegion": null, + "results": [] + } + """; + + @Test + void deserializesResponseWithoutDisplayMapRegion() throws Exception { + ObjectMapper objectMapper = AppleMapsObjectMapperFactory.create(); + + SearchResponse searchResponse = + objectMapper.readValue(JSON_WITHOUT_DISPLAY_MAP_REGION, SearchResponse.class); + + assertEquals(Optional.empty(), searchResponse.displayMapRegion()); + } + + @Test + void deserializesResponseWithNullDisplayMapRegion() throws Exception { + ObjectMapper objectMapper = AppleMapsObjectMapperFactory.create(); + + SearchResponse searchResponse = + objectMapper.readValue(JSON_WITH_NULL_DISPLAY_MAP_REGION, SearchResponse.class); + + assertEquals(Optional.empty(), searchResponse.displayMapRegion()); + } +} diff --git a/src/test/java/com/williamcallahan/applemaps/domain/request/EtaInputTest.java b/src/test/java/com/williamcallahan/applemaps/domain/request/EtaInputTest.java index 9945ef9..a64fb5d 100644 --- a/src/test/java/com/williamcallahan/applemaps/domain/request/EtaInputTest.java +++ b/src/test/java/com/williamcallahan/applemaps/domain/request/EtaInputTest.java @@ -2,9 +2,11 @@ import com.williamcallahan.applemaps.domain.model.RouteLocation; import com.williamcallahan.applemaps.domain.model.TransportType; +import java.net.URI; import java.util.List; import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -15,8 +17,9 @@ class EtaInputTest { RouteLocation.fromLatitudeLongitude(37.441765, -122.172593) ); private static final String DEPARTURE_DATE = "2026-01-01T10:15:30Z"; + private static final String API_BASE_URI = "https://maps-api.apple.com/v1/etas"; private static final String EXPECTED_QUERY = - "?origin=37.331423,-122.030503&destinations=37.325565,-121.946352|37.441765,-122.172593" + + "?origin=37.331423%2C-122.030503&destinations=37.325565%2C-121.946352%7C37.441765%2C-122.172593" + "&transportType=Cycling&departureDate=2026-01-01T10%3A15%3A30Z"; @Test @@ -29,6 +32,16 @@ void toQueryStringIncludesEtaParameters() { assertEquals(EXPECTED_QUERY, etaInput.toQueryString()); } + @Test + void toQueryStringCreatesValidUri() { + EtaInput etaInput = EtaInput.builder(ORIGIN, DESTINATIONS) + .transportType(TransportType.CYCLING) + .departureDate(DEPARTURE_DATE) + .build(); + + assertDoesNotThrow(() -> URI.create(API_BASE_URI + etaInput.toQueryString())); + } + @Test void buildRejectsEmptyDestinations() { IllegalArgumentException exception = assertThrows(