Skip to content

Commit

Permalink
342 OpenAPI Implementation for findCapitalProjectGeoJsonByManagingCod…
Browse files Browse the repository at this point in the history
…eCapitalProjectId endpoint

  - write controller, module, repository, respo schema, service
  - e2e and service unit tests
  - use m_pnt and m_ply for cp geojson (+ multipoint regex fix)

Co-authored-by: horatio <[email protected]>
Co-authored-by: tangoyankee <[email protected]>
  • Loading branch information
3 people committed Aug 5, 2024
1 parent 5a3fe36 commit f1b0aaa
Show file tree
Hide file tree
Showing 10 changed files with 295 additions and 4 deletions.
2 changes: 1 addition & 1 deletion openapi/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ paths:
$ref: "#/components/responses/InternalServerError"
/capital-projects/{managingCode}/{capitalProjectId}/geojson:
get:
summary: 🚧 Find a single capital project as a geojson feature
summary: Find a single capital project as a geojson feature
operationId: findCapitalProjectGeoJsonByManagingCodeCapitalProjectId
tags:
- Capital Projects
Expand Down
17 changes: 17 additions & 0 deletions src/capital-project/capital-project.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,11 @@ import { Response } from "express";
import {
FindCapitalCommitmentsByManagingCodeCapitalProjectIdPathParams,
FindCapitalProjectByManagingCodeCapitalProjectIdPathParams,
FindCapitalProjectGeoJsonByManagingCodeCapitalProjectIdPathParams,
FindCapitalProjectTilesPathParams,
findCapitalCommitmentsByManagingCodeCapitalProjectIdPathParamsSchema,
findCapitalProjectByManagingCodeCapitalProjectIdPathParamsSchema,
findCapitalProjectGeoJsonByManagingCodeCapitalProjectIdPathParamsSchema,
findCapitalProjectTilesPathParamsSchema,
} from "src/gen";
import { CapitalProjectService } from "./capital-project.service";
Expand Down Expand Up @@ -45,6 +47,21 @@ export class CapitalProjectController {
);
}

@UsePipes(
new ZodTransformPipe(
findCapitalProjectGeoJsonByManagingCodeCapitalProjectIdPathParamsSchema,
),
)
@Get("/:managingCode/:capitalProjectId/geojson")
async findGeoJsonByManagingCodeCapitalProjectId(
@Param()
params: FindCapitalProjectGeoJsonByManagingCodeCapitalProjectIdPathParams,
) {
return await this.capitalProjectService.findGeoJsonByManagingCodeCapitalProjectId(
params,
);
}

@UsePipes(new ZodTransformPipe(findCapitalProjectTilesPathParamsSchema))
@Get("/:z/:x/:y.pbf")
async findTiles(
Expand Down
29 changes: 27 additions & 2 deletions src/capital-project/capital-project.repository.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import {
capitalCommitmentEntitySchema,
capitalCommitmentFundEntitySchema,
capitalProjectEntitySchema,
MultiPointSchema,
MultiPolygonSchema,
} from "src/schema";
import { mvtEntitySchema } from "src/schema/mvt";
import { z } from "zod";
Expand All @@ -18,18 +20,41 @@ export type CheckByManagingCodeCapitalProjectIdRepo = z.infer<
typeof checkByManagingCodeCapitalProjectIdRepoSchema
>;

export const findByManagingCodeCapitalProjectIdRepoSchema = z.array(
export const capitalProjectBudgetedEntitySchema =
capitalProjectEntitySchema.extend({
sponsoringAgencies: z.array(agencyEntitySchema.shape.initials),
budgetTypes: z.array(agencyBudgetEntitySchema.shape.type),
commitmentsTotal: capitalCommitmentFundEntitySchema.shape.value,
}),
});

export type CapitalProjectBudgetedEntity = z.infer<
typeof capitalProjectBudgetedEntitySchema
>;

export const findByManagingCodeCapitalProjectIdRepoSchema = z.array(
capitalProjectBudgetedEntitySchema,
);

export type FindByManagingCodeCapitalProjectIdRepo = z.infer<
typeof findByManagingCodeCapitalProjectIdRepoSchema
>;

