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 ERROR or a WARNING. 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:

Other validators for less obvious validation rules are manually defined, they extend either a:

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 GtfsEntity to validate
  • the NoticeContainer that 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 NoticeContainer that 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

See sample example.

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

See sample example.

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();