diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..181ee1e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +.git +.gitignore +target +**/target +.idea +*.iml +.vscode +HELP.md +*.log +Dockerfile +.dockerignore +README.md diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..df488ec --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,49 @@ +name: Build + +on: + push: + branches: + - main + pull_request: + branches: + - main + +env: + java_version: '17' + java_distribution: 'zulu' + +jobs: + maven-build: + name: Maven build, test and code-style check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + - name: Set up JDK ${{ env.java_version }}-${{ env.java_distribution }} + uses: actions/setup-java@v5 + with: + java-version: ${{ env.java_version }} + distribution: ${{ env.java_distribution }} + - name: Print Java and Maven versions + run: mvn --version + - name: Cache Maven dependencies + uses: actions/cache@v5 + with: + path: ~/.m2/repository + key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }} + restore-keys: | + ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }} + ${{ runner.os }}-maven- + ${{ runner.os }}- + # `mvn verify` runs the OpenAPI generation, compilation, the integration + # tests and the Spotless (google-java-format) code-style check. + - name: Run Maven build + run: mvn --batch-mode -ntp clean verify + - name: Upload build artifact + if: success() + uses: actions/upload-artifact@v7 + with: + name: gtfs-validator-api-jar + path: target/*.jar + if-no-files-found: warn diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000..4d77121 --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,66 @@ +name: Docker + +on: + push: + branches: + - main + tags: + - 'v*' + pull_request: + branches: + - main + release: + types: [published] + +env: + # Publish to the GitHub Container Registry under the owning org/repo. + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + docker: + name: Build and publish Docker image + runs-on: ubuntu-24.04 + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + - name: Set up QEMU + uses: docker/setup-qemu-action@v4 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + # Only authenticate and push for events that are not pull requests. + # Pull requests still build the image to validate the Dockerfile. + - name: Log in to the Container registry + if: github.event_name != 'pull_request' + uses: docker/login-action@v4 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Extract Docker metadata + id: meta + uses: docker/metadata-action@v6 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=ref,event=branch + type=ref,event=pr + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + type=raw,value=latest,enable={{is_default_branch}} + type=sha + - name: Build and push Docker image + uses: docker/build-push-action@v7 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.gitignore b/.gitignore index 524f096..bf91998 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,12 @@ # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml hs_err_pid* replay_pid* + +# Maven build output +target/ +HELP.md + +# IDE +.idea/ +*.iml +.vscode/ diff --git a/.openapi-generator-ignore b/.openapi-generator-ignore new file mode 100644 index 0000000..6a05e2c --- /dev/null +++ b/.openapi-generator-ignore @@ -0,0 +1,13 @@ +# Use this file to prevent files from being overwritten by the generator. +# https://github.com/OpenAPITools/openapi-generator/blob/master/docs/customization.md#ignore-file-format + +# We provide our own Spring Boot application bootstrap and configuration. +**/OpenApiGeneratorApplication.java +**/OpenApiGeneratorApplicationTests.java +**/HomeController.java +**/SpringDocConfiguration.java +**/application.properties +**/pom.xml +**/README.md +**/.openapi-generator-ignore +**/.openapi-generator/** diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f15caf0 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,28 @@ +# Multi-stage build for the GTFS Validator API. + +# --- Build stage ------------------------------------------------------------- +FROM maven:3.9-eclipse-temurin-17 AS build +WORKDIR /workspace + +# Cache dependencies first. +COPY pom.xml . +COPY .openapi-generator-ignore . +RUN mvn -B -q dependency:go-offline + +# Build the application. +COPY src ./src +COPY docs ./docs +RUN mvn -B -q clean package -DskipTests + +# --- Runtime stage ----------------------------------------------------------- +FROM eclipse-temurin:17-jre +RUN groupadd -r spring && useradd -r -g spring spring + +WORKDIR /app +COPY --from=build /workspace/target/gtfs-validator-api-*.jar /app/gtfs-validator-api.jar + +USER spring:spring +EXPOSE 8080 + +ENV JAVA_OPTS="" +ENTRYPOINT ["sh", "-c", "exec java $JAVA_OPTS -jar /app/gtfs-validator-api.jar"] diff --git a/README.md b/README.md index f86828c..8518624 100644 --- a/README.md +++ b/README.md @@ -1 +1,171 @@ -# gtfs-validator-api \ No newline at end of file +# gtfs-validator-api + +Synchronous HTTP API for the [MobilityData Canonical GTFS Schedule Validator](https://github.com/MobilityData/gtfs-validator). + +Submit a GTFS feed (by URL or upload) and receive the full validation report in a single +response, as JSON or as the rendered HTML document. The Spring Boot server code is +**generated from the OpenAPI schema** (`docs/GTFSValidatorAPI.yaml`) using the +`openapi-generator-maven-plugin` (spring generator, delegate pattern), and the endpoints are +implemented on top of the published validator core Maven artifacts +(`org.mobilitydata.gtfs-validator`). + +This module replaces `gtfs-validator/web/service`. + +## Versions + +The API project version is **independent** of the validator core version: + +| Version | Where | Current | +|---------|-------|---------| +| API project version | `pom.xml` | `1.0.0` | +| Validator core (dependency) | `gtfs-validator.version` property | `8.0.1` | +| OpenAPI spec version | `docs/GTFSValidatorAPI.yaml` `info.version` | `2.0.0` | + +The validator core version is reported at runtime by `GET /v2/metadata`. + +## Endpoints + +Base path: `/v2`. + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/metadata` | Service metadata (validator version, limits). | +| `POST` | `/validate` | Validate a feed by URL (`application/json` body `{url, countryCode?}`). | +| `POST` | `/validate-upload` | Validate an uploaded GTFS ZIP (`multipart/form-data`). | + +Both validation endpoints negotiate the response format via the `Accept` header: + +- `application/json` (default) — the structured `ValidationReport` (mirrors `report.json`). +- `text/html` — the rendered HTML report. +- any other value — `406 Not Acceptable`. + +Interactive API docs (Swagger UI) are served at `/swagger-ui.html`; the raw spec at `/GTFSValidatorAPI.yaml`. + +## Build & test + +Requires JDK 17. + +```bash +mvn clean package # generate, compile, test, and build the executable jar +mvn test # run the integration tests only +mvn verify # also runs the spotless code-style check +``` + +## Code style + +Matches the [gtfs-validator](https://github.com/MobilityData/gtfs-validator) repo: +[google-java-format](https://github.com/google/google-java-format) (version `1.25.2`) +enforced via the Spotless Maven plugin. The check runs during `mvn verify`. + +```bash +mvn spotless:apply # auto-format sources +mvn spotless:check # verify formatting (also part of `mvn verify`) +``` + +## Run locally + +```bash +mvn spring-boot:run +# or +java -jar target/gtfs-validator-api-1.0.0.jar +``` + +The service listens on port `8080`. + +### Examples + +```bash +# Metadata +curl http://localhost:8080/v2/metadata + +# Validate by upload (JSON report) +curl -X POST http://localhost:8080/v2/validate-upload \ + -F "file=@feed.zip" -F "countryCode=CA" \ + -H "Accept: application/json" + +# Validate by upload (HTML report) +curl -X POST http://localhost:8080/v2/validate-upload \ + -F "file=@feed.zip" -H "Accept: text/html" -o report.html + +# Validate by URL +curl -X POST http://localhost:8080/v2/validate \ + -H "Content-Type: application/json" \ + -d '{"url":"https://example.org/gtfs.zip","countryCode":"CA"}' +``` + +## Docker + +```bash +docker build -t gtfs-validator-api:1.0.0 . +docker run --rm -p 8080:8080 gtfs-validator-api:1.0.0 +``` + +Pass JVM options via `JAVA_OPTS`, e.g. `-e JAVA_OPTS="-Xmx4g"`. + +### Pre-built image + +CI publishes multi-arch images (`linux/amd64`, `linux/arm64`) to the GitHub +Container Registry on every push to the default branch and on version tags: + +```bash +docker pull ghcr.io/mobilitydata/gtfs-validator-api:latest +docker run --rm -p 8080:8080 ghcr.io/mobilitydata/gtfs-validator-api:latest +``` + +## Continuous integration + +GitHub Actions workflows live in `.github/workflows/`: + +| Workflow | Trigger | Purpose | +|----------|---------|---------| +| `build.yml` | push / PR to `main`/`master`, manual | `mvn clean verify` — OpenAPI generation, compile, integration tests and the Spotless code-style check; uploads the built jar. | +| `docker.yml` | push / PR / tag `v*` / release, manual | Builds the Docker image (multi-arch). On non-PR events it pushes to `ghcr.io//gtfs-validator-api` with branch, `latest`, semver and commit-SHA tags. | + +## Configuration + +Key properties (see `src/main/resources/application.properties`): + +| Property | Default | Description | +|----------|---------|-------------| +| `openapi.gTFSValidator.base-path` | `/v2` | API base path. | +| `spring.servlet.multipart.max-file-size` | `-1` (unlimited) | Max upload size; set a concrete value to cap it. | +| `gtfs.validator.limits.max-upload-bytes` | _(unset)_ | `maxUploadBytes` advertised in `/metadata`. | +| `gtfs.validator.limits.max-requests-per-minute` | _(unset)_ | `maxRequestsPerMinute` advertised in `/metadata`. | + +## Logging + +By default the service logs human-readable plain text to the console — convenient for +local development. Activate the `json` profile to switch the console to **structured +JSON** (one object per line, with `severity`, `message`, ISO-8601 `timestamp`, logger, +thread, MDC values and structured key/value pairs; exception stack traces are folded +into `message`). This format is understood by cloud log aggregators that parse stdout. + +```bash +# Local: plain text (default, no profile) +mvn spring-boot:run + +# Structured JSON (e.g. in a container / cloud) +SPRING_PROFILES_ACTIVE=json java -jar target/gtfs-validator-api-*.jar +# or in Docker: +docker run -e SPRING_PROFILES_ACTIVE=json -p 8080:8080 gtfs-validator-api:1.0.0 +``` + +Implemented with Spring Boot's built-in [structured logging](https://docs.spring.io/spring-boot/reference/features/logging.html#features.logging.structured) +(no extra dependencies); see `JsonLogFormatter` and `application-json.properties`. + +Logs from the validator core and other libraries are captured in the same format: +the core logs via Flogger/`java.util.logging`, which Spring Boot bridges to SLF4J → +Logback → this formatter. (The pom excludes the stray `commons-logging` jar pulled in +by `commons-validator` so JCL is handled by `spring-jcl` and not emitted as raw, +non-JSON lines.) + +## Project layout + +``` +docs/GTFSValidatorAPI.yaml # OpenAPI single source of truth (generator input + served spec) +src/main/java/.../api/Application.java # Spring Boot entry point +src/main/java/.../api/handler/ # delegate handlers + validator integration +src/main/java/.../api/logging/ # structured JSON log formatter (json profile) +target/generated-sources/openapi/ # generated API interfaces + models +target/classes/static/GTFSValidatorAPI.yaml # spec copied here at build time, served at /GTFSValidatorAPI.yaml +``` \ No newline at end of file diff --git a/docs/GTFSValidatorAPI.yaml b/docs/GTFSValidatorAPI.yaml new file mode 100644 index 0000000..ebbf995 --- /dev/null +++ b/docs/GTFSValidatorAPI.yaml @@ -0,0 +1,453 @@ +openapi: 3.1.0 + +info: + title: GTFS Validator API + version: 2.0.0 + summary: Synchronous HTTP API for the MobilityData Canonical GTFS Schedule Validator. + description: | + Simplified synchronous API. Submit a GTFS feed and receive the full + validation report in a single response. Three endpoints are provided: + + - `GET /metadata` — retrieve service metadata (validator version, limits, features). + - `POST /validate` — validate a feed by URL. + - `POST /validate-upload` — validate a feed by uploading a ZIP. + + Both endpoints block until validation completes. Use the `Accept` + request header to choose the response format: + + - `Accept: application/json` (default) — returns the structured JSON + validation report (`ValidationReport`). + - `Accept: text/html` — returns the rendered HTML report as a document. + + Any other `Accept` value results in `406 Not Acceptable`. + license: + name: Apache 2.0 + url: https://www.apache.org/licenses/LICENSE-2.0 + contact: + name: MobilityData + url: https://github.com/MobilityData/gtfs-validator-api + +servers: + - url: http://localhost:8080/v2 + description: Local development + +tags: + - name: Metadata + description: Service information and capabilities. + - name: Validation + description: Submit a GTFS feed and receive a validation report. + +paths: + + /metadata: + get: + tags: [Metadata] + operationId: getMetadata + summary: Retrieve service metadata. + description: | + Returns information about the running validator service: the + validator version, build timestamp, supported GTFS feature list, + and any configurable limits (max upload size, rate-limit ceiling). + No authentication required. + responses: + '200': + description: Service metadata. + content: + application/json: + schema: { $ref: '#/components/schemas/ServiceMetadata' } + + /validate: + post: + tags: [Validation] + operationId: validateByUrl + summary: Validate a GTFS feed by URL. + description: | + Fetches the GTFS ZIP from `url` and runs validation. Blocks until + complete and returns the full validation report. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [url] + properties: + url: + type: string + format: uri + description: Public URL to a GTFS ZIP. + countryCode: + type: string + pattern: '^[A-Za-z]{2}$' + description: | + ISO 3166-1 alpha-2 country code used for phone-number + validation, e.g. `CA`, `US`, `nl`. Case-insensitive. + additionalProperties: false + examples: + minimal: + summary: URL only + value: + url: https://example.org/gtfs.zip + withCountry: + summary: URL with country code + value: + url: https://example.org/gtfs.zip + countryCode: CA + responses: + '200': + description: Validation complete. + headers: + Content-Type: + description: Matches the negotiated `Accept` header. + schema: { type: string } + content: + application/json: + schema: { $ref: '#/components/schemas/ValidationReport' } + text/html: + schema: + type: string + description: Rendered HTML validation report. + '400': { $ref: '#/components/responses/BadRequest' } + '406': { $ref: '#/components/responses/NotAcceptable' } + '422': { $ref: '#/components/responses/UnprocessableFeed' } + '429': { $ref: '#/components/responses/RateLimited' } + + /validate-upload: + post: + tags: [Validation] + operationId: validateByUpload + summary: Validate a GTFS feed by uploading a ZIP. + description: | + Accepts a GTFS ZIP as a `multipart/form-data` upload. Blocks until + validation completes and returns the full validation report. + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [file] + properties: + file: + type: string + format: binary + description: The GTFS ZIP file to validate. + countryCode: + type: string + pattern: '^[A-Za-z]{2}$' + description: | + ISO 3166-1 alpha-2 country code used for phone-number + validation, e.g. `CA`, `US`, `nl`. Case-insensitive. + additionalProperties: false + responses: + '200': + description: Validation complete. + headers: + Content-Type: + description: Matches the negotiated `Accept` header. + schema: { type: string } + content: + application/json: + schema: { $ref: '#/components/schemas/ValidationReport' } + text/html: + schema: + type: string + description: Rendered HTML validation report. + '400': { $ref: '#/components/responses/BadRequest' } + '406': { $ref: '#/components/responses/NotAcceptable' } + '413': { $ref: '#/components/responses/PayloadTooLarge' } + '422': { $ref: '#/components/responses/UnprocessableFeed' } + '429': { $ref: '#/components/responses/RateLimited' } + +components: + + schemas: + + ServiceMetadata: + type: object + required: [validatorVersion] + description: Static information about the running validator service. + properties: + validatorVersion: + type: string + description: Validator release version, e.g. `7.1.0`. + commitHash: + type: string + example: 8635fdac4fbff025b4eaca6972fcc9504bc1552d + limits: + type: object + description: Configurable service limits. + properties: + maxUploadBytes: + type: integer + description: Maximum accepted upload size in bytes. + maxRequestsPerMinute: + type: integer + description: Rate-limit ceiling (requests per minute per IP). + additionalProperties: false + additionalProperties: false + + ValidationReport: + type: object + description: | + Full validator report. Mirrors the structure of `report.json` + produced by the MobilityData canonical GTFS Schedule Validator CLI. + properties: + summary: + $ref: '#/components/schemas/ValidationSummary' + notices: + type: array + items: + $ref: '#/components/schemas/Notice' + additionalProperties: true + + ValidationSummary: + type: object + description: | + Metadata about the validation run: validator version, feed info, + agency list, included files, entity counts, and detected features. + properties: + validatorVersion: + type: string + description: Version of the validator that produced this report, e.g. `7.1.0`. + validatedAt: + type: string + format: date-time + description: Timestamp when validation completed. + gtfsInput: + type: string + description: Path or URL of the GTFS ZIP that was validated. + threads: + type: integer + description: Number of threads the validator used. + outputDirectory: + type: string + description: Server-side output directory (internal detail). + systemErrorsReportName: + type: string + description: Filename of the system errors report, typically `system_errors.json`. + validationReportName: + type: string + description: Filename of the JSON report, typically `report.json`. + htmlReportName: + type: string + description: Filename of the HTML report, typically `report.html`. + countryCode: + type: string + description: Country code used for validation. `ZZ` indicates none was provided. + dateForValidation: + type: string + format: date + description: The date used for time-dependent validation rules. + feedInfo: + $ref: '#/components/schemas/FeedInfo' + agencies: + type: array + description: Transit agencies found in `agency.txt`. + items: + $ref: '#/components/schemas/AgencyInfo' + files: + type: array + description: List of GTFS files included in the ZIP. + items: + type: string + examples: + - - agency.txt + - routes.txt + - stops.txt + - trips.txt + - stop_times.txt + validationTimeSeconds: + type: number + description: Wall-clock time spent on validation, in seconds. + memoryUsageRecords: + type: array + description: Memory usage snapshots at key stages of validation. + items: + $ref: '#/components/schemas/MemoryUsageRecord' + counts: + $ref: '#/components/schemas/EntityCounts' + gtfsFeatures: + type: array + description: | + Detected GTFS features such as `Shapes`, `Feed Information`, + `Route Colors`, `Headsigns`, `Fares V2`, `Flex`, `Pathways`. + items: + type: string + + FeedInfo: + type: object + description: Metadata from `feed_info.txt`, plus derived fields. + properties: + publisherName: + type: string + description: Value of `feed_publisher_name`. + publisherUrl: + type: string + format: uri + description: Value of `feed_publisher_url`. + feedLanguage: + type: string + description: Human-readable language name derived from `feed_lang`. + feedStartDate: + type: string + format: date + description: Value of `feed_start_date`. + feedEndDate: + type: string + format: date + description: Value of `feed_end_date`. + feedEmail: + type: string + format: email + description: Value of `feed_contact_email`, if present. + feedServiceWindowStart: + type: string + format: date + description: Value of `feedServiceWindowStart`. + feedServiceWindowEnd: + type: string + format: date + description: Value of `feedServiceWindowEnd`. + + AgencyInfo: + type: object + description: Details of a transit agency from `agency.txt`. + properties: + name: + type: string + description: Agency name. + url: + type: string + format: uri + description: Agency website URL. + phone: + type: string + description: Agency phone number. Empty string if not provided. + email: + type: string + description: Agency email address. Empty string if not provided. + timezone: + type: string + description: IANA timezone, e.g. `Europe/Warsaw`, `America/Toronto`. + + MemoryUsageRecord: + type: object + description: Memory usage snapshot at a particular validation stage. + properties: + key: + type: string + description: Stage identifier, e.g. `GtfsFeedLoader.loadTables`. + totalMemory: + type: integer + freeMemory: + type: integer + maxMemory: + type: integer + diffMemory: + type: integer + description: Change in used memory since the previous record. + + EntityCounts: + type: object + description: | + Counts of entities found in the feed as well as validation notice + totals. Keys for entity types (Agencies, Routes, Stops, etc.) are + capitalized to match the validator's output. + properties: + Agencies: + type: integer + minimum: 0 + Routes: + type: integer + minimum: 0 + Stops: + type: integer + minimum: 0 + Trips: + type: integer + minimum: 0 + Shapes: + type: integer + minimum: 0 + Blocks: + type: integer + minimum: 0 + additionalProperties: + type: integer + description: Other entity counts the validator may report. + + Notice: + type: object + required: [code, severity, totalNotices] + description: A validation notice emitted by the validator. + properties: + code: + type: string + description: Notice code, e.g. `missing_required_field`. + severity: + type: string + enum: [ERROR, WARNING, INFO] + totalNotices: + type: integer + minimum: 1 + description: How many times this notice was emitted. + sampleNotices: + type: array + description: | + Up to N example occurrences with field-level context. Shape + depends on the rule and is passed through from the validator. + items: + type: object + additionalProperties: true + + Error: + type: object + required: [code, message] + properties: + code: + type: string + description: | + Stable machine-readable error code, e.g. `invalid_url`, + `download_failed`, `unsupported_archive`, `validator_crashed`. + message: + type: string + description: Human-readable explanation, safe to show end users. + details: + type: object + additionalProperties: true + description: Optional structured detail. + + responses: + BadRequest: + description: Request validation failed. + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + UnprocessableFeed: + description: The feed was downloaded or received but could not be validated (e.g. corrupt ZIP, not a valid GTFS archive). + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + PayloadTooLarge: + description: The uploaded file exceeds the configured size limit. + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + NotAcceptable: + description: | + The `Accept` header specifies a media type that is not supported. + Supported types are `application/json` and `text/html`. + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + RateLimited: + description: Too many requests. + headers: + Retry-After: + schema: { type: integer } + description: Seconds to wait before retrying. + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..f639e52 --- /dev/null +++ b/pom.xml @@ -0,0 +1,297 @@ + + + 4.0.0 + + org.mobilitydata.gtfs-validator + gtfs-validator-api + + 1.0.0 + jar + + gtfs-validator-api + Spring Boot HTTP API for the MobilityData Canonical GTFS Schedule Validator. + https://github.com/MobilityData/gtfs-validator-api + + + Apache License, Version 2.0 + https://www.apache.org/licenses/LICENSE-2.0 + repo + + + + + MobilityData + developers@mobilitydata.org + MobilityData + https://mobilitydata.org/ + + + + scm:git:ssh://git@github.com/MobilityData/gtfs-validator-api.git + scm:git:ssh://git@github.com/MobilityData/gtfs-validator-api.git + https://github.com/MobilityData/gtfs-validator-api.git + HEAD + + + + 17 + 17 + 17 + 17 + UTF-8 + + 4.1.0 + 7.23.0 + 3.7.0 + 1.25.2 + 3.0.3 + 2.2.51 + 0.2.10 + 3.0.0 + + + 8.0.1 + + + 33.6.0-jre + 1.28.0 + 1.11.0 + 1.10.1 + + + + + + org.springframework.boot + spring-boot-dependencies + ${spring-boot.version} + pom + import + + + + + com.google.guava + guava + ${guava.version} + + + org.apache.commons + commons-compress + ${commons-compress.version} + + + commons-beanutils + commons-beanutils + ${commons-beanutils.version} + + + commons-validator + commons-validator + ${commons-validator.version} + + + + + + + org.springframework.boot + spring-boot-starter-web + + + + org.springframework.boot + spring-boot-starter-validation + + + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + ${springdoc.version} + + + + + io.swagger.core.v3 + swagger-annotations + ${swagger-annotations.version} + + + org.openapitools + jackson-databind-nullable + ${jackson-databind-nullable.version} + + + jakarta.annotation + jakarta.annotation-api + ${jakarta-annotation.version} + + + + + org.mobilitydata.gtfs-validator + gtfs-validator-main + ${gtfs-validator.version} + + + + commons-logging + commons-logging + + + + + + org.mobilitydata.gtfs-validator + gtfs-validator-core + ${gtfs-validator.version} + + + commons-logging + commons-logging + + + + + org.mobilitydata.gtfs-validator + gtfs-validator-model + ${gtfs-validator.version} + + + + + org.springframework.boot + spring-boot-starter-test + test + + + + org.springframework.boot + spring-boot-webmvc-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + ${spring-boot.version} + + + + repackage + build-info + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + 17 + + + + + com.diffplug.spotless + spotless-maven-plugin + ${spotless-maven-plugin.version} + + + + + ${google-java-format.version} + + + + + + spotless-check + verify + + check + + + + + + + org.apache.maven.plugins + maven-resources-plugin + + + + copy-openapi-spec + generate-resources + + copy-resources + + + ${project.build.outputDirectory}/static + + + ${project.basedir}/docs + + GTFSValidatorAPI.yaml + + false + + + + + + + + + org.openapitools + openapi-generator-maven-plugin + ${openapi-generator-maven-plugin.version} + + + + generate + + + ${project.basedir}/docs/GTFSValidatorAPI.yaml + spring + ${project.build.directory}/generated-sources/openapi + org.mobilitydata.gtfsvalidator.api.gen + org.mobilitydata.gtfsvalidator.api.model + org.mobilitydata.gtfsvalidator.api.handler + false + false + false + false + ${project.basedir}/.openapi-generator-ignore + + true + false + false + java8 + true + true + true + none + + + + + + + + diff --git a/src/main/java/org/mobilitydata/gtfsvalidator/api/Application.java b/src/main/java/org/mobilitydata/gtfsvalidator/api/Application.java new file mode 100644 index 0000000..f08a144 --- /dev/null +++ b/src/main/java/org/mobilitydata/gtfsvalidator/api/Application.java @@ -0,0 +1,28 @@ +/* + * Copyright 2026 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.api; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** Entry point for the GTFS Validator API Spring Boot application. */ +@SpringBootApplication +public class Application { + + public static void main(String[] args) { + SpringApplication.run(Application.class, args); + } +} diff --git a/src/main/java/org/mobilitydata/gtfsvalidator/api/handler/ApiException.java b/src/main/java/org/mobilitydata/gtfsvalidator/api/handler/ApiException.java new file mode 100644 index 0000000..0d60974 --- /dev/null +++ b/src/main/java/org/mobilitydata/gtfsvalidator/api/handler/ApiException.java @@ -0,0 +1,46 @@ +/* + * Copyright 2026 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.api.handler; + +import org.springframework.http.HttpStatus; + +/** + * Exception carrying a machine-readable error code and an HTTP status, mapped to the API {@code + * Error} response schema by {@link ApiExceptionHandler}. + */ +public class ApiException extends RuntimeException { + + private final HttpStatus status; + private final String code; + + public ApiException(HttpStatus status, String code, String message) { + this(status, code, message, null); + } + + public ApiException(HttpStatus status, String code, String message, Throwable cause) { + super(message, cause); + this.status = status; + this.code = code; + } + + public HttpStatus getStatus() { + return status; + } + + public String getCode() { + return code; + } +} diff --git a/src/main/java/org/mobilitydata/gtfsvalidator/api/handler/ApiExceptionHandler.java b/src/main/java/org/mobilitydata/gtfsvalidator/api/handler/ApiExceptionHandler.java new file mode 100644 index 0000000..0d38134 --- /dev/null +++ b/src/main/java/org/mobilitydata/gtfsvalidator/api/handler/ApiExceptionHandler.java @@ -0,0 +1,129 @@ +/* + * Copyright 2026 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.api.handler; + +import org.mobilitydata.gtfsvalidator.api.model.Error; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.HttpMediaTypeNotAcceptableException; +import org.springframework.web.HttpMediaTypeNotSupportedException; +import org.springframework.web.HttpRequestMethodNotSupportedException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingServletRequestParameterException; +import org.springframework.web.bind.ServletRequestBindingException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.multipart.MaxUploadSizeExceededException; +import org.springframework.web.multipart.MultipartException; +import org.springframework.web.multipart.support.MissingServletRequestPartException; +import org.springframework.web.servlet.resource.NoResourceFoundException; + +/** Maps exceptions to the API {@code Error} response schema. */ +@RestControllerAdvice +public class ApiExceptionHandler { + + private static final Logger logger = LoggerFactory.getLogger(ApiExceptionHandler.class); + + @ExceptionHandler(ApiException.class) + public ResponseEntity handleApiException(ApiException ex) { + if (ex.getStatus().is5xxServerError()) { + logger.error("API error", ex); + } else { + logger.debug("API error: {}", ex.getMessage()); + } + return ResponseEntity.status(ex.getStatus()).body(error(ex.getCode(), ex.getMessage())); + } + + @ExceptionHandler(MaxUploadSizeExceededException.class) + public ResponseEntity handlePayloadTooLarge(MaxUploadSizeExceededException ex) { + return ResponseEntity.status(HttpStatus.CONTENT_TOO_LARGE) + .body(error("payload_too_large", "The uploaded file exceeds the configured size limit.")); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleValidation(MethodArgumentNotValidException ex) { + String message = + ex.getBindingResult().getFieldError() != null + ? ex.getBindingResult().getFieldError().getField() + + ": " + + ex.getBindingResult().getFieldError().getDefaultMessage() + : "Request validation failed."; + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(error("bad_request", message)); + } + + @ExceptionHandler(HttpMediaTypeNotAcceptableException.class) + public ResponseEntity handleNotAcceptable(HttpMediaTypeNotAcceptableException ex) { + return ResponseEntity.status(HttpStatus.NOT_ACCEPTABLE) + .body( + error( + "not_acceptable", "Supported response types are application/json and text/html.")); + } + + @ExceptionHandler(NoResourceFoundException.class) + public ResponseEntity handleNotFound(NoResourceFoundException ex) { + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body( + error( + "not_found", + "No such endpoint: " + ex.getResourcePath() + ". The API base path is /v2.")); + } + + @ExceptionHandler(HttpRequestMethodNotSupportedException.class) + public ResponseEntity handleMethodNotSupported(HttpRequestMethodNotSupportedException ex) { + return ResponseEntity.status(HttpStatus.METHOD_NOT_ALLOWED) + .body(error("method_not_allowed", ex.getMessage())); + } + + @ExceptionHandler(HttpMediaTypeNotSupportedException.class) + public ResponseEntity handleUnsupportedMediaType(HttpMediaTypeNotSupportedException ex) { + return ResponseEntity.status(HttpStatus.UNSUPPORTED_MEDIA_TYPE) + .body( + error( + "unsupported_media_type", + "Unsupported Content-Type. Upload the feed as multipart/form-data with a 'file'" + + " part (let your client set the multipart boundary; do not set the" + + " Content-Type header manually).")); + } + + @ExceptionHandler({ + MultipartException.class, + MissingServletRequestPartException.class, + MissingServletRequestParameterException.class, + ServletRequestBindingException.class + }) + public ResponseEntity handleBadMultipart(Exception ex) { + logger.debug("Bad multipart request: {}", ex.getMessage()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body( + error( + "bad_request", + "Malformed multipart request. Send multipart/form-data with a 'file' part" + + " containing the GTFS ZIP, e.g. curl --form 'file=@feed.zip'.")); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleUnexpected(Exception ex) { + logger.error("Unexpected error", ex); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(error("internal_error", "An unexpected error occurred.")); + } + + private Error error(String code, String message) { + return new Error(code, message); + } +} diff --git a/src/main/java/org/mobilitydata/gtfsvalidator/api/handler/MetadataApiDelegateHandler.java b/src/main/java/org/mobilitydata/gtfsvalidator/api/handler/MetadataApiDelegateHandler.java new file mode 100644 index 0000000..f4f31ba --- /dev/null +++ b/src/main/java/org/mobilitydata/gtfsvalidator/api/handler/MetadataApiDelegateHandler.java @@ -0,0 +1,54 @@ +/* + * Copyright 2026 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.api.handler; + +import org.mobilitydata.gtfsvalidator.api.gen.MetadataApiDelegate; +import org.mobilitydata.gtfsvalidator.api.model.ServiceMetadata; +import org.mobilitydata.gtfsvalidator.api.model.ServiceMetadataLimits; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; + +/** Implements the {@code /metadata} endpoint. */ +@Service +public class MetadataApiDelegateHandler implements MetadataApiDelegate { + + private final VersionProvider versionProvider; + private final Integer maxUploadBytes; + private final Integer maxRequestsPerMinute; + + public MetadataApiDelegateHandler( + VersionProvider versionProvider, + @Value("${gtfs.validator.limits.max-upload-bytes:#{null}}") Integer maxUploadBytes, + @Value("${gtfs.validator.limits.max-requests-per-minute:#{null}}") + Integer maxRequestsPerMinute) { + this.versionProvider = versionProvider; + this.maxUploadBytes = maxUploadBytes; + this.maxRequestsPerMinute = maxRequestsPerMinute; + } + + @Override + public ResponseEntity getMetadata() { + ServiceMetadata metadata = new ServiceMetadata(versionProvider.getValidatorVersion()); + if (maxUploadBytes != null || maxRequestsPerMinute != null) { + ServiceMetadataLimits limits = new ServiceMetadataLimits(); + limits.setMaxUploadBytes(maxUploadBytes); + limits.setMaxRequestsPerMinute(maxRequestsPerMinute); + metadata.setLimits(limits); + } + return ResponseEntity.ok(metadata); + } +} diff --git a/src/main/java/org/mobilitydata/gtfsvalidator/api/handler/ValidationApiDelegateHandler.java b/src/main/java/org/mobilitydata/gtfsvalidator/api/handler/ValidationApiDelegateHandler.java new file mode 100644 index 0000000..57c2c8b --- /dev/null +++ b/src/main/java/org/mobilitydata/gtfsvalidator/api/handler/ValidationApiDelegateHandler.java @@ -0,0 +1,125 @@ +/* + * Copyright 2026 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.api.handler; + +import java.io.IOException; +import org.mobilitydata.gtfsvalidator.api.gen.ValidationApiDelegate; +import org.mobilitydata.gtfsvalidator.api.model.ValidateByUrlRequest; +import org.mobilitydata.gtfsvalidator.api.model.ValidationReport; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.web.context.request.RequestAttributes; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; +import org.springframework.web.multipart.MultipartFile; + +/** + * Implements the validation endpoints. Runs the GTFS validator core and returns the resulting + * report either as JSON ({@code application/json}, default) or as the rendered HTML document + * ({@code text/html}) based on the request's {@code Accept} header. + */ +@Service +public class ValidationApiDelegateHandler implements ValidationApiDelegate { + + private static final Logger logger = LoggerFactory.getLogger(ValidationApiDelegateHandler.class); + + private final ValidationService validationService; + + public ValidationApiDelegateHandler(ValidationService validationService) { + this.validationService = validationService; + } + + @Override + public ResponseEntity validateByUrl(ValidateByUrlRequest validateByUrlRequest) { + if (validateByUrlRequest == null || validateByUrlRequest.getUrl() == null) { + throw new ApiException(HttpStatus.BAD_REQUEST, "invalid_url", "A feed url is required."); + } + logger.debug("Validating feed by url: {}", validateByUrlRequest.getUrl()); + ValidationService.Reports reports = + validationService.validateFromUrl( + validateByUrlRequest.getUrl(), validateByUrlRequest.getCountryCode()); + return toResponse(reports); + } + + @Override + public ResponseEntity validateByUpload(MultipartFile file, String countryCode) { + if (file == null || file.isEmpty()) { + throw new ApiException( + HttpStatus.BAD_REQUEST, "missing_file", "A non-empty feed file is required."); + } + logger.debug( + "Validating uploaded feed: {} ({} bytes)", file.getOriginalFilename(), file.getSize()); + try { + ValidationService.Reports reports = + validationService.validateFromUpload(file.getInputStream(), countryCode); + return toResponse(reports); + } catch (IOException e) { + throw new ApiException( + HttpStatus.UNPROCESSABLE_CONTENT, + "unprocessable_feed", + "Could not read the uploaded feed.", + e); + } + } + + /** + * Builds the response in the negotiated format. The generic type is erased at runtime, so we + * return either the raw JSON report string or the rendered HTML document with the matching + * content type. + */ + @SuppressWarnings("unchecked") + private ResponseEntity toResponse(ValidationService.Reports reports) { + if (wantsHtml()) { + if (reports.html() == null) { + throw new ApiException( + HttpStatus.UNPROCESSABLE_CONTENT, + "validator_crashed", + "The validator did not produce an HTML report for the feed."); + } + return (ResponseEntity) + (ResponseEntity) + ResponseEntity.ok().contentType(MediaType.TEXT_HTML).body(reports.html()); + } + return (ResponseEntity) + (ResponseEntity) + ResponseEntity.ok().contentType(MediaType.APPLICATION_JSON).body(reports.json()); + } + + /** Returns true when the client prefers {@code text/html} over {@code application/json}. */ + private boolean wantsHtml() { + RequestAttributes attributes = RequestContextHolder.getRequestAttributes(); + if (!(attributes instanceof ServletRequestAttributes servletAttributes)) { + return false; + } + String accept = servletAttributes.getRequest().getHeader("Accept"); + if (accept == null || accept.isBlank()) { + return false; + } + for (MediaType mediaType : MediaType.parseMediaTypes(accept)) { + if (mediaType.isCompatibleWith(MediaType.APPLICATION_JSON)) { + return false; + } + if (mediaType.isCompatibleWith(MediaType.TEXT_HTML)) { + return true; + } + } + return false; + } +} diff --git a/src/main/java/org/mobilitydata/gtfsvalidator/api/handler/ValidationService.java b/src/main/java/org/mobilitydata/gtfsvalidator/api/handler/ValidationService.java new file mode 100644 index 0000000..cadc1eb --- /dev/null +++ b/src/main/java/org/mobilitydata/gtfsvalidator/api/handler/ValidationService.java @@ -0,0 +1,173 @@ +/* + * Copyright 2026 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.api.handler; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Comparator; +import java.util.stream.Stream; +import org.mobilitydata.gtfsvalidator.input.CountryCode; +import org.mobilitydata.gtfsvalidator.runner.ValidationRunner; +import org.mobilitydata.gtfsvalidator.runner.ValidationRunnerConfig; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; + +/** + * Runs the GTFS validator core against a feed and exposes the produced JSON and HTML reports. + * + *