export const capitalProjectBudgetedGeoJsonEntitySchema =
capitalProjectBudgetedEntitySchema.extend({
geometry: z.union([MultiPointSchema, MultiPolygonSchema]).nullable(),
});

export type CapitalProjectBudgetedGeoJsonEntityRepo = z.infer<
typeof capitalProjectBudgetedGeoJsonEntitySchema
>;

export const findGeoJsonByManagingCodeCapitalProjectIdRepoSchema = z.array(
capitalProjectBudgetedGeoJsonEntitySchema,
);

export type FindGeoJsonByManagingCodeCapitalProjectIdRepo = z.infer<
typeof findGeoJsonByManagingCodeCapitalProjectIdRepoSchema
>;
export const findTilesRepoSchema = mvtEntitySchema;

export type FindTilesRepo = z.infer<typeof findTilesRepoSchema>;
Expand Down
64 changes: 64 additions & 0 deletions src/capital-project/capital-project.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { DataRetrievalException } from "src/exception";
import {
FindCapitalCommitmentsByManagingCodeCapitalProjectIdPathParams,
FindCapitalProjectByManagingCodeCapitalProjectIdPathParams,
FindCapitalProjectGeoJsonByManagingCodeCapitalProjectIdPathParams,
FindCapitalProjectTilesPathParams,
} from "src/gen";
import { DB, DbType } from "src/global/providers/db.provider";
Expand All @@ -18,6 +19,7 @@ import {
CheckByManagingCodeCapitalProjectIdRepo,
FindByManagingCodeCapitalProjectIdRepo,
FindCapitalCommitmentsByManagingCodeCapitalProjectIdRepo,
FindGeoJsonByManagingCodeCapitalProjectIdRepo,
FindTilesRepo,
} from "./capital-project.repository.schema";

Expand Down Expand Up @@ -103,6 +105,68 @@ export class CapitalProjectRepository {
}
}

async findGeoJsonByManagingCodeCapitalProjectId(
params: FindCapitalProjectGeoJsonByManagingCodeCapitalProjectIdPathParams,
): Promise<FindGeoJsonByManagingCodeCapitalProjectIdRepo> {
const { managingCode, capitalProjectId } = params;
try {
return await this.db
.select({
id: capitalProject.id,
managingCode: capitalProject.managingCode,
description: capitalProject.description,
managingAgency: capitalProject.managingAgency,
minDate: capitalProject.minDate,
maxDate: capitalProject.maxDate,
category: capitalProject.category,
sponsoringAgencies: sql<
Array<string>
>`ARRAY_AGG(DISTINCT ${agencyBudget.sponsor})`,
budgetTypes: sql<
Array<string>
>`ARRAY_AGG(DISTINCT ${agencyBudget.type})`,
commitmentsTotal: sum(capitalCommitmentFund.value).mapWith(Number),
geometry: sql<string | null>`
CASE
WHEN
${capitalProject.liFtMPoly} IS NOT null
THEN
ST_asGeoJSON(ST_Transform(${capitalProject.liFtMPoly}, 4326),6)
ELSE
ST_asGeoJSON(ST_Transform(${capitalProject.liFtMPnt}, 4326),6)
END
`.as("geometry"),
})
.from(capitalProject)
.leftJoin(
capitalCommitment,
and(
eq(capitalProject.managingCode, capitalCommitment.managingCode),
eq(capitalProject.id, capitalCommitment.capitalProjectId),
),
)
.leftJoin(
agencyBudget,
eq(agencyBudget.code, capitalCommitment.budgetLineCode),
)
.leftJoin(
capitalCommitmentFund,
eq(capitalCommitmentFund.capitalCommitmentId, capitalCommitment.id),
)
.where(
and(
eq(capitalProject.managingCode, managingCode),
eq(capitalProject.id, capitalProjectId),
eq(capitalCommitmentFund.category, "total"),
),
)
.groupBy(capitalProject.managingCode, capitalProject.id)
.limit(1);
} catch {
throw new DataRetrievalException();
}
}

