Skip to content

Commit

Permalink
City council district tiles
Browse files Browse the repository at this point in the history
TODO: generate migrations after the sibling community district tile
endpoint is merged

Write endpoint to find mvts for city council districts fills and labels

closes #254
  • Loading branch information
TangoYankee committed Jun 15, 2024
1 parent c801216 commit b29ff1e
Show file tree
Hide file tree
Showing 9 changed files with 224 additions and 22 deletions.
2 changes: 1 addition & 1 deletion openapi/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
37 changes: 34 additions & 3 deletions src/city-council-district/city-council-district.controller.ts
Original file line number Diff line number Diff line change
@@ -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(
Expand All @@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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<typeof findManyRepoSchema>;

export const findTilesRepoSchema = mvtEntitySchema;

export type FindTilesRepo = z.infer<typeof findTilesRepoSchema>;
64 changes: 63 additions & 1 deletion src/city-council-district/city-council-district.repository.ts
Original file line number Diff line number Diff line change
@@ -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(
Expand All @@ -20,4 +26,60 @@ export class CityCouncilDistrictRepository {
throw new DataRetrievalException();
}
}

async findTiles(
params: FindCityCouncilDistrictTilesPathParams,
): Promise<FindTilesRepo> {
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();
}
}
}
32 changes: 26 additions & 6 deletions src/city-council-district/city-council-district.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand All @@ -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(),
);
});
});
});
5 changes: 5 additions & 0 deletions src/city-council-district/city-council-district.service.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -16,4 +17,8 @@ export class CityCouncilDistrictService {
cityCouncilDistricts,
};
}

async findTiles(params: FindCityCouncilDistrictTilesPathParams) {
return await this.cityCouncilDistrictRepository.findTiles(params);
}
}
23 changes: 16 additions & 7 deletions src/schema/city-council-district.ts
Original file line number Diff line number Diff line change
@@ -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})$")),
Expand Down
56 changes: 53 additions & 3 deletions test/city-council-district/city-council-district.e2e-spec.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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())
Expand All @@ -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);
});
});
});
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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;
}
}

0 comments on commit b29ff1e

Please sign in to comment.