Each invocation runs synchronously in an isolated temporary output directory which is deleted + * before the call returns. + */ +@Service +public class ValidationService { + + private static final Logger logger = LoggerFactory.getLogger(ValidationService.class); + + private static final String JSON_REPORT_NAME = "report.json"; + private static final String HTML_REPORT_NAME = "report.html"; + private static final String SYSTEM_ERRORS_REPORT_NAME = "system_errors.json"; + + private final ValidationRunner runner; + + public ValidationService(ValidationRunner runner) { + this.runner = runner; + } + + /** Holder for the rendered validation reports. */ + public record Reports(String json, String html) {} + + /** + * Validates a feed located at a remote URL. + * + * @param url public URL of a GTFS ZIP + * @param countryCode optional ISO 3166-1 alpha-2 country code (may be {@code null}/blank) + */ + public Reports validateFromUrl(URI url, String countryCode) { + if (url == null) { + throw new ApiException(HttpStatus.BAD_REQUEST, "invalid_url", "A feed url is required."); + } + if (url.getScheme() == null + || !(url.getScheme().equalsIgnoreCase("http") + || url.getScheme().equalsIgnoreCase("https"))) { + throw new ApiException( + HttpStatus.BAD_REQUEST, "invalid_url", "Feed URL must be an http(s) URL: " + url); + } + return validate(url, countryCode); + } + + /** + * Validates a feed from an uploaded ZIP stream. + * + * @param zipStream stream of the uploaded GTFS ZIP + * @param countryCode optional ISO 3166-1 alpha-2 country code (may be {@code null}/blank) + */ + public Reports validateFromUpload(InputStream zipStream, String countryCode) { + Path uploadFile = null; + try { + uploadFile = Files.createTempFile("gtfs-upload-", ".zip"); + Files.copy(zipStream, uploadFile, java.nio.file.StandardCopyOption.REPLACE_EXISTING); + return validate(uploadFile.toUri(), countryCode); + } catch (IOException e) { + throw new ApiException( + HttpStatus.UNPROCESSABLE_CONTENT, + "unprocessable_feed", + "Could not read the uploaded feed.", + e); + } finally { + deleteQuietly(uploadFile); + } + } + + private Reports validate(URI source, String countryCode) { + Path outputDirectory = null; + try { + outputDirectory = Files.createTempDirectory("gtfs-validation-"); + + ValidationRunnerConfig.Builder configBuilder = + ValidationRunnerConfig.builder() + .setGtfsSource(source) + .setOutputDirectory(outputDirectory) + .setValidationReportFileName(JSON_REPORT_NAME) + .setHtmlReportFileName(HTML_REPORT_NAME) + .setSystemErrorsReportFileName(SYSTEM_ERRORS_REPORT_NAME) + .setStdoutOutput(false) + // Skip the remote update check; rely on the bundled validator version. + .setSkipValidatorUpdate(true); + + if (countryCode != null && !countryCode.isBlank()) { + configBuilder.setCountryCode(CountryCode.forStringOrUnknown(countryCode)); + } + + ValidationRunner.Status status = runner.run(configBuilder.build()); + if (status != ValidationRunner.Status.SUCCESS) { + throw new ApiException( + HttpStatus.UNPROCESSABLE_CONTENT, + "unprocessable_feed", + "The feed could not be validated. It may be corrupt or not a valid GTFS archive."); + } + + Path jsonReport = outputDirectory.resolve(JSON_REPORT_NAME); + Path htmlReport = outputDirectory.resolve(HTML_REPORT_NAME); + if (!Files.exists(jsonReport)) { + throw new ApiException( + HttpStatus.UNPROCESSABLE_CONTENT, + "validator_crashed", + "The validator did not produce a report for the feed."); + } + + String json = Files.readString(jsonReport); + String html = Files.exists(htmlReport) ? Files.readString(htmlReport) : null; + return new Reports(json, html); + } catch (ApiException e) { + throw e; + } catch (IOException e) { + throw new ApiException( + HttpStatus.UNPROCESSABLE_CONTENT, + "unprocessable_feed", + "An error occurred while validating the feed.", + e); + } finally { + deleteRecursivelyQuietly(outputDirectory); + } + } + + private void deleteQuietly(Path path) { + if (path == null) { + return; + } + try { + Files.deleteIfExists(path); + } catch (IOException e) { + logger.warn("Could not delete temp file {}", path, e); + } + } + + private void deleteRecursivelyQuietly(Path directory) { + if (directory == null) { + return; + } + try (Stream paths = Files.walk(directory)) { + paths.sorted(Comparator.reverseOrder()).forEach(this::deleteQuietly); + } catch (IOException e) { + logger.warn("Could not delete temp directory {}", directory, e); + } + } +} diff --git a/src/main/java/org/mobilitydata/gtfsvalidator/api/handler/ValidatorConfiguration.java b/src/main/java/org/mobilitydata/gtfsvalidator/api/handler/ValidatorConfiguration.java new file mode 100644 index 0000000..c42f752 --- /dev/null +++ b/src/main/java/org/mobilitydata/gtfsvalidator/api/handler/ValidatorConfiguration.java @@ -0,0 +1,44 @@ +/* + * Copyright 2026 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.api.handler; + +import org.mobilitydata.gtfsvalidator.runner.ApplicationType; +import org.mobilitydata.gtfsvalidator.runner.ValidationRunner; +import org.mobilitydata.gtfsvalidator.util.VersionResolver; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Wires the GTFS validator core components ({@link VersionResolver} and {@link ValidationRunner}) + * as Spring beans so they can be injected into the API delegate handlers. + */ +@Configuration +public class ValidatorConfiguration { + + @Bean + public VersionResolver versionResolver() { + return new VersionResolver(ApplicationType.WEB); + } + + @Bean + public ValidationRunner validationRunner(VersionResolver versionResolver) { + // Warning: ValidationRunner is not thread safe. + // Calling the service in a concurrent scenario may lead to unexpected + // memory usage report, validation rules and the rest of the report is not compromised. + // see: https://github.com/MobilityData/gtfs-validator/issues/2168 + return new ValidationRunner(versionResolver); + } +} diff --git a/src/main/java/org/mobilitydata/gtfsvalidator/api/handler/VersionProvider.java b/src/main/java/org/mobilitydata/gtfsvalidator/api/handler/VersionProvider.java new file mode 100644 index 0000000..c108a8f --- /dev/null +++ b/src/main/java/org/mobilitydata/gtfsvalidator/api/handler/VersionProvider.java @@ -0,0 +1,50 @@ +/* + * Copyright 2026 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.api.handler; + +import java.time.Duration; +import org.mobilitydata.gtfsvalidator.util.VersionResolver; +import org.springframework.stereotype.Component; + +/** + * Provides validator version information for the {@code /metadata} endpoint. + * + *

The version reported here is the version of the bundled GTFS validator core, resolved from the + * validator's {@link VersionResolver}. It is intentionally independent of this API project's own + * version. + */ +@Component +public class VersionProvider { + + private static final Duration RESOLVE_TIMEOUT = Duration.ofSeconds(5); + + private final VersionResolver versionResolver; + + public VersionProvider(VersionResolver versionResolver) { + this.versionResolver = versionResolver; + } + + /** + * Returns the validator core version, or {@code "unknown"} if it cannot be resolved (e.g. when + * running from exploded classes without a JAR manifest). + */ + public String getValidatorVersion() { + return versionResolver + .getVersionInfoWithTimeout(RESOLVE_TIMEOUT, true) + .currentVersion() + .orElse("unknown"); + } +} diff --git a/src/main/java/org/mobilitydata/gtfsvalidator/api/handler/WebConfig.java b/src/main/java/org/mobilitydata/gtfsvalidator/api/handler/WebConfig.java new file mode 100644 index 0000000..8bfed60 --- /dev/null +++ b/src/main/java/org/mobilitydata/gtfsvalidator/api/handler/WebConfig.java @@ -0,0 +1,37 @@ +/* + * Copyright 2026 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.api.handler; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +/** Permissive CORS configuration. The API requires no authentication. */ +@Configuration +public class WebConfig implements WebMvcConfigurer { + + @Override + public void addCorsMappings(CorsRegistry registry) { + registry + .addMapping("/**") + .allowedOriginPatterns("*") + .allowedMethods("GET", "POST", "OPTIONS") + .allowedHeaders("*") + .exposedHeaders("*") + .allowCredentials(false) + .maxAge(3600); + } +} diff --git a/src/main/java/org/mobilitydata/gtfsvalidator/api/logging/JsonLogFormatter.java b/src/main/java/org/mobilitydata/gtfsvalidator/api/logging/JsonLogFormatter.java new file mode 100644 index 0000000..63eb03c --- /dev/null +++ b/src/main/java/org/mobilitydata/gtfsvalidator/api/logging/JsonLogFormatter.java @@ -0,0 +1,125 @@ +/* + * Copyright 2026 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.api.logging; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.pattern.ThrowableProxyConverter; +import ch.qos.logback.classic.spi.ILoggingEvent; +import java.time.format.DateTimeFormatter; +import java.util.Map; +import org.slf4j.event.KeyValuePair; +import org.springframework.boot.logging.structured.StructuredLogFormatter; + +/** + * Emits one structured JSON object per log line, intended for cloud log aggregators that parse + * stdout. Each entry carries a {@code severity}, {@code message} and ISO-8601 {@code timestamp}, + * plus the logger name, thread, MDC values and any structured key/value arguments. Exception stack + * traces are appended to {@code message} so error trackers can pick them up. + * + *

Activated only under the {@code json} Spring profile (see {@code + * application-json.properties}); the default profile keeps the human-readable console output. + */ +public class JsonLogFormatter implements StructuredLogFormatter { + + private static final DateTimeFormatter TIMESTAMP = DateTimeFormatter.ISO_INSTANT; + + private final ThrowableProxyConverter throwableConverter = new ThrowableProxyConverter(); + + public JsonLogFormatter() { + throwableConverter.start(); + } + + @Override + public String format(ILoggingEvent event) { + StringBuilder json = new StringBuilder(256); + json.append('{'); + writeField(json, "timestamp", TIMESTAMP.format(event.getInstant()), true); + writeField(json, "severity", severity(event.getLevel()), false); + writeField(json, "logger", event.getLoggerName(), false); + writeField(json, "thread", event.getThreadName(), false); + writeField(json, "message", message(event), false); + + for (Map.Entry mdc : event.getMDCPropertyMap().entrySet()) { + writeField(json, mdc.getKey(), mdc.getValue(), false); + } + if (event.getKeyValuePairs() != null) { + for (KeyValuePair pair : event.getKeyValuePairs()) { + writeField(json, pair.key, String.valueOf(pair.value), false); + } + } + json.append('}').append('\n'); + return json.toString(); + } + + /** Maps logback levels onto the severities understood by common cloud log collectors. */ + private static String severity(Level level) { + if (level == null) { + return "DEFAULT"; + } + if (level == Level.WARN) { + return "WARNING"; + } + if (level == Level.TRACE) { + return "DEBUG"; + } + return level.toString(); + } + + private String message(ILoggingEvent event) { + String message = event.getFormattedMessage(); + if (event.getThrowableProxy() == null) { + return message; + } + String stackTrace = throwableConverter.convert(event); + return (message == null || message.isEmpty()) ? stackTrace : message + "\n" + stackTrace; + } + + private static void writeField(StringBuilder json, String key, String value, boolean first) { + if (value == null) { + return; + } + if (!first) { + json.append(','); + } + appendEscaped(json, key); + json.append(':'); + appendEscaped(json, value); + } + + private static void appendEscaped(StringBuilder json, String value) { + json.append('"'); + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + switch (c) { + case '"' -> json.append("\\\""); + case '\\' -> json.append("\\\\"); + case '\n' -> json.append("\\n"); + case '\r' -> json.append("\\r"); + case '\t' -> json.append("\\t"); + case '\b' -> json.append("\\b"); + case '\f' -> json.append("\\f"); + default -> { + if (c < 0x20) { + json.append(String.format("\\u%04x", (int) c)); + } else { + json.append(c); + } + } + } + } + json.append('"'); + } +} diff --git a/src/main/resources/application-json.properties b/src/main/resources/application-json.properties new file mode 100644 index 0000000..da33ddb --- /dev/null +++ b/src/main/resources/application-json.properties @@ -0,0 +1,4 @@ +# Activated only when the "json" profile is active (SPRING_PROFILES_ACTIVE=json). +# Switches console output to structured JSON for cloud log aggregators. The default +# profile (no profile) keeps the human-readable plain-text console output. +logging.structured.format.console=org.mobilitydata.gtfsvalidator.api.logging.JsonLogFormatter diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties new file mode 100644 index 0000000..a1b7fdc --- /dev/null +++ b/src/main/resources/application.properties @@ -0,0 +1,27 @@ +spring.application.name=gtfs-validator-api + +# API base path is /v2 (matches the OpenAPI servers entry). +openapi.gTFSValidator.base-path=/v2 + +# Response compression (validation reports can be large). +server.compression.enabled=true +server.compression.min-response-size=1024 +server.compression.mime-types=application/json,text/html + +# Upload size is unlimited by default (the schema does not mandate a limit). +# Operators can cap it by setting both properties below to a concrete size, +# and advertise it via gtfs.validator.limits.max-upload-bytes. +spring.servlet.multipart.max-file-size=-1 +spring.servlet.multipart.max-request-size=-1 + +# Reported by GET /v2/metadata (limits object). Unset by default, so no limit is +# advertised. Set this (and the multipart limits above) to enforce/announce a cap. +# gtfs.validator.limits.max-upload-bytes= +# gtfs.validator.limits.max-requests-per-minute= + +# Serve the OpenAPI spec (single source of truth: docs/GTFSValidatorAPI.yaml, copied +# into the jar's static resources at build time) and Swagger UI. +springdoc.swagger-ui.url=/GTFSValidatorAPI.yaml +springdoc.api-docs.enabled=true + +logging.level.org.mobilitydata.gtfsvalidator.api=INFO diff --git a/src/test/java/org/mobilitydata/gtfsvalidator/api/ValidationIntegrationTest.java b/src/test/java/org/mobilitydata/gtfsvalidator/api/ValidationIntegrationTest.java new file mode 100644 index 0000000..6efb779 --- /dev/null +++ b/src/test/java/org/mobilitydata/gtfsvalidator/api/ValidationIntegrationTest.java @@ -0,0 +1,169 @@ +/* + * 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.api; + +import static org.hamcrest.Matchers.containsString; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.web.servlet.MockMvc; + +/** Integration tests covering the three endpoints and content negotiation. */ +@SpringBootTest +@AutoConfigureMockMvc +class ValidationIntegrationTest { + + @Autowired private MockMvc mockMvc; + + /** + * Builds a minimal valid GTFS feed as an in-memory ZIP. Generating the fixture at runtime keeps + * the test self-contained and avoids committing a binary artifact to the repository. + */ + private MockMultipartFile demoFeed() throws Exception { + Map files = new LinkedHashMap<>(); + files.put( + "agency.txt", + "agency_id,agency_name,agency_url,agency_timezone\n" + + "1,Demo Transit,https://example.com,America/Toronto\n"); + files.put( + "calendar.txt", + "service_id,monday,tuesday,wednesday,thursday,friday,saturday,sunday,start_date,end_date\n" + + "WK,1,1,1,1,1,0,0,20240101,20241231\n"); + files.put( + "routes.txt", + "route_id,agency_id,route_short_name,route_long_name,route_type\n" + + "R1,1,1,Demo Route,3\n"); + files.put( + "stop_times.txt", + "trip_id,arrival_time,departure_time,stop_id,stop_sequence\n" + + "T1,08:00:00,08:00:00,S1,1\n" + + "T1,08:10:00,08:10:00,S2,2\n"); + files.put( + "stops.txt", + "stop_id,stop_name,stop_lat,stop_lon\nS1,Stop 1,45.5,-73.5\nS2,Stop 2,45.6,-73.6\n"); + files.put("trips.txt", "route_id,service_id,trip_id\nR1,WK,T1\n"); + + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + try (ZipOutputStream zip = new ZipOutputStream(buffer)) { + for (Map.Entry entry : files.entrySet()) { + zip.putNextEntry(new ZipEntry(entry.getKey())); + zip.write(entry.getValue().getBytes(StandardCharsets.UTF_8)); + zip.closeEntry(); + } + } + return new MockMultipartFile("file", "demo-gtfs.zip", "application/zip", buffer.toByteArray()); + } + + @Test + void metadataReturnsValidatorVersion() throws Exception { + mockMvc + .perform(get("/v2/metadata").accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.validatorVersion").isNotEmpty()); + } + + @Test + void uploadReturnsJsonReport() throws Exception { + mockMvc + .perform( + multipart("/v2/validate-upload") + .file(demoFeed()) + .param("countryCode", "CA") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.summary.validatorVersion").isNotEmpty()) + .andExpect(jsonPath("$.notices").isArray()); + } + + @Test + void uploadReturnsHtmlReportWhenRequested() throws Exception { + mockMvc + .perform(multipart("/v2/validate-upload").file(demoFeed()).accept(MediaType.TEXT_HTML)) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_HTML)) + .andExpect(content().string(containsString("