Merge pull request #42 from brendan-ch/feat/graphql-parking-data

[INT-64] feat/graphql-parking-data
This commit is contained in:
2025-04-16 17:30:40 -07:00
committed by GitHub
17 changed files with 360 additions and 28 deletions

View File

@@ -1,3 +1,6 @@
# The Interchange system schema.
# Note how Passio ID and parking ID are abstracted away
# from the endpoints.
type System { type System {
id: ID! id: ID!
name: String! name: String!
@@ -8,7 +11,11 @@ type System {
shuttles: [Shuttle!] shuttles: [Shuttle!]
shuttle(id: ID): Shuttle shuttle(id: ID): Shuttle
# TODO: Implement these in system resolvers parkingSystem: ParkingSystem
}
type ParkingSystem {
systemId: ID!
parkingStructures: [ParkingStructure!] parkingStructures: [ParkingStructure!]
parkingStructure(id: ID): ParkingStructure parkingStructure(id: ID): ParkingStructure
} }
@@ -84,6 +91,8 @@ type Query {
isNotificationScheduled(input: NotificationInput!): Boolean isNotificationScheduled(input: NotificationInput!): Boolean
secondsThresholdForNotification(input: NotificationInput!): Int secondsThresholdForNotification(input: NotificationInput!): Int
schemaVersion: ID!
} }
# Mutations # Mutations

View File

@@ -8,9 +8,11 @@ import { StopResolvers } from "./resolvers/StopResolvers";
import { ShuttleResolvers } from "./resolvers/ShuttleResolvers"; import { ShuttleResolvers } from "./resolvers/ShuttleResolvers";
import { RouteResolvers } from "./resolvers/RouteResolvers"; import { RouteResolvers } from "./resolvers/RouteResolvers";
import { MutationResolvers } from "./resolvers/MutationResolvers"; import { MutationResolvers } from "./resolvers/MutationResolvers";
import { ParkingSystemResolvers } from "./resolvers/ParkingSystemResolvers";
export const MergedResolvers: Resolvers<ServerContext> = { export const MergedResolvers: Resolvers<ServerContext> = {
...QueryResolvers, ...QueryResolvers,
...ParkingSystemResolvers,
...SystemResolvers, ...SystemResolvers,
...RouteResolvers, ...RouteResolvers,
...ShuttleResolvers, ...ShuttleResolvers,

View File

@@ -7,6 +7,12 @@ import { ShuttleGetterSetterRepository } from "../repositories/ShuttleGetterSett
import { InMemoryNotificationRepository } from "../repositories/InMemoryNotificationRepository"; import { InMemoryNotificationRepository } from "../repositories/InMemoryNotificationRepository";
import { AppleNotificationSender } from "../notifications/senders/AppleNotificationSender"; import { AppleNotificationSender } from "../notifications/senders/AppleNotificationSender";
import { ApiBasedShuttleRepositoryLoader } from "../loaders/shuttle/ApiBasedShuttleRepositoryLoader"; import { ApiBasedShuttleRepositoryLoader } from "../loaders/shuttle/ApiBasedShuttleRepositoryLoader";
import { ParkingGetterSetterRepository } from "../repositories/ParkingGetterSetterRepository";
import { InMemoryParkingRepository } from "../repositories/InMemoryParkingRepository";
import {
buildParkingRepositoryLoaderIfExists,
ParkingRepositoryLoaderBuilderArguments
} from "../loaders/parking/buildParkingRepositoryLoaderIfExists";
export interface InterchangeSystemBuilderArguments { export interface InterchangeSystemBuilderArguments {
name: string; name: string;
@@ -20,16 +26,23 @@ export interface InterchangeSystemBuilderArguments {
* ID for fetching shuttle data from the Passio GO! system. * ID for fetching shuttle data from the Passio GO! system.
*/ */
passioSystemId: string; passioSystemId: string;
/**
* ID for the parking repository ID in the codebase.
*/
parkingSystemId?: string;
} }
export class InterchangeSystem { export class InterchangeSystem {
constructor( private constructor(
public name: string, public name: string,
public id: string, public id: string,
public shuttleTimedDataLoader: TimedApiBasedRepositoryLoader, public shuttleTimedDataLoader: TimedApiBasedRepositoryLoader,
public shuttleRepository: ShuttleGetterSetterRepository, public shuttleRepository: ShuttleGetterSetterRepository,
public notificationScheduler: ETANotificationScheduler, public notificationScheduler: ETANotificationScheduler,
public notificationRepository: NotificationRepository, public notificationRepository: NotificationRepository,
public parkingTimedDataLoader: TimedApiBasedRepositoryLoader | null,
public parkingRepository: ParkingGetterSetterRepository | null,
) { ) {
} }
@@ -61,6 +74,9 @@ export class InterchangeSystem {
); );
notificationScheduler.startListeningForUpdates(); notificationScheduler.startListeningForUpdates();
let { parkingRepository, timedParkingLoader } = this.buildParkingLoaderAndRepository(args.parkingSystemId);
timedParkingLoader?.start();
return new InterchangeSystem( return new InterchangeSystem(
args.name, args.name,
args.id, args.id,
@@ -68,6 +84,8 @@ export class InterchangeSystem {
shuttleRepository, shuttleRepository,
notificationScheduler, notificationScheduler,
notificationRepository, notificationRepository,
timedParkingLoader,
parkingRepository,
); );
} }
@@ -100,6 +118,9 @@ export class InterchangeSystem {
); );
notificationScheduler.startListeningForUpdates(); notificationScheduler.startListeningForUpdates();
let { parkingRepository, timedParkingLoader } = this.buildParkingLoaderAndRepository(args.parkingSystemId);
// Timed parking loader is not started
return new InterchangeSystem( return new InterchangeSystem(
args.name, args.name,
args.id, args.id,
@@ -107,6 +128,32 @@ export class InterchangeSystem {
shuttleRepository, shuttleRepository,
notificationScheduler, notificationScheduler,
notificationRepository, notificationRepository,
timedParkingLoader,
parkingRepository,
); );
} }
private static buildParkingLoaderAndRepository(id?: string) {
if (id === undefined) {
return { parkingRepository: null, timedParkingLoader: null };
}
let parkingRepository: ParkingGetterSetterRepository | null = new InMemoryParkingRepository();
const loaderBuilderArguments: ParkingRepositoryLoaderBuilderArguments = {
id,
repository: parkingRepository,
};
let parkingLoader = buildParkingRepositoryLoaderIfExists(
loaderBuilderArguments,
);
let timedParkingLoader = null;
if (parkingLoader == null) {
parkingRepository = null;
} else {
timedParkingLoader = new TimedApiBasedRepositoryLoader(parkingLoader);
}
return { parkingRepository, timedParkingLoader };
}
} }

