diff --git a/.gitignore b/.gitignore index 3f060f5..f872e7b 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,6 @@ lerna-debug.log* .env.docker.dev .env.prod swagger-description.md +bom.json +sbom.cyclonedx.json +tsconfig.build.tsbuildinfo \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index c0bf728..3acd7e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ The format follows [Keep a Changelog 1.1.0](https://keepachangelog.com/en/1.1.0/ - `GEOSPATIAL_ANALYZER_CORS_ORIGINS` environment variable to configure allowed CORS origins as a comma-separated list. CORS remains disabled when the variable is unset. - `sn_flurstueck_f`/`flurstueck_f` now provide `gemarkungsschluessel` and `gemarkungsname`. +- `intersect` and `within` now provide optional `buffer` (distance in meters; value range: 0 - 20 000). Using a polygonal approximation, its deviation from the exact circular arc is at most 10cm. The optional specification of `returnBufferGeometry` (boolean) returns its coordinates. ### Changed diff --git a/documentation/intersect.md b/documentation/intersect.md index bd3218c..b1542c7 100644 --- a/documentation/intersect.md +++ b/documentation/intersect.md @@ -5,6 +5,8 @@ In this document we will describe important and good-to-know facts about the int ## Functionality Returns all features that are touched by the transferred geometries. +An optional buffer around each input geometry is also supported. +Using the buffer parameter, its distance can be entered in meters (between 0 and 20 000m). ## Examples @@ -37,7 +39,9 @@ Post-call http://localhost:3000/v2/intersect with JSON body: "topics": ["kreis_f"], "returnGeometry": false, "outputFormat": "geojson", - "outSRS": 4326 + "outSRS": 4326, + "buffer": 100, + "returnBufferGeometry": false } ``` @@ -60,7 +64,9 @@ Post-call http://localhost:3000/v2/intersect with JSON body: "topics": ["kreis_f", "land_f"], "returnGeometry": false, "outputFormat": "geojson", - "outSRS": 4326 + "outSRS": 4326, + "buffer": 100, + "returnBufferGeometry": false } ``` @@ -86,7 +92,9 @@ Post-call http://localhost:3000/v2/intersect with JSON body: "topics": ["kreis_f"], "returnGeometry": false, "outputFormat": "geojson", - "outSRS": 4326 + "outSRS": 4326, + "buffer": 100, + "returnBufferGeometry": false } ``` @@ -111,7 +119,9 @@ Post-call http://localhost:3000/v2/intersect with JSON body: "topics": ["kreis_f"], "returnGeometry": false, "outputFormat": "esrijson", - "outSRS": 25833 + "outSRS": 25833, + "buffer": 100, + "returnBufferGeometry": false } ``` @@ -122,3 +132,8 @@ Post-call http://localhost:3000/v2/intersect with JSON body: - MultiLineString - MultiPoint - GeometryCollection + +- Accuracy of buffer is limited (10cm) + - polygonal approximation using quadSegs (count of segments per quarter circle) + - count depends on buffer distance (so max. error from "real" buffer is 10cm) + - max. 256 quadSegs \ No newline at end of file diff --git a/documentation/neighbour.md b/documentation/neighbour.md index b357e32..f46ff42 100644 --- a/documentation/neighbour.md +++ b/documentation/neighbour.md @@ -117,7 +117,7 @@ Post-call http://localhost:3000/v2/nearestNeighbour with JSON body: ## Known Limitations - Work in progres -- Currently, unsupported user parameter +- Unsupported user parameter: - buffer - Complete parameter validation - API token authentication diff --git a/documentation/within.md b/documentation/within.md index e4f8767..1c8623f 100644 --- a/documentation/within.md +++ b/documentation/within.md @@ -5,6 +5,8 @@ In this document we will describe important and good-to-know facts about the wit ## Functionality Returns all features, where the transferred geometries are completely contained. +An optional buffer around each input geometry is also supported. +Using the buffer parameter, its distance can be entered in meters (between 0 and 20 000m). ## Examples @@ -37,7 +39,9 @@ Post-call http://localhost:3000/v2/within with JSON body: "topics": ["kreis_f"], "returnGeometry": false, "outputFormat": "geojson", - "outSRS": 4326 + "outSRS": 4326, + "buffer": 100, + "returnBufferGeometry": false } ``` @@ -60,7 +64,9 @@ Post-call http://localhost:3000/v2/within with JSON body: "topics": ["kreis_f", "land_f"], "returnGeometry": false, "outputFormat": "geojson", - "outSRS": 4326 + "outSRS": 4326, + "buffer": 100, + "returnBufferGeometry": false } ``` @@ -86,7 +92,9 @@ Post-call http://localhost:3000/v2/within with JSON body: "topics": ["kreis_f"], "returnGeometry": false, "outputFormat": "geojson", - "outSRS": 4326 + "outSRS": 4326, + "buffer": 100, + "returnBufferGeometry": false } ``` @@ -111,7 +119,9 @@ Post-call http://localhost:3000/v2/within with JSON body: "topics": ["kreis_f"], "returnGeometry": false, "outputFormat": "geojson", - "outSRS": 4326 + "outSRS": 4326, + "buffer": 100, + "returnBufferGeometry": false } ``` @@ -122,3 +132,8 @@ Post-call http://localhost:3000/v2/within with JSON body: - MultiLineString - MultiPoint - GeometryCollection + +- Accuracy of buffer is limited (10cm) + - polygonal approximation using quadSegs (count of segments per quarter circle) + - count depends on buffer distance (so max. error from "real" buffer is 10cm) + - max. 256 quadSegs \ No newline at end of file diff --git a/src/general/adapter/postgres.service.ts b/src/general/adapter/postgres.service.ts index 0ddb5ae..955fde2 100644 --- a/src/general/adapter/postgres.service.ts +++ b/src/general/adapter/postgres.service.ts @@ -34,6 +34,19 @@ export class PostgresService extends DbAdapterService { return `ST_TRANSFORM(${featureWkt.value}::text, ${toCrs})`; } + override bufferFeature( + feature: SqlParameter, + distance: SqlParameter, + srid: number, + quadSegs: number, + ): string { + return `ST_Transform( + ST_Buffer( + ST_Transform(${feature.value}, 4326)::geography, + ${distance.value}, 'quad_segs=${quadSegs}')::geometry, + ${srid})`; + } + override getJsonStructure(returnGeometry: boolean): string { const recordValue = returnGeometry ? `ST_AsGeoJSON( @@ -57,6 +70,29 @@ export class PostgresService extends DbAdapterService { `; } + override getBufferJsonStructure( + bufferGeometry: string, + bufferDistance: string, + ): string { + return ` + json_build_object( + 'type', 'FeatureCollection', + 'features', json_build_array( + json_build_object( + 'type', 'Feature', + 'geometry', + ST_AsGeoJSON(${bufferGeometry})::json, + 'properties', + json_build_object( + '__buffer', true, + '__bufferDistance', ${bufferDistance} + ) + ) + ) + ) + `; + } + override getJsonRecordAlias(): string { return 'custom_from_select'; } diff --git a/src/general/buffer.util.spec.ts b/src/general/buffer.util.spec.ts new file mode 100644 index 0000000..cde22a8 --- /dev/null +++ b/src/general/buffer.util.spec.ts @@ -0,0 +1,22 @@ +import { getBufferQuadSegs } from './buffer.util'; + +describe('getBufferQuadSegs', () => { + it('returns the minimum (8) for buffer distance <= 0', () => { + expect(getBufferQuadSegs(0)).toBe(8); + expect(getBufferQuadSegs(-5)).toBe(8); + }); + + it('returns more segments for larger buffer distances (finer approximation needed)', () => { + const small = getBufferQuadSegs(1); + const large = getBufferQuadSegs(10000); + expect(large).toBeGreaterThan(small); + }); + + it('never exceeds the maximum of 256', () => { + expect(getBufferQuadSegs(20000)).toBeLessThanOrEqual(256); + }); + + it('never falls below the minimum of 8', () => { + expect(getBufferQuadSegs(0.001)).toBeGreaterThanOrEqual(8); + }); +}); diff --git a/src/general/buffer.util.ts b/src/general/buffer.util.ts new file mode 100644 index 0000000..85d334c --- /dev/null +++ b/src/general/buffer.util.ts @@ -0,0 +1,19 @@ +export function getBufferQuadSegs(bufferDistance: number): number { + const maxError = 0.1; + const minQuadSegs = 8; + const maxQuadSegs = 256; + + if (bufferDistance <= 0) { + return minQuadSegs; + } + + if (maxError >= 2 * bufferDistance) { + return minQuadSegs; + } + + const quadSegs = Math.ceil( + Math.PI / (4 * Math.acos(1 - maxError / bufferDistance)), + ); + + return Math.min(maxQuadSegs, Math.max(minQuadSegs, quadSegs)); +} diff --git a/src/general/db-adapter.service.ts b/src/general/db-adapter.service.ts index 6ff82a0..f6617cb 100644 --- a/src/general/db-adapter.service.ts +++ b/src/general/db-adapter.service.ts @@ -29,6 +29,18 @@ export abstract class DbAdapterService { abstract getValueAtFeature(point: SqlParameter, raster: SqlParameter): string; + abstract bufferFeature( + feature: SqlParameter, + distance: SqlParameter, + srid: number, + quadSegs: number, + ): string; + + abstract getBufferJsonStructure( + bufferGeometry: string, + bufferDistance: string, + ): string; + abstract transformFeature(featureWkt: SqlParameter, toCrs: number): string; abstract getJsonStructure(returnGeometry: boolean): string; diff --git a/src/general/dto/parameter.dto.ts b/src/general/dto/parameter.dto.ts index 2f747f5..29533c9 100644 --- a/src/general/dto/parameter.dto.ts +++ b/src/general/dto/parameter.dto.ts @@ -44,6 +44,22 @@ export const SCHEMA_VALID_OUT_SRS: Readonly = { ], }; +export const SCHEMA_VALID_RETURN_BUFFER_GEOMETRY: Readonly = { + anyOf: [ + { + properties: { + returnBufferGeometry: { enum: [false] }, + }, + }, + { + required: ['buffer'], + properties: { + buffer: { not: { enum: [0] } }, + }, + }, + ], +}; + @ApiExtraModels(EsriJsonDto, GeoJSONFeatureDto, GeoJSONFeatureCollectionDto) export class ParameterDto implements GeospatialRequest { @ApiProperty({ @@ -125,7 +141,30 @@ export class ParameterDto implements GeospatialRequest { outSRS: number; } -export class IntersectParameterDto extends ParameterDto {} +export const MAX_BUFFER_DISTANCE = 20000; + +export class BufferParameterDto extends ParameterDto { + @ApiProperty({ + required: false, + type: Number, + example: 100, + description: 'Buffer distance around each input geometry in meters', + minimum: 0, + maximum: MAX_BUFFER_DISTANCE, + }) + buffer?: number; + + @ApiProperty({ + required: false, + default: false, + description: + 'Return the calculated buffer geometry of the input geometry in the response', + example: false, + }) + returnBufferGeometry?: boolean; +} + +export class IntersectParameterDto extends BufferParameterDto {} export class NearestNeighbourParameterDto extends ParameterDto { @ApiProperty({ example: 3 }) count: number; @@ -140,4 +179,4 @@ export class ValuesAtPointParameterDto extends ParameterDto { }) topics: string[]; } -export class WithinParameterDto extends ParameterDto {} +export class WithinParameterDto extends BufferParameterDto {} diff --git a/src/general/general.constants.ts b/src/general/general.constants.ts index e26228f..df4349a 100644 --- a/src/general/general.constants.ts +++ b/src/general/general.constants.ts @@ -31,6 +31,7 @@ export const DB_RASTER_DATA_NAME = 'rast'; export const DB_TOPIC_NAME = 'topic'; export const QUERY_FEATURE_INDEX = 'feature_wkt_'; +export const QUERY_BUFFER_INDEX = 'buffer_'; /** * Choosing an appropriate status code is surprisingly unclear: diff --git a/src/general/general.service.ts b/src/general/general.service.ts index 6c2caef..6ca88a2 100644 --- a/src/general/general.service.ts +++ b/src/general/general.service.ts @@ -502,28 +502,46 @@ export class GeneralService { } else { features.forEach((feature) => { feature.properties.__requestParams = requestParams; + + const isBufferFeature = feature.properties.__buffer === true; + feature.properties.__geoProperties = map.get(result.id); + + if (isBufferFeature) { + return; + } + feature.properties.__topic = result.topic; const sourceName = (feature.properties as any)[ SOURCE_NAME_PROPERTY ] as string | undefined; + const source = this._resolveSource(result.topic, sourceName); // merge per field: source override wins, topic-level is fallback const topicValueMetadata = this.identifierValueMetadataMap.get(result.topic) ?? {}; + const unit = source?.unit ?? topicValueMetadata.unit; + const verticalDatum = source?.verticalDatum ?? topicValueMetadata.verticalDatum; - if (unit) (feature.properties as any).__unit = unit; - if (verticalDatum) + + if (unit) { + (feature.properties as any).__unit = unit; + } + + if (verticalDatum) { (feature.properties as any).__verticalDatum = verticalDatum; + } const topicAttribution = this.identifierAttributionMap.get( result.topic, ); + const providers = source?.attribution ?? topicAttribution; + if (providers && providers.length) { (feature.properties as any).__attribution = providers; } @@ -679,6 +697,7 @@ export interface GeospatialLogicalRequest { fieldsToQuery: string[]; topic: string; topicIndex: number; + buffer?: number; } /** @@ -698,4 +717,10 @@ export interface GeospatialRequest { outSRS: number; returnGeometry: boolean; + + /** + * Optional buffer distance and its geometry around each input geometry in meters. + */ + buffer?: number; + returnBufferGeometry?: boolean; } diff --git a/src/general/geospatial.service.ts b/src/general/geospatial.service.ts index 3b11081..788f69b 100644 --- a/src/general/geospatial.service.ts +++ b/src/general/geospatial.service.ts @@ -1,13 +1,16 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, InternalServerErrorException } from '@nestjs/common'; import { DataSource, SelectQueryBuilder } from 'typeorm'; import { TransformService } from '../transform/transform.service'; import { DbAdapterService } from './db-adapter.service'; +import { getBufferQuadSegs } from './buffer.util'; import { EsriJsonDto } from './dto/esri-json.dto'; import { GeoJSONFeatureDto } from './dto/geo-json.dto'; import { DB_FEATURE_ID_NAME, DB_JSON_STRUCTURE_NAME, DB_TOPIC_NAME, + QUERY_FEATURE_INDEX, + QUERY_BUFFER_INDEX, } from './general.constants'; import { SqlLiteral } from './general.interface'; import { @@ -45,6 +48,7 @@ export abstract class GeospatialService { ); const queries = []; + const bufferQueries = []; const params = {}; for (const [featureIndex, feature] of features.entries()) { @@ -66,6 +70,7 @@ export abstract class GeospatialService { this.generalService.identifierAllowedAttributesMap.get(topic), topic, topicIndex, + buffer: request.buffer, }, request, ); @@ -73,13 +78,54 @@ export abstract class GeospatialService { queries.push(queryBuilder.getQuery()); Object.assign(params, queryBuilder.getParameters()); } + if ( + request.returnBufferGeometry === true && + request.buffer !== undefined && + request.buffer > 0 + ) { + const bufferQueryBuilder = this.dataSource + .createQueryBuilder() + .from('(SELECT 1)', 'buffer_source'); + + const bufferGeometry = this.getBufferGeometry( + bufferQueryBuilder, + feature, + featureIndex, + request.buffer, + ); + + const bufferDistanceParameter = `${QUERY_BUFFER_INDEX}output_${featureIndex}`; + + const bufferFeatureIdParameter = `_buffer_feature_id_${featureIndex}`; + + bufferQueryBuilder.setParameter( + bufferFeatureIdParameter, + this.generalService.getAndSetGeoID(feature, featureIndex), + ); + + bufferQueryBuilder + .select(`'__BUFFER__'`, DB_TOPIC_NAME) + .addSelect(`:${bufferFeatureIdParameter}::text`, DB_FEATURE_ID_NAME) + .addSelect( + this.adapter.getBufferJsonStructure( + bufferGeometry, + `:${bufferDistanceParameter}`, + ), + DB_JSON_STRUCTURE_NAME, + ); + + bufferQueries.push(bufferQueryBuilder.getQuery()); + Object.assign(params, bufferQueryBuilder.getParameters()); + } } + const allQueries = [...queries, ...bufferQueries]; + const qb = this.dataSource .createQueryBuilder() .select('*') .from( - this.adapter.unionAll(queries), + this.adapter.unionAll(allQueries), 'union_query', ) .setParameters(params); @@ -143,4 +189,95 @@ export abstract class GeospatialService { return qb; } + protected getInputGeometry( + queryBuilder: SelectQueryBuilder, + srid: number, + feature: GeoJSONFeatureDto, + featureIndex: number, + ): string { + if (feature.geometry === null) { + return this.adapter.transformFeature( + { raw: true, value: `ST_GeomFromText('POINT EMPTY', 4326)` }, + srid, + ); + } + + const featureParameter = `${QUERY_FEATURE_INDEX}${featureIndex}`; + + queryBuilder.setParameter( + featureParameter, + JSON.stringify(feature.geometry), + ); + + return this.adapter.transformFeature( + { raw: true, value: `ST_GeomFromGeoJSON(:${featureParameter})` }, + srid, + ); + } + protected getAnalysisGeometry( + queryBuilder: SelectQueryBuilder, + srid: number, + feature: GeoJSONFeatureDto, + featureIndex: number, + buffer?: number, + ): string { + let queryFeature = this.getInputGeometry( + queryBuilder, + srid, + feature, + featureIndex, + ); + + if (buffer !== undefined && buffer > 0) { + const bufferParameter = `${QUERY_BUFFER_INDEX}${featureIndex}`; + + queryBuilder.setParameter(bufferParameter, buffer); + + queryFeature = this.adapter.bufferFeature( + { + raw: true, + value: queryFeature, + }, + { + raw: true, + value: `:${bufferParameter}`, + }, + srid, + getBufferQuadSegs(buffer), + ); + } + + return queryFeature; + } + + protected getBufferGeometry( + queryBuilder: SelectQueryBuilder, + feature: GeoJSONFeatureDto, + featureIndex: number, + buffer: number, + ): string { + if (buffer <= 0) { + throw new InternalServerErrorException( + 'getBufferGeometry called with a non-positive buffer distance', + ); + } + + const inputGeometry = this.getInputGeometry( + queryBuilder, + 4326, + feature, + featureIndex, + ); + + const bufferParameter = `${QUERY_BUFFER_INDEX}output_${featureIndex}`; + + queryBuilder.setParameter(bufferParameter, buffer); + + return this.adapter.bufferFeature( + { raw: true, value: inputGeometry }, + { raw: true, value: `:${bufferParameter}` }, + 4326, + getBufferQuadSegs(buffer), + ); + } } diff --git a/src/intersect/intersect.controller.ts b/src/intersect/intersect.controller.ts index 0d3d543..96a96ea 100644 --- a/src/intersect/intersect.controller.ts +++ b/src/intersect/intersect.controller.ts @@ -12,6 +12,7 @@ import { GeoJSONFeatureDto } from '../general/dto/geo-json.dto'; import { IntersectParameterDto, SCHEMA_VALID_OUT_SRS, + SCHEMA_VALID_RETURN_BUFFER_GEOMETRY, } from '../general/dto/parameter.dto'; import { TopicDefinitionOutsideDto } from '../general/dto/topic-definition-outside.dto'; import { HTTP_STATUS_SQL_TIMEOUT } from '../general/general.constants'; @@ -44,6 +45,7 @@ export class IntersectController { allOf: [ { $ref: getSchemaPath(IntersectParameterDto) }, SCHEMA_VALID_OUT_SRS, + SCHEMA_VALID_RETURN_BUFFER_GEOMETRY, ], }, }) diff --git a/src/intersect/intersect.service.ts b/src/intersect/intersect.service.ts index e206f8d..2c0da18 100644 --- a/src/intersect/intersect.service.ts +++ b/src/intersect/intersect.service.ts @@ -1,13 +1,8 @@ import { Injectable } from '@nestjs/common'; -import { geojsonToWKT } from '@terraformer/wkt'; import { DataSource, SelectQueryBuilder } from 'typeorm'; import { GeoJSONFeatureDto } from '../general/dto/geo-json.dto'; import { IntersectParameterDto } from '../general/dto/parameter.dto'; -import { - DB_GEOMETRY_NAME, - QUERY_FEATURE_INDEX, - STANDARD_SRID, -} from '../general/general.constants'; +import { DB_GEOMETRY_NAME } from '../general/general.constants'; import { topicDefinitionOutside } from '../general/general.interface'; import { GeneralService, @@ -36,7 +31,8 @@ export class IntersectService extends GeospatialService { queryBuilder: SelectQueryBuilder, logicalRequest: GeospatialLogicalRequest, ): void { - const { fieldsToQuery, topic, feature, featureIndex } = logicalRequest; + const { fieldsToQuery, topic, feature, featureIndex, buffer } = + logicalRequest; const topicSource = this.generalService.getSourceForIdentifier(topic); @@ -55,6 +51,7 @@ export class IntersectService extends GeospatialService { topicSource.srid, feature, featureIndex, + buffer, ); queryBuilder.andWhere(featureIntersect); } @@ -64,22 +61,14 @@ export class IntersectService extends GeospatialService { srid: number, feature: GeoJSONFeatureDto, featureIndex: number, + buffer?: number, ): string { - // GeoJSON RFC7946: A Feature object's geometry member may be null (section - // 3.2), but a Geometry object itself may never be null (section 3.1). - const featureWkt = - feature.geometry !== null - ? geojsonToWKT(feature.geometry) - : 'POINT EMPTY'; - - // setup for left and right side of intersect - queryStart.setParameter( - `${QUERY_FEATURE_INDEX}${featureIndex}`, - STANDARD_SRID + featureWkt, - ); - const queryFeature = this.adapter.transformFeature( - { raw: true, value: `:${QUERY_FEATURE_INDEX}${featureIndex}` }, + const queryFeature = this.getAnalysisGeometry( + queryStart, srid, + feature, + featureIndex, + buffer, ); // intersect call diff --git a/src/nearest-neighbour/nearest-neighbour.service.ts b/src/nearest-neighbour/nearest-neighbour.service.ts index 4fc965b..153738d 100644 --- a/src/nearest-neighbour/nearest-neighbour.service.ts +++ b/src/nearest-neighbour/nearest-neighbour.service.ts @@ -1,14 +1,8 @@ import { Injectable } from '@nestjs/common'; -import { geojsonToWKT } from '@terraformer/wkt'; import { DataSource, LessThanOrEqual, SelectQueryBuilder } from 'typeorm'; import { GeoJSONFeatureDto } from '../general/dto/geo-json.dto'; import { NearestNeighbourParameterDto } from '../general/dto/parameter.dto'; -import { - DB_DIST_NAME, - DB_GEOMETRY_NAME, - QUERY_FEATURE_INDEX, - STANDARD_SRID, -} from '../general/general.constants'; +import { DB_DIST_NAME, DB_GEOMETRY_NAME } from '../general/general.constants'; import { topicDefinitionOutside } from '../general/general.interface'; import { GeneralService, @@ -76,14 +70,11 @@ export class NearestNeighbourService extends GeospatialService { queryBuilder: SelectQueryBuilder, logicalRequest: GeospatialLogicalRequest, ): void { - const { fieldsToQuery, topic, feature, featureIndex } = logicalRequest; + const { fieldsToQuery, topic, feature, featureIndex, buffer } = + logicalRequest; const topicSource = this.generalService.getSourceForIdentifier(topic); @@ -53,6 +49,7 @@ export class WithinService extends GeospatialService { topicSource.srid, feature, featureIndex, + buffer, ); queryBuilder.andWhere(featureWithin); } @@ -62,15 +59,14 @@ export class WithinService extends GeospatialService { srid: number, feature: GeoJSONFeatureDto, featureIndex: number, + buffer?: number, ): string { - // setup for left and right side of Within - queryStart.setParameter( - `${QUERY_FEATURE_INDEX}${featureIndex}`, - STANDARD_SRID + geojsonToWKT(feature.geometry), - ); - const queryFeature = this.adapter.transformFeature( - { raw: true, value: `:${QUERY_FEATURE_INDEX}${featureIndex}` }, + const queryFeature = this.getAnalysisGeometry( + queryStart, srid, + feature, + featureIndex, + buffer, ); // Within call diff --git a/test/buffer.e2e-spec.ts b/test/buffer.e2e-spec.ts new file mode 100644 index 0000000..dc50349 --- /dev/null +++ b/test/buffer.e2e-spec.ts @@ -0,0 +1,245 @@ +import { + FastifyAdapter, + NestFastifyApplication, +} from '@nestjs/platform-fastify'; +import { Test, TestingModule } from '@nestjs/testing'; +import { setUpOpenAPIAndValidation } from '../src/app-init'; +import { createE2eTestModules } from './helpers/database.helper'; +import { GeneralModule } from '../src/general/general.module'; +import { TransformModule } from '../src/transform/transform.module'; +import { WithinController } from '../src/within/within.controller'; +import { WithinService } from '../src/within/within.service'; +import { IntersectController } from '../src/intersect/intersect.controller'; +import { IntersectService } from '../src/intersect/intersect.service'; +import { NearestNeighbourController } from '../src/nearest-neighbour/nearest-neighbour.controller'; +import { NearestNeighbourService } from '../src/nearest-neighbour/nearest-neighbour.service'; +import { getGeoJSONFeatureFromResponse } from './common/test'; +import { MAX_BUFFER_DISTANCE } from '../src/general/dto/parameter.dto'; +import { + HEADERS_JSON, + POST, + URL_START, + WITHIN_URL, + INTERSECT_URL, + NEAREST_URL, +} from './common/constants'; +import { getGeoJSONFeature } from './common/testDataPreparer'; + +describe('Buffer parameter validation (e2e)', () => { + let app: NestFastifyApplication; + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + controllers: [ + WithinController, + IntersectController, + NearestNeighbourController, + ], + imports: [...createE2eTestModules(), GeneralModule, TransformModule], + providers: [WithinService, IntersectService, NearestNeighbourService], + }).compile(); + + app = moduleFixture.createNestApplication( + new FastifyAdapter(), + ); + await setUpOpenAPIAndValidation(app); + await app.init(); + await app.getHttpAdapter().getInstance().ready(); + }); + + afterAll(async () => { + await app.close(); + }); + + const ENDPOINTS = [ + { name: 'within', url: WITHIN_URL, extraFields: {} }, + { name: 'intersect', url: INTERSECT_URL, extraFields: {} }, + ]; + + describe.each(ENDPOINTS)('$name', ({ url, extraFields }) => { + it('rejects returnBufferGeometry: true without buffer', async () => { + const payload = { + ...getGeoJSONFeature({ topics: ['kreis_f'] }), + ...extraFields, + returnBufferGeometry: true, + }; + const result = await app.inject({ + method: POST, + url: URL_START + url, + payload, + headers: HEADERS_JSON, + }); + expect(result.statusCode).toBe(400); + }); + + it('rejects returnBufferGeometry: true together with buffer: 0', async () => { + const payload = { + ...getGeoJSONFeature({ topics: ['kreis_f'] }), + ...extraFields, + buffer: 0, + returnBufferGeometry: true, + }; + const result = await app.inject({ + method: POST, + url: URL_START + url, + payload, + headers: HEADERS_JSON, + }); + expect(result.statusCode).toBe(400); + }); + + it('rejects a buffer distance above the configured maximum', async () => { + const payload = { + ...getGeoJSONFeature({ topics: ['kreis_f'] }), + ...extraFields, + buffer: MAX_BUFFER_DISTANCE + 1, + returnBufferGeometry: true, + }; + const result = await app.inject({ + method: POST, + url: URL_START + url, + payload, + headers: HEADERS_JSON, + }); + expect(result.statusCode).toBe(400); + }); + + it('[KNOWN BEHAVIOR] accepts buffer > 0 without returnBufferGeometry at all', async () => { + const payload = { + ...getGeoJSONFeature({ topics: ['kreis_f'] }), + ...extraFields, + buffer: 100, + }; + const result = await app.inject({ + method: POST, + url: URL_START + url, + payload, + headers: HEADERS_JSON, + }); + expect(result.statusCode).toBe(200); + }); + }); + + it('/POST within: returnBufferGeometry adds exactly one __buffer feature, topic features unchanged', async () => { + const input = { + ...(await getGeoJSONFeature({ + topics: ['kreis_f'], + returnGeometry: false, + fixGeometry: { type: 'Point', coordinates: [13.75, 51.072] }, + })), + buffer: 500, + returnBufferGeometry: true, + }; + const result = await app.inject({ + method: POST, + url: URL_START + WITHIN_URL, + payload: input, + headers: HEADERS_JSON, + }); + expect(result.statusCode).toBe(200); + + const features = await getGeoJSONFeatureFromResponse(result); + + const bufferFeatures = features.filter( + (f) => f.properties?.['__buffer'] === true, + ); + const topicFeatures = features.filter( + (f) => f.properties?.['__buffer'] !== true, + ); + + expect(bufferFeatures.length).toBe(1); + const bufferFeature = bufferFeatures[0]; + expect(bufferFeature.properties['__bufferDistance']).toBe(500); + expect(bufferFeature.properties['__topic']).toBeUndefined(); + expect(bufferFeature.geometry.type).toBe('Polygon'); + + expect(topicFeatures.length).toBe(1); + expect(topicFeatures[0].properties['__topic']).toBe('kreis_f'); + }); + it('/POST intersect: buffer features carry the __geoProperties of their matching input geometry', async () => { + const payload = { + topics: ['kreis_f'], + inputGeometries: [ + { + type: 'Feature', + geometry: { type: 'Point', coordinates: [13.04, 51.19] }, + properties: { __geometryIdentifier__: 'my-custom-id', name: 'first' }, + }, + { + type: 'Feature', + geometry: { type: 'Point', coordinates: [14.05, 51.12] }, + properties: { name: 'second' }, + }, + ], + outputFormat: 'geojson', + returnGeometry: false, + outSRS: 4326, + buffer: 500, + returnBufferGeometry: true, + }; + + const result = await app.inject({ + method: POST, + url: URL_START + INTERSECT_URL, + payload, + headers: HEADERS_JSON, + }); + expect(result.statusCode).toBe(200); + + const features = JSON.parse(result.payload) as any[]; + const bufferFeatures = features.filter( + (f) => f.properties?.__buffer === true, + ); + expect(bufferFeatures).toHaveLength(2); + + const firstBuffer = bufferFeatures.find( + (f) => + f.properties.__geoProperties?.__geometryIdentifier__ === 'my-custom-id', + ); + expect(firstBuffer).toBeDefined(); + expect(firstBuffer.properties.__geoProperties.__geometryIdentifier__).toBe( + 'my-custom-id', + ); + expect(firstBuffer.properties.__geoProperties.name).toBe('first'); + expect(firstBuffer.properties.__topic).toBeUndefined(); + + const secondBuffer = bufferFeatures.find( + (f) => f.properties.__geoProperties?.name === 'second', + ); + expect(secondBuffer).toBeDefined(); + expect(secondBuffer.properties.__geoProperties.__geometryIdentifier__).toBe( + '__ID_1', + ); + }); + it('nearestNeighbour rejects buffer as an unknown field', async () => { + const payload = { + ...getGeoJSONFeature({ topics: ['kreis_f'] }), + count: 1, + maxDistanceToNeighbour: 100000, + buffer: 100, + }; + const result = await app.inject({ + method: POST, + url: URL_START + NEAREST_URL, + payload, + headers: HEADERS_JSON, + }); + expect(result.statusCode).toBe(400); + }); + + it('nearestNeighbour rejects returnBufferGeometry as an unknown field', async () => { + const payload = { + ...getGeoJSONFeature({ topics: ['kreis_f'] }), + count: 1, + maxDistanceToNeighbour: 100000, + returnBufferGeometry: true, + }; + const result = await app.inject({ + method: POST, + url: URL_START + NEAREST_URL, + payload, + headers: HEADERS_JSON, + }); + expect(result.statusCode).toBe(400); + }); +}); diff --git a/test/intersect.e2e-spec.ts b/test/intersect.e2e-spec.ts index 36925e4..693d1b0 100644 --- a/test/intersect.e2e-spec.ts +++ b/test/intersect.e2e-spec.ts @@ -388,4 +388,41 @@ describe('IntersectController (e2e)', () => { expect(result.statusCode).toBe(400); }); + + it('/POST Intersect: a 100m buffer intersects more Flurstücke than no buffer', async () => { + const point = { type: 'Point' as const, coordinates: [13.756, 51.029] }; + + const inputWithoutBuffer = await getGeoJSONFeature({ + topics: ['flurstueck_f'], + returnGeometry: false, + fixGeometry: point, + }); + const resultWithoutBuffer = await app.inject({ + method: POST, + url: URL_START + INTERSECT_URL, + payload: inputWithoutBuffer, + headers: HEADERS_JSON, + }); + expect(resultWithoutBuffer.statusCode).toBe(200); + const withoutBuffer = + await getGeoJSONFeatureFromResponse(resultWithoutBuffer); + + const inputWithBuffer = { + ...(await getGeoJSONFeature({ + topics: ['flurstueck_f'], + returnGeometry: false, + fixGeometry: point, + })), + buffer: 100, + }; + const resultWithBuffer = await app.inject({ + method: POST, + url: URL_START + INTERSECT_URL, + payload: inputWithBuffer, + headers: HEADERS_JSON, + }); + expect(resultWithBuffer.statusCode).toBe(200); + const withBuffer = await getGeoJSONFeatureFromResponse(resultWithBuffer); + expect(withBuffer.length).toBeGreaterThan(withoutBuffer.length); + }); }); diff --git a/test/within.e2e-spec.ts b/test/within.e2e-spec.ts index a2d14a0..7749967 100644 --- a/test/within.e2e-spec.ts +++ b/test/within.e2e-spec.ts @@ -326,4 +326,44 @@ describe('WithinController (e2e)', () => { expect(result.statusCode).toBe(400); }); + + it('/POST within: a buffer around a point can push it outside a small Kreis', async () => { + const point = { type: 'Point' as const, coordinates: [13.75, 51.072] }; + + const inputWithoutBuffer = await getGeoJSONFeature({ + topics: ['kreis_f'], + returnGeometry: false, + fixGeometry: point, + }); + const resultWithoutBuffer = await app.inject({ + method: POST, + url: URL_START + WITHIN_URL, + payload: inputWithoutBuffer, + headers: HEADERS_JSON, + }); + expect(resultWithoutBuffer.statusCode).toBe(200); + const withoutBuffer = + await getGeoJSONFeatureFromResponse(resultWithoutBuffer); + expect(withoutBuffer[0].properties['NO_RESULT']).toBeUndefined(); + + const inputWithBuffer = { + ...(await getGeoJSONFeature({ + topics: ['kreis_f'], + returnGeometry: false, + fixGeometry: point, + })), + buffer: 20000, + }; + const resultWithBuffer = await app.inject({ + method: POST, + url: URL_START + WITHIN_URL, + payload: inputWithBuffer, + headers: HEADERS_JSON, + }); + expect(resultWithBuffer.statusCode).toBe(200); + const withBuffer = await getGeoJSONFeatureFromResponse(resultWithBuffer); + + expect(withBuffer.length).toBe(1); + expect(withBuffer[0].properties['NO_RESULT']).toBe('No result to request'); + }); });