Merge pull request #1412 from jcabrerizo/security-provider-unauthorized-helper

Allow to forward some to the headers when a SecurityProviderDeniedAut…
diff --git a/core/src/main/java/org/apache/brooklyn/core/workflow/store/WorkflowStateActiveInMemory.java b/core/src/main/java/org/apache/brooklyn/core/workflow/store/WorkflowStateActiveInMemory.java
index af6ef21..762b26e 100644
--- a/core/src/main/java/org/apache/brooklyn/core/workflow/store/WorkflowStateActiveInMemory.java
+++ b/core/src/main/java/org/apache/brooklyn/core/workflow/store/WorkflowStateActiveInMemory.java
@@ -18,6 +18,9 @@
  */
 package org.apache.brooklyn.core.workflow.store;
 
+import java.util.Map;
+import java.util.Set;
+
 import org.apache.brooklyn.api.entity.Entity;
 import org.apache.brooklyn.api.mgmt.ManagementContext;
 import org.apache.brooklyn.config.ConfigKey;
@@ -25,11 +28,10 @@
 import org.apache.brooklyn.core.mgmt.BrooklynTaskTags;
 import org.apache.brooklyn.core.workflow.WorkflowExecutionContext;
 import org.apache.brooklyn.util.collections.MutableMap;
