Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
{
"use": "ca.concordia.encs.citydata.producers.CKANProducer",
"withParams": [
{
"name": "url",
"value": "https://ngci.encs.concordia.ca/ckan/api/3"
},
{
"name": "resourceId",
"value": "1823209d-e66e-4a8e-b12a-2aced638da4c"
}
],
"apply": [
{
"name": "ca.concordia.encs.citydata.operations.GeoJsonFilterOperation",
"withParams": [
{
"name": "centerLongitude",
"value": -73.74085
},
{
"name": "centerLatitude",
"value": 45.51895
},
{
"name": "radiusMeters",
"value": 200.0
}
]
}
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
{
"use": "ca.concordia.encs.citydata.producers.CKANProducer",
"withParams": [
{
"name": "url",
"value": "https://ngci.encs.concordia.ca/ckan/api/3"
},
{
"name": "resourceId",
"value": "a98f2d84-aa71-4adc-a12e-480ce133877e"
}
],
"apply": [
{
"name": "ca.concordia.encs.citydata.operations.GeoJsonFilterOperation",
"withParams": [
{
"name": "centerLongitude",
"value": -73.74085
},
{
"name": "centerLatitude",
"value": 45.51895
},
{
"name": "radiusMeters",
"value": 200.0
}
]
}
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
package ca.concordia.encs.citydata.operations;

import java.util.ArrayList;
import java.util.List;
import java.util.Map;

import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;

import ca.concordia.encs.citydata.core.contracts.IOperation;
import ca.concordia.encs.citydata.core.implementations.AbstractOperation;

/**
* Filters GeoJSON FeatureCollection features by distance to a given point.
* A feature is kept when its geometry centroid is within radiusMeters.
*
* @author Aboolfazl Rezaei
* @since 2026-02-25
*/
public class GeoJsonFilterOperation extends AbstractOperation<String> implements IOperation<String> {

private static final double EARTH_RADIUS_METERS = 6371000.0;

private Double centerLongitude;
private Double centerLatitude;
private Double radiusMeters;

public void setCenterLongitude(Double centerLongitude) {
this.centerLongitude = centerLongitude;
}

public void setCenterLatitude(Double centerLatitude) {
this.centerLatitude = centerLatitude;
}

public void setRadiusMeters(Double radiusMeters) {
this.radiusMeters = radiusMeters;
}

@Override
public ArrayList<String> apply(ArrayList<String> inputs) {
this.validateParameters();
final ArrayList<String> filteredOutputs = new ArrayList<>();

for (String input : inputs) {
if (input == null || input.isBlank()) {
continue;
}

final JsonElement element = JsonParser.parseString(input);
if (element.isJsonObject()) {
final JsonObject object = element.getAsJsonObject();
filteredOutputs.add(filterElementIfFeatureCollection(object).toString());
} else if (element.isJsonArray()) {
final JsonArray resultArray = new JsonArray();
for (JsonElement child : element.getAsJsonArray()) {
if (child.isJsonObject()) {
resultArray.add(filterElementIfFeatureCollection(child.getAsJsonObject()));
} else {
resultArray.add(child);
}
}
filteredOutputs.add(resultArray.toString());
} else {
filteredOutputs.add(input);
}
}
return filteredOutputs;
}

private JsonObject filterElementIfFeatureCollection(JsonObject inputObject) {
if (!inputObject.has("type") || !"FeatureCollection".equalsIgnoreCase(inputObject.get("type").getAsString())
|| !inputObject.has("features") || !inputObject.get("features").isJsonArray()) {
return inputObject;
}

final JsonArray inputFeatures = inputObject.getAsJsonArray("features");
final JsonArray filteredFeatures = new JsonArray();

for (JsonElement featureElement : inputFeatures) {
if (!featureElement.isJsonObject()) {
continue;
}
final JsonObject feature = featureElement.getAsJsonObject();
final Coordinate centroid = getFeatureCentroid(feature);
if (centroid == Coordinate.NONE) {
continue;
}

final double distance = haversineMeters(this.centerLatitude, this.centerLongitude, centroid.latitude,
centroid.longitude);
if (distance <= this.radiusMeters) {
filteredFeatures.add(feature);
}
}

final JsonObject output = new JsonObject();
for (Map.Entry<String, JsonElement> entry : inputObject.entrySet()) {
if (!"features".equals(entry.getKey())) {
output.add(entry.getKey(), entry.getValue());
}
}
output.add("features", filteredFeatures);
return output;
}

private Coordinate getFeatureCentroid(JsonObject feature) {
if (feature == null || !feature.has("geometry") || !feature.get("geometry").isJsonObject()) {
return Coordinate.NONE;
}
return getGeometryCentroid(feature.getAsJsonObject("geometry"));
}

private Coordinate getGeometryCentroid(JsonObject geometry) {
if (geometry == null || !geometry.has("type") || !geometry.get("type").isJsonPrimitive()) {
return Coordinate.NONE;
}
final List<Coordinate> coordinates = new ArrayList<>();
collectCoordinatesFromGeometry(geometry, coordinates);

if (coordinates.isEmpty()) {
return Coordinate.NONE;
}
return averageCoordinates(coordinates);
}

private void collectCoordinatesFromGeometry(JsonObject geometry, List<Coordinate> collector) {
if (geometry == null || !geometry.has("type") || !geometry.get("type").isJsonPrimitive()) {
return;
}
final String geometryType = geometry.get("type").getAsString();

if ("GeometryCollection".equalsIgnoreCase(geometryType) && geometry.has("geometries")
&& geometry.get("geometries").isJsonArray()) {
for (JsonElement child : geometry.getAsJsonArray("geometries")) {
if (child.isJsonObject()) {
collectCoordinatesFromGeometry(child.getAsJsonObject(), collector);
}
}
return;
}

if (geometry.has("coordinates")) {
collectCoordinatesRecursive(geometry.get("coordinates"), collector);
}
}

private void collectCoordinatesRecursive(JsonElement coordinatesElement, List<Coordinate> collector) {
if (coordinatesElement == null || !coordinatesElement.isJsonArray()) {
return;
}
final JsonArray array = coordinatesElement.getAsJsonArray();
if (isCoordinatePair(array)) {
collector.add(new Coordinate(array.get(1).getAsDouble(), array.get(0).getAsDouble()));
return;
}

for (JsonElement child : array) {
collectCoordinatesRecursive(child, collector);
}
}

private boolean isCoordinatePair(JsonArray array) {
return array.size() >= 2 && array.get(0).isJsonPrimitive() && array.get(1).isJsonPrimitive()
&& array.get(0).getAsJsonPrimitive().isNumber() && array.get(1).getAsJsonPrimitive().isNumber();
}

private Coordinate averageCoordinates(List<Coordinate> coordinates) {
double lat = 0.0;
double lon = 0.0;
for (Coordinate coordinate : coordinates) {
lat += coordinate.latitude;
lon += coordinate.longitude;
}
return new Coordinate(lat / coordinates.size(), lon / coordinates.size());
}

private double haversineMeters(double latitudeA, double longitudeA, double latitudeB, double longitudeB) {
final double latitudeDelta = Math.toRadians(latitudeB - latitudeA);
final double longitudeDelta = Math.toRadians(longitudeB - longitudeA);
final double base = Math.pow(Math.sin(latitudeDelta / 2.0), 2)
+ Math.cos(Math.toRadians(latitudeA)) * Math.cos(Math.toRadians(latitudeB))
* Math.pow(Math.sin(longitudeDelta / 2.0), 2);
return 2.0 * EARTH_RADIUS_METERS * Math.asin(Math.sqrt(base));
}

private void validateParameters() {
if (this.centerLatitude == null || this.centerLongitude == null || this.radiusMeters == null) {
throw new IllegalArgumentException(
"GeoJsonFilterOperation requires centerLatitude, centerLongitude and radiusMeters.");
}
if (this.centerLatitude < -90.0 || this.centerLatitude > 90.0) {
throw new IllegalArgumentException("centerLatitude must be in range [-90, 90].");
}
if (this.centerLongitude < -180.0 || this.centerLongitude > 180.0) {
throw new IllegalArgumentException("centerLongitude must be in range [-180, 180].");
}
if (this.radiusMeters <= 0.0) {
throw new IllegalArgumentException("radiusMeters must be > 0.");
}
}

private static final class Coordinate {
static final Coordinate NONE = new Coordinate(Double.NaN, Double.NaN);

private final double latitude;
private final double longitude;

private Coordinate(double latitude, double longitude) {
this.latitude = latitude;
this.longitude = longitude;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,12 @@ void testListOperationsController() throws Exception {
mockMvc.perform(get("/operations/list")).andExpect(status().is2xxSuccessful())
.andExpect(content().string(containsString("ca.concordia.encs.citydata.operations.MergeOperation")))
.andExpect(content().string(containsString("targetProducerParams")))
.andExpect(content().string(containsString("targetProducer")));
.andExpect(content().string(containsString("targetProducer")))
.andExpect(content()
.string(containsString("ca.concordia.encs.citydata.operations.GeoJsonFilterOperation")))
.andExpect(content().string(containsString("centerLongitude")))
.andExpect(content().string(containsString("centerLatitude")))
.andExpect(content().string(containsString("radiusMeters")));
}

@Test
Expand Down
Copy link
Copy Markdown
Collaborator

@MinetteMeyo MinetteMeyo Mar 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here you provided a Unit Test. Unless you don't want any user to run it through an HTTP request, it's ok. But since you'll finally merge it into the project, I suggest you update the test into an integration test.

The issue: you instantiate and call the operation directly final GeoJsonFilterOperation operation = new GeoJsonFilterOperation(); final ArrayList<String> output = operation.apply(input);
insteal of wiring the producer's (CKANProducer here, according to your operation and queries) output through the SequentialRunner into the operation input as it is done in all the integration tests. This will provide a real query payload with CKANProducer as the data source and GeoJsonOperation as the operation to apply, and this is consistent with how the other operation is tested.

You can get inspired by MergeOperationTest.java

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the comment and your review. I have changed this to an integration test with AI's help

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, I disabled testFilterDhnBuildingsByPointRadius, the buildings CKAN dataset is too large for the CI heap.

I switched testMissingRadiusParameterReturnsError to use the roads payload instead of buildings: the roads dataset fetches successfully within the heap limits, then the operation fails fast with IllegalArgumentException when radiusMeters is missing.

Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package ca.concordia.encs.citydata.operations;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.http.MediaType;

import com.google.gson.JsonArray;
import com.google.gson.JsonObject;

import ca.concordia.encs.citydata.PayloadFactory;
import ca.concordia.encs.citydata.config.TestConfig;
import ca.concordia.encs.citydata.core.BaseIntegrationTest;
import ca.concordia.encs.citydata.core.configs.AppConfig;

/**
* Integration tests for GeoJsonFilterOperation.
*
* @author Aboolfazl Rezaei
* @since 2026-02-25
*/
@SpringBootTest(classes = { AppConfig.class, TestConfig.class })
@AutoConfigureMockMvc
@ComponentScan(basePackages = "ca.concordia.encs.citydata.core")
public class GeoJsonFilterOperationTest extends BaseIntegrationTest {

@Test
@Disabled("DHN buildings dataset is too large for heap-constrained CI β€” known CKANProducer limitation")
public void testFilterDhnBuildingsByPointRadius() throws Exception {
String jsonPayload = PayloadFactory.getExampleQuery("dhnBuildingsGeoJsonFilterByPointRadius");
mockMvc.perform(post("/apply/sync").header("Authorization", "Bearer " + getToken())
.contentType(MediaType.APPLICATION_JSON).content(jsonPayload)).andExpect(status().isOk());
}

@Test
public void testFilterDhnRoadsByPointRadius() throws Exception {
String jsonPayload = PayloadFactory.getExampleQuery("dhnRoadsGeoJsonFilterByPointRadius");
mockMvc.perform(post("/apply/sync").header("Authorization", "Bearer " + getToken())
.contentType(MediaType.APPLICATION_JSON).content(jsonPayload)).andExpect(status().isOk());
}

@Test
public void testMissingRadiusParameterReturnsError() throws Exception {
String jsonPayload = PayloadFactory.getExampleQuery("dhnRoadsGeoJsonFilterByPointRadius");
JsonObject jsonObject = com.google.gson.JsonParser.parseString(jsonPayload).getAsJsonObject();
JsonArray withParams = jsonObject.getAsJsonArray("apply").get(0).getAsJsonObject()
.getAsJsonArray("withParams");
for (int i = 0; i < withParams.size(); i++) {
if (withParams.get(i).getAsJsonObject().get("name").getAsString().equals("radiusMeters")) {
withParams.remove(i);
break;
}
}
mockMvc.perform(post("/apply/sync").header("Authorization", "Bearer " + getToken())
.contentType(MediaType.APPLICATION_JSON).content(jsonObject.toString()))
.andExpect(status().is5xxServerError());
}

@Test
public void testInvalidJsonReturnsClientError() throws Exception {
String brokenJson = PayloadFactory.getInvalidJson();
mockMvc.perform(post("/apply/sync").header("Authorization", "Bearer " + getToken())
.contentType(MediaType.APPLICATION_JSON).content(brokenJson))
.andExpect(status().is4xxClientError());
}
}
Loading