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",