diff --git a/api/apps/api/src/decorators/shapefile.decorator.ts b/api/apps/api/src/decorators/shapefile.decorator.ts index 0f465f2fab..c650fe9cb7 100644 --- a/api/apps/api/src/decorators/shapefile.decorator.ts +++ b/api/apps/api/src/decorators/shapefile.decorator.ts @@ -1,3 +1,4 @@ +import { isDefined } from '@marxan/utils'; import { applyDecorators } from '@nestjs/common'; import { ApiBody, @@ -7,7 +8,6 @@ import { } from '@nestjs/swagger'; import { ShapefileGeoJSONResponseDTO } from '@marxan-api/modules/scenarios/dto/shapefile.geojson.response.dto'; -import { isDefined } from '../utils/is-defined'; export function ApiConsumesShapefile(withGeoJsonResponse = true) { return applyDecorators( diff --git a/api/apps/api/src/modules/analysis/providers/shared/adapters/are-puids-allowed-adapter.ts b/api/apps/api/src/modules/analysis/providers/shared/adapters/are-puids-allowed-adapter.ts index ccf948f1ff..0722851eec 100644 --- a/api/apps/api/src/modules/analysis/providers/shared/adapters/are-puids-allowed-adapter.ts +++ b/api/apps/api/src/modules/analysis/providers/shared/adapters/are-puids-allowed-adapter.ts @@ -1,9 +1,9 @@ import { Injectable } from '@nestjs/common'; +import { isDefined } from '@marxan/utils'; import { differenceWith } from 'lodash'; import { ArePuidsAllowedPort } from '../are-puids-allowed.port'; import { ScenariosPlanningUnitService } from '../../../../scenarios-planning-unit/scenarios-planning-unit.service'; -import { isDefined } from '../../../../../utils/is-defined'; @Injectable() export class ArePuidsAllowedAdapter diff --git a/api/apps/api/test/jest-e2e.json b/api/apps/api/test/jest-e2e.json index 40446b12dc..e24573d6f7 100644 --- a/api/apps/api/test/jest-e2e.json +++ b/api/apps/api/test/jest-e2e.json @@ -8,6 +8,8 @@ "^.+\\.(t|j)s$": "ts-jest" }, "moduleNameMapper": { - "@marxan-api/(.*)": "/../src/$1" + "@marxan-api/(.*)": "/../src/$1", + "^@marxan/utils": ["/../../../libs/utils/src"], + "^@marxan/utils/(.*)$": ["/../../..//libs/utils/src/$1"] } } diff --git a/api/apps/geoprocessing/src/modules/surface-cost/adapters/pu-cost-extractor.spec.ts b/api/apps/geoprocessing/src/modules/surface-cost/adapters/pu-cost-extractor.spec.ts new file mode 100644 index 0000000000..39d0d3e0ed --- /dev/null +++ b/api/apps/geoprocessing/src/modules/surface-cost/adapters/pu-cost-extractor.spec.ts @@ -0,0 +1,62 @@ +import { Test } from '@nestjs/testing'; +import { PromiseType } from 'utility-types'; +import { + getGeoJson, + getGeoJsonWithMissingCost, + getGeometryMultiPolygon, +} from '../application/__mocks__/geojson'; +import { PuCostExtractor } from './pu-cost-extractor'; + +let sut: PuCostExtractor; +let fixtures: PromiseType>; + +beforeEach(async () => { + fixtures = await getFixtures(); + sut = fixtures.getService(); +}); + +describe(`when features miss cost`, () => { + it(`throws exception`, () => { + expect(() => sut.extract(fixtures.geoFeaturesWithoutCost())).toThrow( + /missing cost/, + ); + }); +}); + +describe(`when given GeoJson isn't a FeatureCollection`, () => { + it(`throws exception`, () => { + expect(() => sut.extract(fixtures.simpleGeometry())).toThrow( + /Only FeatureCollection is supported/, + ); + }); +}); + +describe(`when given GeoJson has pu costs`, () => { + it(`resolves them`, () => { + expect(sut.extract(fixtures.geoFeaturesWithData())).toMatchInlineSnapshot(` + Array [ + Object { + "cost": 200, + "planningUnitId": "uuid-1", + }, + Object { + "cost": 100, + "planningUnitId": "uuid-2", + }, + ] + `); + }); +}); + +const getFixtures = async () => { + const sandbox = await Test.createTestingModule({ + providers: [PuCostExtractor], + }).compile(); + + return { + getService: () => sandbox.get(PuCostExtractor), + geoFeaturesWithoutCost: () => getGeoJsonWithMissingCost(), + geoFeaturesWithData: () => getGeoJson(), + simpleGeometry: () => getGeometryMultiPolygon(), + }; +}; diff --git a/api/apps/geoprocessing/src/modules/surface-cost/adapters/pu-cost-extractor.ts b/api/apps/geoprocessing/src/modules/surface-cost/adapters/pu-cost-extractor.ts new file mode 100644 index 0000000000..9634f32f5e --- /dev/null +++ b/api/apps/geoprocessing/src/modules/surface-cost/adapters/pu-cost-extractor.ts @@ -0,0 +1,48 @@ +import { isDefined } from '@marxan/utils'; +import { MaybeProperties } from '@marxan/utils/types'; +import { FeatureCollection, GeoJSON, Geometry } from 'geojson'; +import { PlanningUnitCost } from '../ports/planning-unit-cost'; +import { PuExtractorPort } from '../ports/pu-extractor/pu-extractor.port'; + +type Properties = { + cost: number; + planningUnitId: string; +}; + +type MaybeCost = MaybeProperties; + +export class PuCostExtractor implements PuExtractorPort { + extract(geo: GeoJSON): PlanningUnitCost[] { + if (!this.isFeatureCollection(geo)) { + throw new Error('Only FeatureCollection is supported.'); + } + const input: FeatureCollection = geo; + + const puCosts = input.features + .map((feature) => feature.properties) + .filter(this.hasCostValues); + + if (puCosts.length !== input.features.length) { + throw new Error( + `Some of the Features are missing cost and/or planning unit id.`, + ); + } + + return puCosts.map((puCost) => ({ + planningUnitId: puCost.planningUnitId, + cost: puCost.cost, + })); + } + + private isFeatureCollection(geo: GeoJSON): geo is FeatureCollection { + return geo.type === 'FeatureCollection'; + } + + private hasCostValues(properties: MaybeCost): properties is Properties { + return ( + isDefined(properties) && + isDefined(properties.cost) && + isDefined(properties.planningUnitId) + ); + } +} diff --git a/api/apps/geoprocessing/src/modules/surface-cost/adapters/shapefile-converter.ts b/api/apps/geoprocessing/src/modules/surface-cost/adapters/shapefile-converter.ts new file mode 100644 index 0000000000..073c2e5724 --- /dev/null +++ b/api/apps/geoprocessing/src/modules/surface-cost/adapters/shapefile-converter.ts @@ -0,0 +1,13 @@ +import { GeoJSON } from 'geojson'; +import { ShapefileService } from '../../shapefiles/shapefiles.service'; + +import { CostSurfaceJobInput } from '../cost-surface-job-input'; +import { ShapefileConverterPort } from '../ports/shapefile-converter/shapefile-converter.port'; + +export class ShapefileConverter implements ShapefileConverterPort { + constructor(private readonly converter: ShapefileService) {} + + async convert(file: CostSurfaceJobInput['shapefile']): Promise { + return (await this.converter.getGeoJson(file)).data; + } +} diff --git a/api/apps/geoprocessing/src/modules/surface-cost/application/__mocks__/geojson.ts b/api/apps/geoprocessing/src/modules/surface-cost/application/__mocks__/geojson.ts index cfc0f95c6f..0541ab97f4 100644 --- a/api/apps/geoprocessing/src/modules/surface-cost/application/__mocks__/geojson.ts +++ b/api/apps/geoprocessing/src/modules/surface-cost/application/__mocks__/geojson.ts @@ -1,4 +1,4 @@ -import { Feature, FeatureCollection, MultiPolygon, Polygon } from 'geojson'; +import { FeatureCollection, MultiPolygon, Polygon } from 'geojson'; import { PlanningUnitCost } from '../../ports/planning-unit-cost'; export const getGeoJson = (): FeatureCollection< @@ -56,3 +56,8 @@ export const getGeoJsonWithMissingCost = (): FeatureCollection< }, ], }); + +export const getGeometryMultiPolygon = (): MultiPolygon => ({ + type: 'MultiPolygon', + coordinates: [], +}); diff --git a/api/apps/geoprocessing/src/modules/surface-cost/surface-cost.module.ts b/api/apps/geoprocessing/src/modules/surface-cost/surface-cost.module.ts index db022df450..f4e2208c84 100644 --- a/api/apps/geoprocessing/src/modules/surface-cost/surface-cost.module.ts +++ b/api/apps/geoprocessing/src/modules/surface-cost/surface-cost.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { WorkerModule, WorkerProcessor } from '../worker'; +import { ShapefilesModule } from '../shapefiles/shapefiles.module'; import { SurfaceCostProcessor } from './application/surface-cost-processor'; import { SurfaceCostWorker } from './application/surface-cost-worker'; @@ -11,12 +12,15 @@ import { ArePuidsAllowedPort } from './ports/pu-validator/are-puuids-allowed.por import { ShapefileConverterPort } from './ports/shapefile-converter/shapefile-converter.port'; import { TypeormCostSurface } from './adapters/typeorm-cost-surface'; +import { ShapefileConverter } from './adapters/shapefile-converter'; import { ScenariosPuCostDataGeo } from '../scenarios/scenarios-pu-cost-data.geo.entity'; import { ScenariosPlanningUnitGeoEntity } from '../scenarios/scenarios-planning-unit.geo.entity'; +import { PuCostExtractor } from './adapters/pu-cost-extractor'; @Module({ imports: [ WorkerModule, + ShapefilesModule, TypeOrmModule.forFeature([ ScenariosPuCostDataGeo, ScenariosPlanningUnitGeoEntity, // not used but has to imported somewhere @@ -38,11 +42,11 @@ import { ScenariosPlanningUnitGeoEntity } from '../scenarios/scenarios-planning- }, { provide: PuExtractorPort, - useValue: {}, + useClass: PuCostExtractor, }, { provide: ShapefileConverterPort, - useValue: {}, + useClass: ShapefileConverter, }, ], }) diff --git a/api/apps/geoprocessing/test/jest-e2e.json b/api/apps/geoprocessing/test/jest-e2e.json index 0566b5f5ad..848f80d4b8 100644 --- a/api/apps/geoprocessing/test/jest-e2e.json +++ b/api/apps/geoprocessing/test/jest-e2e.json @@ -9,6 +9,8 @@ "^.+\\.(t|j)s$": "ts-jest" }, "moduleNameMapper": { - "@marxan-geoprocessing/(.*)": "/src/$1" + "@marxan-geoprocessing/(.*)": "/src/$1", + "^@marxan/utils": ["/../../libs/utils/src"], + "^@marxan/utils/(.*)$": ["/../..//libs/utils/src/$1"] } } diff --git a/api/libs/utils/src/index.ts b/api/libs/utils/src/index.ts new file mode 100644 index 0000000000..dff2ab03cc --- /dev/null +++ b/api/libs/utils/src/index.ts @@ -0,0 +1 @@ +export { isDefined } from './is-defined'; diff --git a/api/apps/api/src/utils/is-defined.ts b/api/libs/utils/src/is-defined.ts similarity index 100% rename from api/apps/api/src/utils/is-defined.ts rename to api/libs/utils/src/is-defined.ts diff --git a/api/libs/utils/src/types/index.ts b/api/libs/utils/src/types/index.ts new file mode 100644 index 0000000000..57cfe27a74 --- /dev/null +++ b/api/libs/utils/src/types/index.ts @@ -0,0 +1 @@ +export { MaybeProperties } from './maybe-properties'; diff --git a/api/libs/utils/src/types/maybe-properties.ts b/api/libs/utils/src/types/maybe-properties.ts new file mode 100644 index 0000000000..c9c2ab203c --- /dev/null +++ b/api/libs/utils/src/types/maybe-properties.ts @@ -0,0 +1 @@ +export type MaybeProperties = Partial | undefined | null; diff --git a/api/libs/utils/tsconfig.lib.json b/api/libs/utils/tsconfig.lib.json new file mode 100644 index 0000000000..8c4de11c9b --- /dev/null +++ b/api/libs/utils/tsconfig.lib.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "declaration": true, + "outDir": "../../dist/libs/utils" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "test", "**/*spec.ts"] +} diff --git a/api/nest-cli.json b/api/nest-cli.json index 840cf810bf..8fe8df7b79 100644 --- a/api/nest-cli.json +++ b/api/nest-cli.json @@ -25,6 +25,15 @@ "compilerOptions": { "tsConfigPath": "apps/geoprocessing/tsconfig.app.json" } + }, + "utils": { + "type": "library", + "root": "libs/utils", + "entryFile": "index", + "sourceRoot": "libs/utils/src", + "compilerOptions": { + "tsConfigPath": "libs/utils/tsconfig.lib.json" + } } } -} +} \ No newline at end of file diff --git a/api/package.json b/api/package.json index 0914171886..ef895292df 100644 --- a/api/package.json +++ b/api/package.json @@ -79,7 +79,6 @@ "unzipper": "^0.10.11", "uuid": "8.3.2", "swagger-ui-express": "^4.1.6" - }, "devDependencies": { "@nestjs/cli": "^7.5.1", @@ -145,7 +144,9 @@ ], "moduleNameMapper": { "@marxan-api/(.*)": "/apps/api/src/$1", - "@marxan-geoprocessing/(.*)": "/apps/geoprocessing/src/$1" + "@marxan-geoprocessing/(.*)": "/apps/geoprocessing/src/$1", + "@marxan/utils/(.*)": "/libs/utils/src/$1", + "@marxan/utils": "/libs/utils/src" } } } diff --git a/api/tsconfig.json b/api/tsconfig.json index 929b70b536..5173fc041f 100644 --- a/api/tsconfig.json +++ b/api/tsconfig.json @@ -18,6 +18,12 @@ ], "@marxan-api/*": [ "apps/api/src/*" + ], + "@marxan/utils": [ + "libs/utils/src" + ], + "@marxan/utils/*": [ + "libs/utils/src/*" ] } }, @@ -25,4 +31,4 @@ "node_modules", "dist" ] -} +} \ No newline at end of file