diff --git a/src/main/java/org/springframework/data/geo/CustomMetric.java b/src/main/java/org/springframework/data/geo/CustomMetric.java index 4aafb4cf2..251ee768e 100644 --- a/src/main/java/org/springframework/data/geo/CustomMetric.java +++ b/src/main/java/org/springframework/data/geo/CustomMetric.java @@ -1,5 +1,5 @@ /* - * Copyright 2013-2014 the original author or authors. + * Copyright 2013-2015 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -15,6 +15,8 @@ */ package org.springframework.data.geo; +import org.springframework.util.Assert; + /** * Value object to create custom {@link Metric}s on the fly. * @@ -27,6 +29,7 @@ public class CustomMetric implements Metric { private static final long serialVersionUID = -2972074177454114228L; private final double multiplier; + private final String abbreviation; /** * Creates a custom {@link Metric} using the given multiplier. @@ -34,7 +37,22 @@ public class CustomMetric implements Metric { * @param multiplier */ public CustomMetric(double multiplier) { + + this(multiplier, ""); + } + + /** + * Creates a custom {@link Metric} using the given multiplier and abbreviation. + * + * @param multiplier + * @param abbreviation must not be {@literal null}. + */ + public CustomMetric(double multiplier, String abbreviation) { + + Assert.notNull(abbreviation, "Abbreviation must not be null!"); + this.multiplier = multiplier; + this.abbreviation = abbreviation; } /* @@ -44,4 +62,13 @@ public class CustomMetric implements Metric { public double getMultiplier() { return multiplier; } + + /* + * (non-Javadoc) + * @see org.springframework.data.geo.Metric#getAbbreviation() + */ + @Override + public String getAbbreviation() { + return abbreviation; + } } diff --git a/src/main/java/org/springframework/data/geo/Distance.java b/src/main/java/org/springframework/data/geo/Distance.java index 621ff6fa0..a2095998f 100644 --- a/src/main/java/org/springframework/data/geo/Distance.java +++ b/src/main/java/org/springframework/data/geo/Distance.java @@ -1,5 +1,5 @@ /* - * Copyright 2010-2014 the original author or authors. + * Copyright 2010-2015 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -73,7 +73,7 @@ public class Distance implements Serializable { } /** - * Retruns the {@link Metric} of the {@link Distance}. + * Returns the {@link Metric} of the {@link Distance}. * * @return the metric */ @@ -81,6 +81,16 @@ public class Distance implements Serializable { return metric; } + /** + * Returns a {@link String} representation of the unit the distance is in. + * + * @return the unit + * @see Metric#getAbbreviation() + */ + public String getUnit() { + return metric.getAbbreviation(); + } + /** * Adds the given distance to the current one. The resulting {@link Distance} will be in the same metric as the * current one. diff --git a/src/main/java/org/springframework/data/geo/GeoModule.java b/src/main/java/org/springframework/data/geo/GeoModule.java index 108783eaa..38ff55387 100644 --- a/src/main/java/org/springframework/data/geo/GeoModule.java +++ b/src/main/java/org/springframework/data/geo/GeoModule.java @@ -1,5 +1,5 @@ /* - * Copyright 2014 the original author or authors. + * Copyright 2014-2015 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -18,6 +18,7 @@ package org.springframework.data.geo; import java.util.List; import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.core.Version; import com.fasterxml.jackson.databind.annotation.JsonDeserialize; @@ -47,6 +48,7 @@ public class GeoModule extends SimpleModule { setMixInAnnotation(Polygon.class, PolygonMixin.class); } + @JsonIgnoreProperties("unit") static abstract class DistanceMixin { DistanceMixin(@JsonProperty("value") double value, diff --git a/src/main/java/org/springframework/data/geo/Metric.java b/src/main/java/org/springframework/data/geo/Metric.java index b8040b029..2530cac3e 100644 --- a/src/main/java/org/springframework/data/geo/Metric.java +++ b/src/main/java/org/springframework/data/geo/Metric.java @@ -1,5 +1,5 @@ /* - * Copyright 2011-2014 the original author or authors. + * Copyright 2011-2015 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -32,4 +32,11 @@ public interface Metric extends Serializable { * @return */ double getMultiplier(); + + /** + * Returns the scientific abbreviation of the unit the {@link Metric} is in. + * + * @return + */ + String getAbbreviation(); } diff --git a/src/main/java/org/springframework/data/geo/Metrics.java b/src/main/java/org/springframework/data/geo/Metrics.java index e30c0ec25..6f1cfdfd6 100644 --- a/src/main/java/org/springframework/data/geo/Metrics.java +++ b/src/main/java/org/springframework/data/geo/Metrics.java @@ -1,5 +1,5 @@ /* - * Copyright 2011-2014 the original author or authors. + * Copyright 2011-2015 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -24,17 +24,20 @@ package org.springframework.data.geo; */ public enum Metrics implements Metric { - KILOMETERS(6378.137), MILES(3963.191), NEUTRAL(1); + KILOMETERS(6378.137, "km"), MILES(3963.191, "mi"), NEUTRAL(1, ""); private final double multiplier; + private final String abbreviation; /** * Creates a new {@link Metrics} using the given muliplier. * * @param multiplier the earth radius at equator. */ - private Metrics(double multiplier) { + private Metrics(double multiplier, String abbreviation) { + this.multiplier = multiplier; + this.abbreviation = abbreviation; } /* @@ -44,4 +47,13 @@ public enum Metrics implements Metric { public double getMultiplier() { return multiplier; } + + /* + * (non-Javadoc) + * @see org.springframework.data.geo.Metric#getAbbreviation() + */ + @Override + public String getAbbreviation() { + return abbreviation; + } } diff --git a/src/main/java/org/springframework/data/geo/format/DistanceFormatter.java b/src/main/java/org/springframework/data/geo/format/DistanceFormatter.java new file mode 100644 index 000000000..55cf5cec7 --- /dev/null +++ b/src/main/java/org/springframework/data/geo/format/DistanceFormatter.java @@ -0,0 +1,126 @@ +/* + * Copyright 2014-2015 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.geo.format; + +import java.text.ParseException; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; +import java.util.Map.Entry; + +import org.springframework.core.convert.converter.Converter; +import org.springframework.data.geo.Distance; +import org.springframework.data.geo.Metric; +import org.springframework.data.geo.Metrics; +import org.springframework.format.Formatter; +import org.springframework.util.StringUtils; + +/** + * Converter to create {@link Distance} instances from {@link String} representations. The supported format is a decimal + * followed by whitespace and a metric abbreviation. We currently support the following abbreviations: + * {@value #SUPPORTED_METRICS}. + * + * @author Oliver Gierke + */ +public enum DistanceFormatter implements Converter, Formatter { + + INSTANCE; + + private static final Map SUPPORTED_METRICS; + private static final String INVALID_DISTANCE = "Expected double amount optionally followed by a metrics abbreviation (%s) but got '%s'!"; + + static { + + Map metrics = new LinkedHashMap(); + + for (Metric metric : Metrics.values()) { + metrics.put(metric.getAbbreviation(), metric); + metrics.put(metric.toString().toLowerCase(Locale.US), metric); + } + + SUPPORTED_METRICS = Collections.unmodifiableMap(metrics); + } + + /* + * (non-Javadoc) + * @see org.springframework.core.convert.converter.Converter#convert(java.lang.Object) + */ + @Override + public final Distance convert(String source) { + return source == null ? null : doConvert(source.trim().toLowerCase(Locale.US)); + } + + /* + * (non-Javadoc) + * @see org.springframework.format.Printer#print(java.lang.Object, java.util.Locale) + */ + @Override + public String print(Distance distance, Locale locale) { + return distance == null ? null : String.format("%s%s", distance.getValue(), distance.getUnit().toLowerCase(locale)); + } + + /* + * (non-Javadoc) + * @see org.springframework.format.Parser#parse(java.lang.String, java.util.Locale) + */ + @Override + public Distance parse(String text, Locale locale) throws ParseException { + return !StringUtils.hasText(text) ? null : doConvert(text.trim().toLowerCase(locale)); + } + + /** + * Converts the given {@link String} source into a distance. Expects the source to reflect the {@link Metric} as held + * in the {@link #SUPPORTED_METRICS} map. + * + * @param source must not be {@literal null}. + * @return + */ + private static Distance doConvert(String source) { + + for (Entry metric : SUPPORTED_METRICS.entrySet()) { + if (source.endsWith(metric.getKey())) { + return fromString(source, metric); + } + } + + try { + return new Distance(Double.parseDouble(source)); + } catch (NumberFormatException o_O) { + throw new IllegalArgumentException(String.format(INVALID_DISTANCE, + StringUtils.collectionToCommaDelimitedString(SUPPORTED_METRICS.keySet()), source)); + } + } + + /** + * Creates a {@link Distance} from the given source String and the {@link Metric} detected. + * + * @param source the raw source {@link String}, must not be {@literal null} or empty. + * @param metric the {@link Metric} detected keyed by the keyword it was detected for, must not be {@literal null}. + * @return + */ + private static Distance fromString(String source, Entry metric) { + + String amountString = source.substring(0, source.indexOf(metric.getKey())); + + try { + return new Distance(Double.parseDouble(amountString), metric.getValue()); + } catch (NumberFormatException o_O) { + throw new IllegalArgumentException(String.format(INVALID_DISTANCE, + StringUtils.collectionToCommaDelimitedString(SUPPORTED_METRICS.keySet()), source)); + } + } +} diff --git a/src/main/java/org/springframework/data/geo/format/PointFormatter.java b/src/main/java/org/springframework/data/geo/format/PointFormatter.java new file mode 100644 index 000000000..959f1f18f --- /dev/null +++ b/src/main/java/org/springframework/data/geo/format/PointFormatter.java @@ -0,0 +1,82 @@ +/* + * Copyright 2014-2015 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.geo.format; + +import java.text.ParseException; +import java.util.Locale; + +import org.springframework.core.convert.converter.Converter; +import org.springframework.core.convert.converter.GenericConverter.ConvertiblePair; +import org.springframework.data.geo.Point; +import org.springframework.format.Formatter; +import org.springframework.util.StringUtils; + +/** + * Converter to parse two comma-separated doubles into a {@link Point}. + * + * @author Oliver Gierke + */ +public enum PointFormatter implements Converter, Formatter { + + INSTANCE; + + public static final ConvertiblePair CONVERTIBLE = new ConvertiblePair(String.class, Point.class); + + private static final String INVALID_FORMAT = "Expected two doubles separated by a semicolon but got '%s'!"; + + /* + * (non-Javadoc) + * @see org.springframework.core.convert.converter.Converter#convert(java.lang.Object) + */ + @Override + public Point convert(String source) { + + String[] parts = source.split(","); + + if (parts.length != 2) { + throw new IllegalArgumentException(String.format(INVALID_FORMAT, source)); + } + + try { + + double latitude = Double.parseDouble(parts[0]); + double longitude = Double.parseDouble(parts[1]); + + return new Point(longitude, latitude); + + } catch (NumberFormatException o_O) { + throw new IllegalArgumentException(String.format(INVALID_FORMAT, source), o_O); + } + } + + /* + * (non-Javadoc) + * @see org.springframework.format.Printer#print(java.lang.Object, java.util.Locale) + */ + @Override + public String print(Point point, Locale locale) { + return point == null ? null : String.format("%s,%s", point.getY(), point.getX()); + } + + /* + * (non-Javadoc) + * @see org.springframework.format.Parser#parse(java.lang.String, java.util.Locale) + */ + @Override + public Point parse(String text, Locale locale) throws ParseException { + return !StringUtils.hasText(text) ? null : convert(text); + } +} diff --git a/src/main/java/org/springframework/data/web/config/SpringDataWebConfiguration.java b/src/main/java/org/springframework/data/web/config/SpringDataWebConfiguration.java index b5bccb327..15033b943 100644 --- a/src/main/java/org/springframework/data/web/config/SpringDataWebConfiguration.java +++ b/src/main/java/org/springframework/data/web/config/SpringDataWebConfiguration.java @@ -1,5 +1,5 @@ /* - * Copyright 2013 the original author or authors. + * Copyright 2013-2015 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -21,6 +21,8 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.ApplicationContext; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.data.geo.format.DistanceFormatter; +import org.springframework.data.geo.format.PointFormatter; import org.springframework.data.repository.support.DomainClassConverter; import org.springframework.data.web.PageableHandlerMethodArgumentResolver; import org.springframework.data.web.SortHandlerMethodArgumentResolver; @@ -66,6 +68,9 @@ public class SpringDataWebConfiguration extends WebMvcConfigurerAdapter { @Override public void addFormatters(FormatterRegistry registry) { + registry.addFormatter(DistanceFormatter.INSTANCE); + registry.addFormatter(PointFormatter.INSTANCE); + if (!(registry instanceof FormattingConversionService)) { return; } diff --git a/src/test/java/org/springframework/data/geo/DistanceUnitTests.java b/src/test/java/org/springframework/data/geo/DistanceUnitTests.java index 91a93a428..7c1f9c583 100644 --- a/src/test/java/org/springframework/data/geo/DistanceUnitTests.java +++ b/src/test/java/org/springframework/data/geo/DistanceUnitTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2011-2014 the original author or authors. + * Copyright 2011-2015 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -137,4 +137,12 @@ public class DistanceUnitTests { Distance serialized = (Distance) SerializationUtils.deserialize(SerializationUtils.serialize(dist)); assertThat(serialized, is(dist)); } + + /** + * @see DATACMNS-626 + */ + @Test + public void returnsMetricsAbbreviationAsUnit() { + assertThat(new Distance(10, KILOMETERS).getUnit(), is("km")); + } } diff --git a/src/test/java/org/springframework/data/geo/format/DistanceFormatterUnitTests.java b/src/test/java/org/springframework/data/geo/format/DistanceFormatterUnitTests.java new file mode 100644 index 000000000..40ab07da3 --- /dev/null +++ b/src/test/java/org/springframework/data/geo/format/DistanceFormatterUnitTests.java @@ -0,0 +1,98 @@ +/* + * Copyright 2014-2015 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.geo.format; + +import static org.hamcrest.CoreMatchers.*; +import static org.junit.Assert.*; +import static org.springframework.data.geo.format.DistanceFormatter.*; + +import java.text.ParseException; +import java.util.Arrays; +import java.util.Collection; +import java.util.Locale; + +import org.junit.Test; +import org.junit.experimental.runners.Enclosed; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameter; +import org.junit.runners.Parameterized.Parameters; +import org.springframework.data.geo.Distance; +import org.springframework.data.geo.Metrics; + +/** + * Unit tests for {@link DistanceFormatter}. + * + * @author Oliver Gierke + */ +@RunWith(Enclosed.class) +public class DistanceFormatterUnitTests { + + static Distance REFERENCE = new Distance(10.8, Metrics.KILOMETERS); + + public static class UnparameterizedTests { + + /** + * @see DATAREST-279, DATACMNS-626 + */ + @Test(expected = IllegalArgumentException.class) + public void rejectsArbitraryNonsense() { + INSTANCE.convert("foo"); + } + + /** + * @see DATAREST-279, DATACMNS-626 + */ + @Test(expected = IllegalArgumentException.class) + public void rejectsUnsupportedMetric() { + INSTANCE.convert("10.8cm"); + } + + /** + * @see DATAREST-279, DATACMNS-626 + */ + @Test + public void printsDistance() { + assertThat(INSTANCE.print(REFERENCE, Locale.US), is("10.8km")); + } + } + + @RunWith(Parameterized.class) + public static class ParameterizedTests { + + @Parameters + public static Collection parameters() { + return Arrays.asList(new String[] { "10.8km" }, new String[] { " 10.8km" }, new String[] { " 10.8 km" }, + new String[] { " 10.8 km " }, new String[] { " 10.8 KM" }, new String[] { " 10.8 kilometers" }, + new String[] { " 10.8 KILOMETERS" }, new String[] { " 10.8 KILOMETERS " }); + } + + public @Parameter String source; + + /** + * @see DATAREST-279, DATACMNS-626 + */ + @Test + public void parsesDistanceFromString() { + assertThat(INSTANCE.convert(source), is(REFERENCE)); + } + + @Test + public void parsesDistances() throws ParseException { + assertThat(INSTANCE.parse(source, Locale.US), is(REFERENCE)); + } + } +} diff --git a/src/test/java/org/springframework/data/geo/format/PointFormatterUnitTests.java b/src/test/java/org/springframework/data/geo/format/PointFormatterUnitTests.java new file mode 100644 index 000000000..281ce4449 --- /dev/null +++ b/src/test/java/org/springframework/data/geo/format/PointFormatterUnitTests.java @@ -0,0 +1,104 @@ +/* + * Copyright 2014-2015 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.geo.format; + +import static org.hamcrest.CoreMatchers.*; +import static org.junit.Assert.*; +import static org.springframework.data.geo.format.PointFormatter.*; + +import java.text.ParseException; +import java.util.Arrays; +import java.util.Collection; +import java.util.Locale; + +import org.junit.Test; +import org.junit.experimental.runners.Enclosed; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameter; +import org.junit.runners.Parameterized.Parameters; +import org.springframework.data.geo.Point; + +/** + * Unit tests for {@link PointFormatter}. + * + * @author Oliver Gierke + */ +@RunWith(Enclosed.class) +public class PointFormatterUnitTests { + + static Point REFERENCE = new Point(20.9, 10.8); + + @Test + public void testname() { + // To make sure Maven picks up the tests + } + + public static class UnparameterizedTests { + + /** + * @see DATAREST-279, DATACMNS-626 + */ + @Test(expected = IllegalArgumentException.class) + public void rejectsArbitraryNonsense() { + INSTANCE.convert("foo"); + } + + /** + * @see DATAREST-279, DATACMNS-626 + */ + @Test(expected = IllegalArgumentException.class) + public void rejectsMoreThanTwoCoordinates() { + INSTANCE.convert("10.8,20.9,30.10"); + } + + /** + * @see DATAREST-279, DATACMNS-626 + */ + @Test(expected = IllegalArgumentException.class) + public void rejectsInvalidCoordinate() { + INSTANCE.convert("10.8,foo"); + } + } + + @RunWith(Parameterized.class) + public static class ParameterizedTests { + + @Parameters + public static Collection parameters() { + return Arrays.asList(new String[] { "10.8,20.9" }, new String[] { " 10.8,20.9 " }, + new String[] { " 10.8 ,20.9" }, new String[] { " 10.8, 20.9 " }); + } + + public @Parameter String source; + + /** + * @see DATAREST-279, DATACMNS-626 + */ + @Test + public void convertsPointFromString() { + assertThat(INSTANCE.convert(source), is(REFERENCE)); + } + + /** + * @see DATAREST-279, DATACMNS-626 + */ + @Test + public void parsesPoint() throws ParseException { + assertThat(INSTANCE.parse(source, Locale.US), is(REFERENCE)); + } + } +} diff --git a/src/test/java/org/springframework/data/web/config/EnableSpringDataWebSupportIntegrationTests.java b/src/test/java/org/springframework/data/web/config/EnableSpringDataWebSupportIntegrationTests.java index 7e19821ff..7af4fb17c 100644 --- a/src/test/java/org/springframework/data/web/config/EnableSpringDataWebSupportIntegrationTests.java +++ b/src/test/java/org/springframework/data/web/config/EnableSpringDataWebSupportIntegrationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2013 the original author or authors. + * Copyright 2013-2015 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -28,6 +28,9 @@ import org.junit.After; import org.junit.Test; import org.springframework.context.ApplicationContext; import org.springframework.context.annotation.Configuration; +import org.springframework.core.convert.ConversionService; +import org.springframework.data.geo.Distance; +import org.springframework.data.geo.Point; import org.springframework.data.web.PageableHandlerMethodArgumentResolver; import org.springframework.data.web.PagedResourcesAssemblerArgumentResolver; import org.springframework.data.web.SortHandlerMethodArgumentResolver; @@ -122,6 +125,22 @@ public class EnableSpringDataWebSupportIntegrationTests { assertThat(names, not(hasItem("jacksonGeoModule"))); } + /** + * @see DATACMNS-626 + */ + @Test + public void registersFormatters() { + + ApplicationContext context = WebTestUtils.createApplicationContext(SampleConfig.class); + + ConversionService conversionService = context.getBean(ConversionService.class); + + assertThat(conversionService.canConvert(String.class, Distance.class), is(true)); + assertThat(conversionService.canConvert(Distance.class, String.class), is(true)); + assertThat(conversionService.canConvert(String.class, Point.class), is(true)); + assertThat(conversionService.canConvert(Point.class, String.class), is(true)); + } + @SuppressWarnings("unchecked") private static void assertResolversRegistered(ApplicationContext context, Class... resolverTypes) {