Adding new rules
March 23, 2021 · View on GitHub
We will want to add new rules to this validator as the static GTFS specification evolves. This page outlines the process of adding new rules to this tool.
0. Prepare for implementation
- Check the list of currently implemented rules to make sure the rule doesn't already exist.
- Check the list of planned future rules to see if an issue already exists for the proposed rule.
- If no existing issue exists, open a new issue with the "new rule" label.
- Discuss the rule with the community via the Github issue and come to a general consensus on the exact logic, and if it should be an
ERRORor aWARNING. See definitions for ERROR and WARNING. - Implement new rule using the process below
For the below example, let's look the steps required to implementing existing the rule that makes sure each trip in "trips.txt" is used by at least two stops from stop_times.txt. If a trip is used by 0 or 1 stop from stop_times.txt a WARNING should be issued.
If you want to take a look at a complete set of changes that implement this new rule before diving into the instructions, see this commit on Github.
1. Implement the new rule
a. Determine which *Validator.java class the new rule should extend
All classes that implement rules should use a name that fits the *Validator.java format and must extend either the SingleEntityValidator, or the FileValidator class.
For efficiency of implementation, multiple rules related to similar fields can be implemented in the same *Validator.java class (e.g., to avoid iterating through all rows from GTFS file stop_times.txt for each rule).
Here are the different classes of *Validator.java (all defined in the package org.mobilitydata.gtfsvalidator.validator):
Some validator as the following are integrated to the table definitions and automatically generated:
*ForeignKeyValidatorgenerated byForeignKeyValidatorGenerator- Examine the integrity of foreign keys for all fields annotated@ForeignKey.*EndRangeValidatorgenerated byEndRangeValidatorGenerator- Examine if time or date ranges are in order in tables using@EndRangeannotation.*TableHeaderValidatorgenerated byEndRangeValidatorGenerator- Examine table headers against a schema. If a new table is added, those rules are automatically generated and implemented by the table field annotations used in*Schema.java.
Other validators for less obvious validation rules are manually defined, they extend either a:
SingleEntityValidator- Examines GTFS files supposed to contain an unique (e.gfeed_info.txt).- or a
FileValidator- Examines one or multiple GTFS files.
b. Add the new notice
Within the scope of the new validator, create a new static class and make it extend ValidationNotice as follows:
/**
* A {@code GtfsTrip} should be referred to by at least two {@code GtfsStopTime}
*
* <p>Severity: {@code SeverityLevel.WARNING}
*/
static class UnusableTripNotice extends ValidationNotice {
UnusableTripNotice(long csvRowNumber, String tripId) {
super(
ImmutableMap.of(
"csvRowNumber", csvRowNumber,
"tripId", tripId),
SeverityLevel.WARNING);
}
}
Notices must specify the severity level: ERROR, WARNING, or INFO (see definitions here. trip_id being unique, notices are set up so that the final warning message in the validation report should look like:
{
"notices":[
{
"code":"unusable_trip",
"severity":"WARNING",
"totalNotices":1,
"notices":[
{
"tripId":"3362144",
"csvRowNumber":40150
}
]
}
]
}
Values for tripId and csvRowNumber will be different for each generated notice.
c. Implement the validation rule logic
This exact process will differ for each rule, but first let's cover some of the basics that are the same across any rule implementation in the *Validator.validate() method.
⚠ Note that javadocs should be included in all new files, or updated if modifying existing files.
SingleEntityValidator
/**
* Validates that if one of {@code (start_date, end_date)} fields is provided for {@code
* feed_info.txt}, then the second field is also provided.
*
* <p>Generated notice: {@link MissingFeedInfoDateNotice}.
*/
@GtfsValidator
public class FeedServiceDateValidator extends SingleEntityValidator<GtfsFeedInfo> {
@Override
public void validate(GtfsFeedInfo feedInfo, NoticeContainer noticeContainer) {
if (feedInfo.hasFeedStartDate() && !feedInfo.hasFeedEndDate()) {
noticeContainer.addValidationNotice(
new MissingFeedInfoDateNotice(feedInfo.csvRowNumber(), "feed_end_date"));
} else if (!feedInfo.hasFeedStartDate() && feedInfo.hasFeedEndDate()) {
noticeContainer.addValidationNotice(
new MissingFeedInfoDateNotice(feedInfo.csvRowNumber(), "feed_start_date"));
}
}
/**
* Even though `feed_info.start_date` and `feed_info.end_date` are optional, if one field is
* provided the second one should also be provided.
*
* <p>Severity: {@code SeverityLevel.WARNING}
*/
static class MissingFeedInfoDateNotice extends ValidationNotice {
MissingFeedInfoDateNotice(long csvRowNumber, String fieldName) {
super(
ImmutableMap.of("csvRowNumber", csvRowNumber, "fieldName", fieldName),
SeverityLevel.WARNING);
}
}
}
The *Validator.validate() takes two parameters:
- the
GtfsEntityto validate - the
NoticeContainerthat will store notices generated during the validation process
FileValidator
/**
* Validates that every trip in "trips.txt" is used by at least two stops from "stop_times.txt"
*
* <p>Generated notice: {@link UnusableTripNotice}.
*/
@GtfsValidator
public class TripUsabilityValidator extends FileValidator {
private final GtfsTripTableContainer tripTable;
private final GtfsStopTimeTableContainer stopTimeTable;
@Inject
TripUsabilityValidator(
GtfsTripTableContainer tripTable, GtfsStopTimeTableContainer stopTimeTable) {
this.tripTable = tripTable;
this.stopTimeTable = stopTimeTable;
}
@Override
public void validate(NoticeContainer noticeContainer) {
for (GtfsTrip trip : tripTable.getEntities()) {
String tripId = trip.tripId();
if (stopTimeTable.byTripId(tripId).size() <= 1) {
noticeContainer.addValidationNotice(new UnusableTripNotice(trip.csvRowNumber(), tripId));
}
}
}
/**
* A {@code GtfsTrip} should be referred to by at least two {@code GtfsStopTime}
*
* <p>Severity: {@code SeverityLevel.WARNING}
*/
static class UnusableTripNotice extends ValidationNotice {
UnusableTripNotice(long csvRowNumber, String tripId) {
super(
ImmutableMap.of(
"csvRowNumber", csvRowNumber,
"tripId", tripId),
SeverityLevel.WARNING);
}
}
}
The *Validator.validate() takes only one parameter:
- the
NoticeContainerthat will store notices generated during the validation process
All tables used during the validation process should be defined using at construction. Validators' constructor are injected using @Inject annotation.
d. Annotations
Annotations are commonly used in the validators. As showed by these examples, two annotations should be paid attention to when implementing a new rule:
More details on annotations here.
@GtfsValidator
@GtfsValidator annotates both custom and automatically generated validators to make them discoverable on the fly.
@Inject
@Inject identifies injectable constructors.
2. Document the new rule in RULES.md
Add the rule RULES.md keeping the alphabetical order of the table:
| [NewRuleRelatedToStops](#NewRuleRelatedToStops) | new rule short description |
...and add a definition of that rule in the errors or warnings section (still keeping the alphabetical order):
<a name="NewRuleRelatedToStops"/>
### NewRuleRelatedToStops
New rule long description
#### References:
* [stops.txt specification](http://gtfs.org/reference/static#stopstxt)
When the user clicks on the error code in the validator web interface, they are directed to this section of the rules page so they can find out more information about the rule. So, any information that might help an agency or data consumer fix the problem should be included here.
3. Test the newly added to rule
gtfs-validator tests rely on JUnit 4, and Google Truth.
Validators are tested against data samples via unit tests.
Test a SingleEntityValidator
Detailed steps
1️⃣ Create a GtfsEntity via an annex private method:
private GtfsFeedInfo createFeedInfo(GtfsDate feedEndDate) {
return new GtfsFeedInfo.Builder()
.setCsvRowNumber(1)
.setFeedPublisherName("feed publisher name value")
.setFeedPublisherUrl("https://www.mobilitydata.org")
.setFeedLang(Locale.CANADA)
.setFeedEndDate(feedEndDate)
.build();
}
2️⃣ Create a NoticeContainer:
NoticeContainer container = new NoticeContainer();
3️⃣ Execute the validator one the previously defined parameters (GtfsEntity and NoticeContainer).
validateFeedInfo(createFeedInfo(GtfsDate.fromLocalDate(TEST_NOW.toLocalDate().plusDays(7))))
4️⃣ Verify the content of NoticeContainer:
assertThat(
validateFeedInfo(createFeedInfo(GtfsDate.fromLocalDate(TEST_NOW.toLocalDate().plusDays(7)))))
.containsExactly(
new FeedExpirationDateNotice(
1,
GtfsDate.fromLocalDate(TEST_NOW.toLocalDate()),
GtfsDate.fromLocalDate(TEST_NOW.toLocalDate().plusDays(7)),
GtfsDate.fromLocalDate(TEST_NOW.toLocalDate().plusDays(30))));
Test a FileValidator
Detailed steps
1️⃣ Create an instance of the validator to test
TripUsabilityValidator tripUsabilityValidator = new TripUsabilityValidator();
2️⃣ Create the relevant GtfsTableContainers and inject them in the validator
tripUsabilityValidator.tripTable =
createTripTable(
noticeContainer,
ImmutableList.of(
createTrip(1, "route id value", "service id value", "t0"),
createTrip(3, "route id value", "service id value", "t1")));
tripUsabilityValidator.stopTimeTable =
createStopTimeTable(
noticeContainer,
ImmutableList.of(
createStopTime(0, "t0", "s0", 2),
createStopTime(2, "t0", "s1", 3),
createStopTime(0, "t1", "s3", 5),
createStopTime(2, "t1", "s4", 9)));
3️⃣ Execute the validator .validate() method
underTest.validate(noticeContainer);
4️⃣ Verify the content of NoticeContainer.
assertThat(noticeContainer.getValidationNotices()).isEmpty();