From 1057802cef19f204709861fc5425e27b64315f71 Mon Sep 17 00:00:00 2001 From: William Callahan Date: Fri, 23 Jan 2026 00:07:14 -0800 Subject: [PATCH 01/14] chore(deps): update Jackson BOM to 3.0.4 and NMCP plugin to 1.4.3 --- build.gradle.kts | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) 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" From 22cf6b529584b1515aa77061ad051c541e3fe3ea Mon Sep 17 00:00:00 2001 From: William Callahan Date: Fri, 23 Jan 2026 00:07:22 -0800 Subject: [PATCH 02/14] chore(make): add lint target for running Gradle check Adds a convenient `make lint` target that runs the full Gradle check task with --rerun-tasks to ensure all checks execute regardless of cache state. --- Makefile | 4 ++++ 1 file changed, 4 insertions(+) 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 From e58b274613021e17c98163f003c037210d5dc763 Mon Sep 17 00:00:00 2001 From: William Callahan Date: Fri, 23 Jan 2026 00:09:35 -0800 Subject: [PATCH 03/14] fix: filter null elements in list normalization to prevent NPE List.copyOf() throws NullPointerException when the source list contains null elements. The Apple Maps API responses may include sparse arrays with null entries. This replaces List.copyOf() with a stream-and-filter pattern that safely removes null elements before creating an immutable list. - Update normalizeList in all domain model records - Add explicit null check before streaming to handle null input - Use .filter(Objects::nonNull) to remove null elements --- .../domain/model/AlternateIdsEntry.java | 7 +- .../domain/model/AlternateIdsResponse.java | 7 +- .../domain/model/AutocompleteResult.java | 7 +- .../domain/model/DirectionsResponse.java | 25 +- .../domain/model/DirectionsRoute.java | 7 +- .../applemaps/domain/model/ErrorResponse.java | 11 +- .../applemaps/domain/model/EtaResponse.java | 7 +- .../applemaps/domain/model/Place.java | 7 +- .../applemaps/domain/model/PlaceResults.java | 7 +- .../domain/model/PlacesResponse.java | 7 +- .../model/SearchAutocompleteResponse.java | 7 +- .../domain/model/SearchResponsePlace.java | 7 +- .../domain/model/StructuredAddress.java | 7 +- .../NullElementListNormalizationTest.java | 339 ++++++++++++++++++ 14 files changed, 436 insertions(+), 16 deletions(-) create mode 100644 src/test/java/com/williamcallahan/applemaps/domain/model/NullElementListNormalizationTest.java 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/DirectionsResponse.java b/src/main/java/com/williamcallahan/applemaps/domain/model/DirectionsResponse.java index 936dcf4..9f6981f 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() + .filter(Objects::nonNull) + .map(DirectionsResponse::normalizeStepPath) + .toList(); + } + + private static List normalizeStepPath(List rawPath) { + if (rawPath == null) { + return List.of(); + } + return rawPath.stream() + .filter(Objects::nonNull) .toList(); - return List.copyOf(normalizedPaths); } } 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/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/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/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/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..302bf90 --- /dev/null +++ b/src/test/java/com/williamcallahan/applemaps/domain/model/NullElementListNormalizationTest.java @@ -0,0 +1,339 @@ +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 directionsResponseFiltersNullRoutesAndStepPaths() { + List routesWithNull = new ArrayList<>(List.of(buildDirectionsRoute())); + routesWithNull.add(null); + List stepsWithNull = new ArrayList<>(List.of(buildDirectionsStep())); + stepsWithNull.add(null); + List stepPath = new ArrayList<>(List.of(buildLocation())); + stepPath.add(null); + List> stepPathsWithNull = new ArrayList<>(List.of(stepPath)); + stepPathsWithNull.add(null); + + DirectionsResponse directionsResponse = new DirectionsResponse( + Optional.empty(), + Optional.empty(), + routesWithNull, + stepsWithNull, + stepPathsWithNull + ); + + assertEquals(List.of(buildDirectionsRoute()), directionsResponse.routes()); + assertEquals(List.of(buildDirectionsStep()), directionsResponse.steps()); + assertEquals(List.of(List.of(buildLocation())), directionsResponse.stepPaths()); + } + + @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, Optional.empty()); + } + + 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 + ); + } +} From f83c5e29dabb1737271156c24d6d3dceed9a8f62 Mon Sep 17 00:00:00 2001 From: William Callahan Date: Fri, 23 Jan 2026 00:09:49 -0800 Subject: [PATCH 04/14] feat: add coordinate validation to Location and DirectionsEndpoint Geographic coordinates must be within valid ranges: latitude [-90, 90] and longitude [-180, 180]. Previously, invalid coordinates would propagate through the system and only fail when reaching the Apple Maps API. This adds fail-fast validation at construction time with clear error messages. - Add validateLatitudeLongitude to Location record with range checking - Validate coordinates are finite (not NaN or Infinity) - Call validation from DirectionsEndpoint.fromLatitudeLongitude --- .../domain/model/DirectionsEndpoint.java | 1 + .../applemaps/domain/model/Location.java | 42 ++++++++++++++++++- .../DirectionsEndpointValidationTest.java | 27 ++++++++++++ .../domain/model/LocationValidationTest.java | 40 ++++++++++++++++++ 4 files changed, 109 insertions(+), 1 deletion(-) create mode 100644 src/test/java/com/williamcallahan/applemaps/domain/model/DirectionsEndpointValidationTest.java create mode 100644 src/test/java/com/williamcallahan/applemaps/domain/model/LocationValidationTest.java 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/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/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)); + } +} From 6ec424dd1d61a78c82384e292bd09bc93c906faf Mon Sep 17 00:00:00 2001 From: William Callahan Date: Fri, 23 Jan 2026 00:12:14 -0800 Subject: [PATCH 05/14] fix: make optional fields nullable in API response models Some API response fields documented as required may be absent in certain edge cases. This makes PlaceLookupError.id and SearchResponse.displayMapRegion optional to handle these cases gracefully rather than throwing NPE during deserialization. - Change PlaceLookupError.id from String to Optional - Change SearchResponse.displayMapRegion from required to Optional - Add normalizeOptional helper to handle null Optional inputs --- .../domain/model/PlaceLookupError.java | 7 ++- .../domain/model/SearchResponse.java | 15 +++-- .../domain/model/PlaceLookupErrorTest.java | 61 +++++++++++++++++++ .../SearchResponseDisplayMapRegionTest.java | 42 +++++++++++++ 4 files changed, 117 insertions(+), 8 deletions(-) create mode 100644 src/test/java/com/williamcallahan/applemaps/domain/model/PlaceLookupErrorTest.java create mode 100644 src/test/java/com/williamcallahan/applemaps/domain/model/SearchResponseDisplayMapRegionTest.java 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..409aad7 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,20 @@ package com.williamcallahan.applemaps.domain.model; import java.util.Objects; +import java.util.Optional; /** * An error associated with a place lookup request. */ -public record PlaceLookupError(PlaceLookupErrorCode errorCode, String id) { +public record PlaceLookupError(PlaceLookupErrorCode errorCode, Optional id) { /** * Canonical constructor that validates required fields. * * @param errorCode error code returned by the API - * @param id place identifier associated with the error + * @param id place identifier associated with the error, if available */ public PlaceLookupError { Objects.requireNonNull(errorCode, "errorCode"); - Objects.requireNonNull(id, "id"); + id = Objects.requireNonNullElse(id, Optional.empty()); } } 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/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()); + } +} From f9798304c33d95fc325fb5e45894332f446640ea Mon Sep 17 00:00:00 2001 From: William Callahan Date: Fri, 23 Jan 2026 00:13:54 -0800 Subject: [PATCH 06/14] fix: URL-encode origin and destinations in EtaInput query string The coordinates contain commas and destinations are pipe-separated, but these characters were not being URL-encoded. While many HTTP clients handle this, RFC 3986 requires encoding reserved characters in query parameters. This ensures the generated query strings create valid URIs. - Encode origin and destinations parameters in toQueryString - Add test verifying URI.create accepts the generated query string --- .../applemaps/domain/request/EtaInput.java | 4 ++-- .../applemaps/domain/request/EtaInputTest.java | 15 ++++++++++++++- 2 files changed, 16 insertions(+), 3 deletions(-) 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/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( From dbd1504fa1fe0975ffac0c9b944c253ab105c059 Mon Sep 17 00:00:00 2001 From: William Callahan Date: Fri, 23 Jan 2026 00:20:58 -0800 Subject: [PATCH 07/14] docs: add Javadoc for origin parameter and normalize whitespace The Origin header support was added but lacked documentation explaining its purpose. This adds Javadoc for the origin parameter in constructors and the getOrigin() accessor method. Also normalizes trailing whitespace and removes stray blank lines for consistency. - Document origin parameter in AppleMapsAuthorizationService constructor - Document origin parameter in HttpAppleMapsGateway constructor - Add Javadoc for getOrigin() method explaining return behavior - Remove trailing whitespace and normalize blank lines --- .../java/com/williamcallahan/applemaps/AppleMaps.java | 2 +- .../mapsserver/AppleMapsAuthorizationService.java | 10 ++++++++-- .../adapters/mapsserver/HttpAppleMapsGateway.java | 10 ++++++++-- 3 files changed, 17 insertions(+), 5 deletions(-) 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..483bdea 100644 --- a/src/main/java/com/williamcallahan/applemaps/adapters/mapsserver/AppleMapsAuthorizationService.java +++ b/src/main/java/com/williamcallahan/applemaps/adapters/mapsserver/AppleMapsAuthorizationService.java @@ -39,6 +39,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)); @@ -53,7 +54,12 @@ public AppleMapsAuthorizationService(String authToken, Duration timeout, String this.origin = dependencies.origin(); this.clock = dependencies.clock(); } - + + /** + * Returns the configured Origin header value, if any. + * + * @return the Origin header value, or {@code null} when not set + */ public String getOrigin() { return origin; } @@ -87,7 +93,7 @@ private AccessToken refreshAccessToken() { .timeout(timeout) .uri(tokenUri) .setHeader("Authorization", "Bearer " + authToken); - + if (origin != null) { builder.setHeader("Origin", origin); } 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..a35c357 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,7 +177,6 @@ private URI buildUri(String path, String queryString) { } private T invokeApi(String operation, URI uri, Class responseType) { - HttpRequest.Builder builder = HttpRequest.newBuilder() .GET() .uri(uri) @@ -180,7 +186,7 @@ private T invokeApi(String operation, URI uri, Class responseType) { if (authorizationService.getOrigin() != null) { builder.setHeader("Origin", authorizationService.getOrigin()); } - + HttpRequest httpRequest = builder.build(); try { HttpResponse response = httpClient.send(httpRequest, HttpResponse.BodyHandlers.ofByteArray()); From e1becbc5b0a013d9cb5cb211d640ee808c7b085a Mon Sep 17 00:00:00 2001 From: William Callahan Date: Fri, 23 Jan 2026 00:28:26 -0800 Subject: [PATCH 08/14] fix(ci): append -SNAPSHOT suffix for Sonatype snapshot publishing Sonatype Central's snapshot repository requires versions ending with -SNAPSHOT suffix. The CI workflow was publishing version 0.1.5 without the suffix, causing HTTP 400 errors from the snapshot repository. - Extract base version from gradle.properties in new workflow step - Append -SNAPSHOT suffix to create valid snapshot version - Pass snapshot version via -Pversion to Gradle publish task --- .github/workflows/CI.yaml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) 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 From 9989645ead1755d3472c1c77ed4013ad60ebd7bc Mon Sep 17 00:00:00 2001 From: William Callahan Date: Fri, 23 Jan 2026 00:41:44 -0800 Subject: [PATCH 09/14] fix(DirectionsResponse): preserve stepPaths index alignment with steps Null stepPath entries are now converted to empty lists instead of being filtered out. This maintains positional alignment between stepPaths and steps, so stepPaths.get(i) always corresponds to steps.get(i). Null Location elements within individual paths are still filtered. --- .../applemaps/domain/model/DirectionsResponse.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 9f6981f..fd7a401 100644 --- a/src/main/java/com/williamcallahan/applemaps/domain/model/DirectionsResponse.java +++ b/src/main/java/com/williamcallahan/applemaps/domain/model/DirectionsResponse.java @@ -49,7 +49,6 @@ private static List> normalizeStepPaths(List> rawL return List.of(); } return rawList.stream() - .filter(Objects::nonNull) .map(DirectionsResponse::normalizeStepPath) .toList(); } @@ -62,4 +61,5 @@ private static List normalizeStepPath(List rawPath) { .filter(Objects::nonNull) .toList(); } + } From e59d91e51974fc97fba624b4af180e01d8863320 Mon Sep 17 00:00:00 2001 From: William Callahan Date: Fri, 23 Jan 2026 00:41:52 -0800 Subject: [PATCH 10/14] test(DirectionsResponse): verify stepPaths index alignment preservation Split test into two: one verifying null filtering for routes/steps, and another explicitly testing that null stepPath entries become empty lists to preserve index alignment with the steps array. --- .../NullElementListNormalizationTest.java | 31 ++++++++++++++----- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/src/test/java/com/williamcallahan/applemaps/domain/model/NullElementListNormalizationTest.java b/src/test/java/com/williamcallahan/applemaps/domain/model/NullElementListNormalizationTest.java index 302bf90..4f917a0 100644 --- a/src/test/java/com/williamcallahan/applemaps/domain/model/NullElementListNormalizationTest.java +++ b/src/test/java/com/williamcallahan/applemaps/domain/model/NullElementListNormalizationTest.java @@ -204,27 +204,44 @@ void directionsRouteFiltersNullStepIndexes() { } @Test - void directionsResponseFiltersNullRoutesAndStepPaths() { + void directionsResponseFiltersNullRoutesAndSteps() { List routesWithNull = new ArrayList<>(List.of(buildDirectionsRoute())); routesWithNull.add(null); List stepsWithNull = new ArrayList<>(List.of(buildDirectionsStep())); stepsWithNull.add(null); - List stepPath = new ArrayList<>(List.of(buildLocation())); - stepPath.add(null); - List> stepPathsWithNull = new ArrayList<>(List.of(stepPath)); - stepPathsWithNull.add(null); DirectionsResponse directionsResponse = new DirectionsResponse( Optional.empty(), Optional.empty(), routesWithNull, stepsWithNull, - stepPathsWithNull + List.of() ); assertEquals(List.of(buildDirectionsRoute()), directionsResponse.routes()); assertEquals(List.of(buildDirectionsStep()), directionsResponse.steps()); - assertEquals(List.of(List.of(buildLocation())), directionsResponse.stepPaths()); + } + + @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 From 43f77e5991f02547b988f99762ac6f4969b8960c Mon Sep 17 00:00:00 2001 From: William Callahan Date: Fri, 23 Jan 2026 00:57:08 -0800 Subject: [PATCH 11/14] fix(AppleMapsAuthorizationService): return Optional from getOrigin per NO1a Public methods must not return null per project rules. Changed getOrigin() to return Optional and normalized at construction time. Also filters out blank origin values. Updated callers to use ifPresent pattern. --- .../mapsserver/AppleMapsAuthorizationService.java | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) 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 483bdea..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<>(); @@ -51,16 +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(); } /** * Returns the configured Origin header value, if any. * - * @return the Origin header value, or {@code null} when not set + * @return the Origin header value, or empty when not set */ - public String getOrigin() { + public Optional getOrigin() { return origin; } @@ -94,9 +96,7 @@ private AccessToken refreshAccessToken() { .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 { From 8f0a7335358c9115fdd0056ec862aebc4891b065 Mon Sep 17 00:00:00 2001 From: William Callahan Date: Fri, 23 Jan 2026 00:57:15 -0800 Subject: [PATCH 12/14] fix(HttpAppleMapsGateway): use Optional pattern for origin header Updated to use ifPresent with the Optional returned by AppleMapsAuthorizationService.getOrigin() instead of null check. --- .../applemaps/adapters/mapsserver/HttpAppleMapsGateway.java | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) 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 a35c357..f39c097 100644 --- a/src/main/java/com/williamcallahan/applemaps/adapters/mapsserver/HttpAppleMapsGateway.java +++ b/src/main/java/com/williamcallahan/applemaps/adapters/mapsserver/HttpAppleMapsGateway.java @@ -183,9 +183,8 @@ private T invokeApi(String operation, URI uri, Class responseType) { .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 { From 12d0d2bd892ee2e436f39a6a16ceace96a2b3c18 Mon Sep 17 00:00:00 2001 From: William Callahan Date: Fri, 23 Jan 2026 00:58:34 -0800 Subject: [PATCH 13/14] fix(PlaceLookupError): use nullable String parameter instead of Optional Per NO1c, Optional should not be used as public constructor parameters. Changed record to accept nullable String (renamed to rawId for clarity) and provide an Optional id() accessor that wraps it. This allows callers to pass null directly instead of Optional.empty(). Uses @JsonProperty("id") to map JSON field to rawId. --- .../domain/model/PlaceLookupError.java | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) 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 409aad7..b793571 100644 --- a/src/main/java/com/williamcallahan/applemaps/domain/model/PlaceLookupError.java +++ b/src/main/java/com/williamcallahan/applemaps/domain/model/PlaceLookupError.java @@ -3,18 +3,31 @@ 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, Optional 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, if available + * @param rawId place identifier associated with the error, may be null */ public PlaceLookupError { Objects.requireNonNull(errorCode, "errorCode"); - id = Objects.requireNonNullElse(id, Optional.empty()); + } + + /** + * 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); } } From 6d7f6f388e7ad7d1331f0c50cbf9bd18a832c5c9 Mon Sep 17 00:00:00 2001 From: William Callahan Date: Fri, 23 Jan 2026 00:58:40 -0800 Subject: [PATCH 14/14] test(NullElementListNormalizationTest): pass null for PlaceLookupError id Updated test helper to pass null instead of Optional.empty() following the PlaceLookupError API change where the constructor now accepts a nullable String. --- .../domain/model/NullElementListNormalizationTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/java/com/williamcallahan/applemaps/domain/model/NullElementListNormalizationTest.java b/src/test/java/com/williamcallahan/applemaps/domain/model/NullElementListNormalizationTest.java index 4f917a0..1a2ba36 100644 --- a/src/test/java/com/williamcallahan/applemaps/domain/model/NullElementListNormalizationTest.java +++ b/src/test/java/com/williamcallahan/applemaps/domain/model/NullElementListNormalizationTest.java @@ -269,7 +269,7 @@ private static AlternateIdsEntry buildAlternateIdsEntry() { } private static PlaceLookupError buildPlaceLookupError() { - return new PlaceLookupError(PlaceLookupErrorCode.FAILED_INTERNAL_ERROR, Optional.empty()); + return new PlaceLookupError(PlaceLookupErrorCode.FAILED_INTERNAL_ERROR, null); } private static AutocompleteResult buildAutocompleteResult() {