async findTiles(
params: FindCapitalProjectTilesPathParams,
): Promise<FindTilesRepo> {
Expand Down
33 changes: 33 additions & 0 deletions src/capital-project/capital-project.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { CapitalProjectRepository } from "./capital-project.repository";
import {
findCapitalCommitmentsByManagingCodeCapitalProjectIdQueryResponseSchema,
findCapitalProjectByManagingCodeCapitalProjectIdQueryResponseSchema,
findCapitalProjectGeoJsonByManagingCodeCapitalProjectIdQueryResponseSchema,
findCapitalProjectTilesQueryResponseSchema,
} from "src/gen";
import { ResourceNotFoundException } from "src/exception";
Expand Down Expand Up @@ -58,6 +59,38 @@ describe("CapitalProjectService", () => {
});
});

describe("findGeoJsonByManagingCodeCapitalProjectId", () => {
it("should return a capital project geojson with a valid request", async () => {
const capitalProjectGeoJsonMock =
capitalProjectRepository
.findGeoJsonByManagingCodeCapitalProjectIdMock[0];
const { managingCode, id: capitalProjectId } = capitalProjectGeoJsonMock;
const capitalProjectGeoJson =
await capitalProjectService.findGeoJsonByManagingCodeCapitalProjectId({
managingCode,
capitalProjectId,
});

expect(() =>
findCapitalProjectGeoJsonByManagingCodeCapitalProjectIdQueryResponseSchema.parse(
capitalProjectGeoJson,
),
).not.toThrow();
});

it("should throw a resource error when requesting a missing project", async () => {
const missingManagingCode = "890";
const missingCapitalProjectId = "ABCD";

expect(
capitalProjectService.findGeoJsonByManagingCodeCapitalProjectId({
managingCode: missingManagingCode,
capitalProjectId: missingCapitalProjectId,
}),
).rejects.toThrow(ResourceNotFoundException);
});
});

