diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index f9a3231..da8f3fb 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -158,7 +158,7 @@ paths: $ref: "#/components/responses/InternalServerError" /boroughs/{boroughId}/community-districts/{communityDistrictId}/capital-projects/{z}/{x}/{y}.pbf: get: - summary: 🚧 Mapbox Vector Tiles for capital projects intersecting a community district + summary: Mapbox Vector Tiles for capital projects intersecting a community district operationId: findCapitalProjectTilesByBoroughIdCommunityDistrictId tags: - MVT diff --git a/src/borough/borough.controller.ts b/src/borough/borough.controller.ts index d34bfcb..ba6fe99 100644 --- a/src/borough/borough.controller.ts +++ b/src/borough/borough.controller.ts @@ -4,15 +4,19 @@ import { Injectable, Param, Query, + Res, UseFilters, UsePipes, } from "@nestjs/common"; import { BoroughService } from "./borough.service"; +import { Response } from "express"; import { + FindCapitalProjectTilesByBoroughIdCommunityDistrictIdPathParams, FindCapitalProjectsByBoroughIdCommunityDistrictIdPathParams, FindCapitalProjectsByBoroughIdCommunityDistrictIdQueryParams, FindCommunityDistrictGeoJsonByBoroughIdCommunityDistrictIdPathParams, FindCommunityDistrictsByBoroughIdPathParams, + findCapitalProjectTilesByBoroughIdCommunityDistrictIdPathParamsSchema, findCapitalProjectsByBoroughIdCommunityDistrictIdPathParamsSchema, findCapitalProjectsByBoroughIdCommunityDistrictIdQueryParamsSchema, findCommunityDistrictGeoJsonByBoroughIdCommunityDistrictIdPathParamsSchema, @@ -86,4 +90,25 @@ export class BoroughController { { ...pathParams, ...queryParams }, ); } + + @UsePipes( + new ZodTransformPipe( + findCapitalProjectTilesByBoroughIdCommunityDistrictIdPathParamsSchema, + ), + ) + @Get( + "/:boroughId/community-districts/:communityDistrictId/capital-projects/:z/:x/:y.pbf", + ) + async findCapitalProjectTilesByBoroughIdCommunityDistrictId( + @Param() + params: FindCapitalProjectTilesByBoroughIdCommunityDistrictIdPathParams, + @Res() res: Response, + ) { + const tiles = + await this.boroughService.findCapitalProjectTilesByBoroughIdCommunityDistrictId( + params, + ); + res.set("Content-Type", "application/x-protobuf"); + res.send(tiles); + } } diff --git a/src/borough/borough.repository.schema.ts b/src/borough/borough.repository.schema.ts index a57a09c..5224be8 100644 --- a/src/borough/borough.repository.schema.ts +++ b/src/borough/borough.repository.schema.ts @@ -3,6 +3,7 @@ import { boroughEntitySchema, communityDistrictEntitySchema, MultiPolygonSchema, + mvtEntitySchema, } from "src/schema"; import { z } from "zod"; @@ -47,3 +48,10 @@ export const findCapitalProjectsByBoroughIdCommunityDistrictIdRepoSchema = export type FindCapitalProjectsByBoroughIdCommunityDistrictIdRepo = z.infer< typeof findCapitalProjectsByBoroughIdCommunityDistrictIdRepoSchema >; + +export const findCapitalProjectTilesByBoroughIdCommunityDistrictIdRepoSchema = + mvtEntitySchema; + +export type FindCapitalProjectTilesByBoroughIdCommunityDistrictIdRepo = z.infer< + typeof findCapitalProjectTilesByBoroughIdCommunityDistrictIdRepoSchema +>; diff --git a/src/borough/borough.repository.ts b/src/borough/borough.repository.ts index f02d573..c81a3a6 100644 --- a/src/borough/borough.repository.ts +++ b/src/borough/borough.repository.ts @@ -7,10 +7,14 @@ import { FindCommunityDistrictsByBoroughIdRepo, FindCapitalProjectsByBoroughIdCommunityDistrictIdRepo, FindCommunityDistrictGeoJsonByBoroughIdCommunityDistrictIdRepo, + FindCapitalProjectTilesByBoroughIdCommunityDistrictIdRepo, } from "./borough.repository.schema"; import { capitalProject, communityDistrict } from "src/schema"; -import { eq, sql, and } from "drizzle-orm"; -import { FindCommunityDistrictGeoJsonByBoroughIdCommunityDistrictIdPathParams } from "src/gen"; +import { eq, sql, and, isNotNull } from "drizzle-orm"; +import { + FindCapitalProjectTilesByBoroughIdCommunityDistrictIdPathParams, + FindCommunityDistrictGeoJsonByBoroughIdCommunityDistrictIdPathParams, +} from "src/gen"; export class BoroughRepository { constructor( @@ -132,4 +136,67 @@ export class BoroughRepository { throw new DataRetrievalException(); } } + + async findCapitalProjectTilesByBoroughIdCommunityDistrictId({ + boroughId, + communityDistrictId, + z, + x, + y, + }: FindCapitalProjectTilesByBoroughIdCommunityDistrictIdPathParams): Promise { + try { + const tile = this.db + .select({ + managingCodeCapitalProjectId: + sql`${capitalProject.managingCode} || ${capitalProject.id}`.as( + `managingCodeCapitalProjectId`, + ), + managingAgency: sql`${capitalProject.managingAgency}`.as( + `managingAgency`, + ), + geom: sql` + CASE + WHEN ${capitalProject.mercatorFillMPoly} && ST_TileEnvelope(${z},${x},${y}) + THEN ST_AsMVTGeom( + ${capitalProject.mercatorFillMPoly}, + ST_TileEnvelope(${z},${x},${y}), + 4096, + 64, + true + ) + WHEN ${capitalProject.mercatorFillMPnt} && ST_TileEnvelope(${z},${x},${y}) + THEN ST_AsMVTGeom( + ${capitalProject.mercatorFillMPnt}, + ST_TileEnvelope(${z},${x},${y}), + 4096, + 64, + true + ) + END`.as("geom"), + }) + .from(capitalProject) + .leftJoin( + communityDistrict, + sql` + ST_Intersects(${communityDistrict.mercatorFill}, ${capitalProject.mercatorFillMPoly}) + OR ST_Intersects(${communityDistrict.mercatorFill}, ${capitalProject.mercatorFillMPnt})`, + ) + .where( + and( + eq(communityDistrict.id, communityDistrictId), + eq(communityDistrict.boroughId, boroughId), + ), + ) + .as("tile"); + const data = await this.db + .select({ + mvt: sql`ST_AsMVT(tile, 'capital-project-fill', 4096, 'geom')`, + }) + .from(tile) + .where(isNotNull(tile.geom)); + return data[0].mvt; + } catch { + throw new DataRetrievalException(); + } + } } diff --git a/src/borough/borough.service.spec.ts b/src/borough/borough.service.spec.ts index e29b6f0..831465f 100644 --- a/src/borough/borough.service.spec.ts +++ b/src/borough/borough.service.spec.ts @@ -6,6 +6,7 @@ import { Test } from "@nestjs/testing"; import { findBoroughsQueryResponseSchema, findCapitalProjectsByBoroughIdCommunityDistrictIdQueryResponseSchema, + findCapitalProjectTilesByBoroughIdCommunityDistrictIdQueryResponseSchema, findCommunityDistrictGeoJsonByBoroughIdCommunityDistrictIdQueryResponseSchema, findCommunityDistrictsByBoroughIdQueryResponseSchema, } from "src/gen"; @@ -153,4 +154,24 @@ describe("Borough service unit", () => { expect(parsedBody.order).toBe("managingCode, capitalProjectId"); }); }); + + describe("findCapitalProjectTilesByBoroughIdCommunityDistrictId", () => { + it("should return an mvt when requesting coordinates", async () => { + const mvt = + await boroughService.findCapitalProjectTilesByBoroughIdCommunityDistrictId( + { + boroughId: "1", + communityDistrictId: "01", + z: 1, + x: 1, + y: 1, + }, + ); + expect(() => + findCapitalProjectTilesByBoroughIdCommunityDistrictIdQueryResponseSchema.parse( + mvt, + ), + ).not.toThrow(); + }); + }); }); diff --git a/src/borough/borough.service.ts b/src/borough/borough.service.ts index 828a354..6a61a57 100644 --- a/src/borough/borough.service.ts +++ b/src/borough/borough.service.ts @@ -5,6 +5,7 @@ import { ResourceNotFoundException } from "src/exception"; import { FindCapitalProjectsByBoroughIdCommunityDistrictIdPathParams, FindCapitalProjectsByBoroughIdCommunityDistrictIdQueryParams, + FindCapitalProjectTilesByBoroughIdCommunityDistrictIdPathParams, } from "src/gen"; import { produce } from "immer"; import { CommunityDistrictGeoJsonEntity } from "./borough.repository.schema"; @@ -100,4 +101,12 @@ export class BoroughService { capitalProjects, }; } + + async findCapitalProjectTilesByBoroughIdCommunityDistrictId( + params: FindCapitalProjectTilesByBoroughIdCommunityDistrictIdPathParams, + ) { + return this.boroughRepository.findCapitalProjectTilesByBoroughIdCommunityDistrictId( + params, + ); + } } diff --git a/src/gen/types/FindCapitalProjectTilesByBoroughIdCommunityDistrictId.ts b/src/gen/types/FindCapitalProjectTilesByBoroughIdCommunityDistrictId.ts new file mode 100644 index 0000000..9b5ebeb --- /dev/null +++ b/src/gen/types/FindCapitalProjectTilesByBoroughIdCommunityDistrictId.ts @@ -0,0 +1,53 @@ +import type { Error } from "./Error"; + +export type FindCapitalProjectTilesByBoroughIdCommunityDistrictIdPathParams = { + /** + * @description A single character numeric string containing the common number used to refer to the borough. Possible values are 1-5. + * @type string + */ + boroughId: string; + /** + * @description The two character numeric string containing the number used to refer to the community district. + * @type string + */ + communityDistrictId: string; + /** + * @description viewport zoom component + * @type integer + */ + z: number; + /** + * @description viewport x component + * @type integer + */ + x: number; + /** + * @description viewport y component + * @type integer + */ + y: number; +}; +/** + * @description A protobuf file formatted as Mapbox Vector Tile + */ +export type FindCapitalProjectTilesByBoroughIdCommunityDistrictId200 = string; +/** + * @description Invalid client request + */ +export type FindCapitalProjectTilesByBoroughIdCommunityDistrictId400 = Error; +/** + * @description Server side error + */ +export type FindCapitalProjectTilesByBoroughIdCommunityDistrictId500 = Error; +/** + * @description A protobuf file formatted as Mapbox Vector Tile + */ +export type FindCapitalProjectTilesByBoroughIdCommunityDistrictIdQueryResponse = + string; +export type FindCapitalProjectTilesByBoroughIdCommunityDistrictIdQuery = { + Response: FindCapitalProjectTilesByBoroughIdCommunityDistrictIdQueryResponse; + PathParams: FindCapitalProjectTilesByBoroughIdCommunityDistrictIdPathParams; + Errors: + | FindCapitalProjectTilesByBoroughIdCommunityDistrictId400 + | FindCapitalProjectTilesByBoroughIdCommunityDistrictId500; +}; diff --git a/src/gen/types/index.ts b/src/gen/types/index.ts index c2de845..e9971bf 100644 --- a/src/gen/types/index.ts +++ b/src/gen/types/index.ts @@ -20,6 +20,7 @@ export * from "./FindCapitalCommitmentsByManagingCodeCapitalProjectId"; export * from "./FindCapitalProjectByManagingCodeCapitalProjectId"; export * from "./FindCapitalProjectGeoJsonByManagingCodeCapitalProjectId"; export * from "./FindCapitalProjectTiles"; +export * from "./FindCapitalProjectTilesByBoroughIdCommunityDistrictId"; export * from "./FindCapitalProjectsByBoroughIdCommunityDistrictId"; export * from "./FindCapitalProjectsByCityCouncilId"; export * from "./FindCityCouncilDistrictGeoJsonByCityCouncilDistrictId"; diff --git a/src/gen/zod/findCapitalProjectTilesByBoroughIdCommunityDistrictIdSchema.ts b/src/gen/zod/findCapitalProjectTilesByBoroughIdCommunityDistrictIdSchema.ts new file mode 100644 index 0000000..06dd3a4 --- /dev/null +++ b/src/gen/zod/findCapitalProjectTilesByBoroughIdCommunityDistrictIdSchema.ts @@ -0,0 +1,41 @@ +import { z } from "zod"; +import { errorSchema } from "./errorSchema"; + +export const findCapitalProjectTilesByBoroughIdCommunityDistrictIdPathParamsSchema = + z.object({ + boroughId: z.coerce + .string() + .regex(new RegExp("^([0-9]{1})$")) + .describe( + "A single character numeric string containing the common number used to refer to the borough. Possible values are 1-5.", + ), + communityDistrictId: z.coerce + .string() + .regex(new RegExp("^([0-9]{2})$")) + .describe( + "The two character numeric string containing the number used to refer to the community district.", + ), + z: z.coerce.number().describe("viewport zoom component"), + x: z.coerce.number().describe("viewport x component"), + y: z.coerce.number().describe("viewport y component"), + }); +/** + * @description A protobuf file formatted as Mapbox Vector Tile + */ +export const findCapitalProjectTilesByBoroughIdCommunityDistrictId200Schema = + z.coerce.string(); +/** + * @description Invalid client request + */ +export const findCapitalProjectTilesByBoroughIdCommunityDistrictId400Schema = + z.lazy(() => errorSchema); +/** + * @description Server side error + */ +export const findCapitalProjectTilesByBoroughIdCommunityDistrictId500Schema = + z.lazy(() => errorSchema); +/** + * @description A protobuf file formatted as Mapbox Vector Tile + */ +export const findCapitalProjectTilesByBoroughIdCommunityDistrictIdQueryResponseSchema = + z.coerce.string(); diff --git a/src/gen/zod/index.ts b/src/gen/zod/index.ts index a0f4125..35b076e 100644 --- a/src/gen/zod/index.ts +++ b/src/gen/zod/index.ts @@ -19,6 +19,7 @@ export * from "./findCapitalCommitmentTypesSchema"; export * from "./findCapitalCommitmentsByManagingCodeCapitalProjectIdSchema"; export * from "./findCapitalProjectByManagingCodeCapitalProjectIdSchema"; export * from "./findCapitalProjectGeoJsonByManagingCodeCapitalProjectIdSchema"; +export * from "./findCapitalProjectTilesByBoroughIdCommunityDistrictIdSchema"; export * from "./findCapitalProjectTilesSchema"; export * from "./findCapitalProjectsByBoroughIdCommunityDistrictIdSchema"; export * from "./findCapitalProjectsByCityCouncilIdSchema"; diff --git a/src/gen/zod/operations.ts b/src/gen/zod/operations.ts index 4e7c160..c2c0e2f 100644 --- a/src/gen/zod/operations.ts +++ b/src/gen/zod/operations.ts @@ -30,6 +30,12 @@ import { findCapitalProjectsByBoroughIdCommunityDistrictIdPathParamsSchema, findCapitalProjectsByBoroughIdCommunityDistrictIdQueryParamsSchema, } from "./findCapitalProjectsByBoroughIdCommunityDistrictIdSchema"; +import { + findCapitalProjectTilesByBoroughIdCommunityDistrictIdQueryResponseSchema, + findCapitalProjectTilesByBoroughIdCommunityDistrictId400Schema, + findCapitalProjectTilesByBoroughIdCommunityDistrictId500Schema, + findCapitalProjectTilesByBoroughIdCommunityDistrictIdPathParamsSchema, +} from "./findCapitalProjectTilesByBoroughIdCommunityDistrictIdSchema"; import { findCapitalCommitmentTypesQueryResponseSchema, findCapitalCommitmentTypes400Schema, @@ -264,6 +270,25 @@ export const operations = { 500: findCapitalProjectsByBoroughIdCommunityDistrictId500Schema, }, }, + findCapitalProjectTilesByBoroughIdCommunityDistrictId: { + request: undefined, + parameters: { + path: findCapitalProjectTilesByBoroughIdCommunityDistrictIdPathParamsSchema, + query: undefined, + header: undefined, + }, + responses: { + 200: findCapitalProjectTilesByBoroughIdCommunityDistrictIdQueryResponseSchema, + 400: findCapitalProjectTilesByBoroughIdCommunityDistrictId400Schema, + 500: findCapitalProjectTilesByBoroughIdCommunityDistrictId500Schema, + default: + findCapitalProjectTilesByBoroughIdCommunityDistrictIdQueryResponseSchema, + }, + errors: { + 400: findCapitalProjectTilesByBoroughIdCommunityDistrictId400Schema, + 500: findCapitalProjectTilesByBoroughIdCommunityDistrictId500Schema, + }, + }, findCapitalCommitmentTypes: { request: undefined, parameters: { @@ -691,6 +716,10 @@ export const paths = { { get: operations["findCapitalProjectsByBoroughIdCommunityDistrictId"], }, + "/boroughs/{boroughId}/community-districts/{communityDistrictId}/capital-projects/{z}/{x}/{y}.pbf": + { + get: operations["findCapitalProjectTilesByBoroughIdCommunityDistrictId"], + }, "/capital-commitment-types": { get: operations["findCapitalCommitmentTypes"], }, diff --git a/test/borough/borough.e2e-spec.ts b/test/borough/borough.e2e-spec.ts index a079349..51b7173 100644 --- a/test/borough/borough.e2e-spec.ts +++ b/test/borough/borough.e2e-spec.ts @@ -329,6 +329,102 @@ describe("Borough e2e", () => { }); }); + describe("findCapitalProjectTilesByBoroughIdCommunityDistrictId", () => { + const borough = boroughRepositoryMock.checkBoroughByIdMocks[0]; + const communityDistrict = + communityDistrictRepositoryMock.checkCommunityDistrictByIdMocks[0]; + + it("should 200 and return capital project tiles for a given borough id and community district id", async () => { + const z = 1; + const x = 100; + const y = 200; + await request(app.getHttpServer()) + .get( + `/boroughs/${borough.id}/community-districts/${communityDistrict.id}/capital-projects/${z}/${x}/${y}.pbf`, + ) + .expect("Content-Type", "application/x-protobuf") + .expect(200); + }); + + it("should 400 and when finding by an invalid borough id", async () => { + const invalidId = "MN"; + + const z = 1; + const x = 100; + const y = 200; + const response = await request(app.getHttpServer()) + .get( + `/boroughs/${invalidId}/community-districts/${communityDistrict.id}/capital-projects/${z}/${x}/${y}.pbf`, + ) + .expect(400); + + expect(response.body.message).toBe( + new InvalidRequestParameterException().message, + ); + + expect(response.body.error).toBe(HttpName.BAD_REQUEST); + }); + + it("should 400 and when finding by an invalid community district id", async () => { + const invalidId = "Q1"; + + const z = 1; + const x = 100; + const y = 200; + const response = await request(app.getHttpServer()) + .get( + `/boroughs/${borough.id}/community-districts/${invalidId}/capital-projects/${z}/${x}/${y}.pbf`, + ) + .expect(400); + + expect(response.body.message).toBe( + new InvalidRequestParameterException().message, + ); + + expect(response.body.error).toBe(HttpName.BAD_REQUEST); + }); + + it("should 400 and when finding by a lettered viewport", async () => { + const z = "foo"; + const x = "bar"; + const y = "baz"; + const response = await request(app.getHttpServer()) + .get( + `/boroughs/${borough.id}/community-districts/${communityDistrict.id}/capital-projects/${z}/${x}/${y}.pbf`, + ) + .expect(400); + + expect(response.body.message).toBe( + new InvalidRequestParameterException().message, + ); + + expect(response.body.error).toBe(HttpName.BAD_REQUEST); + }); + + it("should 500 when the database errors", async () => { + const dataRetrievalException = new DataRetrievalException(); + jest + .spyOn( + boroughRepositoryMock, + "findCapitalProjectTilesByBoroughIdCommunityDistrictId", + ) + .mockImplementationOnce(() => { + throw dataRetrievalException; + }); + + const z = 1; + const x = 100; + const y = 200; + const response = await request(app.getHttpServer()) + .get( + `/boroughs/${borough.id}/community-districts/${communityDistrict.id}/capital-projects/${z}/${x}/${y}.pbf`, + ) + .expect(500); + expect(response.body.error).toBe(HttpName.INTERNAL_SEVER_ERROR); + expect(response.body.message).toBe(dataRetrievalException.message); + }); + }); + afterAll(async () => { await app.close(); }); diff --git a/test/borough/borough.repository.mock.ts b/test/borough/borough.repository.mock.ts index 29526d5..f34adb1 100644 --- a/test/borough/borough.repository.mock.ts +++ b/test/borough/borough.repository.mock.ts @@ -4,6 +4,7 @@ import { findCommunityDistrictsByBoroughIdRepoSchema, findCapitalProjectsByBoroughIdCommunityDistrictIdRepoSchema, communityDistrictGeoJsonEntitySchema, + findCapitalProjectTilesByBoroughIdCommunityDistrictIdRepoSchema, } from "src/borough/borough.repository.schema"; import { generateMock } from "@anatine/zod-mock"; import { CommunityDistrictRepositoryMock } from "test/community-district/community-district.repository.mock"; @@ -84,4 +85,23 @@ export class BoroughRepositoryMock { ); return results == undefined ? [] : results[communityDistrictId]; } + + findCapitalProjectTilesByBoroughIdCommunityDistrictIdMock = generateMock( + findCapitalProjectTilesByBoroughIdCommunityDistrictIdRepoSchema, + ); + + /** + * The database will always return tiles, + * even when the view is outside the extents. + * These would merely be empty tiles. + * + * To reflect this behavior in the mock, + * we disregard any viewport parameters and + * always return something. + * + * This applies to all mvt-related mocks + */ + async findCapitalProjectTilesByBoroughIdCommunityDistrictId() { + return this.findCapitalProjectTilesByBoroughIdCommunityDistrictIdMock; + } }