From aa7700910cb48c90a83266b3ba17dc78559fd6f8 Mon Sep 17 00:00:00 2001 From: Krystof Date: Thu, 14 Nov 2024 04:51:47 +0100 Subject: [PATCH 1/3] feat(be): endpoint version v2 --- apps/backend/src/enums/endpoint-version.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/backend/src/enums/endpoint-version.ts b/apps/backend/src/enums/endpoint-version.ts index d3e0fbec..fe8dca10 100644 --- a/apps/backend/src/enums/endpoint-version.ts +++ b/apps/backend/src/enums/endpoint-version.ts @@ -1,3 +1,4 @@ export enum EndpointVersion { v1 = "1", + v2 = "2", } From 6ca70f9f3212f74a4e828d0ee13649752281fc5b Mon Sep 17 00:00:00 2001 From: Krystof Date: Thu, 14 Nov 2024 04:53:08 +0100 Subject: [PATCH 2/3] refactor(be): departure service versions --- ...ure.service.ts => departure-v1.service.ts} | 17 ++- .../modules/departure/departure-v2.service.ts | 97 +++++++++++++ .../modules/departure/departure.controller.ts | 131 +++++++++++++++++- .../src/modules/departure/departure.module.ts | 5 +- 4 files changed, 234 insertions(+), 16 deletions(-) rename apps/backend/src/modules/departure/{departure.service.ts => departure-v1.service.ts} (88%) create mode 100644 apps/backend/src/modules/departure/departure-v2.service.ts diff --git a/apps/backend/src/modules/departure/departure.service.ts b/apps/backend/src/modules/departure/departure-v1.service.ts similarity index 88% rename from apps/backend/src/modules/departure/departure.service.ts rename to apps/backend/src/modules/departure/departure-v1.service.ts index cb422fe0..f3879238 100644 --- a/apps/backend/src/modules/departure/departure.service.ts +++ b/apps/backend/src/modules/departure/departure-v1.service.ts @@ -8,7 +8,7 @@ import { PrismaService } from "src/modules/prisma/prisma.service"; import { getDelayInSeconds } from "src/utils/delay"; @Injectable() -export class DepartureService { +export class DepartureServiceV1 { constructor( private prisma: PrismaService, private golemioService: GolemioService, @@ -53,15 +53,18 @@ export class DepartureService { const searchParams = new URLSearchParams( allPlatformIds .map((id) => ["ids", id]) - .concat([ - ["skip", "canceled"], - ["mode", "departures"], - ["order", "real"], - ]), + .concat( + Object.entries({ + skip: "canceled", + mode: "departures", + order: "real", + minutesAfter: String(24 * 60), + }), + ), ); const res = await this.golemioService.getGolemioData( - `/v2/pid/departureboards?minutesAfter=600&${searchParams.toString()}`, + `/v2/pid/departureboards?${searchParams}`, ); if (!res.ok) { diff --git a/apps/backend/src/modules/departure/departure-v2.service.ts b/apps/backend/src/modules/departure/departure-v2.service.ts new file mode 100644 index 00000000..036e070c --- /dev/null +++ b/apps/backend/src/modules/departure/departure-v2.service.ts @@ -0,0 +1,97 @@ +import { Injectable } from "@nestjs/common"; +import { unique } from "radash"; + +import { departureBoardsSchema } from "src/modules/departure/schema/departure-boards.schema"; +import type { DepartureSchema } from "src/modules/departure/schema/departure.schema"; +import { GolemioService } from "src/modules/golemio/golemio.service"; +import { PrismaService } from "src/modules/prisma/prisma.service"; +import { getDelayInSeconds } from "src/utils/delay"; + +@Injectable() +export class DepartureServiceV2 { + constructor( + private prisma: PrismaService, + private golemioService: GolemioService, + ) {} + + async getDepartures(args: { + stopIds: string[]; + platformIds: string[]; + metroOnly: boolean; + }): Promise { + const dbPlatforms = ( + await this.prisma.platform.findMany({ + select: { id: true }, + where: { + id: { in: args.platformIds }, + ...(args.metroOnly ? { isMetro: true } : {}), + }, + }) + ).map((platform) => platform.id); + + const stopPlatforms = ( + await this.prisma.stop.findMany({ + select: { + platforms: { + select: { id: true }, + where: { ...(args.metroOnly ? { isMetro: true } : {}) }, + }, + }, + where: { id: { in: args.stopIds } }, + }) + ).flatMap((stop) => stop.platforms.map((platform) => platform.id)); + + const allPlatformIds = unique([...dbPlatforms, ...stopPlatforms]).slice( + 0, + 100, + ); + + if (allPlatformIds.length === 0) { + return []; + } + + const searchParams = new URLSearchParams( + allPlatformIds + .map((id) => ["ids", id]) + .concat( + Object.entries({ + skip: "canceled", + mode: "departures", + order: "real", + minutesBefore: String(5), + minutesAfter: String(10 * 60), + }), + ), + ); + + const res = await this.golemioService.getGolemioData( + `/v2/pid/departureboards&${searchParams.toString()}`, + ); + + if (!res.ok) { + throw new Error( + `Failed to fetch departure data: ${res.status} ${res.statusText}`, + ); + } + + const json = await res.json(); + const parsed = departureBoardsSchema.safeParse(json); + + if (!parsed.success) { + throw new Error(parsed.error.message); + } + + const parsedDepartures = parsed.data.departures.map((departure) => { + return { + departure: departure.departure_timestamp, + delay: getDelayInSeconds(departure.delay), + headsign: departure.trip.headsign, + route: departure.route.short_name, + platformId: departure.stop.id, + platformCode: departure.stop.platform_code, + }; + }); + + return parsedDepartures; + } +} diff --git a/apps/backend/src/modules/departure/departure.controller.ts b/apps/backend/src/modules/departure/departure.controller.ts index 37c7c404..d0b97fc4 100644 --- a/apps/backend/src/modules/departure/departure.controller.ts +++ b/apps/backend/src/modules/departure/departure.controller.ts @@ -9,13 +9,14 @@ import { Version, VERSION_NEUTRAL, } from "@nestjs/common"; -import { ApiTags } from "@nestjs/swagger"; +import { ApiOperation, ApiTags } from "@nestjs/swagger"; import { z } from "zod"; import { QUERY_IDS_COUNT_MAX } from "src/constants/constants"; -import { ApiQueries } from "src/decorators/swagger.decorator"; +import { ApiDescription, ApiQueries } from "src/decorators/swagger.decorator"; import { EndpointVersion } from "src/enums/endpoint-version"; -import { DepartureService } from "src/modules/departure/departure.service"; +import { DepartureServiceV1 } from "src/modules/departure/departure-v1.service"; +import { DepartureServiceV2 } from "src/modules/departure/departure-v2.service"; import { departureSchema, type DepartureSchema, @@ -30,10 +31,13 @@ import { toArray } from "src/utils/array.utils"; @UseInterceptors(CacheInterceptor, LogInterceptor) @CacheTTL(4 * 1000) export class DepartureController { - constructor(private readonly departureService: DepartureService) {} + constructor( + private readonly departureServiceV1: DepartureServiceV1, + private readonly departureServiceV2: DepartureServiceV2, + ) {} @Get() - @Version([VERSION_NEUTRAL, EndpointVersion.v1]) + @Version([VERSION_NEUTRAL]) @ApiQueries([ metroOnlyQuery, { @@ -53,6 +57,12 @@ export class DepartureController { required: false, }, ]) + @ApiOperation({ + deprecated: true, + }) + @ApiDescription({ + deprecated: true, + }) async getDepartures(@Query() query): Promise { const schema = z.object({ metroOnly: metroOnlySchema, @@ -75,7 +85,59 @@ export class DepartureController { ); } - const departures = await this.departureService.getDepartures({ + const departures = await this.departureServiceV1.getDepartures({ + stopIds: parsedQuery.stop, + platformIds: parsedQuery.platform, + metroOnly: parsedQuery.metroOnly, + }); + + return departureSchema.array().parse(departures); + } + + @Get() + @Version([EndpointVersion.v1]) + @ApiQueries([ + metroOnlyQuery, + { + name: "platform[]", + description: "Platform IDs", + type: String, + isArray: true, + allowEmptyValue: true, + required: false, + }, + { + name: "stop[]", + description: "Stop IDs", + type: String, + isArray: true, + allowEmptyValue: true, + required: false, + }, + ]) + async getDeparturesV1(@Query() query): Promise { + const schema = z.object({ + metroOnly: metroOnlySchema, + platform: z.string().array().optional().default([]), + stop: z.string().array().optional().default([]), + }); + const parsed = schema.safeParse(query); + if (!parsed.success) { + throw new HttpException( + "Invalid query params", + HttpStatus.BAD_REQUEST, + ); + } + const parsedQuery = parsed.data; + + if (parsedQuery.platform.length + parsedQuery.stop.length === 0) { + throw new HttpException( + "At least one platform or stop ID must be provided", + HttpStatus.BAD_REQUEST, + ); + } + + const departures = await this.departureServiceV1.getDepartures({ stopIds: parsedQuery.stop, platformIds: parsedQuery.platform, metroOnly: parsedQuery.metroOnly, @@ -86,6 +148,9 @@ export class DepartureController { @Get("/platform") @Version([VERSION_NEUTRAL, EndpointVersion.v1]) + @ApiDescription({ + deprecated: true, + }) async getDeparturesByPlatform(@Query("id") id): Promise { const platformSchema = z .string() @@ -101,7 +166,7 @@ export class DepartureController { ); } - const departures = await this.departureService.getDepartures({ + const departures = await this.departureServiceV1.getDepartures({ stopIds: [], platformIds: parsed.data, metroOnly: false, @@ -109,4 +174,56 @@ export class DepartureController { return departureSchema.array().parse(departures); } + + @Get() + @Version([EndpointVersion.v2]) + @ApiQueries([ + metroOnlyQuery, + { + name: "platform[]", + description: "Platform IDs", + type: String, + isArray: true, + allowEmptyValue: true, + required: false, + }, + { + name: "stop[]", + description: "Stop IDs", + type: String, + isArray: true, + allowEmptyValue: true, + required: false, + }, + ]) + async getDeparturesV2(@Query() query): Promise { + const schema = z.object({ + metroOnly: metroOnlySchema, + platform: z.string().array().optional().default([]), + stop: z.string().array().optional().default([]), + }); + const parsed = schema.safeParse(query); + if (!parsed.success) { + throw new HttpException( + "Invalid query params", + HttpStatus.BAD_REQUEST, + ); + } + const parsedQuery = parsed.data; + + if (parsedQuery.platform.length + parsedQuery.stop.length === 0) { + throw new HttpException( + "At least one platform or stop ID must be provided", + HttpStatus.BAD_REQUEST, + ); + } + + const departures = await this.departureServiceV2.getDepartures({ + stopIds: parsedQuery.stop, + platformIds: parsedQuery.platform, + metroOnly: parsedQuery.metroOnly, + }); + + return departureSchema.array().parse(departures); + } } diff --git a/apps/backend/src/modules/departure/departure.module.ts b/apps/backend/src/modules/departure/departure.module.ts index 8190d8c7..c7cfd7d3 100644 --- a/apps/backend/src/modules/departure/departure.module.ts +++ b/apps/backend/src/modules/departure/departure.module.ts @@ -1,12 +1,13 @@ import { Module } from "@nestjs/common"; +import { DepartureServiceV1 } from "src/modules/departure/departure-v1.service"; +import { DepartureServiceV2 } from "src/modules/departure/departure-v2.service"; import { DepartureController } from "src/modules/departure/departure.controller"; -import { DepartureService } from "src/modules/departure/departure.service"; import { GolemioService } from "src/modules/golemio/golemio.service"; @Module({ controllers: [DepartureController], - providers: [DepartureService, GolemioService], + providers: [DepartureServiceV1, DepartureServiceV2, GolemioService], imports: [], }) export class DepartureModule {} From 86f3d0dc8d6aca4793975b797a842bbc0be2813c Mon Sep 17 00:00:00 2001 From: Krystof Date: Thu, 14 Nov 2024 04:54:10 +0100 Subject: [PATCH 3/3] refactor(be): deprecate old endpoints --- .../modules/platform/platform.controller.ts | 118 +++++++++++++++++- .../src/modules/stop/stop.controller.ts | 18 ++- 2 files changed, 132 insertions(+), 4 deletions(-) diff --git a/apps/backend/src/modules/platform/platform.controller.ts b/apps/backend/src/modules/platform/platform.controller.ts index c4de5a5f..dc549423 100644 --- a/apps/backend/src/modules/platform/platform.controller.ts +++ b/apps/backend/src/modules/platform/platform.controller.ts @@ -40,9 +40,10 @@ export class PlatformController { constructor(private readonly platformService: PlatformService) {} @Get("/all") - @Version([VERSION_NEUTRAL, EndpointVersion.v1]) + @Version([VERSION_NEUTRAL]) @ApiDescription({ summary: "List of all platforms", + deprecated: true, }) @ApiQuery(metroOnlyQuery) async getAllPlatforms(@Query() query): Promise { @@ -64,8 +65,33 @@ export class PlatformController { return platformSchema.array().parse(platforms); } + @Get("/all") + @Version([EndpointVersion.v1]) + @ApiDescription({ + summary: "List of all platforms", + }) + @ApiQuery(metroOnlyQuery) + async getAllPlatformsV1(@Query() query): Promise { + const schema = z.object({ + metroOnly: metroOnlySchema, + }); + const parsed = schema.safeParse(query); + if (!parsed.success) { + throw new HttpException( + parsed.error.format(), + HttpStatus.BAD_REQUEST, + ); + } + + const platforms = await this.platformService.getAllPlatforms( + parsed.data, + ); + + return platformSchema.array().parse(platforms); + } + @Get("/closest") - @Version([VERSION_NEUTRAL, EndpointVersion.v1]) + @Version([VERSION_NEUTRAL]) @ApiDescription({ description: ` ⚠️ _For better privacy consider using \`/in-box\`_ @@ -74,6 +100,7 @@ export class PlatformController { Sort platforms by distance to a given location. Location may be saved in logs. `, summary: "List of platforms sorted by distance to a given location", + deprecated: true, }) @ApiQueries([ metroOnlyQuery, @@ -113,10 +140,60 @@ Sort platforms by distance to a given location. Location may be saved in logs. return platformWithDistanceSchema.array().parse(platforms); } + @Get("/closest") + @Version([EndpointVersion.v1]) + @ApiDescription({ + description: ` +⚠️ _For better privacy consider using \`/in-box\`_ + + +Sort platforms by distance to a given location. Location may be saved in logs. +`, + summary: "List of platforms sorted by distance to a given location", + }) + @ApiQueries([ + metroOnlyQuery, + latitudeQuery, + longitudeQuery, + { + name: "count", + type: Number, + required: false, + example: 100, + description: "number of platforms to return, default is `0` (all)", + }, + ]) + async getPlatformsByDistanceV1( + @Query() query, + ): Promise { + const schema = z.object({ + latitude: z.coerce.number(), + longitude: z.coerce.number(), + count: z.coerce.number().int().nonnegative().default(0), + metroOnly: metroOnlySchema, + }); + + const parsed = schema.safeParse(query); + + if (!parsed.success) { + throw new HttpException( + parsed.error.format(), + HttpStatus.BAD_REQUEST, + ); + } + + const platforms = await this.platformService.getPlatformsByDistance( + parsed.data, + ); + + return platformWithDistanceSchema.array().parse(platforms); + } + @Get("/in-box") - @Version([VERSION_NEUTRAL, EndpointVersion.v1]) + @Version([VERSION_NEUTRAL]) @ApiDescription({ summary: "List of platforms within a given bounding box", + deprecated: true, }) @ApiQueries([metroOnlyQuery, ...boundingBoxQuery]) async getPlatformsInBoundingBox(@Query() query): Promise { @@ -145,4 +222,39 @@ Sort platforms by distance to a given location. Location may be saved in logs. return platformSchema.array().parse(platforms); } + + @Get("/in-box") + @Version([EndpointVersion.v1]) + @ApiDescription({ + summary: "List of platforms within a given bounding box", + }) + @ApiQueries([metroOnlyQuery, ...boundingBoxQuery]) + async getPlatformsInBoundingBoxV1( + @Query() query, + ): Promise { + const schema = z.object({ + boundingBox: boundingBoxSchema, + metroOnly: metroOnlySchema, + }); + const parsed = schema.safeParse({ + boundingBox: { + latitude: query?.latitude, + longitude: query?.longitude, + }, + metroOnly: query?.metroOnly, + }); + + if (!parsed.success) { + throw new HttpException( + "Invalid query params", + HttpStatus.BAD_REQUEST, + ); + } + + const platforms = await this.platformService.getPlatformsInBoundingBox( + parsed.data, + ); + + return platformSchema.array().parse(platforms); + } } diff --git a/apps/backend/src/modules/stop/stop.controller.ts b/apps/backend/src/modules/stop/stop.controller.ts index c6e17a50..dd068cd0 100644 --- a/apps/backend/src/modules/stop/stop.controller.ts +++ b/apps/backend/src/modules/stop/stop.controller.ts @@ -9,6 +9,7 @@ import { } from "@nestjs/common"; import { ApiQuery, ApiTags } from "@nestjs/swagger"; +import { ApiDescription } from "src/decorators/swagger.decorator"; import { EndpointVersion } from "src/enums/endpoint-version"; import { LogInterceptor } from "src/modules/logger/log.interceptor"; import { StopService } from "src/modules/stop/stop.service"; @@ -21,8 +22,11 @@ export class StopController { constructor(private readonly stopService: StopService) {} @Get("/all") - @Version([VERSION_NEUTRAL, EndpointVersion.v1]) + @Version([VERSION_NEUTRAL]) @ApiQuery(metroOnlyQuery) + @ApiDescription({ + deprecated: true, + }) async getAllStops( @Query("metroOnly") metroOnlyQuery: unknown, @@ -31,4 +35,16 @@ export class StopController { return this.stopService.getAll({ metroOnly }); } + + @Get("/all") + @Version([EndpointVersion.v1]) + @ApiQuery(metroOnlyQuery) + async getAllStopsV1( + @Query("metroOnly") + metroOnlyQuery: unknown, + ) { + const metroOnly: boolean = metroOnlyQuery === "true"; + + return this.stopService.getAll({ metroOnly }); + } }