Skip to content

Add vehicle rental data layer and rental-aware trip planning - #154

Merged
aaronbrethorst merged 1 commit into
mainfrom
bikeshare
Jul 29, 2026
Merged

Add vehicle rental data layer and rental-aware trip planning#154
aaronbrethorst merged 1 commit into
mainfrom
bikeshare

Conversation

@aaronbrethorst

@aaronbrethorst aaronbrethorst commented Jul 29, 2026

Copy link
Copy Markdown
Member

Summary

  • Rental-aware trip planning (spec v5.1, Phase 0): the GraphQL plan query now selects rentedBike and vehicleRentalStation/rentalVehicle refs, mapped into new Leg.rentedBike and Place.bikeShareId; new TransportMode.bikeRental serializes as BICYCLE_RENT on REST and {mode: BICYCLE, qualifier: RENT} on GraphQL (mode building gained qualifier support).
  • Vehicle rental data layer (Phase 1): new VehicleRentalService protocol + VehicleRental union model (__typename-discriminated, fail-open VehicleFormFactor decoding covering all 8 OTP values), and fetchVehicleRentals(in:formFactors:) on GraphQLAPIService — off-actor decode, GraphQL partial-success tolerance via VehicleRentalFetchResult.partialErrors, and unknown union members skipped (logged) rather than failing a 12k-entity payload.
  • Capability gating: TripPlannerViewModel.availableTransportModes hides .bikeRental unless the injected service supports rentals; default/reset mode selection goes through the same filter; OTPConfiguration's default mode list is now explicit so hosts never inherit new modes silently. New strings localized in all 13 languages.

The stateful VehicleRentalSource (viewport→diffed-snapshot pipeline) is the next PR, per the spec's phasing.

Test plan

  • Full suite on iPhone 17 Pro simulator: TEST SUCCEEDED (200+ tests incl. 20 new: rental leg mapping, qualifier serialization, REST wire token, station/vehicle/mixed decode, form-factor filtering with fail-open, partial success, unknown __typename skip, HTTP errors, displayLabel derivation, capability-gated mode lists and default selection)
  • SwiftLint: 0 violations
  • Live server exercise against https://sound-transit-otp.ibi-transit.com/otp/gtfs/v1 using the exact shipped query documents: rental itinerary returns WALK → BICYCLE (rentedBike=true) with vehicle refs on leg places; vehicleRentalsByBbox returned 2,555 entities (1 station, 2,554 free-floating; 628 bicycles / 1,926 scooters) matching the model shapes
  • Fixtures are captured live-response shapes, including feed realities: fuel.percent null, rentalUris null, placeholder "Default vehicle type" names