View File

@@ -3,8 +3,11 @@ import { ApolloServer } from "@apollo/server";
import { startStandaloneServer } from "@apollo/server/standalone"; import { startStandaloneServer } from "@apollo/server/standalone";
import { MergedResolvers } from "./MergedResolvers"; import { MergedResolvers } from "./MergedResolvers";
import { ServerContext } from "./ServerContext"; import { ServerContext } from "./ServerContext";
import { loadShuttleTestData, supportedIntegrationTestSystems } from "./loaders/shuttle/loadShuttleTestData"; import { loadShuttleTestData } from "./loaders/shuttle/loadShuttleTestData";
import { InterchangeSystem, InterchangeSystemBuilderArguments } from "./entities/InterchangeSystem"; import { InterchangeSystem, InterchangeSystemBuilderArguments } from "./entities/InterchangeSystem";
import { ChapmanApiBasedParkingRepositoryLoader } from "./loaders/parking/ChapmanApiBasedParkingRepositoryLoader";
import { supportedIntegrationTestSystems } from "./loaders/supportedIntegrationTestSystems";
import { loadParkingTestData } from "./loaders/parking/loadParkingTestData";
const typeDefs = readFileSync("./schema.graphqls", "utf8"); const typeDefs = readFileSync("./schema.graphqls", "utf8");
@@ -13,6 +16,7 @@ const supportedSystems: InterchangeSystemBuilderArguments[] = [
{ {
id: "1", id: "1",
passioSystemId: "263", passioSystemId: "263",
parkingSystemId: ChapmanApiBasedParkingRepositoryLoader.id,
name: "Chapman University", name: "Chapman University",
} }
] ]
@@ -35,6 +39,9 @@ async function main() {
// TODO: Have loading of different data for different systems in the future // TODO: Have loading of different data for different systems in the future
await loadShuttleTestData(system.shuttleRepository); await loadShuttleTestData(system.shuttleRepository);
if (system.parkingRepository) {
await loadParkingTestData(system.parkingRepository);
}
return system; return system;
} }