+import org.apache.brooklyn.util.collections.MutableSet;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.util.Map;
-
 public class WorkflowStateActiveInMemory {
 
     private static final Logger log = LoggerFactory.getLogger(WorkflowStateActiveInMemory.class);
@@ -64,14 +66,20 @@
 
     public void expireAbsentEntities() {
         lastInMemClear = System.currentTimeMillis();
-        MutableMap.copyOf(data).forEach( (entityId, mapByWorkflowId) -> {
-            if (mgmt.getEntityManager().getEntity(entityId)==null) data.remove(entityId);
+        Set<String> copy;
+        synchronized (data) {
+            copy = MutableSet.copyOf(data.keySet());
+        }
+        copy.forEach(entityId -> {
+            if (mgmt.getEntityManager().getEntity(entityId) == null) {
+                data.remove(entityId);
+            }
         });
     }
 
     public void checkpoint(WorkflowExecutionContext context) {
         // keep active workflows in memory, even if disabled
-        Map<String, WorkflowExecutionContext> entityActiveWorkflows = data.get(context.getEntity().getId());
+        Map<String, WorkflowExecutionContext> entityActiveWorkflows = getSynchronizedForWorkflowId(context.getEntity().getId());
         if (context.getStatus().expirable) {
             if (entityActiveWorkflows!=null) entityActiveWorkflows.remove(context.getWorkflowId());
         } else {
@@ -94,19 +102,25 @@
     }
 
     public Map<String,WorkflowExecutionContext> getWorkflows(Entity entity) {
-        return MutableMap.copyOf(data.get(entity.getId()));
+        return getSynchronizedForWorkflowId(entity.getId());
     }
 
     boolean deleteWorkflow(WorkflowExecutionContext context) {
-        Map<String, WorkflowExecutionContext> entityActiveWorkflows = data.get(context.getEntity().getId());
+        Map<String, WorkflowExecutionContext> entityActiveWorkflows = getSynchronizedForWorkflowId(context.getEntity().getId());
         if (entityActiveWorkflows!=null) {
             return entityActiveWorkflows.remove(context.getWorkflowId()) != null;
         }
         return false;
     }
 
+    private Map<String, WorkflowExecutionContext> getSynchronizedForWorkflowId(String entityId) {
+        synchronized (data) {
+            return data.get(entityId);
+        }
+    }
+
     public WorkflowExecutionContext getFromTag(BrooklynTaskTags.WorkflowTaskTag tag) {
-        Map<String, WorkflowExecutionContext> activeForEntity = data.get(tag.getEntityId());
+        Map<String, WorkflowExecutionContext> activeForEntity = getSynchronizedForWorkflowId(tag.getEntityId());
         if (activeForEntity!=null) {
             return activeForEntity.get(tag.getWorkflowId());
         }
diff --git a/core/src/main/java/org/apache/brooklyn/feed/http/JsonFunctions.java b/core/src/main/java/org/apache/brooklyn/feed/http/JsonFunctions.java
index 90900fa..69916a7 100644
--- a/core/src/main/java/org/apache/brooklyn/feed/http/JsonFunctions.java
+++ b/core/src/main/java/org/apache/brooklyn/feed/http/JsonFunctions.java
@@ -25,10 +25,19 @@
 import java.math.BigInteger;
 import java.util.Arrays;
 import java.util.List;
+import java.util.Map;
 import java.util.NoSuchElementException;
+import java.util.Objects;
+import java.util.Set;
 
 import javax.annotation.Nullable;
 
+import com.google.common.reflect.TypeToken;
+import com.google.gson.JsonPrimitive;
+import com.google.gson.internal.LazilyParsedNumber;
+import org.apache.brooklyn.util.collections.MutableList;
+import org.apache.brooklyn.util.collections.MutableMap;
+import org.apache.brooklyn.util.collections.MutableSet;
 import org.apache.brooklyn.util.guava.Functionals;
 import org.apache.brooklyn.util.guava.Maybe;
 import org.apache.brooklyn.util.guava.MaybeFunctions;
@@ -41,6 +50,8 @@
 import com.google.gson.JsonObject;
 import com.google.gson.JsonParser;
 import com.jayway.jsonpath.JsonPath;
+import org.apache.brooklyn.util.guava.TypeTokens;
+import org.apache.brooklyn.util.math.NumberMath;
 
 public class JsonFunctions {
 
@@ -340,11 +351,20 @@
 
     @SuppressWarnings("unchecked")
     protected static <T> T doCast(JsonElement input, Class<T> expected) {
+        return doCast(input, TypeToken.of(expected));
+    }
+    protected static <T> T doCast(JsonElement input, TypeToken<T> expectedType) {
         if (input == null) {
             return null;
         } else if (input.isJsonNull()) {
             return null;
-        } else if (expected == boolean.class || expected == Boolean.class) {
+        }
+        Class<? super T> expected = expectedType.getRawType();
+        Function<Function<JsonPrimitive,Boolean>, Boolean> handlePrimitive = fn -> {
+            if (Object.class.equals(expected) && input.isJsonPrimitive()) return fn.apply((JsonPrimitive) input);
+            return false;
+        };
+        if (expected == boolean.class || expected == Boolean.class || handlePrimitive.apply(JsonPrimitive::isBoolean)) {
             return (T) (Boolean) input.getAsBoolean();
         } else if (expected == char.class || expected == Character.class) {
             return (T) (Character) input.getAsCharacter();
@@ -364,30 +384,76 @@
             return (T) input.getAsBigDecimal();
         } else if (expected == BigInteger.class) {
             return (T) input.getAsBigInteger();
-        } else if (Number.class.isAssignableFrom(expected)) {
-            // TODO Will result in a class-cast if it's an unexpected sub-type of Number not handled above
-            return (T) input.getAsNumber();
-        } else if (expected == String.class) {
+        } else if (Number.class.isAssignableFrom(expected) || handlePrimitive.apply(JsonPrimitive::isNumber)) {
+            // May result in a class-cast if it's an unexpected sub-type of Number not handled above
+            // Also ends up as LazilyParsedNumber which we probably don't want
+            Number result = input.getAsNumber();
+            Number r2 = new NumberMath(result, Number.class).asTypeForced(Number.class);
+            if (r2==null) r2 = result;
+            return (T) r2;
+        } else if (expected == String.class || handlePrimitive.apply(JsonPrimitive::isString)) {
             return (T) input.getAsString();
-        } else if (expected.isArray()) {
-            JsonArray array = input.getAsJsonArray();
-            Class<?> componentType = expected.getComponentType();
-            if (JsonElement.class.isAssignableFrom(componentType)) {
-                JsonElement[] result = new JsonElement[array.size()];
-                for (int i = 0; i < array.size(); i++) {
-                    result[i] = array.get(i);
-                }
-                return (T) result;
-            } else {
-                Object[] result = (Object[]) Array.newInstance(componentType, array.size());
-                for (int i = 0; i < array.size(); i++) {
-                    result[i] = cast(componentType).apply(array.get(i));
-                }
-                return (T) result;
-            }
-        } else {
-            throw new IllegalArgumentException("Cannot cast json element to type "+expected);
         }
+
+        // now complex types
+        if (JsonElement.class.isAssignableFrom(expected)) {
+            return (T) input;
+        }
+
+        if (Iterable.class.isAssignableFrom(expected) || expected.isArray()) {
+            JsonArray array = input.getAsJsonArray();
+            MutableList ml = MutableList.of();
+            TypeToken<?> componentType;
+            if (expectedType.getComponentType()!=null) componentType = expectedType.getComponentType();
+            else {
+                TypeToken<?>[] params = TypeTokens.getGenericParameterTypeTokens(expectedType);
+                componentType = params != null && params.length == 1 ? params[0] : TypeToken.of(Object.class);
+            }
+
+            if (JsonElement.class.isAssignableFrom(componentType.getRawType())) ml.addAll(array);
+            else array.forEach(a -> ml.add(doCast(a, componentType)));
+
+            if (expected.isAssignableFrom(MutableList.class)) {
+                return (T) ml;
+            }
+            if (expected.isAssignableFrom(MutableSet.class)) {
+                return (T) MutableSet.copyOf(ml);
+            }
+            if (expected.isArray()) {
+                return (T) ml.toArray((Object[]) Array.newInstance(componentType.getRawType(), 0));
+            }
+        }
+
+        if (Map.class.isAssignableFrom(expected)) {
+            JsonObject jo = input.getAsJsonObject();
+
+            TypeToken<?>[] params = TypeTokens.getGenericParameterTypeTokens(expectedType);
+            TypeToken<?> value;
+            if (params!=null && params.length==1) {
+                // probably shouldn't happen? but if we supported other maps it might
+                value = params[0];
+            } else if (params!=null && params.length==2) {
+                value = params[1];
+                TypeToken<?> key = params[0];
+                if (!TypeTokens.isAssignableFromRaw(key, String.class)) throw new IllegalArgumentException("Keys of type "+key+" not supported when deserializing JSON");
+            } else {
+                value = TypeToken.of(Object.class);
+            }
+
+            Map mm = MutableMap.of();
+            jo.entrySet().forEach(jos -> mm.put(jos.getKey(), doCast(jos.getValue(), value)));
+            if (expected.isAssignableFrom(MutableMap.class)) {
+                return (T) mm;
+            }
+        }
+
+        if (Object.class.equals(expected)) {
+            // primitives should have beenhandled above
+            if (input.isJsonObject()) return (T) doCast(input, Map.class);
+            if (input.isJsonArray()) return (T) doCast(input, List.class);
+        }
+
+        throw new IllegalArgumentException("Cannot cast json element to type "+expected);
     }
     
     public static <T> Function<Maybe<JsonElement>, T> castM(final Class<T> expected) {
diff --git a/core/src/main/java/org/apache/brooklyn/util/core/xstream/MinidevJsonObjectConverter.java b/core/src/main/java/org/apache/brooklyn/util/core/xstream/MinidevJsonObjectConverter.java
new file mode 100644
index 0000000..7afdb29
--- /dev/null
+++ b/core/src/main/java/org/apache/brooklyn/util/core/xstream/MinidevJsonObjectConverter.java
@@ -0,0 +1,60 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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.apache.brooklyn.util.core.xstream;
+
+import java.util.Map;
+import java.util.Map.Entry;
+
+import com.google.common.base.Predicate;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Maps;
+import com.thoughtworks.xstream.converters.MarshallingContext;
+import com.thoughtworks.xstream.converters.UnmarshallingContext;
+import com.thoughtworks.xstream.io.HierarchicalStreamReader;
+import com.thoughtworks.xstream.io.HierarchicalStreamWriter;
+import com.thoughtworks.xstream.mapper.Mapper;
+import net.minidev.json.JSONObject;
+
+// JSONObject is
+public class MinidevJsonObjectConverter extends MapConverter {
+
+    public MinidevJsonObjectConverter(Mapper mapper) {
+        super(mapper);
+    }
+
+    @Override
+    public boolean canConvert(@SuppressWarnings("rawtypes") Class type) {
+        return JSONObject.class.isAssignableFrom(type);
+    }
+
+    @Override
+    public void marshal(Object source, HierarchicalStreamWriter writer, MarshallingContext context) {
+        // marshall as a normal map
+        super.marshal(source, writer, context);
+    }
+
+    // return as a JSONObject; this class invoked when XML attributes specify that is the type
+    @Override
+    public JSONObject unmarshal(HierarchicalStreamReader reader, UnmarshallingContext context) {
+        // unmarshall as a normal map
+        Map result = (Map) super.unmarshal(reader, context);
+        // but return as a JSONObject, just in case that is required
+        return new JSONObject(result);
+    }
+}
diff --git a/core/src/main/java/org/apache/brooklyn/util/core/xstream/XmlSerializer.java b/core/src/main/java/org/apache/brooklyn/util/core/xstream/XmlSerializer.java
index d7b19cb..b643391 100644
--- a/core/src/main/java/org/apache/brooklyn/util/core/xstream/XmlSerializer.java
+++ b/core/src/main/java/org/apache/brooklyn/util/core/xstream/XmlSerializer.java
@@ -159,6 +159,7 @@
         xstream.registerConverter(new ImmutableListConverter(xstream.getMapper()));
         xstream.registerConverter(new ImmutableSetConverter(xstream.getMapper()));
         xstream.registerConverter(new ImmutableMapConverter(xstream.getMapper()));
+        xstream.registerConverter(new MinidevJsonObjectConverter(xstream.getMapper()));
 
         xstream.registerConverter(new HashMultimapConverter(xstream.getMapper()));
 
diff --git a/core/src/test/java/org/apache/brooklyn/core/mgmt/internal/EntityExecutionManagerTest.java b/core/src/test/java/org/apache/brooklyn/core/mgmt/internal/EntityExecutionManagerTest.java
index 1073578..cb6cbd8 100644
--- a/core/src/test/java/org/apache/brooklyn/core/mgmt/internal/EntityExecutionManagerTest.java
+++ b/core/src/test/java/org/apache/brooklyn/core/mgmt/internal/EntityExecutionManagerTest.java
@@ -353,6 +353,7 @@
         return e;
     }
 
+    // non-det failure, see below
     public void testUnmanagedEntityCanBeGcedEvenIfPreviouslyTagged() throws Exception {
         TestEntity e = app.createAndManageChild(EntitySpec.create(TestEntity.class));
         String eId = e.getId();
@@ -376,6 +377,7 @@
                 }
                 if ((tag instanceof WrappedEntity) && ((WrappedEntity) tag).unwrap().getId().equals(eId)
                         && ((WrappedItem<?>) tag).getWrappingType().equals(BrooklynTaskTags.CONTEXT_ENTITY)) {
+                    // non-deterministic failure observed here; only once so far, so might simply be GC being ignored
                     fail("tags contains unmanaged entity (wrapped) " + tag + "; tasks: " + app.getManagementContext().getExecutionManager().getTasksWithTag(tag));
                 }
             }
diff --git a/core/src/test/java/org/apache/brooklyn/feed/http/JsonFunctionsTest.java b/core/src/test/java/org/apache/brooklyn/feed/http/JsonFunctionsTest.java
index 638e10d..8e7b3f2 100644
--- a/core/src/test/java/org/apache/brooklyn/feed/http/JsonFunctionsTest.java
+++ b/core/src/test/java/org/apache/brooklyn/feed/http/JsonFunctionsTest.java
@@ -18,10 +18,16 @@
  */
 package org.apache.brooklyn.feed.http;
 
+import java.lang.reflect.Array;
+import java.util.List;
+import java.util.Map;
 import java.util.NoSuchElementException;
 
+import com.google.common.reflect.TypeToken;
+import org.apache.brooklyn.test.Asserts;
 import org.apache.brooklyn.util.collections.Jsonya;
 import org.apache.brooklyn.util.collections.Jsonya.Navigator;
+import org.apache.brooklyn.util.collections.MutableList;
 import org.apache.brooklyn.util.collections.MutableMap;
 import org.apache.brooklyn.util.guava.Functionals;
 import org.apache.brooklyn.util.guava.Maybe;
@@ -102,6 +108,15 @@
         Maybe<JsonElement> m = JsonFunctions.walkM("europe", "france").apply( Maybe.of( europeMap()) );
         JsonFunctions.castM(String.class).apply(m);
     }
+
+    @Test
+    public void testCastCollectionsAndMaps() {
+        Asserts.assertEquals(JsonFunctions.doCast(JsonParser.parseString("[1,2,3]"), (new Integer[]{}).getClass()), new Integer[] { 1, 2, 3 });
+        Asserts.assertEquals(JsonFunctions.doCast(JsonParser.parseString("[1,2,3]"), new TypeToken<List<Integer>>() {}), MutableList.of(1, 2, 3));
+        Asserts.assertEquals(JsonFunctions.doCast(JsonParser.parseString("[1,2,3]"), JsonElement.class), JsonParser.parseString("[1,2,3]"));
+        Asserts.assertEquals(JsonFunctions.doCast(JsonParser.parseString("{ \"a\": 1 }"), new TypeToken<Map<String,Integer>>() {}), MutableMap.of("a", 1));
+        Asserts.assertEquals(JsonFunctions.doCast(JsonParser.parseString("{ \"a\": 1 }"), Object.class), MutableMap.of("a", 1));
+    }
     
     @Test
     public void testWalkN() {
diff --git a/core/src/test/java/org/apache/brooklyn/util/core/xstream/XmlSerializerTest.java b/core/src/test/java/org/apache/brooklyn/util/core/xstream/XmlSerializerTest.java
index b3c38d2..5710b32 100644
--- a/core/src/test/java/org/apache/brooklyn/util/core/xstream/XmlSerializerTest.java
+++ b/core/src/test/java/org/apache/brooklyn/util/core/xstream/XmlSerializerTest.java
@@ -28,13 +28,14 @@
 import java.util.Map;
 import java.util.function.Consumer;
 import java.util.function.Supplier;
+
+import net.minidev.json.JSONObject;
 import org.apache.brooklyn.test.Asserts;
 import org.apache.brooklyn.util.collections.MutableList;
 import org.apache.brooklyn.util.collections.MutableMap;
 import org.apache.brooklyn.util.collections.MutableSet;
 import org.apache.brooklyn.util.core.config.ConfigBag;
 import org.apache.brooklyn.util.core.xstream.LambdaPreventionMapper.LambdaPersistenceMode;
-import org.apache.brooklyn.util.exceptions.Exceptions;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import static org.testng.Assert.assertEquals;
@@ -217,11 +218,12 @@
     }
 
 
-    protected void assertSerializeAndDeserialize(Object val) throws Exception {
+    protected Object assertSerializeAndDeserialize(Object val) throws Exception {
         String xml = serializer.toString(val);
         Object result = serializer.fromString(xml);
         LOG.debug("val="+val+"'; xml="+xml+"; result="+result);
         assertEquals(result, val);
+        return result;
     }
 
     public static class StringHolder {
@@ -270,7 +272,7 @@
     public static class IntHolder {
         public int val;
         
-        IntHolder(int val) {
+        public IntHolder(int val) {
             this.val = val;
         }
         @Override
@@ -329,4 +331,30 @@
             return (messageType == o.messageType) && important == o.important && content.equals(o.content);
         }
     }
+
+
+    static class MinidevJsonObjectHolder {
+        JSONObject jo;
+
+        @Override
+        public boolean equals(Object o2) {
+            return (o2 instanceof MinidevJsonObjectHolder) && java.util.Objects.equals( ((MinidevJsonObjectHolder)o2).jo, jo );
+        }
+    }
+
+    @Test
+    public void testMinidevJsonObject() throws Exception {
+        JSONObject x = new JSONObject(MutableMap.of("cc", 3));
+        x = new JSONObject(MutableMap.of("a", 1, "b", 2, "c", x));
+        Map x0 = (Map) assertSerializeAndDeserialize(x);
+        Asserts.assertTrue(x0 instanceof JSONObject);
+        Asserts.assertTrue(x0.get("c") instanceof JSONObject);
+
+        // holder test doesn't really do anything here as above preserves type, type info stored in attribute;
+        // but kept for good measure
+        MinidevJsonObjectHolder y = new MinidevJsonObjectHolder();
+        y.jo = x;
+        MinidevJsonObjectHolder y0 = (MinidevJsonObjectHolder) assertSerializeAndDeserialize(y);
+        Asserts.assertTrue(y0.jo instanceof JSONObject);
+    }
 }
diff --git a/rest/rest-resources/src/test/java/org/apache/brooklyn/rest/resources/SensorResourceTest.java b/rest/rest-resources/src/test/java/org/apache/brooklyn/rest/resources/SensorResourceTest.java
index d5f3e19..23dbdcb 100644
--- a/rest/rest-resources/src/test/java/org/apache/brooklyn/rest/resources/SensorResourceTest.java
+++ b/rest/rest-resources/src/test/java/org/apache/brooklyn/rest/resources/SensorResourceTest.java
@@ -28,6 +28,7 @@
 import javax.ws.rs.core.MediaType;
 import javax.ws.rs.core.Response;
 
+import net.minidev.json.JSONObject;
 import org.apache.brooklyn.api.entity.Entity;
 import org.apache.brooklyn.api.location.Location;
 import org.apache.brooklyn.api.mgmt.ManagementContext;
@@ -444,4 +445,40 @@
                 .get();
         Asserts.assertStringDoesNotContain(""+response.readEntity(Object.class), ""+SECRET_VALUE);
     }
+
+    @Test
+    public void testSetFromMapPreservesMaps() throws Exception {
+        final AttributeSensor<Object> OBJ_SENSOR = Sensors.newSensor(Object.class, "obj_sensor");
+        final Runnable REMOVE = () -> entity.sensors().remove(OBJ_SENSOR);
+        try {
+
+            Response response = client().path(SENSORS_ENDPOINT)
+                    .type(MediaType.APPLICATION_JSON_TYPE)
+                    .post(MutableMap.of(OBJ_SENSOR.getName(), MutableMap.of("a", 1, "b", MutableMap.of("bb", "2"))));
+            assertEquals(response.getStatus(), Response.Status.NO_CONTENT.getStatusCode());
+
+            final Runnable CHECK = () -> {
+                // we don't want minidev JSON objects ending up in sensors; convert to normal maps
+                Map osm = (Map) entity.getAttribute(OBJ_SENSOR);
+                assertEquals(osm.get("a"), 1);
+                Asserts.assertFalse(osm instanceof JSONObject);
+
+                Map osmb = (Map) osm.get("b");
+                assertEquals(osmb.get("bb"), "2");
+                Asserts.assertFalse(osmb instanceof JSONObject);
+            };
+            CHECK.run();
+
+            REMOVE.run();
+
+            // and should be similar when posting to the endpont
+            response = client().path(SENSORS_ENDPOINT+"/"+OBJ_SENSOR.getName())
+                    .type(MediaType.APPLICATION_JSON_TYPE)
+                    .post(MutableMap.of("a", 1, "b", MutableMap.of("bb", "2")));
+            assertEquals(response.getStatus(), Response.Status.NO_CONTENT.getStatusCode());
+            CHECK.run();
+
+        } finally { REMOVE.run(); }
+    }
+
 }
diff --git a/utils/common/src/main/java/org/apache/brooklyn/util/math/NumberMath.java b/utils/common/src/main/java/org/apache/brooklyn/util/math/NumberMath.java
new file mode 100644
index 0000000..023506f
--- /dev/null
+++ b/utils/common/src/main/java/org/apache/brooklyn/util/math/NumberMath.java
@@ -0,0 +1,178 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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.apache.brooklyn.util.math;
+
+import java.math.BigDecimal;
+import java.math.BigInteger;
+import java.util.Optional;
+import java.util.function.BiFunction;
+import java.util.function.Function;
+import java.util.function.Predicate;
+import javax.annotation.Nullable;
+
+public class NumberMath<T extends Number> {
+
+    static final BigDecimal DEFAULT_TOLERANCE = new BigDecimal(0.00000001);
+
+    final T number;
+    final Class<T> desiredType;
+    final Function<Number, T> handlerForUncastableType;
+    final BigDecimal tolerance;
+
+    public NumberMath(T number) {
+        this(number, (Class<T>)number.getClass(), null);
+    }
+    /** callers can pass `Number` as second arg if they will accept any type; otherwise the input type is expected as the default */
+    public NumberMath(T number, Class<T> desiredType) {
+        this(number, desiredType, null);
+    }
+    public NumberMath(T number, Class<T> desiredType, Function<Number,T> handlerForUncastableType) {
+        this(number, desiredType, handlerForUncastableType, null);
+    }
+    public NumberMath(T number, Class<T> desiredType, Function<Number,T> handlerForUncastableType, BigDecimal tolerance) {
+        this.number = number;
+        this.desiredType = desiredType;
+        this.handlerForUncastableType = handlerForUncastableType==null ? (x -> { throw new IllegalArgumentException("Cannot cast "+x+" to "+number.getClass()); }) : handlerForUncastableType;
+        this.tolerance = tolerance==null ? DEFAULT_TOLERANCE : null;
+    }
+
+    public BigDecimal asBigDecimal() { return asBigDecimal(number); }
+    public Optional<BigInteger> asBigIntegerWithinTolerance() { return asBigIntegerWithinTolerance(number); }
+
+    public <T extends Number> T asTypeForced(Class<T> desiredType) {
+        return asTypeFirstMatching(number, desiredType, y -> withinTolerance(number, y));
+    }
+    public <T extends Number> Optional<T> asTypeWithinTolerance(Class<T> desiredType, Number tolerance) {
+        return Optional.ofNullable(asTypeFirstMatching(number, desiredType, y -> withinTolerance(number, y, tolerance)));
+    }
+
+    public static boolean isPrimitiveWholeNumberType(Number number) {
+        return number instanceof Long || number instanceof Integer || number instanceof Short || number instanceof Byte;
+    }
+
+    public static boolean isPrimitiveFloatingPointType(Number number) {
+        return number instanceof Double || number instanceof Float;
+    }
+
+    public static boolean isPrimitiveNumberType(Number number) {
+        return isPrimitiveFloatingPointType(number) || isPrimitiveWholeNumberType(number);
+    }
+
+    public static BigDecimal asBigDecimal(Number number) {
+        if (number instanceof BigDecimal) return (BigDecimal) number;
+        if (isPrimitiveFloatingPointType(number)) return new BigDecimal(number.doubleValue());
+        if (isPrimitiveWholeNumberType(number)) return new BigDecimal(number.longValue());
+        if (number instanceof BigInteger) return new BigDecimal((BigInteger) number);
+        return new BigDecimal(""+number);
+    }
+
+    public Optional<BigInteger> asBigIntegerWithinTolerance(Number number) {
+        BigInteger candidate = asBigIntegerForced(number);
+        if (withinTolerance(number, candidate)) return Optional.of(candidate);
+        return Optional.empty();
+    }
+
+    public static BigInteger asBigIntegerForced(Number number) {
+        if (number instanceof BigInteger) return (BigInteger) number;
+        if (isPrimitiveWholeNumberType(number)) return BigInteger.valueOf(number.longValue());
+        return asBigDecimal(number).toBigInteger();
+    }
+
+    public static <T extends Number> T asTypeForced(Number x, Class<T> desiredType) {
+        return asTypeFirstMatching(x, desiredType, y -> withinTolerance(x, y, DEFAULT_TOLERANCE));
+    }
+    public static <T extends Number> Optional<T> asTypeWithinTolerance(Number x, Class<T> desiredType, Number tolerance) {
+        return Optional.ofNullable(asTypeFirstMatching(x, desiredType, y -> withinTolerance(x, y, tolerance)));
+    }
+    protected static <T extends Number> T asTypeFirstMatching(Number x, Class<T> desiredType, Predicate<T> check) {
+        if (desiredType.isAssignableFrom(Integer.class)) { T candidate = (T) (Object) x.intValue(); if (check!=null && check.test(candidate)) return candidate; }
+        if (desiredType.isAssignableFrom(Long.class)) { T candidate = (T) (Object) x.longValue(); if (check!=null && check.test(candidate)) return candidate; }
+        if (desiredType.isAssignableFrom(Double.class)) { T candidate = (T) (Object) x.doubleValue(); if (check!=null && check.test(candidate)) return candidate; }
+        if (desiredType.isAssignableFrom(Float.class)) { T candidate = (T) (Object) x.floatValue(); if (check!=null && check.test(candidate)) return candidate; }
+        if (desiredType.isAssignableFrom(Short.class)) { T candidate = (T) (Object) x.shortValue(); if (check!=null && check.test(candidate)) return candidate; }
+        if (desiredType.isAssignableFrom(Byte.class)) { T candidate = (T) (Object) x.byteValue(); if (check!=null && check.test(candidate)) return candidate; }
+        if (desiredType.isAssignableFrom(BigInteger.class)) { T candidate = (T) asBigIntegerForced(x); if (check!=null && check.test(candidate)) return candidate; }
+        if (desiredType.isAssignableFrom(BigDecimal.class)) { T candidate = (T) asBigDecimal(x); if (check!=null && check.test(candidate)) return candidate; }
+        return null;
+    }
+
+    public boolean withinTolerance(Number a, Number b) {
+        return withinTolerance(a, b, tolerance);
+    }
+    public static boolean withinTolerance(Number a, Number b, Number tolerance) {
+        return asBigDecimal(a).subtract(asBigDecimal(b)).abs().compareTo(asBigDecimal(tolerance)) <= 0;
+    }
+
+    // from https://www.w3schools.com/java/java_type_casting.asp
+//    In Java, there are two types of casting:
+//
+//    Widening Casting (automatically) - converting a smaller type to a larger type size
+//    byte -> short -> char -> int -> long -> float -> double
+//
+//    Narrowing Casting (manually) - converting a larger type to a smaller size type
+//    double -> float -> long -> int -> char -> short -> byte
+
+    protected T attemptCast(Number candidate) {
+        Optional<T> result = asTypeWithinTolerance(candidate, desiredType, tolerance);
+        if (result.isPresent()) return result.get();
+        return handlerForUncastableType.apply(candidate);
+    }
+
+    protected T attemptUnary(Function<Long,Long> intFn, Function<Double,Double> doubleFn, Function<BigInteger,BigInteger> bigIntegerFn, Function<BigDecimal,BigDecimal> bigDecimalFn) {
+        if (isPrimitiveWholeNumberType(number)) return attemptCast(intFn.apply(number.longValue()));
+        if (isPrimitiveNumberType(number)) return attemptCast(doubleFn.apply(number.doubleValue()));
+        if (number instanceof BigInteger) return attemptCast(bigIntegerFn.apply((BigInteger)number));
+        if (number instanceof BigDecimal) return attemptCast(bigDecimalFn.apply((BigDecimal)number));
+        return attemptCast(bigDecimalFn.apply(asBigDecimal()));
+    }
+
+    protected T attemptBinary(T rhs, @Nullable BiFunction<Long,Long,Long> intFn, BiFunction<Double,Double,Double> doubleFn, @Nullable BiFunction<BigInteger,BigInteger,BigInteger> bigIntegerFn, BiFunction<BigDecimal,BigDecimal,BigDecimal> bigDecimalFn) {
+        if (isPrimitiveWholeNumberType(number) && isPrimitiveWholeNumberType(rhs) && intFn!=null) return attemptCast(intFn.apply(number.longValue(), rhs.longValue()));
+        if (isPrimitiveNumberType(number) && isPrimitiveNumberType(rhs)) return attemptCast(doubleFn.apply(number.doubleValue(), rhs.doubleValue()));
+        if (number instanceof BigInteger && bigIntegerFn!=null) {
+            BigInteger rhsI = asBigIntegerWithinTolerance(rhs).orElse(null);
+            if (rhsI!=null) return attemptCast(bigIntegerFn.apply((BigInteger) number, rhsI));
+        }
+        if (number instanceof BigDecimal) {
+            return attemptCast(bigDecimalFn.apply((BigDecimal) number, asBigDecimal(rhs)));
+        }
+        return attemptCast(bigDecimalFn.apply(asBigDecimal(), asBigDecimal(rhs)));
+    }
+    protected T attemptBinaryWithDecimalPrecision(T rhs, BiFunction<Double,Double,Double> doubleFn, BiFunction<BigDecimal,BigDecimal,BigDecimal> bigDecimalFn) {
+        return attemptBinary(rhs, null, doubleFn, null, bigDecimalFn);
+    }
+
+    public static <T extends Number> T pairwise(BiFunction<NumberMath<T>,T,T> fn, T ...rhsAll) {
+        T result = rhsAll[0];
+        for (T rhs: rhsAll) result = fn.apply(new NumberMath<T>(result),rhs);
+        return result;
+    }
+
+    public T abs() { return attemptUnary(x -> x<0 ? -x : x, x -> x<0 ? -x : x, BigInteger::abs, BigDecimal::abs); }
+    public T negate() { return attemptUnary(x -> -x, x -> -x, BigInteger::negate, BigDecimal::negate); }
+
+    public T add(T rhs) { return attemptBinary(rhs, (x,y) -> x+y, (x,y) -> x+y, BigInteger::add, BigDecimal::add); }
+    public T subtract(T rhs) { return attemptBinary(rhs, (x,y) -> x-y, (x,y) -> x-y, BigInteger::subtract, BigDecimal::subtract); }
+    public T multiply(T rhs) { return attemptBinary(rhs, (x,y) -> x*y, (x,y) -> x*y, BigInteger::multiply, BigDecimal::multiply); }
+    public T divide(T rhs) { return attemptBinaryWithDecimalPrecision(rhs, (x,y) -> x/y, BigDecimal::divide); }
+
+    public T max(T rhs) { return attemptBinary(rhs, (x,y) -> x>y ? x : y, (x,y) -> x>y ? x : y, BigInteger::max, BigDecimal::max); }
+    public T min(T rhs) { return attemptBinary(rhs, (x,y) -> x<y ? x : y, (x,y) -> x<y ? x : y, BigInteger::min, BigDecimal::min); }
+
+}
diff --git a/utils/common/src/test/java/org/apache/brooklyn/util/math/NumberMathTest.java b/utils/common/src/test/java/org/apache/brooklyn/util/math/NumberMathTest.java
new file mode 100644
index 0000000..8bd5f72
--- /dev/null
+++ b/utils/common/src/test/java/org/apache/brooklyn/util/math/NumberMathTest.java
@@ -0,0 +1,47 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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.apache.brooklyn.util.math;
+
+import org.apache.brooklyn.test.Asserts;
+import org.testng.annotations.Test;
+
+public class NumberMathTest {
+
+    @Test
+    public void testVarious() {
+        Asserts.assertEquals((int) new NumberMath<>(1).add(2), 3);
+        Asserts.assertEquals((Object) new NumberMath<>(1).add(2), 3);
+
+        Asserts.assertEquals(new NumberMath<>((Number)1).add(2.0d), 3);
+        Asserts.assertFailsWith(() -> new NumberMath<>((Number)1).add(2.1d), e -> Asserts.expectedFailureContains(e, "Cannot cast 3.1 to class java.lang.Integer"));
+        Asserts.assertFailsWith(() -> new NumberMath<>((Number)1).add(2.1d), e -> Asserts.expectedFailureContains(e, "Cannot cast 3.1 to class java.lang.Integer"));
+        Asserts.assertEquals(new NumberMath<>(1, Number.class).add(2.0d), 3);
+        Asserts.assertEquals(new NumberMath<>(1.1).add(2.0d), 3.1d);
+        Asserts.assertEquals(new NumberMath<>((Number)1.1).add(2.1d), 3.2d);
+        Asserts.assertThat(new NumberMath<Number>((byte)(-10)).add(4), x -> { Asserts.assertInstanceOf(x, Byte.class); Asserts.assertEquals(x, (byte) -6); return true; });
+
+        // division must be exact
+//        Asserts.assertEquals((Object) new NumberMath<>(3).divide(2), 1);
+        Asserts.assertFailsWith(() -> new NumberMath<>(3).divide(2), e -> Asserts.expectedFailureContains(e, "Cannot cast 1.5 to class java.lang.Integer"));
+
+        // if division might be double then cast to Number
+        Asserts.assertEquals(new NumberMath(3, Number.class).divide(2), 1.5d);
+    }
+
+}