From b8012a57b6edbb14550ddd5e6f7643774e0f7525 Mon Sep 17 00:00:00 2001 From: jcpitre <106176106+jcpitre@users.noreply.github.com> Date: Mon, 21 Oct 2024 15:31:31 +0200 Subject: [PATCH 1/2] feat: Added a validator for forbidden shape_distance (#1896) Added a validator for the presence of shape_distance with location_id or location_group_id --- .../notice/ForbiddenGeographyIdNotice.java | 2 +- ...mesShapeDistTraveledPresenceValidator.java | 103 ++++++++++++++++++ ...hapeDistTraveledPresenceValidatorTest.java | 80 ++++++++++++++ 3 files changed, 184 insertions(+), 1 deletion(-) create mode 100644 main/src/main/java/org/mobilitydata/gtfsvalidator/validator/StopTimesShapeDistTraveledPresenceValidator.java create mode 100644 main/src/test/java/org/mobilitydata/gtfsvalidator/validator/StopTimesShapeDistTraveledPresenceValidatorTest.java diff --git a/core/src/main/java/org/mobilitydata/gtfsvalidator/notice/ForbiddenGeographyIdNotice.java b/core/src/main/java/org/mobilitydata/gtfsvalidator/notice/ForbiddenGeographyIdNotice.java index 41647bbbee..0a823a648e 100644 --- a/core/src/main/java/org/mobilitydata/gtfsvalidator/notice/ForbiddenGeographyIdNotice.java +++ b/core/src/main/java/org/mobilitydata/gtfsvalidator/notice/ForbiddenGeographyIdNotice.java @@ -33,7 +33,7 @@ public class ForbiddenGeographyIdNotice extends ValidationNotice { /** The row of the faulty record. */ private final int csvRowNumber; - /** The sThe id that already exists. */ + /** The id that already exists. */ private final String stopId; /** The id that already exists. */ diff --git a/main/src/main/java/org/mobilitydata/gtfsvalidator/validator/StopTimesShapeDistTraveledPresenceValidator.java b/main/src/main/java/org/mobilitydata/gtfsvalidator/validator/StopTimesShapeDistTraveledPresenceValidator.java new file mode 100644 index 0000000000..9d13045246 --- /dev/null +++ b/main/src/main/java/org/mobilitydata/gtfsvalidator/validator/StopTimesShapeDistTraveledPresenceValidator.java @@ -0,0 +1,103 @@ +/* + * Copyright 2024 MobilityData + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mobilitydata.gtfsvalidator.validator; + +import static org.mobilitydata.gtfsvalidator.annotation.GtfsValidationNotice.SectionRef.FILE_REQUIREMENTS; +import static org.mobilitydata.gtfsvalidator.notice.SeverityLevel.ERROR; + +import org.mobilitydata.gtfsvalidator.annotation.GtfsValidationNotice; +import org.mobilitydata.gtfsvalidator.annotation.GtfsValidator; +import org.mobilitydata.gtfsvalidator.notice.NoticeContainer; +import org.mobilitydata.gtfsvalidator.notice.ValidationNotice; +import org.mobilitydata.gtfsvalidator.table.GtfsStopTime; + +/** + * Check that only entries with stop_id have shape_dist_traveled. A GeoJSON location or location + * group is forbidden to have an associated shape_dist_traveled field in stop_times.txt. + * + *

Generated notice: {@link ForbiddenShapeDistTraveledNotice}. + */ +@GtfsValidator +public class StopTimesShapeDistTraveledPresenceValidator + extends SingleEntityValidator { + + @Override + public void validate(GtfsStopTime stopTime, NoticeContainer noticeContainer) { + if (stopTime.hasStopId()) { + return; + } + if (stopTime.hasLocationGroupId() || stopTime.hasLocationId()) { + if (stopTime.hasShapeDistTraveled()) { + noticeContainer.addValidationNotice( + new ForbiddenShapeDistTraveledNotice( + stopTime.csvRowNumber(), + stopTime.tripId(), + stopTime.locationGroupId(), + stopTime.locationId(), + stopTime.shapeDistTraveled())); + } + } + } + + @Override + public boolean shouldCallValidate(ColumnInspector header) { + // No point in validating if there is no shape_dist_traveled column + // And we need to have either location_id or location_group_id for this validator to make sense + return header.hasColumn(GtfsStopTime.SHAPE_DIST_TRAVELED_FIELD_NAME) + && (header.hasColumn(GtfsStopTime.LOCATION_ID_FIELD_NAME) + || header.hasColumn(GtfsStopTime.LOCATION_GROUP_ID_FIELD_NAME)); + } + + /** + * A stop_time entry has a `shape_dist_traveled` without a `stop_id` value. + * + *

A GeoJSON location or location group has an associated shape_dist_traveled field in + * stop_times.txt. shape_dist_traveled values should only be provided for stops. + */ + @GtfsValidationNotice( + severity = ERROR, + sections = @GtfsValidationNotice.SectionRefs(FILE_REQUIREMENTS)) + public static class ForbiddenShapeDistTraveledNotice extends ValidationNotice { + + /** The row of the faulty record. */ + private final int csvRowNumber; + + /** The trip_id for which the shape_dist_traveled is defined */ + private final String tripId; + + /** The location_grpup_id for which the shape_dist_traveled is defined */ + private final String locationGroupId; + + /** The location_id for which the shape_dist_traveled is defined */ + private final String locationId; + + /** The shape_dist_traveled value */ + private final double shapeDistTraveled; + + public ForbiddenShapeDistTraveledNotice( + int csvRowNumber, + String tripId, + String locationGroupId, + String locationId, + double shapeDistTraveled) { + this.csvRowNumber = csvRowNumber; + this.tripId = tripId; + this.locationGroupId = locationGroupId; + this.locationId = locationId; + this.shapeDistTraveled = shapeDistTraveled; + } + } +} diff --git a/main/src/test/java/org/mobilitydata/gtfsvalidator/validator/StopTimesShapeDistTraveledPresenceValidatorTest.java b/main/src/test/java/org/mobilitydata/gtfsvalidator/validator/StopTimesShapeDistTraveledPresenceValidatorTest.java new file mode 100644 index 0000000000..2c7d17baf0 --- /dev/null +++ b/main/src/test/java/org/mobilitydata/gtfsvalidator/validator/StopTimesShapeDistTraveledPresenceValidatorTest.java @@ -0,0 +1,80 @@ +/* + * Copyright 2021 MobilityData IO + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.mobilitydata.gtfsvalidator.validator; + +import static com.google.common.truth.Truth.assertThat; + +import com.google.common.collect.ImmutableList; +import java.util.List; +import org.junit.Test; +import org.mobilitydata.gtfsvalidator.notice.NoticeContainer; +import org.mobilitydata.gtfsvalidator.notice.ValidationNotice; +import org.mobilitydata.gtfsvalidator.table.GtfsStopTime; + +public class StopTimesShapeDistTraveledPresenceValidatorTest { + public static GtfsStopTime createStopTime( + int csvRowNumber, + String tripId, + String stopId, + String locationGroupId, + String locationId, + int stopSequence, + double shapeDistTraveled) { + var builder = + new GtfsStopTime.Builder() + .setCsvRowNumber(csvRowNumber) + .setTripId(tripId) + .setStopSequence(stopSequence) + .setShapeDistTraveled(shapeDistTraveled); + if (stopId != null) { + builder.setStopId(stopId); + } + if (locationGroupId != null) { + builder.setLocationGroupId(locationGroupId); + } + if (locationId != null) { + builder.setLocationId(locationId); + } + + return builder.build(); + } + + private static List generateNotices(List stopTimes) { + NoticeContainer noticeContainer = new NoticeContainer(); + + var validator = new StopTimesShapeDistTraveledPresenceValidator(); + for (var stopTime : stopTimes) { + validator.validate(stopTime, noticeContainer); + } + return noticeContainer.getValidationNotices(); + } + + @Test + public void locationWithShapeDistanceShouldGenerateNotice() { + assertThat( + generateNotices( + ImmutableList.of( + createStopTime(1, "first trip", null, "loc1", null, 2, 10.0d), + createStopTime(2, "first trip", null, null, "loc2", 42, 45.0d), + createStopTime(3, "first trip", "stop1", null, null, 46, 64.0d)))) + .containsExactly( + new StopTimesShapeDistTraveledPresenceValidator.ForbiddenShapeDistTraveledNotice( + 1, "first trip", "loc1", "", 10.0d), + new StopTimesShapeDistTraveledPresenceValidator.ForbiddenShapeDistTraveledNotice( + 2, "first trip", "", "loc2", 45.0d)); + } +} From 84a34697dab1aff76c51f45d95b78466f1e07542 Mon Sep 17 00:00:00 2001 From: cka-y <60586858+cka-y@users.noreply.github.com> Date: Mon, 21 Oct 2024 10:14:28 -0400 Subject: [PATCH 2/2] feat:`locations.geojson` file parsing and geometry validation (#1879) --- .../notice/InvalidGeometryNotice.java | 51 ++++ .../notice/MalformedJsonNotice.java | 32 +++ .../notice/MissingRequiredElementNotice.java | 40 +++ .../notice/UnsupportedFeatureTypeNotice.java | 44 ++++ .../notice/UnsupportedGeoJsonTypeNotice.java | 36 +++ .../notice/UnsupportedGeometryTypeNotice.java | 45 ++++ .../table/GtfsEntityContainer.java | 4 +- .../table/GtfsFileDescriptor.java | 4 +- main/build.gradle | 1 + .../table/GeoJsonFileLoader.java | 227 ++++++++++++++++++ .../table/GeojsonFileLoader.java | 101 -------- .../table/GtfsGeoJsonFeature.java | 100 ++++++++ ...ema.java => GtfsGeoJsonFeatureSchema.java} | 4 +- ...java => GtfsGeoJsonFeaturesContainer.java} | 36 +-- .../table/GtfsGeoJsonFileDescriptor.java | 46 ++++ .../table/GtfsGeojsonFeature.java | 34 --- .../table/GtfsGeojsonFileDescriptor.java | 46 ---- .../geojson/GeoJsonGeometryValidator.java | 129 ++++++++++ .../util/geojson/GeometryType.java | 15 ++ .../UnparsableGeoJsonFeatureException.java | 10 + .../validator/MissingStopsFileValidator.java | 8 +- main/src/main/resources/report.html | 10 +- .../table/GeoJsonFileLoaderTest.java | 149 ++++++++++++ .../table/GeojsonFileLoaderTest.java | 115 --------- .../geojson/GeoJsonGeometryValidatorTest.java | 141 +++++++++++ .../MissingStopsFileValidatorTest.java | 12 +- .../validator/NoticeFieldsTest.java | 6 + 27 files changed, 1112 insertions(+), 334 deletions(-) create mode 100644 core/src/main/java/org/mobilitydata/gtfsvalidator/notice/InvalidGeometryNotice.java create mode 100644 core/src/main/java/org/mobilitydata/gtfsvalidator/notice/MalformedJsonNotice.java create mode 100644 core/src/main/java/org/mobilitydata/gtfsvalidator/notice/MissingRequiredElementNotice.java create mode 100644 core/src/main/java/org/mobilitydata/gtfsvalidator/notice/UnsupportedFeatureTypeNotice.java create mode 100644 core/src/main/java/org/mobilitydata/gtfsvalidator/notice/UnsupportedGeoJsonTypeNotice.java create mode 100644 core/src/main/java/org/mobilitydata/gtfsvalidator/notice/UnsupportedGeometryTypeNotice.java create mode 100644 main/src/main/java/org/mobilitydata/gtfsvalidator/table/GeoJsonFileLoader.java delete mode 100644 main/src/main/java/org/mobilitydata/gtfsvalidator/table/GeojsonFileLoader.java create mode 100644 main/src/main/java/org/mobilitydata/gtfsvalidator/table/GtfsGeoJsonFeature.java rename main/src/main/java/org/mobilitydata/gtfsvalidator/table/{GtfsGeojsonFeatureSchema.java => GtfsGeoJsonFeatureSchema.java} (60%) rename main/src/main/java/org/mobilitydata/gtfsvalidator/table/{GtfsGeojsonFeaturesContainer.java => GtfsGeoJsonFeaturesContainer.java} (67%) create mode 100644 main/src/main/java/org/mobilitydata/gtfsvalidator/table/GtfsGeoJsonFileDescriptor.java delete mode 100644 main/src/main/java/org/mobilitydata/gtfsvalidator/table/GtfsGeojsonFeature.java delete mode 100644 main/src/main/java/org/mobilitydata/gtfsvalidator/table/GtfsGeojsonFileDescriptor.java create mode 100644 main/src/main/java/org/mobilitydata/gtfsvalidator/util/geojson/GeoJsonGeometryValidator.java create mode 100644 main/src/main/java/org/mobilitydata/gtfsvalidator/util/geojson/GeometryType.java create mode 100644 main/src/main/java/org/mobilitydata/gtfsvalidator/util/geojson/UnparsableGeoJsonFeatureException.java create mode 100644 main/src/test/java/org/mobilitydata/gtfsvalidator/table/GeoJsonFileLoaderTest.java delete mode 100644 main/src/test/java/org/mobilitydata/gtfsvalidator/table/GeojsonFileLoaderTest.java create mode 100644 main/src/test/java/org/mobilitydata/gtfsvalidator/util/geojson/GeoJsonGeometryValidatorTest.java diff --git a/core/src/main/java/org/mobilitydata/gtfsvalidator/notice/InvalidGeometryNotice.java b/core/src/main/java/org/mobilitydata/gtfsvalidator/notice/InvalidGeometryNotice.java new file mode 100644 index 0000000000..4194cb8f04 --- /dev/null +++ b/core/src/main/java/org/mobilitydata/gtfsvalidator/notice/InvalidGeometryNotice.java @@ -0,0 +1,51 @@ +/* + * Copyright 2024 MobilityData LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mobilitydata.gtfsvalidator.notice; + +import static org.mobilitydata.gtfsvalidator.notice.SeverityLevel.ERROR; + +import org.mobilitydata.gtfsvalidator.annotation.GtfsValidationNotice; + +/** + * A polygon in `locations.geojson` is unparsable or invalid. + * + *

Each polygon must be valid by the definition of the OpenGIS Simple Features + * Specification, section 6.1.11 . + */ +@GtfsValidationNotice(severity = ERROR) +public class InvalidGeometryNotice extends ValidationNotice { + + /** The id of the faulty record. */ + private final String featureId; + + /** The index of the feature in the feature collection. */ + private final int featureIndex; + + /** The geometry type of the feature containing the invalid polygon. */ + private final String geometryType; + + /** The validation error details. */ + private final String message; + + public InvalidGeometryNotice( + String featureId, int featureIndex, String geometryType, String validationError) { + this.featureId = featureId; + this.featureIndex = featureIndex; + this.geometryType = geometryType; + this.message = validationError; + } +} diff --git a/core/src/main/java/org/mobilitydata/gtfsvalidator/notice/MalformedJsonNotice.java b/core/src/main/java/org/mobilitydata/gtfsvalidator/notice/MalformedJsonNotice.java new file mode 100644 index 0000000000..6c4591ad17 --- /dev/null +++ b/core/src/main/java/org/mobilitydata/gtfsvalidator/notice/MalformedJsonNotice.java @@ -0,0 +1,32 @@ +/* + * Copyright 2024 MobilityData LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mobilitydata.gtfsvalidator.notice; + +import static org.mobilitydata.gtfsvalidator.notice.SeverityLevel.ERROR; + +import org.mobilitydata.gtfsvalidator.annotation.GtfsValidationNotice; + +/** A JSON file is malformed. */ +@GtfsValidationNotice(severity = ERROR) +public class MalformedJsonNotice extends ValidationNotice { + + /** The name of the faulty file. */ + private final String filename; + + public MalformedJsonNotice(String filename) { + this.filename = filename; + } +} diff --git a/core/src/main/java/org/mobilitydata/gtfsvalidator/notice/MissingRequiredElementNotice.java b/core/src/main/java/org/mobilitydata/gtfsvalidator/notice/MissingRequiredElementNotice.java new file mode 100644 index 0000000000..15603f8370 --- /dev/null +++ b/core/src/main/java/org/mobilitydata/gtfsvalidator/notice/MissingRequiredElementNotice.java @@ -0,0 +1,40 @@ +/* + * Copyright 2024 MobilityData LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mobilitydata.gtfsvalidator.notice; + +import static org.mobilitydata.gtfsvalidator.notice.SeverityLevel.ERROR; + +import org.mobilitydata.gtfsvalidator.annotation.GtfsValidationNotice; + +/** A required element is missing in `locations.geojson`. */ +@GtfsValidationNotice(severity = ERROR) +public class MissingRequiredElementNotice extends ValidationNotice { + /** Index of the feature in the feature collection. */ + private final Integer featureIndex; + + /** The id of the faulty record. */ + private final String featureId; + + /** The missing required element. */ + private final String missingElement; + + public MissingRequiredElementNotice( + String featureId, String missingElement, Integer featureIndex) { + this.featureId = featureId; + this.featureIndex = featureIndex; + this.missingElement = missingElement; + } +} diff --git a/core/src/main/java/org/mobilitydata/gtfsvalidator/notice/UnsupportedFeatureTypeNotice.java b/core/src/main/java/org/mobilitydata/gtfsvalidator/notice/UnsupportedFeatureTypeNotice.java new file mode 100644 index 0000000000..a083acd3e3 --- /dev/null +++ b/core/src/main/java/org/mobilitydata/gtfsvalidator/notice/UnsupportedFeatureTypeNotice.java @@ -0,0 +1,44 @@ +/* + * Copyright 2024 MobilityData LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mobilitydata.gtfsvalidator.notice; + +import static org.mobilitydata.gtfsvalidator.notice.SeverityLevel.ERROR; + +import org.mobilitydata.gtfsvalidator.annotation.GtfsValidationNotice; + +/** + * An unsupported feature type is used in the `locations.geojson` file. + * + *

Use `Feature` instead to comply with the spec. + */ +@GtfsValidationNotice(severity = ERROR) +public class UnsupportedFeatureTypeNotice extends ValidationNotice { + + /** The value of the unsupported GeoJSON type. */ + Integer featureIndex; + + /** The id of the faulty record. */ + String featureId; + + /** The feature type of the faulty record. */ + String featureType; + + public UnsupportedFeatureTypeNotice(Integer featureIndex, String featureId, String featureType) { + this.featureIndex = featureIndex; + this.featureId = featureId; + this.featureType = featureType; + } +} diff --git a/core/src/main/java/org/mobilitydata/gtfsvalidator/notice/UnsupportedGeoJsonTypeNotice.java b/core/src/main/java/org/mobilitydata/gtfsvalidator/notice/UnsupportedGeoJsonTypeNotice.java new file mode 100644 index 0000000000..e75482c293 --- /dev/null +++ b/core/src/main/java/org/mobilitydata/gtfsvalidator/notice/UnsupportedGeoJsonTypeNotice.java @@ -0,0 +1,36 @@ +/* + * Copyright 2024 MobilityData LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mobilitydata.gtfsvalidator.notice; + +import static org.mobilitydata.gtfsvalidator.notice.SeverityLevel.ERROR; + +import org.mobilitydata.gtfsvalidator.annotation.GtfsValidationNotice; + +/** + * An unsupported GeoJSON type is used in the `locations.geojson` file. + * + *

Use `FeatureCollection` instead to comply with the spec. + */ +@GtfsValidationNotice(severity = ERROR) +public class UnsupportedGeoJsonTypeNotice extends ValidationNotice { + + /** The value of the unsupported GeoJSON type. */ + private final String geoJsonType; + + public UnsupportedGeoJsonTypeNotice(String geoJsonType) { + this.geoJsonType = geoJsonType; + } +} diff --git a/core/src/main/java/org/mobilitydata/gtfsvalidator/notice/UnsupportedGeometryTypeNotice.java b/core/src/main/java/org/mobilitydata/gtfsvalidator/notice/UnsupportedGeometryTypeNotice.java new file mode 100644 index 0000000000..1bd6301ae5 --- /dev/null +++ b/core/src/main/java/org/mobilitydata/gtfsvalidator/notice/UnsupportedGeometryTypeNotice.java @@ -0,0 +1,45 @@ +/* + * Copyright 2024 MobilityData LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mobilitydata.gtfsvalidator.notice; + +import static org.mobilitydata.gtfsvalidator.notice.SeverityLevel.ERROR; + +import org.mobilitydata.gtfsvalidator.annotation.GtfsValidationNotice; + +/** + * A GeoJSON feature has an unsupported geometry type in `locations.geojson`. + * + *

Each feature must have a geometry type that is supported by the GTFS spec. The supported + * geometry types `Polygon` and `MultiPolygon`. + */ +@GtfsValidationNotice(severity = ERROR) +public class UnsupportedGeometryTypeNotice extends ValidationNotice { + + /** The index of the feature in the feature collection. */ + private final int featureIndex; + + /** The id of the faulty record. */ + private final String featureId; + + /** The geometry type of the faulty record. */ + private final String geometryType; + + public UnsupportedGeometryTypeNotice(int featureIndex, String featureId, String geometryType) { + this.featureIndex = featureIndex; + this.featureId = featureId; + this.geometryType = geometryType; + } +} diff --git a/core/src/main/java/org/mobilitydata/gtfsvalidator/table/GtfsEntityContainer.java b/core/src/main/java/org/mobilitydata/gtfsvalidator/table/GtfsEntityContainer.java index fcaef33a9e..28c2e61464 100644 --- a/core/src/main/java/org/mobilitydata/gtfsvalidator/table/GtfsEntityContainer.java +++ b/core/src/main/java/org/mobilitydata/gtfsvalidator/table/GtfsEntityContainer.java @@ -7,9 +7,9 @@ * This class is the parent of containers holding table (csv) entities and containers holding JSON * entities * - * @param The entity for this container (e.g. GtfsCalendarDate or GtfsGeojsonFeature ) + * @param The entity for this container (e.g. GtfsCalendarDate or GtfsGeoJsonFeature ) * @param The descriptor for the file for the container (e.g. GtfsCalendarDateTableDescriptor or - * GtfsGeojsonFileDescriptor) + * GtfsGeoJsonFileDescriptor) */ public abstract class GtfsEntityContainer { diff --git a/core/src/main/java/org/mobilitydata/gtfsvalidator/table/GtfsFileDescriptor.java b/core/src/main/java/org/mobilitydata/gtfsvalidator/table/GtfsFileDescriptor.java index 9635d50935..741a9001b6 100644 --- a/core/src/main/java/org/mobilitydata/gtfsvalidator/table/GtfsFileDescriptor.java +++ b/core/src/main/java/org/mobilitydata/gtfsvalidator/table/GtfsFileDescriptor.java @@ -4,10 +4,10 @@ /** * This class provides some info about the different files within a GTFS dataset. Its children - * relate to either a csv table or a geojson file. + * relate to either a csv table or a GeoJSON file. * * @param The entity that will be extracted from the file. For example, GtfsCalendarDate or - * GtfsGeojsonFeature + * GtfsGeoJsonFeature */ public abstract class GtfsFileDescriptor { diff --git a/main/build.gradle b/main/build.gradle index a64445d84b..8aa066d16e 100644 --- a/main/build.gradle +++ b/main/build.gradle @@ -43,6 +43,7 @@ dependencies { implementation 'org.thymeleaf:thymeleaf:3.0.15.RELEASE' implementation 'com.vladsch.flexmark:flexmark-all:0.64.8' implementation 'io.github.classgraph:classgraph:4.8.146' + implementation 'org.locationtech.jts:jts-core:1.20.0' testImplementation group: 'junit', name: 'junit', version: '4.13' testImplementation 'com.google.truth:truth:1.0.1' testImplementation 'com.google.truth.extensions:truth-java8-extension:1.0.1' diff --git a/main/src/main/java/org/mobilitydata/gtfsvalidator/table/GeoJsonFileLoader.java b/main/src/main/java/org/mobilitydata/gtfsvalidator/table/GeoJsonFileLoader.java new file mode 100644 index 0000000000..db9bc81aa4 --- /dev/null +++ b/main/src/main/java/org/mobilitydata/gtfsvalidator/table/GeoJsonFileLoader.java @@ -0,0 +1,227 @@ +package org.mobilitydata.gtfsvalidator.table; + +import com.google.common.flogger.FluentLogger; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParseException; +import com.google.gson.JsonParser; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.util.ArrayList; +import java.util.List; +import org.locationtech.jts.geom.*; +import org.mobilitydata.gtfsvalidator.notice.*; +import org.mobilitydata.gtfsvalidator.util.geojson.GeoJsonGeometryValidator; +import org.mobilitydata.gtfsvalidator.util.geojson.GeometryType; +import org.mobilitydata.gtfsvalidator.util.geojson.UnparsableGeoJsonFeatureException; +import org.mobilitydata.gtfsvalidator.validator.ValidatorProvider; + +/** + * This class knows how to load a GeoJSON file. Typical GeoJSON file: { "type": "FeatureCollection", + * "features": [ { "id": "area_548", "type": "Feature", "geometry": { "type": "Polygon", + * "coordinates": [ [ [ -122.4112929, 48.0834848 ], ... ] ] }, "properties": { "stop_name": "Some + * name", "stop_desc": "Some description" } }, ... ] } + */ +public class GeoJsonFileLoader extends TableLoader { + private static final FluentLogger logger = FluentLogger.forEnclosingClass(); + private GeoJsonGeometryValidator geometryValidator; + + @Override + public GtfsEntityContainer load( + GtfsFileDescriptor fileDescriptor, + ValidatorProvider validatorProvider, + InputStream inputStream, + NoticeContainer noticeContainer) { + GtfsGeoJsonFileDescriptor geoJsonFileDescriptor = (GtfsGeoJsonFileDescriptor) fileDescriptor; + geometryValidator = new GeoJsonGeometryValidator(noticeContainer); + try { + List entities = extractFeaturesFromStream(inputStream, noticeContainer); + return geoJsonFileDescriptor.createContainerForEntities(entities, noticeContainer); + } catch (JsonParseException jpex) { + noticeContainer.addValidationNotice(new MalformedJsonNotice(GtfsGeoJsonFeature.FILENAME)); + logger.atSevere().withCause(jpex).log("Malformed JSON in locations.geojson"); + return fileDescriptor.createContainerForInvalidStatus(TableStatus.UNPARSABLE_ROWS); + } catch (IOException ioex) { + noticeContainer.addSystemError(new IOError(ioex)); + return fileDescriptor.createContainerForInvalidStatus(TableStatus.UNPARSABLE_ROWS); + } catch (UnparsableGeoJsonFeatureException ugex) { + logger.atSevere().withCause(ugex).log("Unparsable GeoJSON feature"); + return fileDescriptor.createContainerForInvalidStatus(TableStatus.UNPARSABLE_ROWS); + } catch (Exception ex) { + logger.atSevere().withCause(ex).log("Error while loading locations.geojson"); + return fileDescriptor.createContainerForInvalidStatus(TableStatus.UNPARSABLE_ROWS); + } + } + + public List extractFeaturesFromStream( + InputStream inputStream, NoticeContainer noticeContainer) + throws IOException, UnparsableGeoJsonFeatureException { + List features = new ArrayList<>(); + boolean hasUnparsableFeature = false; + try (InputStreamReader reader = new InputStreamReader(inputStream)) { + JsonObject jsonObject = JsonParser.parseReader(reader).getAsJsonObject(); + if (!jsonObject.has("type")) { + noticeContainer.addValidationNotice(new MissingRequiredElementNotice(null, "type", null)); + throw new UnparsableGeoJsonFeatureException("Missing required field 'type'"); + } else if (!jsonObject.get("type").getAsString().equals("FeatureCollection")) { + noticeContainer.addValidationNotice( + new UnsupportedGeoJsonTypeNotice(jsonObject.get("type").getAsString())); + throw new UnparsableGeoJsonFeatureException("Unsupported GeoJSON type"); + } + JsonArray featuresArray = jsonObject.getAsJsonArray("features"); + for (int i = 0; i < featuresArray.size(); i++) { + JsonElement feature = featuresArray.get(i); + GtfsGeoJsonFeature gtfsGeoJsonFeature = extractFeature(feature, noticeContainer, i); + hasUnparsableFeature |= gtfsGeoJsonFeature == null; + if (gtfsGeoJsonFeature != null) { + features.add(gtfsGeoJsonFeature); + } + } + } + if (hasUnparsableFeature) { + throw new UnparsableGeoJsonFeatureException("Unparsable GeoJSON feature"); + } + return features; + } + + public GtfsGeoJsonFeature extractFeature( + JsonElement feature, NoticeContainer noticeContainer, int featureIndex) { + GtfsGeoJsonFeature gtfsGeoJsonFeature; + List missingRequiredFields = new ArrayList<>(); + String featureId = null; + if (feature.isJsonObject()) { + JsonObject featureObject = feature.getAsJsonObject(); + // Handle feature id + if (!featureObject.has(GtfsGeoJsonFeature.FEATURE_ID_FIELD_NAME)) { + missingRequiredFields.add( + GtfsGeoJsonFeature.FEATURE_COLLECTION_FIELD_NAME + + '.' + + GtfsGeoJsonFeature.FEATURE_ID_FIELD_NAME); + } else { + featureId = featureObject.get(GtfsGeoJsonFeature.FEATURE_ID_FIELD_NAME).getAsString(); + if (featureId == null || featureId.isEmpty()) { + missingRequiredFields.add( + GtfsGeoJsonFeature.FEATURE_COLLECTION_FIELD_NAME + + '.' + + GtfsGeoJsonFeature.FEATURE_ID_FIELD_NAME); + } + } + + // Handle feature type + if (!featureObject.has(GtfsGeoJsonFeature.FEATURE_TYPE_FIELD_NAME)) { + missingRequiredFields.add( + GtfsGeoJsonFeature.FEATURE_COLLECTION_FIELD_NAME + + '.' + + GtfsGeoJsonFeature.FEATURE_TYPE_FIELD_NAME); + } else if (!featureObject + .get(GtfsGeoJsonFeature.FEATURE_TYPE_FIELD_NAME) + .getAsString() + .equals("Feature")) { + noticeContainer.addValidationNotice( + new UnsupportedFeatureTypeNotice( + featureIndex, + featureId, + featureObject.get(GtfsGeoJsonFeature.FEATURE_TYPE_FIELD_NAME).getAsString())); + } + + // Handle properties + if (!featureObject.has(GtfsGeoJsonFeature.FEATURE_PROPERTIES_FIELD_NAME)) { + missingRequiredFields.add( + GtfsGeoJsonFeature.FEATURE_COLLECTION_FIELD_NAME + + '.' + + GtfsGeoJsonFeature.FEATURE_PROPERTIES_FIELD_NAME); + } + + // Handle geometry + if (!featureObject.has(GtfsGeoJsonFeature.GEOMETRY_FIELD_NAME)) { + missingRequiredFields.add( + GtfsGeoJsonFeature.FEATURE_COLLECTION_FIELD_NAME + + '.' + + GtfsGeoJsonFeature.GEOMETRY_FIELD_NAME); + } else { + JsonObject geometry = featureObject.getAsJsonObject(GtfsGeoJsonFeature.GEOMETRY_FIELD_NAME); + // Handle geometry type and coordinates + if (!geometry.has(GtfsGeoJsonFeature.GEOMETRY_TYPE_FIELD_NAME)) { + missingRequiredFields.add( + GtfsGeoJsonFeature.FEATURE_COLLECTION_FIELD_NAME + + '.' + + GtfsGeoJsonFeature.GEOMETRY_FIELD_NAME + + '.' + + GtfsGeoJsonFeature.GEOMETRY_TYPE_FIELD_NAME); + } else if (!geometry.has(GtfsGeoJsonFeature.GEOMETRY_COORDINATES_FIELD_NAME)) { + missingRequiredFields.add( + GtfsGeoJsonFeature.FEATURE_COLLECTION_FIELD_NAME + + '.' + + GtfsGeoJsonFeature.GEOMETRY_FIELD_NAME + + '.' + + GtfsGeoJsonFeature.GEOMETRY_COORDINATES_FIELD_NAME); + } else if (missingRequiredFields + .isEmpty()) { // All required fields are present - Validate geometry + // Create a new GtfsGeoJsonFeature + gtfsGeoJsonFeature = new GtfsGeoJsonFeature(); + gtfsGeoJsonFeature.setFeatureId( + featureObject.get(GtfsGeoJsonFeature.FEATURE_ID_FIELD_NAME).getAsString()); + + String type = geometry.get(GtfsGeoJsonFeature.GEOMETRY_TYPE_FIELD_NAME).getAsString(); + + if (type.equals(GeometryType.POLYGON.getType())) { + gtfsGeoJsonFeature.setGeometryType(GeometryType.POLYGON); + Polygon polygon = + geometryValidator.createPolygon( + geometry.getAsJsonArray(GtfsGeoJsonFeature.GEOMETRY_COORDINATES_FIELD_NAME), + gtfsGeoJsonFeature, + featureIndex); + if (polygon == null) return null; + gtfsGeoJsonFeature.setGeometryDefinition(polygon); + + } else if (type.equals(GeometryType.MULTI_POLYGON.getType())) { + gtfsGeoJsonFeature.setGeometryType(GeometryType.MULTI_POLYGON); + MultiPolygon multiPolygon = + geometryValidator.createMultiPolygon( + geometry.getAsJsonArray(GtfsGeoJsonFeature.GEOMETRY_COORDINATES_FIELD_NAME), + gtfsGeoJsonFeature, + featureIndex); + if (multiPolygon == null) return null; + gtfsGeoJsonFeature.setGeometryDefinition(multiPolygon); + + } else { + noticeContainer.addValidationNotice( + new UnsupportedGeometryTypeNotice(featureIndex, featureId, type)); + } + JsonObject properties = + featureObject.getAsJsonObject(GtfsGeoJsonFeature.FEATURE_PROPERTIES_FIELD_NAME); + if (properties.has(GtfsGeoJsonFeature.FEATURE_PROPERTIES_STOP_NAME_FIELD_NAME)) { + gtfsGeoJsonFeature.setStopName( + properties + .get(GtfsGeoJsonFeature.FEATURE_PROPERTIES_STOP_NAME_FIELD_NAME) + .getAsString()); + } + if (properties.has(GtfsGeoJsonFeature.FEATURE_PROPERTIES_STOP_DESC_FIELD_NAME)) { + gtfsGeoJsonFeature.setStopDesc( + properties + .get(GtfsGeoJsonFeature.FEATURE_PROPERTIES_STOP_DESC_FIELD_NAME) + .getAsString()); + } + + return gtfsGeoJsonFeature; + } + } + } + addMissingRequiredFieldsNotices( + missingRequiredFields, noticeContainer, featureId, featureIndex); + return null; + } + + private static void addMissingRequiredFieldsNotices( + List missingRequiredFields, + NoticeContainer noticeContainer, + String featureId, + int featureIndex) { + for (String missingRequiredField : missingRequiredFields) { + noticeContainer.addValidationNotice( + new MissingRequiredElementNotice(featureId, missingRequiredField, featureIndex)); + } + } +} diff --git a/main/src/main/java/org/mobilitydata/gtfsvalidator/table/GeojsonFileLoader.java b/main/src/main/java/org/mobilitydata/gtfsvalidator/table/GeojsonFileLoader.java deleted file mode 100644 index 87357ba0f8..0000000000 --- a/main/src/main/java/org/mobilitydata/gtfsvalidator/table/GeojsonFileLoader.java +++ /dev/null @@ -1,101 +0,0 @@ -package org.mobilitydata.gtfsvalidator.table; - -import com.google.common.flogger.FluentLogger; -import com.google.gson.JsonArray; -import com.google.gson.JsonElement; -import com.google.gson.JsonObject; -import com.google.gson.JsonParseException; -import com.google.gson.JsonParser; -import java.io.IOException; -import java.io.InputStream; -import java.io.InputStreamReader; -import java.util.ArrayList; -import java.util.List; -import org.mobilitydata.gtfsvalidator.notice.IOError; -import org.mobilitydata.gtfsvalidator.notice.NoticeContainer; -import org.mobilitydata.gtfsvalidator.validator.ValidatorProvider; - -/** - * This class knows how to load a geojson file. Typical geojson file: { "type": "FeatureCollection", - * "features": [ { "id": "area_548", "type": "Feature", "geometry": { "type": "Polygon", - * "coordinates": [ [ [ -122.4112929, 48.0834848 ], ... ] ] }, "properties": { "stop_name": "Some - * name", "stop_desc": "Some description" } }, ... ] } - */ -public class GeojsonFileLoader extends TableLoader { - private static final FluentLogger logger = FluentLogger.forEnclosingClass(); - - @Override - public GtfsEntityContainer load( - GtfsFileDescriptor fileDescriptor, - ValidatorProvider validatorProvider, - InputStream inputStream, - NoticeContainer noticeContainer) { - GtfsGeojsonFileDescriptor geojsonFileDescriptor = (GtfsGeojsonFileDescriptor) fileDescriptor; - try { - List entities = extractFeaturesFromStream(inputStream, noticeContainer); - return geojsonFileDescriptor.createContainerForEntities(entities, noticeContainer); - } catch (JsonParseException jpex) { - // TODO: Add a notice for malformed locations.geojson - logger.atSevere().withCause(jpex).log("Malformed JSON in locations.geojson"); - return geojsonFileDescriptor.createContainerForEntities(new ArrayList<>(), noticeContainer); - } catch (IOException ioex) { - noticeContainer.addSystemError(new IOError(ioex)); - return fileDescriptor.createContainerForInvalidStatus(TableStatus.UNPARSABLE_ROWS); - } catch (Exception ex) { - logger.atSevere().withCause(ex).log("Error while loading locations.geojson"); - return fileDescriptor.createContainerForInvalidStatus(TableStatus.UNPARSABLE_ROWS); - } - } - - public List extractFeaturesFromStream( - InputStream inputStream, NoticeContainer noticeContainer) throws IOException { - List features = new ArrayList<>(); - try (InputStreamReader reader = new InputStreamReader(inputStream)) { - JsonObject jsonObject = JsonParser.parseReader(reader).getAsJsonObject(); - JsonArray featuresArray = jsonObject.getAsJsonArray("features"); - for (JsonElement feature : featuresArray) { - GtfsGeojsonFeature gtfsGeojsonFeature = extractFeature(feature, noticeContainer); - if (gtfsGeojsonFeature != null) { - features.add(gtfsGeojsonFeature); - } - } - } - return features; - } - - public GtfsGeojsonFeature extractFeature(JsonElement feature, NoticeContainer noticeContainer) { - GtfsGeojsonFeature gtfsGeojsonFeature = null; - if (feature.isJsonObject()) { - JsonObject featureObject = feature.getAsJsonObject(); - if (featureObject.has("properties")) { - JsonObject properties = featureObject.getAsJsonObject("properties"); - // Add stop_name and stop_desc - } else { - // Add a notice because properties is required - } - if (featureObject.has("id")) { - gtfsGeojsonFeature = new GtfsGeojsonFeature(); - gtfsGeojsonFeature.setFeatureId(featureObject.get("id").getAsString()); - } else { - // Add a notice because id is required - } - - if (featureObject.has("geometry")) { - JsonObject geometry = featureObject.getAsJsonObject("geometry"); - if (geometry.has("type")) { - String type = geometry.get("type").getAsString(); - if (type.equals("Polygon")) { - // Extract the polygon - } else if (type.equals("Multipolygon")) { - // extract the multipolygon - } - } else { - // Add a notice because type is required - } - } else { - // Add a notice because geometry is required - } - } - return gtfsGeojsonFeature; - } -} diff --git a/main/src/main/java/org/mobilitydata/gtfsvalidator/table/GtfsGeoJsonFeature.java b/main/src/main/java/org/mobilitydata/gtfsvalidator/table/GtfsGeoJsonFeature.java new file mode 100644 index 0000000000..a7e75d4ab5 --- /dev/null +++ b/main/src/main/java/org/mobilitydata/gtfsvalidator/table/GtfsGeoJsonFeature.java @@ -0,0 +1,100 @@ +package org.mobilitydata.gtfsvalidator.table; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import org.locationtech.jts.geom.Polygonal; +import org.mobilitydata.gtfsvalidator.util.geojson.GeometryType; + +/** This class contains the information from one feature in the GeoJSON file. */ +public final class GtfsGeoJsonFeature implements GtfsEntity { + public static final String FILENAME = "locations.geojson"; + + public static final String FEATURE_COLLECTION_FIELD_NAME = "features"; + + public static final String FEATURE_ID_FIELD_NAME = "id"; + + public static final String FEATURE_TYPE_FIELD_NAME = "type"; + + public static final String FEATURE_PROPERTIES_FIELD_NAME = "properties"; + public static final String FEATURE_PROPERTIES_STOP_NAME_FIELD_NAME = "stop_name"; + public static final String FEATURE_PROPERTIES_STOP_DESC_FIELD_NAME = "stop_desc"; + + public static final String GEOMETRY_FIELD_NAME = "geometry"; + public static final String GEOMETRY_TYPE_FIELD_NAME = "type"; + public static final String GEOMETRY_COORDINATES_FIELD_NAME = "coordinates"; + + private String featureId; // The id of a feature in the GeoJSON file. + private GeometryType geometryType; // The type of the geometry. + private Polygonal geometryDefinition; // The geometry of the feature. + private String stopName; // The name of the location as displayed to the riders. + private String stopDesc; // A description of the location. + + public GtfsGeoJsonFeature() {} + + // TODO: Change the interface hierarchy so we dont need this. It's not relevant for geojson + @Override + public int csvRowNumber() { + return 0; + } + + @Nonnull + public String featureId() { + return featureId; + } + + public boolean hasFeatureId() { + return featureId != null; + } + + public void setFeatureId(@Nullable String featureId) { + this.featureId = featureId; + } + + public Polygonal geometryDefinition() { + return geometryDefinition; + } + + public Boolean hasGeometryDefinition() { + return geometryDefinition != null; + } + + public void setGeometryDefinition(Polygonal polygon) { + this.geometryDefinition = polygon; + } + + public GeometryType geometryType() { + return geometryType; + } + + public Boolean hasGeometryType() { + return geometryType != null; + } + + public void setGeometryType(GeometryType type) { + this.geometryType = type; + } + + public String stopName() { + return stopName; + } + + public Boolean hasStopName() { + return stopName != null; + } + + public void setStopName(@Nullable String stopName) { + this.stopName = stopName; + } + + public String stopDesc() { + return stopDesc; + } + + public Boolean hasStopDesc() { + return stopDesc != null; + } + + public void setStopDesc(@Nullable String stopDesc) { + this.stopDesc = stopDesc; + } +} diff --git a/main/src/main/java/org/mobilitydata/gtfsvalidator/table/GtfsGeojsonFeatureSchema.java b/main/src/main/java/org/mobilitydata/gtfsvalidator/table/GtfsGeoJsonFeatureSchema.java similarity index 60% rename from main/src/main/java/org/mobilitydata/gtfsvalidator/table/GtfsGeojsonFeatureSchema.java rename to main/src/main/java/org/mobilitydata/gtfsvalidator/table/GtfsGeoJsonFeatureSchema.java index 0a644ae7d2..326b363f4c 100644 --- a/main/src/main/java/org/mobilitydata/gtfsvalidator/table/GtfsGeojsonFeatureSchema.java +++ b/main/src/main/java/org/mobilitydata/gtfsvalidator/table/GtfsGeoJsonFeatureSchema.java @@ -3,11 +3,11 @@ import org.mobilitydata.gtfsvalidator.annotation.GtfsJson; /** - * This class contains the information from one feature in the geojson file. Note that currently no + * This class contains the information from one feature in the GeoJSON file. Note that currently no * class is autogenerated from this schema, contrarily to csv based entities. */ @GtfsJson("locations.geojson") -public interface GtfsGeojsonFeatureSchema extends GtfsEntity { +public interface GtfsGeoJsonFeatureSchema extends GtfsEntity { String featureId(); } diff --git a/main/src/main/java/org/mobilitydata/gtfsvalidator/table/GtfsGeojsonFeaturesContainer.java b/main/src/main/java/org/mobilitydata/gtfsvalidator/table/GtfsGeoJsonFeaturesContainer.java similarity index 67% rename from main/src/main/java/org/mobilitydata/gtfsvalidator/table/GtfsGeojsonFeaturesContainer.java rename to main/src/main/java/org/mobilitydata/gtfsvalidator/table/GtfsGeoJsonFeaturesContainer.java index 04c48d4909..bdf9cea7cb 100644 --- a/main/src/main/java/org/mobilitydata/gtfsvalidator/table/GtfsGeojsonFeaturesContainer.java +++ b/main/src/main/java/org/mobilitydata/gtfsvalidator/table/GtfsGeoJsonFeaturesContainer.java @@ -23,57 +23,57 @@ import org.mobilitydata.gtfsvalidator.notice.NoticeContainer; /** - * Container for geojson features. Contrarily to the csv containers, this class is not auto + * Container for GeoJSON features. Contrarily to the csv containers, this class is not auto * generated since we have only one such class. */ -public class GtfsGeojsonFeaturesContainer - extends GtfsEntityContainer { +public class GtfsGeoJsonFeaturesContainer + extends GtfsEntityContainer { - private final Map byLocationIdMap = new HashMap<>(); + private final Map byLocationIdMap = new HashMap<>(); - private final List entities; + private final List entities; - public GtfsGeojsonFeaturesContainer( - GtfsGeojsonFileDescriptor descriptor, - List entities, + public GtfsGeoJsonFeaturesContainer( + GtfsGeoJsonFileDescriptor descriptor, + List entities, NoticeContainer noticeContainer) { super(descriptor, TableStatus.PARSABLE_HEADERS_AND_ROWS); this.entities = entities; setupIndices(noticeContainer); } - public GtfsGeojsonFeaturesContainer( - GtfsGeojsonFileDescriptor descriptor, TableStatus tableStatus) { + public GtfsGeoJsonFeaturesContainer( + GtfsGeoJsonFileDescriptor descriptor, TableStatus tableStatus) { super(descriptor, tableStatus); this.entities = new ArrayList<>(); } @Override - public Class getEntityClass() { - return GtfsGeojsonFeature.class; + public Class getEntityClass() { + return GtfsGeoJsonFeature.class; } @Override - public List getEntities() { + public List getEntities() { return entities; } @Override public String gtfsFilename() { - return "locations.geojson"; + return GtfsGeoJsonFeature.FILENAME; } @Override - public Optional byTranslationKey(String recordId, String recordSubId) { + public Optional byTranslationKey(String recordId, String recordSubId) { return Optional.empty(); } private void setupIndices(NoticeContainer noticeContainer) { - for (GtfsGeojsonFeature newEntity : entities) { + for (GtfsGeoJsonFeature newEntity : entities) { if (!newEntity.hasFeatureId()) { continue; } - GtfsGeojsonFeature oldEntity = byLocationIdMap.getOrDefault(newEntity.featureId(), null); + GtfsGeoJsonFeature oldEntity = byLocationIdMap.getOrDefault(newEntity.featureId(), null); if (oldEntity == null) { byLocationIdMap.put(newEntity.featureId(), newEntity); } @@ -81,7 +81,7 @@ private void setupIndices(NoticeContainer noticeContainer) { // else { // noticeContainer.addValidationNotice( // new JsonDuplicateKeyNotice( - // gtfsFilename(), GtfsGeojsonFeature.FEATURE_ID_FIELD_NAME, + // gtfsFilename(), GtfsGeoJsonFeature.FEATURE_ID_FIELD_NAME, // newEntity.featureId())); // } } diff --git a/main/src/main/java/org/mobilitydata/gtfsvalidator/table/GtfsGeoJsonFileDescriptor.java b/main/src/main/java/org/mobilitydata/gtfsvalidator/table/GtfsGeoJsonFileDescriptor.java new file mode 100644 index 0000000000..55a9b97148 --- /dev/null +++ b/main/src/main/java/org/mobilitydata/gtfsvalidator/table/GtfsGeoJsonFileDescriptor.java @@ -0,0 +1,46 @@ +package org.mobilitydata.gtfsvalidator.table; + +import java.util.List; +import javax.annotation.Nonnull; +import org.mobilitydata.gtfsvalidator.notice.NoticeContainer; + +/** + * File descriptor for GeoJSON file. Contrarily to the csv file descriptor, this class is not auto + * generated since we have only one such class. + */ +public class GtfsGeoJsonFileDescriptor extends GtfsFileDescriptor { + + public GtfsGeoJsonFileDescriptor() { + setRequired(false); + } + + public GtfsGeoJsonFeaturesContainer createContainerForEntities( + List entities, NoticeContainer noticeContainer) { + return new GtfsGeoJsonFeaturesContainer(this, entities, noticeContainer); + } + + @Override + public GtfsGeoJsonFeaturesContainer createContainerForInvalidStatus(TableStatus tableStatus) { + return new GtfsGeoJsonFeaturesContainer(this, tableStatus); + } + + @Override + public boolean isRecommended() { + return false; + } + + @Override + public Class getEntityClass() { + return GtfsGeoJsonFeature.class; + } + + @Override + public String gtfsFilename() { + return "locations.geojson"; + } + + @Nonnull + public TableLoader getTableLoader() { + return new GeoJsonFileLoader(); + } +} diff --git a/main/src/main/java/org/mobilitydata/gtfsvalidator/table/GtfsGeojsonFeature.java b/main/src/main/java/org/mobilitydata/gtfsvalidator/table/GtfsGeojsonFeature.java deleted file mode 100644 index 76c5a33bd5..0000000000 --- a/main/src/main/java/org/mobilitydata/gtfsvalidator/table/GtfsGeojsonFeature.java +++ /dev/null @@ -1,34 +0,0 @@ -package org.mobilitydata.gtfsvalidator.table; - -import javax.annotation.Nonnull; -import javax.annotation.Nullable; - -/** This class contains the information from one feature in the geojson file. */ -public final class GtfsGeojsonFeature implements GtfsEntity { - public static final String FILENAME = "locations.geojson"; - - public static final String FEATURE_ID_FIELD_NAME = "feature_id"; - - private String featureId; // The id of a feature in the GeoJSON file. - - public GtfsGeojsonFeature() {} - - // TODO: Change the interface hierarchy so we dont need this. It's not relevant for geojson - @Override - public int csvRowNumber() { - return 0; - } - - @Nonnull - public String featureId() { - return featureId; - } - - public boolean hasFeatureId() { - return featureId != null; - } - - public void setFeatureId(@Nullable String featureId) { - this.featureId = featureId; - } -} diff --git a/main/src/main/java/org/mobilitydata/gtfsvalidator/table/GtfsGeojsonFileDescriptor.java b/main/src/main/java/org/mobilitydata/gtfsvalidator/table/GtfsGeojsonFileDescriptor.java deleted file mode 100644 index ce96f087ca..0000000000 --- a/main/src/main/java/org/mobilitydata/gtfsvalidator/table/GtfsGeojsonFileDescriptor.java +++ /dev/null @@ -1,46 +0,0 @@ -package org.mobilitydata.gtfsvalidator.table; - -import java.util.List; -import javax.annotation.Nonnull; -import org.mobilitydata.gtfsvalidator.notice.NoticeContainer; - -/** - * File descriptor for geojson file. Contrarily to the csv file descriptor, this class is not auto - * generated since we have only one such class. - */ -public class GtfsGeojsonFileDescriptor extends GtfsFileDescriptor { - - public GtfsGeojsonFileDescriptor() { - setRequired(false); - } - - public GtfsGeojsonFeaturesContainer createContainerForEntities( - List entities, NoticeContainer noticeContainer) { - return new GtfsGeojsonFeaturesContainer(this, entities, noticeContainer); - } - - @Override - public GtfsGeojsonFeaturesContainer createContainerForInvalidStatus(TableStatus tableStatus) { - return new GtfsGeojsonFeaturesContainer(this, tableStatus); - } - - @Override - public boolean isRecommended() { - return false; - } - - @Override - public Class getEntityClass() { - return GtfsGeojsonFeature.class; - } - - @Override - public String gtfsFilename() { - return "locations.geojson"; - } - - @Nonnull - public TableLoader getTableLoader() { - return new GeojsonFileLoader(); - } -} diff --git a/main/src/main/java/org/mobilitydata/gtfsvalidator/util/geojson/GeoJsonGeometryValidator.java b/main/src/main/java/org/mobilitydata/gtfsvalidator/util/geojson/GeoJsonGeometryValidator.java new file mode 100644 index 0000000000..23df0630fd --- /dev/null +++ b/main/src/main/java/org/mobilitydata/gtfsvalidator/util/geojson/GeoJsonGeometryValidator.java @@ -0,0 +1,129 @@ +package org.mobilitydata.gtfsvalidator.util.geojson; + +import com.google.gson.JsonArray; +import org.locationtech.jts.geom.*; +import org.locationtech.jts.operation.valid.IsValidOp; +import org.mobilitydata.gtfsvalidator.notice.InvalidGeometryNotice; +import org.mobilitydata.gtfsvalidator.notice.NoticeContainer; +import org.mobilitydata.gtfsvalidator.table.GtfsGeoJsonFeature; + +/** + * Utility class responsible for handling GeoJSON geometry validation and creation. This class + * provides methods to create and validate Polygon and MultiPolygon geometries from GeoJSON data + * structures. + */ +public class GeoJsonGeometryValidator { + + // GeometryFactory instance used to create geometric shapes. + private final GeometryFactory geometryFactory = new GeometryFactory(); + private final NoticeContainer noticeContainer; + + public GeoJsonGeometryValidator(NoticeContainer noticeContainer) { + this.noticeContainer = noticeContainer; + } + + /** + * Creates a Polygon from a JsonArray representing the rings of the polygon. Validates the polygon + * and adds a validation notice to the NoticeContainer if invalid. + * + * @return A valid Polygon object or null if the geometry is invalid. + */ + public Polygon createPolygon(JsonArray rings, GtfsGeoJsonFeature feature, int featureIndex) { + Coordinate[][] polygonRings = parseCoordinates(rings); + try { + Polygon polygon = + geometryFactory.createPolygon( + geometryFactory.createLinearRing(polygonRings[0]), createInteriorRings(polygonRings)); + if (!IsValidOp.isValid(polygon)) { + addInvalidGeometryNotice(polygon, feature, featureIndex); + return null; + } + return polygon; + } catch (IllegalArgumentException e) { + addInvalidGeometryNotice(e, feature, featureIndex); + return null; + } + } + + /** + * Creates a MultiPolygon from a JsonArray representing an array of polygons. Validates each + * polygon and adds a validation notice to the NoticeContainer if invalid. + * + * @return A valid MultiPolygon object or null if any of the geometries are invalid. + */ + public MultiPolygon createMultiPolygon( + JsonArray polygons, GtfsGeoJsonFeature feature, int featureIndex) { + Polygon[] multiPolygonArray = new Polygon[polygons.size()]; + for (int p = 0; p < polygons.size(); p++) { + Polygon polygon = createPolygon(polygons.get(p).getAsJsonArray(), feature, p); + if (polygon == null) { + return null; + } + multiPolygonArray[p] = polygon; + } + + try { + MultiPolygon multiPolygon = geometryFactory.createMultiPolygon(multiPolygonArray); + if (!IsValidOp.isValid(multiPolygon)) { + addInvalidGeometryNotice(multiPolygon, feature, featureIndex); + return null; + } + return multiPolygon; + } catch (IllegalArgumentException e) { + addInvalidGeometryNotice(e, feature, featureIndex); + return null; + } + } + + /** Parses a JsonArray to extract coordinates for creating polygon rings. */ + private Coordinate[][] parseCoordinates(JsonArray rings) { + Coordinate[][] polygonRings = new Coordinate[rings.size()][]; + for (int r = 0; r < rings.size(); r++) { + JsonArray ringCoordinates = rings.get(r).getAsJsonArray(); + Coordinate[] coordinates = new Coordinate[ringCoordinates.size()]; + for (int i = 0; i < ringCoordinates.size(); i++) { + JsonArray points = ringCoordinates.get(i).getAsJsonArray(); + coordinates[i] = new Coordinate(points.get(0).getAsDouble(), points.get(1).getAsDouble()); + } + polygonRings[r] = coordinates; + } + return polygonRings; + } + + /** Creates the interior rings of a polygon from the parsed coordinates. */ + private LinearRing[] createInteriorRings(Coordinate[][] polygonRings) { + if (polygonRings.length > 1) { + LinearRing[] interiorRings = new LinearRing[polygonRings.length - 1]; + for (int i = 1; i < polygonRings.length; i++) { + interiorRings[i - 1] = geometryFactory.createLinearRing(polygonRings[i]); + } + return interiorRings; + } + return null; + } + + /** Adds a validation notice to the NoticeContainer if the geometry is invalid. */ + private void addInvalidGeometryNotice( + Geometry geometry, GtfsGeoJsonFeature feature, int featureIndex) { + noticeContainer.addValidationNotice( + new InvalidGeometryNotice( + feature.featureId(), + featureIndex, + feature.geometryType().getType(), + new IsValidOp(geometry).getValidationError().getMessage())); + } + + /** + * Adds a validation notice to the NoticeContainer if an exception occurs during geometry + * creation. + */ + private void addInvalidGeometryNotice( + IllegalArgumentException e, GtfsGeoJsonFeature feature, int featureIndex) { + noticeContainer.addValidationNotice( + new InvalidGeometryNotice( + feature.featureId(), + featureIndex, + feature.geometryType().getType(), + e.getLocalizedMessage())); + } +} diff --git a/main/src/main/java/org/mobilitydata/gtfsvalidator/util/geojson/GeometryType.java b/main/src/main/java/org/mobilitydata/gtfsvalidator/util/geojson/GeometryType.java new file mode 100644 index 0000000000..79d5b509e1 --- /dev/null +++ b/main/src/main/java/org/mobilitydata/gtfsvalidator/util/geojson/GeometryType.java @@ -0,0 +1,15 @@ +package org.mobilitydata.gtfsvalidator.util.geojson; + +public enum GeometryType { + POLYGON("Polygon"), + MULTI_POLYGON("MultiPolygon"); + private final String type; + + GeometryType(String type) { + this.type = type; + } + + public String getType() { + return type; + } +} diff --git a/main/src/main/java/org/mobilitydata/gtfsvalidator/util/geojson/UnparsableGeoJsonFeatureException.java b/main/src/main/java/org/mobilitydata/gtfsvalidator/util/geojson/UnparsableGeoJsonFeatureException.java new file mode 100644 index 0000000000..94ad811cd2 --- /dev/null +++ b/main/src/main/java/org/mobilitydata/gtfsvalidator/util/geojson/UnparsableGeoJsonFeatureException.java @@ -0,0 +1,10 @@ +package org.mobilitydata.gtfsvalidator.util.geojson; + +/** Exception thrown when a GeoJSON feature is unparsable. */ +public class UnparsableGeoJsonFeatureException extends Exception { + public UnparsableGeoJsonFeatureException(String message) { + super(message); + } + + public UnparsableGeoJsonFeatureException() {} +} diff --git a/main/src/main/java/org/mobilitydata/gtfsvalidator/validator/MissingStopsFileValidator.java b/main/src/main/java/org/mobilitydata/gtfsvalidator/validator/MissingStopsFileValidator.java index 9d00ffbbbd..3561fb2c05 100644 --- a/main/src/main/java/org/mobilitydata/gtfsvalidator/validator/MissingStopsFileValidator.java +++ b/main/src/main/java/org/mobilitydata/gtfsvalidator/validator/MissingStopsFileValidator.java @@ -10,18 +10,18 @@ public class MissingStopsFileValidator extends FileValidator { private final GtfsStopTableContainer stopTableContainer; - private final GtfsGeojsonFeaturesContainer geojsonFeaturesContainer; + private final GtfsGeoJsonFeaturesContainer geoJsonFeaturesContainer; @Inject MissingStopsFileValidator( - GtfsStopTableContainer table, GtfsGeojsonFeaturesContainer geojsonFeaturesContainer) { + GtfsStopTableContainer table, GtfsGeoJsonFeaturesContainer geoJsonFeaturesContainer) { this.stopTableContainer = table; - this.geojsonFeaturesContainer = geojsonFeaturesContainer; + this.geoJsonFeaturesContainer = geoJsonFeaturesContainer; } @Override public void validate(NoticeContainer noticeContainer) { - if (stopTableContainer.isMissingFile() && geojsonFeaturesContainer.isMissingFile()) { + if (stopTableContainer.isMissingFile() && geoJsonFeaturesContainer.isMissingFile()) { noticeContainer.addValidationNotice(new MissingRequiredFileNotice("stops.txt")); } } diff --git a/main/src/main/resources/report.html b/main/src/main/resources/report.html index 1db8f42bed..f01ae6f30a 100644 --- a/main/src/main/resources/report.html +++ b/main/src/main/resources/report.html @@ -247,10 +247,12 @@

GTFS Schedule Validation Report

Use this report alongside our documentation.

-

- ⚠ This feed contains GTFS Flex features. Please note that GTFS Flex validation support is still in development. - You can manually review all the validation rules for Flex data here. -

+
+

+ ⚠ This feed contains GTFS Flex features. Please note that GTFS Flex validation support is still in development. + You can manually review all the validation rules for Flex data here. +

+

A new version of the Canonical GTFS Schedule validator is available! diff --git a/main/src/test/java/org/mobilitydata/gtfsvalidator/table/GeoJsonFileLoaderTest.java b/main/src/test/java/org/mobilitydata/gtfsvalidator/table/GeoJsonFileLoaderTest.java new file mode 100644 index 0000000000..93325594e4 --- /dev/null +++ b/main/src/test/java/org/mobilitydata/gtfsvalidator/table/GeoJsonFileLoaderTest.java @@ -0,0 +1,149 @@ +package org.mobilitydata.gtfsvalidator.table; + +import static com.google.common.truth.Truth.assertThat; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.stream.Collectors; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; +import org.mobilitydata.gtfsvalidator.notice.InvalidGeometryNotice; +import org.mobilitydata.gtfsvalidator.notice.NoticeContainer; + +/** Runs GeoJsonFileLoader on test json data. */ +@RunWith(JUnit4.class) +public class GeoJsonFileLoaderTest { + + static String validGeoJsonData; + static String invalidPolygonGeoJsonData; + NoticeContainer noticeContainer; + + @BeforeClass + public static void setUpBeforeClass() { + // Create the valid and invalid JSON data strings, using single quotes for readability. + validGeoJsonData = + String.join( + "\n", + "{", + " 'type': 'FeatureCollection',", + " 'features': [", + " {", + " 'id': 'id1',", + " 'type': 'Feature',", + " 'geometry': {", + " 'type': 'Polygon',", + " 'coordinates': [", + " [", + " [100.0, 0.0],", + " [101.0, 0.0],", + " [101.0, 1.0],", + " [100.0, 1.0],", + " [100.0, 0.0]", + " ]", + " ]", + " },", + " 'properties': {}", + " },", + " {", + " 'id': 'id2',", + " 'type': 'Feature',", + " 'geometry': {", + " 'type': 'Polygon',", + " 'coordinates': [", + " [", + " [200.0, 0.0],", + " [201.0, 0.0],", + " [201.0, 2.0],", + " [200.0, 2.0],", + " [200.0, 0.0]", + " ]", + " ]", + " },", + " 'properties': {}", + " }", + " ]", + "}"); + + invalidPolygonGeoJsonData = + String.join( + "\n", + "{", + " 'type': 'FeatureCollection',", + " 'features': [", + " {", + " 'id': 'id_invalid',", + " 'type': 'Feature',", + " 'geometry': {", + " 'type': 'Polygon',", + " 'coordinates': [", + " [", + " [100.0, 0.0],", + " [101.0, 0.0],", + " [100.5, 0.5]" + + // Invalid Polygon: not closed + " ]", + " ]", + " },", + " 'properties': {}", + " }", + " ]", + "}"); + + // Replace single quotes with double quotes for JSON compliance + validGeoJsonData = validGeoJsonData.replace("'", "\""); + invalidPolygonGeoJsonData = invalidPolygonGeoJsonData.replace("'", "\""); + } + + @Before + public void setUp() { + noticeContainer = new NoticeContainer(); + } + + @Test + public void testGtfsGeoJsonFileLoader() /*throws ValidatorLoaderException*/ { + + var container = createLoader(validGeoJsonData); + var geoJsonContainer = (GtfsGeoJsonFeaturesContainer) container; + assertThat(container).isNotNull(); + assertThat(container.getTableStatus()).isEqualTo(TableStatus.PARSABLE_HEADERS_AND_ROWS); + assertThat(geoJsonContainer.entityCount()).isEqualTo(2); + assertThat(geoJsonContainer.getEntities().get(0).featureId()).isEqualTo("id1"); + assertThat(geoJsonContainer.getEntities().get(1).featureId()).isEqualTo("id2"); + } + + @Test + public void testBrokenJson() { + var container = createLoader("This is a broken json"); + assertThat(container.entityCount()).isEqualTo(0); + } + + @Test + public void testInvalidPolygonGeometry() { + // Testing for invalid polygon where coordinates do not form a closed ring + var container = createLoader(invalidPolygonGeoJsonData); + + // Check if the container is in the correct state + assertThat(container.getTableStatus()).isEqualTo(TableStatus.UNPARSABLE_ROWS); + + // Check if the correct validation notice is generated for the invalid geometry + List notices = + noticeContainer.getValidationNotices().stream() + .filter(InvalidGeometryNotice.class::isInstance) + .map(InvalidGeometryNotice.class::cast) + .collect(Collectors.toList()); + + assertThat(notices.size()).isGreaterThan(0); + } + + private GtfsEntityContainer createLoader(String jsonData) { + GeoJsonFileLoader loader = new GeoJsonFileLoader(); + var fileDescriptor = new GtfsGeoJsonFileDescriptor(); + InputStream inputStream = new ByteArrayInputStream(jsonData.getBytes(StandardCharsets.UTF_8)); + return loader.load(fileDescriptor, null, inputStream, noticeContainer); + } +} diff --git a/main/src/test/java/org/mobilitydata/gtfsvalidator/table/GeojsonFileLoaderTest.java b/main/src/test/java/org/mobilitydata/gtfsvalidator/table/GeojsonFileLoaderTest.java deleted file mode 100644 index 17d81eb2e9..0000000000 --- a/main/src/test/java/org/mobilitydata/gtfsvalidator/table/GeojsonFileLoaderTest.java +++ /dev/null @@ -1,115 +0,0 @@ -/* - * Copyright 2024 MobilityData - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.mobilitydata.gtfsvalidator.table; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotNull; - -import java.io.ByteArrayInputStream; -import java.io.InputStream; -import java.nio.charset.StandardCharsets; -import org.junit.BeforeClass; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.junit.runners.JUnit4; -import org.mobilitydata.gtfsvalidator.notice.NoticeContainer; - -/** Runs GeojsonFileLoader on test json data. */ -@RunWith(JUnit4.class) -public class GeojsonFileLoaderTest { - - static String validGeojsonData; - - @BeforeClass - public static void setUpBeforeClass() { - // To make the json text clearer, use single quotes and replace them by double quotes before - // using - validGeojsonData = - String.join( - "\n", - "{", - " 'type': 'FeatureCollection',", - " 'features': [", - " {", - " 'id': 'id1',", - " 'type': 'Feature',", - " 'geometry': {", - " 'type': 'Point',", - " 'coordinates': [", - " [102.0, 0.0],", - " [103.0, 1.0],", - " [104.0, 0.0],", - " [105.0, 1.0]", - " ]", - " },", - " 'properties': {}", - " },", - " {", - " 'type': 'Feature',", - " 'id': 'id2',", - " 'geometry': {", - " 'type': 'Polygon',", - " 'coordinates': [", - " [", - " [100.0, 0.0],", - " [101.0, 0.0],", - " [101.0, 1.0],", - " [100.0, 1.0],", - " [100.0, 0.0]", - " ]", - " ]", - " },", - " 'properties': {}", - " }", - " ]", - "}"); - - validGeojsonData = validGeojsonData.replace("'", "\""); - } - - @Test - public void testGtfsGeojsonFileLoader() /*throws ValidatorLoaderException*/ { - - var container = createLoader(validGeojsonData); - var geojsonContainer = (GtfsGeojsonFeaturesContainer) container; - assertNotNull(container); - assertEquals( - "Test geojson file is not parsable", - container.getTableStatus(), - TableStatus.PARSABLE_HEADERS_AND_ROWS); - assertEquals(2, container.entityCount()); - assertEquals("id1", geojsonContainer.getEntities().get(0).featureId()); - assertEquals("id2", geojsonContainer.getEntities().get(1).featureId()); - } - - @Test - public void testBrokenJson() { - var container = createLoader("This is a broken json"); - assertEquals( - "Parsing the Geojson file should fail, returning an empty list of entities", - 0, - container.entityCount()); - } - - private GtfsEntityContainer createLoader(String jsonData) { - GeojsonFileLoader loader = new GeojsonFileLoader(); - var fileDescriptor = new GtfsGeojsonFileDescriptor(); - NoticeContainer noticeContainer = new NoticeContainer(); - InputStream inputStream = new ByteArrayInputStream(jsonData.getBytes(StandardCharsets.UTF_8)); - return loader.load(fileDescriptor, null, inputStream, noticeContainer); - } -} diff --git a/main/src/test/java/org/mobilitydata/gtfsvalidator/util/geojson/GeoJsonGeometryValidatorTest.java b/main/src/test/java/org/mobilitydata/gtfsvalidator/util/geojson/GeoJsonGeometryValidatorTest.java new file mode 100644 index 0000000000..0969ef829a --- /dev/null +++ b/main/src/test/java/org/mobilitydata/gtfsvalidator/util/geojson/GeoJsonGeometryValidatorTest.java @@ -0,0 +1,141 @@ +package org.mobilitydata.gtfsvalidator.util.geojson; + +import static com.google.common.truth.Truth.assertThat; + +import com.google.gson.JsonArray; +import com.google.gson.JsonPrimitive; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; +import org.locationtech.jts.geom.MultiPolygon; +import org.locationtech.jts.geom.Polygon; +import org.mobilitydata.gtfsvalidator.notice.InvalidGeometryNotice; +import org.mobilitydata.gtfsvalidator.notice.NoticeContainer; +import org.mobilitydata.gtfsvalidator.table.GtfsGeoJsonFeature; + +@RunWith(JUnit4.class) +public class GeoJsonGeometryValidatorTest { + private GeoJsonGeometryValidator validator; + private NoticeContainer noticeContainer; + private GtfsGeoJsonFeature feature; + + @Before + public void setUp() { + noticeContainer = new NoticeContainer(); + validator = new GeoJsonGeometryValidator(noticeContainer); + feature = new GtfsGeoJsonFeature(); + feature.setFeatureId("test_feature"); + } + + @Test + public void testValidPolygonShouldReturnPolygon() { + JsonArray validPolygon = createValidPolygonJsonArray(); + feature.setGeometryType(GeometryType.POLYGON); + + Polygon polygon = validator.createPolygon(validPolygon, feature, 0); + + assertThat(polygon).isNotNull(); + assertThat(polygon.isValid()).isTrue(); + assertThat(noticeContainer.getValidationNotices().size()).isEqualTo(0); + } + + @Test + public void testInvalidPolygonShouldReturnNullAndAddNotice() { + JsonArray invalidPolygon = createInvalidPolygonJsonArray(); + feature.setGeometryType(GeometryType.POLYGON); + + Polygon polygon = validator.createPolygon(invalidPolygon, feature, 0); + + assertThat(polygon).isNull(); + List notices = + noticeContainer.getValidationNotices().stream() + .filter(InvalidGeometryNotice.class::isInstance) + .map(InvalidGeometryNotice.class::cast) + .collect(Collectors.toList()); + assertThat(notices.size()).isEqualTo(1); + } + + @Test + public void testValidMultiPolygonShouldReturnMultiPolygon() { + JsonArray validMultiPolygon = createValidMultiPolygonJsonArray(); + feature.setGeometryType(GeometryType.MULTI_POLYGON); + + MultiPolygon multiPolygon = validator.createMultiPolygon(validMultiPolygon, feature, 0); + + assertThat(multiPolygon).isNotNull(); + assertThat(multiPolygon.isValid()).isTrue(); + assertThat(noticeContainer.getValidationNotices().size()).isEqualTo(0); + } + + @Test + public void testInvalidMultiPolygonShouldReturnNullAndAddNotice() { + JsonArray invalidMultiPolygon = createInvalidMultiPolygonJsonArray(); + feature.setGeometryType(GeometryType.MULTI_POLYGON); + + MultiPolygon multiPolygon = validator.createMultiPolygon(invalidMultiPolygon, feature, 0); + + assertThat(multiPolygon).isNull(); + List notices = + noticeContainer.getValidationNotices().stream() + .filter(InvalidGeometryNotice.class::isInstance) + .map(InvalidGeometryNotice.class::cast) + .collect(Collectors.toList()); + assertThat(notices.size()).isEqualTo(1); + } + + // Helper methods to create test data + private JsonArray createValidPolygonJsonArray() { + return createValidPolygonJsonArray(Optional.empty()); + } + + private JsonArray createValidPolygonJsonArray(Optional deltaValue) { + JsonArray ring = new JsonArray(); + int delta = deltaValue.orElse(0); + ring.add(createPointArray(delta, delta)); + ring.add(createPointArray(delta, 1 + delta)); + ring.add(createPointArray(1 + delta, 1 + delta)); + ring.add(createPointArray(1 + delta, delta)); + ring.add(createPointArray(delta, delta)); + + JsonArray polygon = new JsonArray(); + polygon.add(ring); + return polygon; + } + + private JsonArray createInvalidPolygonJsonArray() { + JsonArray ring = new JsonArray(); + ring.add(createPointArray(0, 0)); + ring.add(createPointArray(0, 1)); + ring.add(createPointArray(0, 0)); // Invalid polygon (not closed properly) + + JsonArray polygon = new JsonArray(); + polygon.add(ring); + return polygon; + } + + private JsonArray createValidMultiPolygonJsonArray() { + JsonArray multiPolygon = new JsonArray(); + multiPolygon.add(createValidPolygonJsonArray()); + multiPolygon.add(createValidPolygonJsonArray(Optional.of(5))); + return multiPolygon; + } + + private JsonArray createInvalidMultiPolygonJsonArray() { + JsonArray multiPolygon = new JsonArray(); + // self-intersecting polygon + multiPolygon.add(createValidPolygonJsonArray()); + multiPolygon.add(createValidPolygonJsonArray()); + return multiPolygon; + } + + private JsonArray createPointArray(double x, double y) { + JsonArray point = new JsonArray(); + point.add(new JsonPrimitive(x)); + point.add(new JsonPrimitive(y)); + return point; + } +} diff --git a/main/src/test/java/org/mobilitydata/gtfsvalidator/validator/MissingStopsFileValidatorTest.java b/main/src/test/java/org/mobilitydata/gtfsvalidator/validator/MissingStopsFileValidatorTest.java index 88a6dfae60..92b00b667e 100644 --- a/main/src/test/java/org/mobilitydata/gtfsvalidator/validator/MissingStopsFileValidatorTest.java +++ b/main/src/test/java/org/mobilitydata/gtfsvalidator/validator/MissingStopsFileValidatorTest.java @@ -6,8 +6,8 @@ import org.junit.Test; import org.mobilitydata.gtfsvalidator.notice.MissingRequiredFileNotice; import org.mobilitydata.gtfsvalidator.notice.NoticeContainer; -import org.mobilitydata.gtfsvalidator.table.GtfsGeojsonFeaturesContainer; -import org.mobilitydata.gtfsvalidator.table.GtfsGeojsonFileDescriptor; +import org.mobilitydata.gtfsvalidator.table.GtfsGeoJsonFeaturesContainer; +import org.mobilitydata.gtfsvalidator.table.GtfsGeoJsonFileDescriptor; import org.mobilitydata.gtfsvalidator.table.GtfsStopTableContainer; import org.mobilitydata.gtfsvalidator.table.TableStatus; @@ -16,8 +16,8 @@ public class MissingStopsFileValidatorTest { public void stopsTxtMissingFileShouldGenerateNotice() { // If stops.txt is missing and locations.geojson is missing, a notice should be generated NoticeContainer noticeContainer = new NoticeContainer(); - GtfsGeojsonFileDescriptor descriptor = new GtfsGeojsonFileDescriptor(); - GtfsGeojsonFeaturesContainer geoJsonFeaturesContainer = + GtfsGeoJsonFileDescriptor descriptor = new GtfsGeoJsonFileDescriptor(); + GtfsGeoJsonFeaturesContainer geoJsonFeaturesContainer = descriptor.createContainerForInvalidStatus(TableStatus.MISSING_FILE); GtfsStopTableContainer stopTableContainer = GtfsStopTableContainer.forStatus(TableStatus.MISSING_FILE); @@ -31,8 +31,8 @@ public void stopsTxtMissingFileShouldGenerateNotice() { public void stopsTxtMissingFileShouldNotGenerateNotice() { // If stops.txt is missing, but locations.geojson is present, no notice should be generated NoticeContainer noticeContainer = new NoticeContainer(); - GtfsGeojsonFileDescriptor descriptor = new GtfsGeojsonFileDescriptor(); - GtfsGeojsonFeaturesContainer geoJsonFeaturesContainer = + GtfsGeoJsonFileDescriptor descriptor = new GtfsGeoJsonFileDescriptor(); + GtfsGeoJsonFeaturesContainer geoJsonFeaturesContainer = descriptor.createContainerForEntities(new ArrayList<>(), noticeContainer); GtfsStopTableContainer stopTableContainer = GtfsStopTableContainer.forStatus(TableStatus.MISSING_FILE); diff --git a/main/src/test/java/org/mobilitydata/gtfsvalidator/validator/NoticeFieldsTest.java b/main/src/test/java/org/mobilitydata/gtfsvalidator/validator/NoticeFieldsTest.java index d46efe7589..6a1a55ae91 100644 --- a/main/src/test/java/org/mobilitydata/gtfsvalidator/validator/NoticeFieldsTest.java +++ b/main/src/test/java/org/mobilitydata/gtfsvalidator/validator/NoticeFieldsTest.java @@ -89,6 +89,9 @@ public void testNoticeClassFieldNames() { "expectedRouteId", "fareMediaId1", "fareMediaId2", + "featureId", + "featureIndex", + "featureType", "feedEndDate", "feedLang", "fieldName", @@ -104,6 +107,8 @@ public void testNoticeClassFieldNames() { "filename", "firstIndex", "geoDistanceToShape", + "geoJsonType", + "geometryType", "hasEntrance", "hasExit", "headerCount", @@ -127,6 +132,7 @@ public void testNoticeClassFieldNames() { "maxShapeDistanceTraveled", "maxTripDistanceTraveled", "message", + "missingElement", "newCsvRowNumber", "oldCsvRowNumber", "parentCsvRowNumber",