View File

@@ -48,7 +48,7 @@ export class ChapmanApiBasedParkingRepositoryLoader implements ParkingRepository
} }
} }
private constructIParkingStructureFromJson(jsonStructure: any) { public constructIParkingStructureFromJson(jsonStructure: any) {
const structureToReturn: IParkingStructure = { const structureToReturn: IParkingStructure = {
capacity: jsonStructure.Capacity, capacity: jsonStructure.Capacity,
coordinates: { coordinates: {
@@ -57,7 +57,7 @@ export class ChapmanApiBasedParkingRepositoryLoader implements ParkingRepository
}, },
id: ChapmanApiBasedParkingRepositoryLoader.generateId(jsonStructure.Address), id: ChapmanApiBasedParkingRepositoryLoader.generateId(jsonStructure.Address),
name: jsonStructure.Name, name: jsonStructure.Name,
spotsAvailable: jsonStructure.CurrentCount, spotsAvailable: jsonStructure.CurrentCount > jsonStructure.Capacity ? jsonStructure.Capacity : jsonStructure.CurrentCount,
address: jsonStructure.Address address: jsonStructure.Address
} }

View File

@@ -1,12 +1,12 @@
import { ParkingGetterSetterRepository } from "../../repositories/ParkingGetterSetterRepository"; import { ParkingGetterSetterRepository } from "../../repositories/ParkingGetterSetterRepository";
import { ChapmanApiBasedParkingRepositoryLoader } from "./ChapmanApiBasedParkingRepositoryLoader"; import { ChapmanApiBasedParkingRepositoryLoader } from "./ChapmanApiBasedParkingRepositoryLoader";
interface ParkingRepositoryBuilderArguments { export interface ParkingRepositoryLoaderBuilderArguments {
id: string; id: string;
repository: ParkingGetterSetterRepository; repository: ParkingGetterSetterRepository;
} }
export function buildParkingRepositoryLoaderIfExists(args: ParkingRepositoryBuilderArguments) { export function buildParkingRepositoryLoaderIfExists(args: ParkingRepositoryLoaderBuilderArguments) {
if (args.id === ChapmanApiBasedParkingRepositoryLoader.id) { if (args.id === ChapmanApiBasedParkingRepositoryLoader.id) {
return new ChapmanApiBasedParkingRepositoryLoader(args.repository); return new ChapmanApiBasedParkingRepositoryLoader(args.repository);
} }

View File

@@ -0,0 +1,33 @@
import { ParkingGetterSetterRepository } from "../../repositories/ParkingGetterSetterRepository";
import { IParkingStructure } from "../../entities/ParkingRepositoryEntities";
const parkingStructures: IParkingStructure[] = [
{
address: "300 E Walnut, Orange, CA 92867",
capacity: 871,
spotsAvailable: 211,
coordinates: {
latitude: 33.7945513,
longitude: -117.8518707,
},
name: "Anderson Structure",
id: "1",
},
{
address: "200 W Sycamore Ave, Orange, CA 92866-1053",
capacity: 692,
spotsAvailable: 282,
coordinates: {
latitude: 33.792937,
longitude: -117.854782
},
name: "Barrera",
id: "2",
}
];
export async function loadParkingTestData(repository: ParkingGetterSetterRepository) {
await Promise.all(parkingStructures.map(async structure => {
await repository.addOrUpdateParkingStructure(structure);
}))
}

View File

@@ -1,15 +1,7 @@
// Mock data // Mock data
import { IEta, IOrderedStop, IRoute, IShuttle, IStop } from "../../entities/ShuttleRepositoryEntities"; import { IEta, IOrderedStop, IRoute, IShuttle, IStop } from "../../entities/ShuttleRepositoryEntities";
import { ShuttleGetterSetterRepository } from "../../repositories/ShuttleGetterSetterRepository"; import { ShuttleGetterSetterRepository } from "../../repositories/ShuttleGetterSetterRepository";
import { InterchangeSystemBuilderArguments } from "../../entities/InterchangeSystem"; import { supportedIntegrationTestSystems } from "../supportedIntegrationTestSystems";
export const supportedIntegrationTestSystems: InterchangeSystemBuilderArguments[] = [
{
id: "1",
name: "Chapman University",
passioSystemId: "263",
},
];
const redRoutePolylineCoordinates = [ const redRoutePolylineCoordinates = [
{ {

View File

@@ -0,0 +1,11 @@
import { InterchangeSystemBuilderArguments } from "../entities/InterchangeSystem";
import { ChapmanApiBasedParkingRepositoryLoader } from "./parking/ChapmanApiBasedParkingRepositoryLoader";
export const supportedIntegrationTestSystems: InterchangeSystemBuilderArguments[] = [
{
id: "1",
name: "Chapman University",
passioSystemId: "263",
parkingSystemId: ChapmanApiBasedParkingRepositoryLoader.id,
},
];

View File

@@ -0,0 +1,29 @@
import { Resolvers } from "../generated/graphql";
import { ServerContext } from "../ServerContext";
export const ParkingSystemResolvers: Resolvers<ServerContext> = {
ParkingSystem: {
parkingStructures: async (parent, _args, contextValue, _info) => {
const system = contextValue.findSystemById(parent.systemId);
if (!system) {
return [];
}
const parkingRepository = system.parkingRepository;
if (!parkingRepository) return [];
return await parkingRepository.getParkingStructures();
},
parkingStructure: async (parent, args, contextValue, info) => {
if (!args.id) return null;
const system = contextValue.findSystemById(parent.systemId);
if (!system) {
return null;
}
const parkingRepository = system.parkingRepository;
if (!parkingRepository) return null;
return await parkingRepository.getParkingStructureById(args.id);
},
}
}

View File

@@ -1,6 +1,8 @@
import { ServerContext } from "../ServerContext"; import { ServerContext } from "../ServerContext";
import { Resolvers } from "../generated/graphql"; import { Resolvers } from "../generated/graphql";
const GRAPHQL_SCHEMA_VERSION = "1";
export const QueryResolvers: Resolvers<ServerContext> = { export const QueryResolvers: Resolvers<ServerContext> = {
Query: { Query: {
systems: async (_parent, args, contextValue, _info) => { systems: async (_parent, args, contextValue, _info) => {
@@ -46,5 +48,8 @@ export const QueryResolvers: Resolvers<ServerContext> = {
return null; return null;
}, },
schemaVersion: async (_parent, _args, _contextValue, _info) => {
return GRAPHQL_SCHEMA_VERSION;
},
}, },
} }

View File

@@ -78,11 +78,17 @@ export const SystemResolvers: Resolvers<ServerContext> = {
return await system.shuttleRepository.getShuttles(); return await system.shuttleRepository.getShuttles();
}, },
parkingStructures: async (_parent, _args, _contextValue, _info) => { parkingSystem: async (parent, _args, contextValue, _info) => {
return []; const system = contextValue.findSystemById(parent.id);
}, if (!system) {
parkingStructure: async (_parent, _args, _contextValue, _info) => { return null;
return null; }
if (!system.parkingRepository) return null;
return {
systemId: parent.id,
};
}, },
}, },
} }

View File

@@ -42,8 +42,6 @@ describe("ChapmanApiBasedParkingRepositoryLoader", () => {
}); });
}); });
describe("fetchAndUpdateParkingStructures", () => { describe("fetchAndUpdateParkingStructures", () => {
it("fetches and update parking structures with unique IDs", async () => { it("fetches and update parking structures with unique IDs", async () => {
updateGlobalFetchMockJson(chapmanParkingStructureData); updateGlobalFetchMockJson(chapmanParkingStructureData);
@@ -89,4 +87,20 @@ describe("ChapmanApiBasedParkingRepositoryLoader", () => {
}); });
}) })
}); });
describe("constructIParkingStructureFromJson", () => {
it("normalizes the spots available if it's over the capacity", async () => {
const sampleJsonStructure: any = {
Capacity: 10,
Latitude: 1,
Longitude: 1,
Address: "300 E Walnut, Orange, CA 92867",
Name: "Anderson Structure",
CurrentCount: 11,
};
const returnedStructure = loader.constructIParkingStructureFromJson(sampleJsonStructure);
expect(returnedStructure.spotsAvailable).toEqual(returnedStructure.capacity);
});
});
}); });

View File

@@ -0,0 +1,139 @@
import { beforeEach, describe, expect, it } from "@jest/globals";
import { generateParkingStructures } from "../testHelpers/mockDataGenerators";
import { setupTestServerContext, setupTestServerHolder } from "../testHelpers/apolloTestServerHelpers";
import { InterchangeSystem } from "../../src/entities/InterchangeSystem";
import assert = require("node:assert");
describe("ParkingSystemResolver", () => {
const holder = setupTestServerHolder();
const context = setupTestServerContext();
let mockSystem: InterchangeSystem;
beforeEach(async () => {
mockSystem = context.systems[0];
});
async function getResponseFromQueryNeedingSystemId(query: string) {
return await holder.testServer.executeOperation({
query,
variables: {
systemId: mockSystem.id,
},
}, {
contextValue: context,
});
}
describe("parkingStructures", () => {
const query = `
query GetParkingStructuresBySystem($systemId: ID!) {
system(id: $systemId) {
parkingSystem {
parkingStructures {
name
id
capacity
spotsAvailable
coordinates {
latitude
longitude
}
address
}
}
}
}
`
it("gets parking structures associated with the system id", async () => {
const expectedParkingStructures = generateParkingStructures();
await Promise.all(expectedParkingStructures.map(async (structure) => {
await context.systems[0].parkingRepository?.addOrUpdateParkingStructure(structure);
}));
const response = await getResponseFromQueryNeedingSystemId(query);
assert(response.body.kind === "single");
expect(response.body.singleResult.errors).toBeUndefined();
const parkingStructures = (response.body.singleResult.data as any).system.parkingSystem.parkingStructures;
expect(parkingStructures).toEqual(expectedParkingStructures);
});
it("returns a blank array if there are no parking structures", async () => {
const response = await getResponseFromQueryNeedingSystemId(query);
assert(response.body.kind === "single");
expect(response.body.singleResult.errors).toBeUndefined();
const parkingStructures = (response.body.singleResult.data as any).system.parkingSystem.parkingStructures;
expect(parkingStructures).toHaveLength(0);
});
});
describe("parkingStructure", () => {
async function getResponseForParkingStructureQuery(parkingStructureId: string) {
const query = `
query GetParkingStructureBySystem($systemId: ID!, $parkingStructureId: ID!) {
system(id: $systemId) {
parkingSystem {
parkingStructure(id: $parkingStructureId) {
name
id
capacity
spotsAvailable
coordinates {
latitude
longitude
}
address
}
}
}
}
`;
return await holder.testServer.executeOperation({
query,
variables: {
systemId: mockSystem.id,
parkingStructureId,
}
}, {
contextValue: context
});
}
it("returns the correct parking structure given the id", async () => {
const generatedParkingStructures = generateParkingStructures();
await Promise.all(generatedParkingStructures.map(async (structure) => {
await context.systems[0].parkingRepository?.addOrUpdateParkingStructure(structure);
}));
const expectedParkingStructure = generatedParkingStructures[1];
const response = await getResponseForParkingStructureQuery(expectedParkingStructure.id);
assert(response.body.kind === "single");
expect(response.body.singleResult.errors).toBeUndefined();
const parkingStructure = (response.body.singleResult.data as any).system.parkingSystem.parkingStructure;
expect(parkingStructure).toEqual(expectedParkingStructure);
});
it("returns null if there is no matching parking structure", async () => {
const generatedParkingStructures = generateParkingStructures();
await Promise.all(generatedParkingStructures.map(async (structure) => {
await context.systems[0].parkingRepository?.addOrUpdateParkingStructure(structure);
}));
const nonexistentId = generatedParkingStructures[0].id + "12345";
const response = await getResponseForParkingStructureQuery(nonexistentId);
assert(response.body.kind === "single");
expect(response.body.singleResult.errors).toBeUndefined();
const parkingStructure = (response.body.singleResult.data as any).system.parkingSystem.parkingStructure;
expect(parkingStructure).toBeNull();
});
});
});

View File

@@ -1,6 +1,11 @@
import { beforeEach, describe, expect, it } from "@jest/globals"; import { beforeEach, describe, expect, it } from "@jest/globals";
import { setupTestServerContext, setupTestServerHolder } from "../testHelpers/apolloTestServerHelpers"; import { setupTestServerContext, setupTestServerHolder } from "../testHelpers/apolloTestServerHelpers";
import { generateMockRoutes, generateMockShuttles, generateMockStops } from "../testHelpers/mockDataGenerators"; import {
generateMockRoutes,
generateMockShuttles,
generateMockStops,
generateParkingStructures
} from "../testHelpers/mockDataGenerators";
import { import {
addMockRouteToRepository, addMockRouteToRepository,
addMockShuttleToRepository, addMockShuttleToRepository,
@@ -308,4 +313,5 @@ describe("SystemResolvers", () => {
expect(shuttles.length === expectedShuttles.length); expect(shuttles.length === expectedShuttles.length);
}); });
}); });
}); });

View File

@@ -1,12 +1,12 @@
import { readFileSync } from "fs"; import { readFileSync } from "fs";
import { ApolloServer } from "@apollo/server"; import { ApolloServer } from "@apollo/server";
import { MergedResolvers } from "../../src/MergedResolvers"; import { MergedResolvers } from "../../src/MergedResolvers";
import { UnoptimizedInMemoryShuttleRepository } from "../../src/repositories/UnoptimizedInMemoryShuttleRepository";
import { beforeEach } from "@jest/globals"; import { beforeEach } from "@jest/globals";
import { ServerContext } from "../../src/ServerContext"; import { ServerContext } from "../../src/ServerContext";
import { ETANotificationScheduler } from "../../src/notifications/schedulers/ETANotificationScheduler";
import { InMemoryNotificationRepository } from "../../src/repositories/InMemoryNotificationRepository";
import { InterchangeSystem } from "../../src/entities/InterchangeSystem"; import { InterchangeSystem } from "../../src/entities/InterchangeSystem";
import {
ChapmanApiBasedParkingRepositoryLoader
} from "../../src/loaders/parking/ChapmanApiBasedParkingRepositoryLoader";
function setUpTestServer() { function setUpTestServer() {
@@ -20,7 +20,10 @@ function setUpTestServer() {
} }
const systemInfoForTesting = { const systemInfoForTesting = {
id: "1", name: "Chapman University", passioSystemId: "263" id: "1",
name: "Chapman University",
passioSystemId: "263",
parkingSystemId: ChapmanApiBasedParkingRepositoryLoader.id,
}; };
export function buildSystemForTesting() { export function buildSystemForTesting() {

View File

@@ -1,8 +1,37 @@
import { IEta, IOrderedStop, IRoute, IShuttle, IStop } from "../../src/entities/ShuttleRepositoryEntities"; import { IEta, IOrderedStop, IRoute, IShuttle, IStop } from "../../src/entities/ShuttleRepositoryEntities";
import { IParkingStructure } from "../../src/entities/ParkingRepositoryEntities";
// Use a single set of generators in case any of the // Use a single set of generators in case any of the
// interfaces change in the future // interfaces change in the future
export function generateParkingStructures(): IParkingStructure[] {
// Copied from debugger
return [
{
"capacity": 871,
"coordinates": {
"latitude": 33.7945513,
"longitude": -117.8518707
},
"id": "b0723baf8a6b8bcc37c821473373049e",
"name": "Anderson Structure",
"spotsAvailable": 163,
"address": "300 E Walnut, Orange, CA 92867"
},
{
"capacity": 692,
"coordinates": {
"latitude": 33.792937,
"longitude": -117.854782
},
"id": "81b9e1ed004cf6def2e6c568aaf79ece",
"name": "Barrera",
"spotsAvailable": 179,
"address": "200 W Sycamore Ave, Orange, CA 92866-1053"
}
];
}
export function generateMockShuttles(): IShuttle[] { export function generateMockShuttles(): IShuttle[] {
return [ return [
{ {