Review notes (deliberate choices worth a look)

  • displayLabel/RentalNetwork.displayName live on the models (spec v5.1 seam decision: OTPKit owns derived labels so hosts don't reinvent them). The network-name heuristic takes the first identifier token, so multi-word operators (e.g. capital_bikeshare → "Capital") will need a host override or mapping later.
  • The formFactors parameter is client-side post-filtering (the OTP query has no server-side filter); it stays in the protocol because the upcoming VehicleRentalSource is its primary consumer.
  • Public rental models decode directly off the wire (field names match the GraphQL schema) rather than going through an internal DTO layer like the plan path — accepted coupling for a read-only browse payload.
  • VehicleRentalTests uses Swift Testing, consistent with the newer package tests; CLAUDE.md still says package tests are XCTest and could use an update.
  • Station availability fields are optional with vehiclesAvailableCount/docksAvailableCount conveniences preferring the typed availableVehicles/availableSpaces data — everything is nullable in the GraphQL schema.

View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

Summary by CodeRabbit

  • New Features

    • Added support for bike-rental trip planning and rental vehicle data, including stations, free-floating vehicles, availability, locations, and vehicle types.
    • Rental results can be filtered by bicycle, scooter, and other form factors.
    • Rental legs now identify associated rental vehicles and stations.
    • Added clearer rental vehicle labels and availability information.
  • Bug Fixes

    • Improved handling of partial rental data and unknown vehicle types without failing the entire response.
  • Localization

    • Added bike-rental and vehicle-type translations across supported languages.

Bikeshare spec v5.1 Phases 0-1 for OTPKit:

- Extend the GraphQL plan query with rentedBike and vehicleRentalStation/
  rentalVehicle refs; map them to new Leg.rentedBike and Place.bikeShareId
- Add TransportMode.bikeRental (REST wire token BICYCLE_RENT; GraphQL
  {mode: BICYCLE, qualifier: RENT} via new qualifier support)
- Add VehicleRentalService protocol, VehicleRental union model with
  fail-open FormFactor decoding, and fetchVehicleRentals(in:formFactors:)
  on GraphQLAPIService with partial-success tolerance
- Gate rental modes on service capability via availableTransportModes;
  default/reset mode selection honors the same filter
- Localize new strings across all 13 languages

Verified against the live Sound Transit OTP server: rental itineraries
return rentedBike=true with vehicle refs, and vehicleRentalsByBbox decodes
2,555 live entities through the shipped query documents.
@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds docked and free-floating vehicle rental models, GraphQL rental fetching and decoding, rental-aware trip mapping, capability-aware transport mode selection, localized labels, and comprehensive fixtures and tests.

Changes

Vehicle rental domain models

Layer / File(s) Summary
Rental entities and matching helpers
OTPKit/Sources/OTPKit/Core/Models/OTP/RentalVehicle.swift, VehicleRental.swift, VehicleRentalStation.swift, VehicleRentalSupportingTypes.swift, VehicleFormFactor.swift
Adds Codable rental models, form-factor normalization, availability and operative-state helpers, union accessors, matching, and localized display-label generation.
Trip rental metadata and transport mode
OTPKit/Sources/OTPKit/Core/Models/OTP/Leg.swift, Place.swift, TransportMode.swift, StringExtension.swift
Adds rental identifiers and flags to trip models, preserves rental state when merging legs, and introduces the bike-rental transport mode and capitalization helper.
Rental request value types
OTPKit/Sources/OTPKit/Core/Models/OTP/VehicleRentalBoundingBox.swift, VehicleRentalFetchResult.swift, OTPKit/Tests/VehicleRentalTests.swift
Adds bounding-box and fetch-result types, with model decoding, matching, display-label, and union-decoding tests.

GraphQL rental flow

Layer / File(s) Summary
GraphQL service and wire contracts
OTPKit/Sources/OTPKit/Network/VehicleRentalService.swift, GraphQLAPIService.swift, GraphQLPlanResponse.swift
Adds the rental service API, rental queries and variables, generic GraphQL envelopes, rental-leg fields, and station/vehicle identifier mapping.
Rental union decoding and partial results
OTPKit/Sources/OTPKit/Network/GraphQLRentalsResponse.swift, GraphQLAPIService.swift
Decodes station and vehicle union members, skips unknown union types, filters form factors, and returns partial data with GraphQL errors.
GraphQL fixtures and service validation
OTPKit/Tests/Fixtures/graphql_plan_rental.json, graphql_rentals_*.json, OTPKit/Tests/GraphQLAPIServiceTests.swift
Covers rental plan mapping, mixed rental entities, partial errors, request variables, filtering, unknown values, and HTTP or GraphQL failures.

Capability-aware presentation

Layer / File(s) Summary
Transport-mode availability
OTPKit/Sources/OTPKit/Core/OTPConfiguration.swift, OTPKit/Sources/OTPKit/Presentation/ViewModel/TripPlannerViewModel.swift, OTPKit/Tests/Helpers/TestFixtures.swift
Uses explicit default transport modes, filters bike rental by service capability, and applies capability-aware defaults and resets with rental-capable test mocks.
Localized rental labels
OTPKit/Sources/OTPKit/Resources/*/Localizable.strings
Adds bike-rental transport labels and rental vehicle-type labels across supported localizations.
Transport-mode behavior tests
OTPKit/Tests/TripPlanRequestTests.swift, OTPKit/Tests/TripPlannerViewModelTests.swift
Validates REST wire tokens, available-mode filtering, and default selection behavior.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant TripPlannerViewModel
  participant VehicleRentalService
  participant GraphQLAPIService
  participant OTPGraphQL
  TripPlannerViewModel->>VehicleRentalService: fetchVehicleRentals(boundingBox, formFactors)
  VehicleRentalService->>GraphQLAPIService: rental fetch request
  GraphQLAPIService->>OTPGraphQL: vehicleRentalsByBbox
  OTPGraphQL-->>GraphQLAPIService: rental data and partial errors
  GraphQLAPIService-->>TripPlannerViewModel: VehicleRentalFetchResult
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 30.91% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: adding vehicle rental support and making trip planning rental-aware.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch bikeshare

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@aaronbrethorst
aaronbrethorst merged commit 433a0fd into main Jul 29, 2026
4 of 5 checks passed
@aaronbrethorst
aaronbrethorst deleted the bikeshare branch July 29, 2026 07:00

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
OTPKit/Sources/OTPKit/Network/GraphQLPlanResponse.swift (1)

110-116: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Make the new rental reference models Codable.

GraphQLVehicleRentalStationRef and GraphQLRentalVehicleRef are JSON models but only conform to Decodable.

Proposed fix
-struct GraphQLVehicleRentalStationRef: Decodable {
+struct GraphQLVehicleRentalStationRef: Codable {
     let stationId: String?
 }

-struct GraphQLRentalVehicleRef: Decodable {
+struct GraphQLRentalVehicleRef: Codable {
     let vehicleId: String?
 }

As per coding guidelines, “All models must conform to Codable for JSON serialization.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@OTPKit/Sources/OTPKit/Network/GraphQLPlanResponse.swift` around lines 110 -
116, Update GraphQLVehicleRentalStationRef and GraphQLRentalVehicleRef to
conform to Codable instead of only Decodable, preserving their existing optional
stationId and vehicleId properties.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@OTPKit/Sources/OTPKit/Core/Models/OTP/VehicleRentalStation.swift`:
- Around line 60-71: Update VehicleRentalStation.matches so only missing typed
availability (availableVehicles?.byType == nil) fails open; an explicitly empty
byType array must return false, while preserving the existing matching behavior
for populated arrays. Add a regression test in VehicleRentalTests.swift covering
an empty byType array and a form-factor filter.

In `@OTPKit/Sources/OTPKit/Network/GraphQLAPIService.swift`:
- Around line 307-310: Update the station inventory GraphQL selection under
availableVehicles.byType to request propulsionType alongside formFactor, then
extend graphql_rentals_mixed.json with a station e-bike vehicle type and update
its mapping test to verify VehicleType.isPowered distinguishes it correctly.

---

Nitpick comments:
In `@OTPKit/Sources/OTPKit/Network/GraphQLPlanResponse.swift`:
- Around line 110-116: Update GraphQLVehicleRentalStationRef and
GraphQLRentalVehicleRef to conform to Codable instead of only Decodable,
preserving their existing optional stationId and vehicleId properties.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: ec3100af-b263-4fc6-8fc5-f156d024ceeb

📥 Commits

Reviewing files that changed from the base of the PR and between 7be9420 and facecc0.

📒 Files selected for processing (38)
  • OTPKit/Sources/OTPKit/Core/Extensions/StringExtension.swift
  • OTPKit/Sources/OTPKit/Core/Models/OTP/Leg.swift
  • OTPKit/Sources/OTPKit/Core/Models/OTP/Place.swift
  • OTPKit/Sources/OTPKit/Core/Models/OTP/RentalVehicle.swift
  • OTPKit/Sources/OTPKit/Core/Models/OTP/TransportMode.swift
  • OTPKit/Sources/OTPKit/Core/Models/OTP/VehicleFormFactor.swift
  • OTPKit/Sources/OTPKit/Core/Models/OTP/VehicleRental.swift
  • OTPKit/Sources/OTPKit/Core/Models/OTP/VehicleRentalBoundingBox.swift
  • OTPKit/Sources/OTPKit/Core/Models/OTP/VehicleRentalFetchResult.swift
  • OTPKit/Sources/OTPKit/Core/Models/OTP/VehicleRentalStation.swift
  • OTPKit/Sources/OTPKit/Core/Models/OTP/VehicleRentalSupportingTypes.swift
  • OTPKit/Sources/OTPKit/Core/OTPConfiguration.swift
  • OTPKit/Sources/OTPKit/Network/GraphQLAPIService.swift
  • OTPKit/Sources/OTPKit/Network/GraphQLPlanResponse.swift
  • OTPKit/Sources/OTPKit/Network/GraphQLRentalsResponse.swift
  • OTPKit/Sources/OTPKit/Network/VehicleRentalService.swift
  • OTPKit/Sources/OTPKit/Presentation/ViewModel/TripPlannerViewModel.swift
  • OTPKit/Sources/OTPKit/Resources/ar.lproj/Localizable.strings
  • OTPKit/Sources/OTPKit/Resources/en.lproj/Localizable.strings
  • OTPKit/Sources/OTPKit/Resources/es.lproj/Localizable.strings
  • OTPKit/Sources/OTPKit/Resources/fil.lproj/Localizable.strings
  • OTPKit/Sources/OTPKit/Resources/fr.lproj/Localizable.strings
  • OTPKit/Sources/OTPKit/Resources/it.lproj/Localizable.strings
  • OTPKit/Sources/OTPKit/Resources/ko.lproj/Localizable.strings
  • OTPKit/Sources/OTPKit/Resources/pl.lproj/Localizable.strings
  • OTPKit/Sources/OTPKit/Resources/pt-BR.lproj/Localizable.strings
  • OTPKit/Sources/OTPKit/Resources/ru.lproj/Localizable.strings
  • OTPKit/Sources/OTPKit/Resources/vi.lproj/Localizable.strings
  • OTPKit/Sources/OTPKit/Resources/zh-Hans.lproj/Localizable.strings
  • OTPKit/Sources/OTPKit/Resources/zh-Hant.lproj/Localizable.strings
  • OTPKit/Tests/Fixtures/graphql_plan_rental.json
  • OTPKit/Tests/Fixtures/graphql_rentals_mixed.json
  • OTPKit/Tests/Fixtures/graphql_rentals_partial_error.json
  • OTPKit/Tests/GraphQLAPIServiceTests.swift
  • OTPKit/Tests/Helpers/TestFixtures.swift
  • OTPKit/Tests/TripPlanRequestTests.swift
  • OTPKit/Tests/TripPlannerViewModelTests.swift
  • OTPKit/Tests/VehicleRentalTests.swift

Comment on lines +60 to +71
/// True when the station stocks any vehicle matching one of the given form factors.
/// Fail-open: a station with no typed availability breakdown is assumed to match.
public func matches(formFactors: Set<VehicleFormFactor>) -> Bool {
guard let byType = availableVehicles?.byType, !byType.isEmpty else {
return true
}

return byType.contains { typeCount in
guard let formFactor = typeCount.vehicleType.formFactor else { return true }
return formFactors.contains(formFactor)
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Empty byType array is treated the same as missing data.

The guard !byType.isEmpty makes a station with an explicit, empty typed-availability breakdown (zero vehicle types currently at the station) fail-open to true, even though the docstring's fail-open rationale is specifically for missing typed data (nil), not an explicitly-empty one. This can surface a station with zero available vehicles as "matching" any form-factor filter.

Also, RentalVehicle.matches and VehicleRentalStation.matches currently have no direct test coverage in OTPKit/Tests/VehicleRentalTests.swift — worth adding a regression test for this specific empty-array case alongside the fix.

🐛 Proposed fix
     public func matches(formFactors: Set<VehicleFormFactor>) -> Bool {
-        guard let byType = availableVehicles?.byType, !byType.isEmpty else {
+        guard let byType = availableVehicles?.byType else {
             return true
         }
 
         return byType.contains { typeCount in
             guard let formFactor = typeCount.vehicleType.formFactor else { return true }
             return formFactors.contains(formFactor)
         }
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/// True when the station stocks any vehicle matching one of the given form factors.
/// Fail-open: a station with no typed availability breakdown is assumed to match.
public func matches(formFactors: Set<VehicleFormFactor>) -> Bool {
guard let byType = availableVehicles?.byType, !byType.isEmpty else {
return true
}
return byType.contains { typeCount in
guard let formFactor = typeCount.vehicleType.formFactor else { return true }
return formFactors.contains(formFactor)
}
}
/// True when the station stocks any vehicle matching one of the given form factors.
/// Fail-open: a station with no typed availability breakdown is assumed to match.
public func matches(formFactors: Set<VehicleFormFactor>) -> Bool {
guard let byType = availableVehicles?.byType else {
return true
}
return byType.contains { typeCount in
guard let formFactor = typeCount.vehicleType.formFactor else { return true }
return formFactors.contains(formFactor)
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@OTPKit/Sources/OTPKit/Core/Models/OTP/VehicleRentalStation.swift` around
lines 60 - 71, Update VehicleRentalStation.matches so only missing typed
availability (availableVehicles?.byType == nil) fails open; an explicitly empty
byType array must return false, while preserving the existing matching behavior
for populated arrays. Add a regression test in VehicleRentalTests.swift covering
an empty byType array and a form-factor filter.

Comment on lines +307 to +310
availableVehicles {
total
byType { count vehicleType { formFactor } }
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Select propulsionType for station vehicle types.

Station inventory currently decodes VehicleType without propulsionType, unlike free-floating vehicles. Docked e-bikes will therefore appear indistinguishable from human-powered bikes through VehicleType.isPowered. Add the field here and extend graphql_rentals_mixed.json/its mapping test with a station e-bike type.

Proposed fix
-            byType { count vehicleType { formFactor } }
+            byType { count vehicleType { formFactor propulsionType } }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
availableVehicles {
total
byType { count vehicleType { formFactor } }
}
availableVehicles {
total
byType { count vehicleType { formFactor propulsionType } }
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@OTPKit/Sources/OTPKit/Network/GraphQLAPIService.swift` around lines 307 -
310, Update the station inventory GraphQL selection under
availableVehicles.byType to request propulsionType alongside formFactor, then
extend graphql_rentals_mixed.json with a station e-bike vehicle type and update
its mapping test to verify VehicleType.isPowered distinguishes it correctly.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant