This is a GraphQL API that serves multiple GTFS static feeds from a PostgreSQL database with the PostGIS extension enabled, allowing fetching of geometry data created from the static feeds.
- Running the API
- Testing the API
- Configuring authentication
- Configuring the cache store (Redis)
- Configuring the database (PostgreSQL/PostGIS)
- Querying the GraphQL API
- Entity Relationship Diagram
# development
$ npm run start
# watch mode
$ npm run start:dev
# production mode
$ npm run start:prod
# unit tests
$ npm run test
# e2e tests
$ npm run test:e2e
# test coverage
$ npm run test:cov
This API requires an x-api-key
header to be sent with a valid key. These keys are defined in an API_KEYS
variable in .env
, separated by commas:
API_KEYS=1XXXXXXXXXXXXXX,2XXXXXXXXXXXXXX,3XXXXXXXXXXXXXX
I am using the Insomnia client, however, if you want to use the GraphQL Playground interface in your browser, you can send this header with ModHeader extension. If you use ModHeader, you can add an x-api-key
request header, then add a Filter with a URL Pattern of http:\/\/localhost:4000\/graphql
to authenticate.
This API uses Redis for caching and session management, which can be configured in .env
:
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_AUTH=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
Example .env
configuration:
DB_HOST=<hostname>
DB_PORT=5432
DB_USERNAME=<username>
DB_PASSWORD=<password>
DB_DATABASE=gtfs
This project depends on a PostgrSQL database populated using the gtfs-sql-importer. This requires a PostGIS-enabled PostgreSQL database:
# Switch to user "postgres"
sudo su postgres
# Run psql
psql
# From the psql prompt, select database
psql> \c gtfs_db
# Create PostGIS extension
CREATE EXTENSION postgis
Basic usage of gtfs-sql-importer
is as follows (executed from within the repo):
Export the following environment variables:
PGDATABASE=mydbname
PGHOST=example.com
PGUSER=username
PGPASSWORD=password
Then, from the root directory of gtfs-sql-importer/
:
make init
make load GTFS=/path/to/gtfs.zip
Where gtfs.zip
is the name of the downloaded .zip
file containing the GTFS data.
View the entity relationship diagram (ERD) generated for the created database here.
It should be fairly straight-forward to query GTFS data if you follow the specification laid out in detail here. You need to convert fields to camel-case (e.g., route_id
=> routeId
), and use singular and plural depending on whether you want a single entity or multiple entities. Below are examples, along with cases where you can specify an addition parameter to query by, such as trips
, which can take a routeId
to only return trips for that route.
NOTE: You must always specify a feedIndex
, unless you are querying for all feeds in the database.
NOTE: Generally, getting a group of entities will only return the top-level entity, except in cases such as Feeds where it might be useful to grab Agency and Route data. This may change, and I may add query parameters to make it easier to get more deeply nested structures if it seems useful and performant enough.
Get all feeds, along with the associated Agency and Routes - this is the initial request, as feedIndex
is required on all subsequent queries:
{
feeds {
feedId
feedLang
agencies {
agencyId
agencyName
agencyUrl
agencyPhone
}
routes {
routeId
routeShortName
routeDesc
}
}
}
Get all Routes:
{
routes(feedIndex: 1) {
routeId
routeDesc
routeColor
}
}
Get a specific Route:
{
route(feedIndex: 1, routeId: "B") {
routeId
routeUrl
routeDesc
routeColor
routeShortName
routeLongName
routeType {
routeType
description
}
transfers {
fromStopId
toStopId
}
}
}
Get all Trips (you can also specify serviceId
):
{
trips(feedIndex: 1) {
tripId
tripHeadsign
routeId
tripType
directionId
}
}
Get the next available trip by routeId
and directionId
(directionId
can be 0
or 1
, and defaults to 0
if unspecified), this returns useful information that can be used by the client to render a valid route, it's stops, and shapeId
(see shape queries below):
{
nextTrip(feedIndex: 1, routeId: "7", directionId: 1) {
tripId
tripHeadsign
directionId
shapeId
route {
routeId
routeShortName
routeLongName
routeDesc
routeColor
}
stopTimes {
stopSequence
departureTime {
hours
minutes
seconds
}
stop {
stopName
geom {
coordinates
}
}
}
}
}
Get a Trip, along with Route info, StopTimes with their associated stop and Point
geometry:
{
trip(feedIndex: 1, tripId: "ASP21GEN-1037-Sunday-00_000600_1..S03R") {
tripId
tripHeadsign
tripShortName
route {
routeId
routeDesc
}
stopTimes {
stopId
stopSequence
stop {
stopName
stopDesc
parentStation
geom {
type
coordinates
}
}
departureTime {
hours
minutes
seconds
}
}
}
}
Once we have trips, with their respective shapeId
s, we can query for the actual shape geometry:
Multiple shapes:
(NOTE: You can also specify shapeIds: "7..N97R"
to return a single shape, just know that it will still be returned in an array.)
{
shapes(shapeIds: ["7..N97R", "7..S97R"]) {
shapeId
geom {
type
coordinates
}
}
}
A single shape:
{
shape(shapeId: "7..N97R") {
shapeId
length
geom {
type
coordinates
}
}
}
NOTE: A Stop
can have a parentStation
defined. A parentStation
might be have a stopId
of 101
, and two stops that have this as a parentStation
might be 101N
and 101S
, indicating separate stops for each direction. We can specify all, only parents, and only children in the query using isParent: true
or isChild: true
, or omitting those values to get all stop entires:
Get all stops:
{
stops(feedIndex: 1) {
stopId
stopName
geom {
type
coordinates
}
}
}
Get all stops that are Parent Stations:
{
stops(feedIndex: 1, isParent: true) {
stopId
stopName
geom {
type
coordinates
}
}
}
Get all stops that are not Parent Stations:
{
stops(feedIndex: 1, isChild: true) {
stopId
stopName
geom {
type
coordinates
}
}
}
Get a stop, along with its transfers (and transfer-types, just to illustrate what that actually gives us):
{
stop(feedIndex: 1, stopId: "127") {
stopId
stopName
parentStation
stopTimezone
geom {
type
coordinates
}
locationType {
locationType
description
}
transfers {
toStopId
fromStopId
minTransferTime
transferType {
transferType
description
}
}
}
}
NOTE: If a stop isn't a parentStation
, transfers will be empty (at least according to the MTA data). Querying a parent station should yield a populated transfers
array, however, there is always a transfers
table entry for the stop itself (perhaps to transfer from Inbound to Outbound), as you can see in the following data:
{
"data": {
"stop": {
"stopId": "127",
"stopName": "Times Sq-42 St",
"parentStation": null,
"stopTimezone": null,
"geom": {
"type": "Point",
"coordinates": [-73.987495, 40.75529]
},
"locationType": {
"locationType": 1,
"description": "station"
},
"transfers": [
{
"toStopId": "127",
"fromStopId": "127",
"minTransferTime": 0,
"transferType": {
"transferType": 2,
"description": "Transfer possible with min_transfer_time window"
}
},
{
"toStopId": "725",
"fromStopId": "127",
"minTransferTime": 180,
"transferType": {
"transferType": 2,
"description": "Transfer possible with min_transfer_time window"
}
},
{
"toStopId": "902",
"fromStopId": "127",
"minTransferTime": 180,
"transferType": {
"transferType": 2,
"description": "Transfer possible with min_transfer_time window"
}
},
{
"toStopId": "A27",
"fromStopId": "127",
"minTransferTime": 300,
"transferType": {
"transferType": 2,
"description": "Transfer possible with min_transfer_time window"
}
},
{
"toStopId": "R16",
"fromStopId": "127",
"minTransferTime": 180,
"transferType": {
"transferType": 2,
"description": "Transfer possible with min_transfer_time window"
}
}
]
}
}
}
You can see in the locationType
section above that the locationType
is station
. For "child" stops (e.g., 127N
or 127S
), you would have a locationType
of stop
. We've also recieved an array of "stations" that are available for transfer after the minTransferTime
value (in seconds). These transfers can be queried together for additional detail as such:
{
stops(feedIndex: 1, stopIds: ["127", "725", "902", "A27", "R16"]) {
stopId
stopName
geom {
type
coordinates
}
}
}
Which yields the following:
{
"data": {
"stops": [
{
"stopId": "127",
"stopName": "Times Sq-42 St",
"geom": {
"type": "Point",
"coordinates": [-73.987495, 40.75529]
}
},
{
"stopId": "725",
"stopName": "Times Sq-42 St",
"geom": {
"type": "Point",
"coordinates": [-73.987691, 40.755477]
}
},
{
"stopId": "902",
"stopName": "Times Sq-42 St",
"geom": {
"type": "Point",
"coordinates": [-73.986229, 40.755983]
}
},
{
"stopId": "A27",
"stopName": "42 St-Port Authority Bus Terminal",
"geom": {
"type": "Point",
"coordinates": [-73.989735, 40.757308]
}
},
{
"stopId": "R16",
"stopName": "Times Sq-42 St",
"geom": {
"type": "Point",
"coordinates": [-73.986754, 40.754672]
}
}
]
}
}
If you have a station ID (e.g., 127
, the parentStation
of stop 127N
and 127S
), you can query for the stops associated with the transfers
stations, for instance, if you are wanting to match a station with real-time tripUpdate
and vehicle
data, which is keyed by stopId
:
{
transfers(feedIndex: 1, parentStation: "127") {
stopId
}
}
Which yields:
{
"data": {
"transfers": [
{
"stopId": "127N"
},
{
"stopId": "127S"
},
{
"stopId": "725N"
},
{
"stopId": "725S"
},
{
"stopId": "902N"
},
{
"stopId": "902S"
},
{
"stopId": "A27N"
},
{
"stopId": "A27S"
},
{
"stopId": "R16N"
},
{
"stopId": "R16S"
}
]
}
}
From this, you can easily filter the results from the real-time feed to get updates relevant to that particular station. NOTE that this query returns full stop entities, so you can also query for name and geometry.