describe("findTiles", () => {
it("should return an mvt when requesting coordinates", async () => {
const mvt = await capitalProjectService.findTiles({
Expand Down
38 changes: 38 additions & 0 deletions src/capital-project/capital-project.service.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
import { produce } from "immer";
import {
FindCapitalCommitmentsByManagingCodeCapitalProjectIdPathParams,
FindCapitalProjectByManagingCodeCapitalProjectIdPathParams,
FindCapitalProjectGeoJsonByManagingCodeCapitalProjectIdPathParams,
FindCapitalProjectTilesPathParams,
} from "src/gen";
import { CapitalProjectRepository } from "./capital-project.repository";
import { Inject } from "@nestjs/common";
import { ResourceNotFoundException } from "src/exception";
import {
CapitalProjectBudgetedEntity,
CapitalProjectBudgetedGeoJsonEntityRepo,
} from "./capital-project.repository.schema";

export class CapitalProjectService {
constructor(
Expand All @@ -25,6 +31,38 @@ export class CapitalProjectService {
return capitalProjects[0];
}

async findGeoJsonByManagingCodeCapitalProjectId(
params: FindCapitalProjectGeoJsonByManagingCodeCapitalProjectIdPathParams,
) {
const capitalProjects =
await this.capitalProjectRepository.findGeoJsonByManagingCodeCapitalProjectId(
params,
);

if (capitalProjects.length < 1) throw new ResourceNotFoundException();

const capitalProject = capitalProjects[0];

const geometry =
capitalProject.geometry === null
? null
: JSON.parse(capitalProject.geometry);

const properties = produce(
capitalProject as Partial<CapitalProjectBudgetedGeoJsonEntityRepo>,
(draft) => {
delete draft["geometry"];
},
) as CapitalProjectBudgetedEntity;

return {
id: `${capitalProject.managingCode}${capitalProject.id}`,
type: "Feature",
properties,
geometry,
};
}

async findTiles(params: FindCapitalProjectTilesPathParams) {
return await this.capitalProjectRepository.findTiles(params);
}
Expand Down
2 changes: 1 addition & 1 deletion src/schema/capital-commitment-fund.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,6 @@ export const capitalCommitmentFundEntitySchema = z.object({
value: z.number(),
});

export type commitmentFundEntitySchema = z.infer<
export type CapitalCommitmentFundEntitySchema = z.infer<
typeof capitalCommitmentFundEntitySchema
>;
6 changes: 6 additions & 0 deletions src/schema/geometry.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { z } from "zod";

export const MultiPointSchema = z
.string()
.regex(
/^{"type":"MultiPoint","coordinates":\[(\[-?[1-9]([0-9]{0,})(\.[0-9]{1,14})?,-?[1-9]([0-9]{0,})(\.[0-9]{1,14})?\])(,\[-?[1-9]([0-9]{0,})(\.[0-9]{1,14})?,-?[1-9]([0-9]{0,})(\.[0-9]{1,14})?\]){0,}\]}$/,
);

export const MultiPolygonSchema = z
.string()
/**
Expand Down
81 changes: 81 additions & 0 deletions test/capital-project/capital-project.e2e-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
import {
findCapitalCommitmentsByManagingCodeCapitalProjectIdQueryResponseSchema,
findCapitalProjectByManagingCodeCapitalProjectIdQueryResponseSchema,
findCapitalProjectGeoJsonByManagingCodeCapitalProjectIdQueryResponseSchema,
} from "src/gen";

describe("Capital Projects", () => {
Expand Down Expand Up @@ -102,6 +103,86 @@ describe("Capital Projects", () => {
});
});

describe("findGeoJsonByManagingCodeCapitalProjectId", () => {
it("should 200 and return a capital project with budget details", async () => {
const capitalProjectGeoJsonMock =
capitalProjectRepository
.findGeoJsonByManagingCodeCapitalProjectIdMock[0];
const { managingCode, id: capitalProjectId } = capitalProjectGeoJsonMock;
const response = await request(app.getHttpServer())
.get(`/capital-projects/${managingCode}/${capitalProjectId}/geojson`)
.expect(200);

expect(() =>
findCapitalProjectGeoJsonByManagingCodeCapitalProjectIdQueryResponseSchema.parse(
response.body,
),
).not.toThrow();
});

it("should 400 when finding by a too long managing code", async () => {
const tooLongManagingCode = "1234";
const capitalProjectId = "JIRO";

const response = await request(app.getHttpServer())
.get(
`/capital-projects/${tooLongManagingCode}/${capitalProjectId}/geojson`,
)
.expect(400);

expect(response.body.message).toBe(
new InvalidRequestParameterException().message,
);
expect(response.body.error).toBe(HttpName.BAD_REQUEST);
});

it("should 400 when finding by a lettered managing code", async () => {
const letteredManagingCode = "ABC";
const capitalProjectId = "JIRO";

const response = await request(app.getHttpServer())
.get(
`/capital-projects/${letteredManagingCode}/${capitalProjectId}/geojson`,
)
.expect(400);

expect(response.body.error).toBe(HttpName.BAD_REQUEST);
});

it("should 404 when finding by missing managing code and capital project id", async () => {
const managingCode = "123";
const capitalProjectId = "JIRO";

const response = await request(app.getHttpServer())
.get(`/capital-projects/${managingCode}/${capitalProjectId}`)
.expect(404);

expect(response.body.message).toBe(HttpName.NOT_FOUND);
});

it("should 500 when the database errors", async () => {
const dataRetrievalException = new DataRetrievalException();
jest
.spyOn(
capitalProjectRepository,
"findGeoJsonByManagingCodeCapitalProjectId",
)
.mockImplementationOnce(() => {
throw dataRetrievalException;
});

const capitalProjectMock =
capitalProjectRepository
.findGeoJsonByManagingCodeCapitalProjectIdMock[0];
const { managingCode, id: capitalProjectId } = capitalProjectMock;
const response = await request(app.getHttpServer())
.get(`/capital-projects/${managingCode}/${capitalProjectId}/geojson`)
.expect(500);
expect(response.body.error).toBe(HttpName.INTERNAL_SEVER_ERROR);
expect(response.body.message).toBe(dataRetrievalException.message);
});
});

describe("findFills", () => {
it("should return pbf files when passing valid viewport", async () => {
const z = 1;
Expand Down
Loading

0 comments on commit f1b0aaa

Please sign in to comment.