diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index 626ad025..7f5fbceb 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -208,7 +208,7 @@ paths: $ref: '#/components/responses/InternalServerError' /city-council-districts/{cityCouncilDistrictId}/capital-projects: get: - summary: 🚧 Find paginated capital projects within a specific city council district. + summary: Find paginated capital projects within a specific city council district. operationId: findCapitalProjectsByCityCouncilId tags: - Capital Projects diff --git a/src/city-council-district/city-council-district.controller.ts b/src/city-council-district/city-council-district.controller.ts index 49f38b89..1740a7a5 100644 --- a/src/city-council-district/city-council-district.controller.ts +++ b/src/city-council-district/city-council-district.controller.ts @@ -1,8 +1,25 @@ -import { Controller, Get, UseFilters } from "@nestjs/common"; +import { + Controller, + Get, + Param, + Res, + UseFilters, + UsePipes, +} from "@nestjs/common"; import { CityCouncilDistrictService } from "./city-council-district.service"; -import { InternalServerErrorExceptionFilter } from "src/filter"; +import { + BadRequestExceptionFilter, + InternalServerErrorExceptionFilter, +} from "src/filter"; +import { + FindCityCouncilDistrictTilesPathParams, + findCityCouncilDistrictTilesPathParamsSchema, +} from "src/gen"; +import { DecodeParamsPipe } from "src/pipes/decode-params-pipe"; +import { ZodValidationPipe } from "src/pipes/zod-validation-pipe"; +import { Response } from "express"; -@UseFilters(InternalServerErrorExceptionFilter) +@UseFilters(BadRequestExceptionFilter, InternalServerErrorExceptionFilter) @Controller("city-council-districts") export class CityCouncilDistrictController { constructor( @@ -13,4 +30,18 @@ export class CityCouncilDistrictController { async findMany() { return this.cityCouncilDistrictService.findMany(); } + + @UsePipes( + new DecodeParamsPipe(findCityCouncilDistrictTilesPathParamsSchema), + new ZodValidationPipe(findCityCouncilDistrictTilesPathParamsSchema), + ) + @Get("/:z/:x/:y.pbf") + async findTiles( + @Param() params: FindCityCouncilDistrictTilesPathParams, + @Res() res: Response, + ) { + const tile = await this.cityCouncilDistrictService.findTiles(params); + res.set("Content-Type", "application/x-protobuf"); + res.send(tile); + } } diff --git a/src/city-council-district/city-council-district.repository.schema.ts b/src/city-council-district/city-council-district.repository.schema.ts index 3a44b62c..0ced0338 100644 --- a/src/city-council-district/city-council-district.repository.schema.ts +++ b/src/city-council-district/city-council-district.repository.schema.ts @@ -1,6 +1,11 @@ +import { mvtEntitySchema } from "src/schema/mvt"; import { cityCouncilDistrictEntitySchema } from "src/schema"; import { z } from "zod"; export const findManyRepoSchema = z.array(cityCouncilDistrictEntitySchema); export type FindManyRepo = z.infer; + +export const findTilesRepoSchema = mvtEntitySchema; + +export type FindTilesRepo = z.infer; diff --git a/src/city-council-district/city-council-district.repository.ts b/src/city-council-district/city-council-district.repository.ts index 4c7cf474..4057179d 100644 --- a/src/city-council-district/city-council-district.repository.ts +++ b/src/city-council-district/city-council-district.repository.ts @@ -1,7 +1,13 @@ import { Inject } from "@nestjs/common"; import { DB, DbType } from "src/global/providers/db.provider"; import { DataRetrievalException } from "src/exception"; -import { FindManyRepo } from "./city-council-district.repository.schema"; +import { + FindManyRepo, + FindTilesRepo, +} from "./city-council-district.repository.schema"; +import { cityCouncilDistrict } from "src/schema"; +import { isNotNull, sql } from "drizzle-orm"; +import { FindCityCouncilDistrictTilesPathParams } from "src/gen"; export class CityCouncilDistrictRepository { constructor( @@ -20,4 +26,60 @@ export class CityCouncilDistrictRepository { throw new DataRetrievalException(); } } + + async findTiles( + params: FindCityCouncilDistrictTilesPathParams, + ): Promise { + const { z, x, y } = params; + + try { + const tileFill = this.db + .select({ + id: cityCouncilDistrict.id, + geomFill: sql`ST_AsMVTGeom( + ${cityCouncilDistrict.mercatorFill}, + ST_TileEnvelope(${z}, ${x}, ${y}), + 4096, 64, true)`.as("geomFill"), + }) + .from(cityCouncilDistrict) + .where( + sql`${cityCouncilDistrict.mercatorFill} && ST_TileEnvelope(${z},${x},${y})`, + ) + .as("tile"); + + const dataFill = await this.db + .select({ + mvt: sql`ST_AsMVT(tile, 'city-council-district-fill', 4096, 'geomFill')`, + }) + .from(tileFill) + .where(isNotNull(tileFill.geomFill)); + + const tileLabel = this.db + .select({ + id: cityCouncilDistrict.id, + geomLabel: sql`ST_AsMVTGeom( + ${cityCouncilDistrict.mercatorLabel}, + ST_TileEnvelope(${z}, ${x}, ${y}), + 4096, 64, true)`.as("geomLabel"), + }) + .from(cityCouncilDistrict) + .where( + sql`${cityCouncilDistrict.mercatorLabel} && ST_TileEnvelope(${z},${x},${y})`, + ) + .as("tile"); + + const dataLabel = this.db + .select({ + mvt: sql`ST_AsMVT(tile, 'city-council-district-label', 4096, 'geomLabel')`, + }) + .from(tileLabel) + .where(isNotNull(tileLabel.geomLabel)); + + const [fill, label] = await Promise.all([dataFill, dataLabel]); + + return Buffer.concat([fill[0].mvt, label[0].mvt] as Uint8Array[]); + } catch { + throw new DataRetrievalException(); + } + } } diff --git a/src/city-council-district/city-council-district.service.spec.ts b/src/city-council-district/city-council-district.service.spec.ts index c3067415..27c0d915 100644 --- a/src/city-council-district/city-council-district.service.spec.ts +++ b/src/city-council-district/city-council-district.service.spec.ts @@ -1,7 +1,10 @@ import { CityCouncilDistrictRepositoryMock } from "test/city-council-district/city-council-district.repository.mock"; import { Test } from "@nestjs/testing"; import { CityCouncilDistrictRepository } from "./city-council-district.repository"; -import { findCityCouncilDistrictsQueryResponseSchema } from "src/gen"; +import { + findCityCouncilDistrictTilesQueryResponseSchema, + findCityCouncilDistrictsQueryResponseSchema, +} from "src/gen"; import { CityCouncilDistrictService } from "./city-council-district.service"; describe("City Council District service unit", () => { @@ -23,10 +26,27 @@ describe("City Council District service unit", () => { ); }); - it("service should return a findCityCouncilDistrictsQueryResponseSchema compliant object", async () => { - const cityCouncilDistricts = await cityCouncilDistrictService.findMany(); - expect(() => - findCityCouncilDistrictsQueryResponseSchema.parse(cityCouncilDistricts), - ).not.toThrow(); + describe("findMany", () => { + it("service should return a findCityCouncilDistrictsQueryResponseSchema compliant object", async () => { + const cityCouncilDistricts = await cityCouncilDistrictService.findMany(); + expect(() => + findCityCouncilDistrictsQueryResponseSchema.parse(cityCouncilDistricts), + ).not.toThrow(); + }); + }); + + describe("findTiles", () => { + it("should return an mvt when requesting coordinates", async () => { + const mvt = await cityCouncilDistrictService.findTiles({ + z: 1, + x: 1, + y: 1, + }); + expect(() => + findCityCouncilDistrictTilesQueryResponseSchema + .parse(mvt) + .not.toThrow(), + ); + }); }); }); diff --git a/src/city-council-district/city-council-district.service.ts b/src/city-council-district/city-council-district.service.ts index d7bb6d17..a81ceff8 100644 --- a/src/city-council-district/city-council-district.service.ts +++ b/src/city-council-district/city-council-district.service.ts @@ -1,5 +1,6 @@ import { Inject, Injectable } from "@nestjs/common"; import { CityCouncilDistrictRepository } from "./city-council-district.repository"; +import { FindCityCouncilDistrictTilesPathParams } from "src/gen"; @Injectable() export class CityCouncilDistrictService { @@ -16,4 +17,8 @@ export class CityCouncilDistrictService { cityCouncilDistricts, }; } + + async findTiles(params: FindCityCouncilDistrictTilesPathParams) { + return await this.cityCouncilDistrictRepository.findTiles(params); + } } diff --git a/src/schema/city-council-district.ts b/src/schema/city-council-district.ts index 674e1c26..ac4907a7 100644 --- a/src/schema/city-council-district.ts +++ b/src/schema/city-council-district.ts @@ -1,13 +1,22 @@ -import { pgTable, text } from "drizzle-orm/pg-core"; +import { index, pgTable, text } from "drizzle-orm/pg-core"; import { multiPolygonGeom, pointGeom } from "src/drizzle-pgis"; import { z } from "zod"; -export const cityCouncilDistrict = pgTable("city_council_district", { - id: text("id").primaryKey(), - liFt: multiPolygonGeom("li_ft", 2263), - mercatorFill: multiPolygonGeom("mercator_fill", 3857), - mercatorLabel: pointGeom("mercator_label", 3857), -}); +export const cityCouncilDistrict = pgTable( + "city_council_district", + { + id: text("id").primaryKey(), + liFt: multiPolygonGeom("li_ft", 2263), + mercatorFill: multiPolygonGeom("mercator_fill", 3857), + mercatorLabel: pointGeom("mercator_label", 3857), + }, + (table) => { + return { + mercatorFillGix: index().using("GIST", table.mercatorFill), + mercatorLabelGix: index().using("GIST", table.mercatorLabel), + }; + }, +); export const cityCouncilDistrictEntitySchema = z.object({ id: z.string().regex(new RegExp("^([0-9]{1,2})$")), diff --git a/test/city-council-district/city-council-district.e2e-spec.ts b/test/city-council-district/city-council-district.e2e-spec.ts index f160eb15..95174039 100644 --- a/test/city-council-district/city-council-district.e2e-spec.ts +++ b/test/city-council-district/city-council-district.e2e-spec.ts @@ -1,7 +1,10 @@ import * as request from "supertest"; import { INestApplication } from "@nestjs/common"; import { Test } from "@nestjs/testing"; -import { DataRetrievalException } from "src/exception"; +import { + DataRetrievalException, + InvalidRequestParameterException, +} from "src/exception"; import { HttpName } from "src/filter"; import { CityCouncilDistrictRepository } from "src/city-council-district/city-council-district.repository"; import { CityCouncilDistrictRepositoryMock } from "./city-council-district.repository.mock"; @@ -25,6 +28,10 @@ describe("City Council District e2e", () => { await app.init(); }); + afterAll(async () => { + await app.close(); + }); + describe("findCityCouncilDistricts", () => { it("should 200 amd return all city council districts", async () => { const response = await request(app.getHttpServer()) @@ -49,9 +56,52 @@ describe("City Council District e2e", () => { expect(response.body.message).toBe(dataRetrievalException.message); expect(response.body.error).toBe(HttpName.INTERNAL_SEVER_ERROR); }); + }); + + describe("findTiles", () => { + it("should return pbf files when passing valid viewport", async () => { + const z = 1; + const x = 100; + const y = 200; + await request(app.getHttpServer()) + .get(`/city-council-districts/${z}/${x}/${y}.pbf`) + .expect("Content-Type", "application/x-protobuf") + .expect(200); + }); + + it("should 400 when finding a lettered viewport", async () => { + const z = "foo"; + const x = "bar"; + const y = "baz"; + + const response = await request(app.getHttpServer()) + .get(`/city-council-districts/${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 there is a data retrieval error", async () => { + const dataRetrievalException = new DataRetrievalException(); + jest + .spyOn(cityCouncilDistrictRepositoryMock, "findTiles") + .mockImplementationOnce(() => { + throw dataRetrievalException; + }); - afterAll(async () => { - await app.close(); + const z = 1; + const x = 100; + const y = 200; + + const response = await request(app.getHttpServer()) + .get(`/city-council-districts/${z}/${x}/${y}.pbf`) + .expect(500); + expect(response.body.message).toBe(dataRetrievalException.message); + expect(response.body.error).toBe(HttpName.INTERNAL_SEVER_ERROR); }); }); }); diff --git a/test/city-council-district/city-council-district.repository.mock.ts b/test/city-council-district/city-council-district.repository.mock.ts index 172b2af5..d3feb78d 100644 --- a/test/city-council-district/city-council-district.repository.mock.ts +++ b/test/city-council-district/city-council-district.repository.mock.ts @@ -1,4 +1,7 @@ -import { findManyRepoSchema } from "src/city-council-district/city-council-district.repository.schema"; +import { + findManyRepoSchema, + findTilesRepoSchema, +} from "src/city-council-district/city-council-district.repository.schema"; import { generateMock } from "@anatine/zod-mock"; export class CityCouncilDistrictRepositoryMock { @@ -7,4 +10,21 @@ export class CityCouncilDistrictRepositoryMock { async findMany() { return this.findManyMocks; } + + findTilesMock = generateMock(findTilesRepoSchema); + + /** + * 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 findTiles() { + return this.findTilesMock; + } }