MemoryBackend.java

/*
 * The contents of this file are subject to the terms of the Common Development and
 * Distribution License (the License). You may not use this file except in compliance with the
 * License.
 *
 * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
 * specific language governing permission and limitations under the License.
 *
 * When distributing Covered Software, include this CDDL Header Notice in each file and include
 * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
 * Header, with the fields enclosed by brackets [] replaced by your own identifying
 * information: "Portions copyright [year] [name of copyright owner]".
 *
 * Copyright 2012-2015 ForgeRock AS.
 */

package org.forgerock.json.resource;

import static org.forgerock.json.resource.Responses.newQueryResponse;
import static org.forgerock.json.resource.Responses.newResourceResponse;
import static org.forgerock.util.promise.Promises.newExceptionPromise;
import static org.forgerock.util.promise.Promises.newResultPromise;

import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;

import org.forgerock.services.context.Context;
import org.forgerock.json.JsonPointer;
import org.forgerock.json.JsonValue;
import org.forgerock.json.JsonValueException;
import org.forgerock.util.encode.Base64;
import org.forgerock.util.promise.Promise;
import org.forgerock.util.query.QueryFilter;
import org.forgerock.util.query.QueryFilterVisitor;

/**
 * A simple in-memory collection resource provider which uses a {@code Map} to
 * store resources. This resource provider is intended for testing purposes only
 * and there are no performance guarantees.
 */
public final class MemoryBackend implements CollectionResourceProvider {
    private enum FilterResult {
        FALSE, TRUE, UNDEFINED;

        static FilterResult valueOf(final boolean b) {
            return b ? TRUE : FALSE;
        }

        boolean toBoolean() {
            return this == TRUE; // UNDEFINED collapses to FALSE.
        }
    }

    private static final class Cookie {
        private final List<SortKey> sortKeys;
        private final int lastResultIndex;

        Cookie(final int lastResultIndex, final List<SortKey> sortKeys) {
            this.sortKeys = sortKeys;
            this.lastResultIndex = lastResultIndex;
        }

        static Cookie valueOf(String base64) {
            final String decoded = new String(Base64.decode(base64));
            final String[] split = decoded.split(":");
            final int lastOffset = Integer.parseInt(split[0]);
            final List<SortKey> sortKeys = new ArrayList<>();
            final String[] splitKeys = split[1].split(",");

            for (String key : splitKeys) {
                if (!key.equals("")) {
                    sortKeys.add(SortKey.valueOf(key));
                }
            }

            return new Cookie(lastOffset, sortKeys);
        }

        String toBase64() {
            final StringBuilder buf = new StringBuilder();
            buf.append(lastResultIndex).append(":");

            for (int i = 0; i < sortKeys.size(); i++) {
                if (i > 0) {
                    buf.append(",");
                }
                buf.append(sortKeys.get(i).toString());
            }

            return Base64.encode(buf.toString().getBytes());
        }

        public List<SortKey> getSortKeys() {
            return sortKeys;
        }

        public int getLastResultIndex() {
            return lastResultIndex;
        }
    }

    private static final class ResourceComparator implements Comparator<ResourceResponse> {
        private final List<SortKey> sortKeys;

        private ResourceComparator(final List<SortKey> sortKeys) {
            this.sortKeys = sortKeys;
        }

        @Override
        public int compare(final ResourceResponse r1, final ResourceResponse r2) {
            for (final SortKey sortKey : sortKeys) {
                final int result = compare(r1, r2, sortKey);
                if (result != 0) {
                    return result;
                }
            }
            return 0;
        }

        private int compare(final ResourceResponse r1, final ResourceResponse r2, final SortKey sortKey) {
            final List<Object> vs1 = getValuesSorted(r1, sortKey.getField());
            final List<Object> vs2 = getValuesSorted(r2, sortKey.getField());
            if (vs1.isEmpty() && vs2.isEmpty()) {
                return 0;
            } else if (vs1.isEmpty()) {
                // Sort resources with missing attributes last.
                return 1;
            } else if (vs2.isEmpty()) {
                // Sort resources with missing attributes last.
                return -1;
            } else {
                // Compare first values only (consistent with LDAP sort control).
                final Object v1 = vs1.get(0);
                final Object v2 = vs2.get(0);
                return sortKey.isAscendingOrder() ? compareValues(v1, v2) : -compareValues(v1, v2);
            }
        }

        private List<Object> getValuesSorted(final ResourceResponse resource, final JsonPointer field) {
            final JsonValue value = resource.getContent().get(field);
            if (value == null) {
                return Collections.emptyList();
            } else if (value.isList()) {
                List<Object> results = value.asList();
                if (results.size() > 1) {
                    results = new ArrayList<>(results);
                    Collections.sort(results, VALUE_COMPARATOR);
                }
                return results;
            } else {
                return Collections.singletonList(value.getObject());
            }
        }
    }

    private static final QueryFilterVisitor<FilterResult, ResourceResponse, JsonPointer> RESOURCE_FILTER =
            new QueryFilterVisitor<FilterResult, ResourceResponse, JsonPointer>() {

            @Override
            public FilterResult visitAndFilter(final ResourceResponse p,
                    final List<org.forgerock.util.query.QueryFilter<JsonPointer>> subFilters) {
                FilterResult result = FilterResult.TRUE;
                for (final org.forgerock.util.query.QueryFilter<JsonPointer> subFilter : subFilters) {
                    final FilterResult r = subFilter.accept(this, p);
                    if (r.ordinal() < result.ordinal()) {
                        result = r;
                    }
                    if (result == FilterResult.FALSE) {
                        break;
                    }
                }
                return result;
            }

            @Override
            public FilterResult visitBooleanLiteralFilter(final ResourceResponse p, final boolean value) {
                return FilterResult.valueOf(value);
            }

            @Override
            public FilterResult visitContainsFilter(final ResourceResponse p, final JsonPointer field,
                    final Object valueAssertion) {
                for (final Object value : getValues(p, field)) {
                    if (isCompatible(valueAssertion, value)) {
                        if (valueAssertion instanceof String) {
                            final String s1 =
                                    ((String) valueAssertion).toLowerCase(Locale.ENGLISH);
                            final String s2 = ((String) value).toLowerCase(Locale.ENGLISH);
                            if (s2.contains(s1)) {
                                return FilterResult.TRUE;
                            }
                        } else {
                            // Use equality matching for numbers and booleans.
                            if (compareValues(valueAssertion, value) == 0) {
                                return FilterResult.TRUE;
                            }
                        }
                    }
                }
                return FilterResult.FALSE;
            }

            @Override
            public FilterResult visitEqualsFilter(final ResourceResponse p, final JsonPointer field,
                    final Object valueAssertion) {
                for (final Object value : getValues(p, field)) {
                    if (isCompatible(valueAssertion, value)
                            && compareValues(valueAssertion, value) == 0) {
                        return FilterResult.TRUE;
                    }
                }
                return FilterResult.FALSE;
            }

            @Override
            public FilterResult visitExtendedMatchFilter(final ResourceResponse p,
                    final JsonPointer field, final String matchingRuleId,
                    final Object valueAssertion) {
                // This backend does not support any extended filters.
                return FilterResult.UNDEFINED;
            }

            @Override
            public FilterResult visitGreaterThanFilter(final ResourceResponse p,
                    final JsonPointer field, final Object valueAssertion) {
                for (final Object value : getValues(p, field)) {
                    if (isCompatible(valueAssertion, value)
                            && compareValues(valueAssertion, value) < 0) {
                        return FilterResult.TRUE;
                    }
                }
                return FilterResult.FALSE;
            }

            @Override
            public FilterResult visitGreaterThanOrEqualToFilter(final ResourceResponse p,
                    final JsonPointer field, final Object valueAssertion) {
                for (final Object value : getValues(p, field)) {
                    if (isCompatible(valueAssertion, value)
                            && compareValues(valueAssertion, value) <= 0) {
                        return FilterResult.TRUE;
                    }
                }
                return FilterResult.FALSE;
            }

            @Override
            public FilterResult visitLessThanFilter(final ResourceResponse p, final JsonPointer field,
                    final Object valueAssertion) {
                for (final Object value : getValues(p, field)) {
                    if (isCompatible(valueAssertion, value)
                            && compareValues(valueAssertion, value) > 0) {
                        return FilterResult.TRUE;
                    }
                }
                return FilterResult.FALSE;
            }

            @Override
            public FilterResult visitLessThanOrEqualToFilter(final ResourceResponse p,
                    final JsonPointer field, final Object valueAssertion) {
                for (final Object value : getValues(p, field)) {
                    if (isCompatible(valueAssertion, value)
                            && compareValues(valueAssertion, value) >= 0) {
                        return FilterResult.TRUE;
                    }
                }
                return FilterResult.FALSE;
            }

            @Override
            public FilterResult visitNotFilter(final ResourceResponse p,
                    final org.forgerock.util.query.QueryFilter<JsonPointer> subFilter) {
                switch (subFilter.accept(this, p)) {
                case FALSE:
                    return FilterResult.TRUE;
                case UNDEFINED:
                    return FilterResult.UNDEFINED;
                default: // TRUE
                    return FilterResult.FALSE;
                }
            }

            @Override
            public FilterResult visitOrFilter(final ResourceResponse p,
                    final List<org.forgerock.util.query.QueryFilter<JsonPointer>> subFilters) {
                FilterResult result = FilterResult.FALSE;
                for (final org.forgerock.util.query.QueryFilter<JsonPointer> subFilter : subFilters) {
                    final FilterResult r = subFilter.accept(this, p);
                    if (r.ordinal() > result.ordinal()) {
                        result = r;
                    }
                    if (result == FilterResult.TRUE) {
                        break;
                    }
                }
                return result;
            }

            @Override
            public FilterResult visitPresentFilter(final ResourceResponse p, final JsonPointer field) {
                final JsonValue value = p.getContent().get(field);
                return FilterResult.valueOf(value != null);
            }

            @Override
            public FilterResult visitStartsWithFilter(final ResourceResponse p,
                    final JsonPointer field, final Object valueAssertion) {
                for (final Object value : getValues(p, field)) {
                    if (isCompatible(valueAssertion, value)) {
                        if (valueAssertion instanceof String) {
                            final String s1 =
                                    ((String) valueAssertion).toLowerCase(Locale.ENGLISH);
                            final String s2 = ((String) value).toLowerCase(Locale.ENGLISH);
                            if (s2.startsWith(s1)) {
                                return FilterResult.TRUE;
                            }
                        } else {
                            // Use equality matching for numbers and booleans.
                            if (compareValues(valueAssertion, value) == 0) {
                                return FilterResult.TRUE;
                            }
                        }
                    }
                }
                return FilterResult.FALSE;
            }

            private List<Object> getValues(final ResourceResponse resource, final JsonPointer field) {
                final JsonValue value = resource.getContent().get(field);
                if (value == null) {
                    return Collections.emptyList();
                } else if (value.isList()) {
                    return value.asList();
                } else {
                    return Collections.singletonList(value.getObject());
                }
            }

        };

    private static final Comparator<Object> VALUE_COMPARATOR = new Comparator<Object>() {
        @Override
        public int compare(final Object o1, final Object o2) {
            return compareValues(o1, o2);
        }
    };

    private static int compareValues(final Object v1, final Object v2) {
        if (v1 instanceof String && v2 instanceof String) {
            final String s1 = (String) v1;
            final String s2 = (String) v2;
            return s1.compareToIgnoreCase(s2);
        } else if (v1 instanceof Number && v2 instanceof Number) {
            final Double n1 = ((Number) v1).doubleValue();
            final Double n2 = ((Number) v2).doubleValue();
            return n1.compareTo(n2);
        } else if (v1 instanceof Boolean && v2 instanceof Boolean) {
            final Boolean b1 = (Boolean) v1;
            final Boolean b2 = (Boolean) v2;
            return b1.compareTo(b2);
        } else {
            // Different types: we need to ensure predictable ordering,
            // so use class name as secondary key.
            return v1.getClass().getName().compareTo(v2.getClass().getName());
        }
    }

    private static boolean isCompatible(final Object v1, final Object v2) {
        return (v1 instanceof String && v2 instanceof String)
                || (v1 instanceof Number && v2 instanceof Number)
                || (v1 instanceof Boolean && v2 instanceof Boolean);
    }

    private final AtomicLong nextResourceId = new AtomicLong();
    private final Map<String, ResourceResponse> resources = new ConcurrentHashMap<>();
    private final Object writeLock = new Object();

    /**
     * Creates a new in-memory collection containing no resources.
     */
    public MemoryBackend() {
        // No implementation required.
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public Promise<ActionResponse, ResourceException> actionCollection(final Context context,
            final ActionRequest request) {
        try {
            if (request.getAction().equals("clear")) {
                final int size;
                synchronized (writeLock) {
                    size = resources.size();
                    resources.clear();
                }
                final JsonValue result = new JsonValue(new LinkedHashMap<>(1));
                result.put("cleared", size);
                return newResultPromise(Responses.newActionResponse(result));
            } else {
                throw new NotSupportedException("Unrecognized action ID '" + request.getAction()
                        + "'. Supported action IDs: clear");
            }
        } catch (final ResourceException e) {
            return newExceptionPromise(e);
        }
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public Promise<ActionResponse, ResourceException> actionInstance(final Context context, final String id,
            final ActionRequest request) {
        final ResourceException e =
                new NotSupportedException("Actions are not supported for resource instances");
        return newExceptionPromise(e);
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public Promise<ResourceResponse, ResourceException> createInstance(final Context context,
            final CreateRequest request) {
        final JsonValue value = request.getContent();
        final String id = request.getNewResourceId();
        final String rev = "0";
        try {
            final ResourceResponse resource;
            while (true) {
                final String eid =
                        id != null ? id : String.valueOf(nextResourceId.getAndIncrement());
                final ResourceResponse tmp = newResourceResponse(eid, rev, value);
                synchronized (writeLock) {
                    final ResourceResponse existingResource = resources.put(eid, tmp);
                    if (existingResource != null) {
                        if (id != null) {
                            // Already exists - put the existing resource back.
                            resources.put(id, existingResource);
                            throw new PreconditionFailedException("The resource with ID '" + id
                                    + "' could not be created because "
                                    + "there is already another resource with the same ID");
                        } else {
                            // Retry with next available resource ID.
                        }
                    } else {
                        // Add succeeded.
                        addIdAndRevision(tmp);
                        resource = tmp;
                        break;
                    }
                }
            }
            return newResultPromise(resource);
        } catch (final ResourceException e) {
            return newExceptionPromise(e);
        }
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public Promise<ResourceResponse, ResourceException> deleteInstance(final Context context, final String id,
            final DeleteRequest request) {
        final String rev = request.getRevision();
        try {
            final ResourceResponse resource;
            synchronized (writeLock) {
                resource = getResourceForUpdate(id, rev);
                resources.remove(id);
            }
            return newResultPromise(resource);
        } catch (final ResourceException e) {
            return newExceptionPromise(e);
        }
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public Promise<ResourceResponse, ResourceException> patchInstance(final Context context, final String id,
            final PatchRequest request) {
        final String rev = request.getRevision();
        try {
            final ResourceResponse resource;
            synchronized (writeLock) {
                final ResourceResponse existingResource = getResourceForUpdate(id, rev);
                final String newRev = getNextRevision(existingResource.getRevision());
                final JsonValue newContent = existingResource.getContent().copy();
                for (final PatchOperation operation : request.getPatchOperations()) {
                    try {
                        if (operation.isAdd()) {
                            newContent.putPermissive(operation.getField(), operation.getValue()
                                    .getObject());
                        } else if (operation.isRemove()) {
                            if (operation.getValue().isNull()) {
                                // Remove entire value.
                                newContent.remove(operation.getField());
                            } else {
                                // Find matching value(s) and remove (assumes reference to array).
                                final JsonValue value = newContent.get(operation.getField());
                                if (value != null) {
                                    if (value.isList()) {
                                        final Object valueToBeRemoved =
                                                operation.getValue().getObject();
                                        final Iterator<Object> iterator = value.asList().iterator();
                                        while (iterator.hasNext()) {
                                            if (valueToBeRemoved.equals(iterator.next())) {
                                                iterator.remove();
                                            }
                                        }
                                    } else {
                                        // Single valued field.
                                        final Object valueToBeRemoved =
                                                operation.getValue().getObject();
                                        if (valueToBeRemoved.equals(value.getObject())) {
                                            newContent.remove(operation.getField());
                                        }
                                    }
                                }
                            }
                        } else if (operation.isReplace()) {
                            newContent.remove(operation.getField());
                            if (!operation.getValue().isNull()) {
                                newContent.putPermissive(operation.getField(), operation.getValue()
                                        .getObject());
                            }
                        } else if (operation.isIncrement()) {
                            final JsonValue value = newContent.get(operation.getField());
                            final Number amount = operation.getValue().asNumber();
                            if (value == null) {
                                throw new BadRequestException("The field '" + operation.getField()
                                        + "' does not exist");
                            } else if (value.isList()) {
                                final List<Object> elements = value.asList();
                                for (int i = 0; i < elements.size(); i++) {
                                    elements.set(i, increment(operation, elements.get(i), amount));
                                }
                            } else {
                                newContent.put(operation.getField(), increment(operation, value
                                        .getObject(), amount));
                            }
                        }
                    } catch (final JsonValueException e) {
                        throw new ConflictException("The field '" + operation.getField()
                                + "' does not exist");
                    }
                }
                resource = newResourceResponse(id, newRev, newContent);
                addIdAndRevision(resource);
                resources.put(id, resource);
            }
            return newResultPromise(resource);
        } catch (final ResourceException e) {
            return newExceptionPromise(e);
        }
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public Promise<QueryResponse, ResourceException> queryCollection(final Context context,
            final QueryRequest request, final QueryResourceHandler handler) {
        if (request.getQueryId() != null) {
            return new NotSupportedException("Query by ID not supported").asPromise();
        } else if (request.getQueryExpression() != null) {
            return new NotSupportedException("Query by expression not supported").asPromise();
        } else {
            // No filtering or query by filter.
            final QueryFilter<JsonPointer> filter = request.getQueryFilter();

            // If paged results are requested then decode the cookie in order to determine
            // the index of the first result to be returned.
            final int pageSize = request.getPageSize();
            final String pagedResultsCookie = request.getPagedResultsCookie();
            final boolean pagedResultsRequested = pageSize > 0;
            final int firstResultIndex;
            final List<SortKey> sortKeys = request.getSortKeys();

            if (pageSize > 0 && pagedResultsCookie != null) {
                if (request.getPagedResultsOffset() > 0) {
                    return new BadRequestException("Cookies and offsets are mutually exclusive").asPromise();
                }

                firstResultIndex = Cookie.valueOf(pagedResultsCookie).getLastResultIndex();
            } else {
                if (request.getPagedResultsOffset() > 0) {
                    firstResultIndex = request.getPagedResultsOffset();
                } else {
                    firstResultIndex = 0;
                }
            }

            final int lastResultIndex =
                    pagedResultsRequested ? firstResultIndex + pageSize : Integer.MAX_VALUE;

            // Select, filter, and return the results. These can be streamed if server
            // side sorting has not been requested.
            int resultIndex = 0;
            int resultCount;
            if (sortKeys.isEmpty()) {
                // No sorting so stream the results.
                for (final ResourceResponse resource : resources.values()) {
                    if (filter == null || filter.accept(RESOURCE_FILTER, resource).toBoolean()) {
                        if (resultIndex >= firstResultIndex && resultIndex < lastResultIndex) {
                            handler.handleResource(resource);
                        }
                        resultIndex++;
                    }
                }

                resultCount = resources.values().size();
            } else {
                // Server side sorting: aggregate the result set then sort. A robust implementation
                // would need to impose administrative limits in order to control memory utilization.
                final List<ResourceResponse> results = new ArrayList<>();
                for (final ResourceResponse resource : resources.values()) {
                    if (filter == null || filter.accept(RESOURCE_FILTER, resource).toBoolean()) {
                        results.add(resource);
                    }
                }
                Collections.sort(results, new ResourceComparator(sortKeys));
                for (final ResourceResponse resource : results) {
                    if (resultIndex >= firstResultIndex && resultIndex < lastResultIndex) {
                        handler.handleResource(resource);
                    }

                    if (resultIndex < lastResultIndex) {
                        resultIndex++;
                    } else {
                        break;
                    }
                }

                resultCount = results.size();
            }

            if (pagedResultsRequested) {
                final String nextCookie = resultIndex < resources.size()
                        ? new Cookie(lastResultIndex, sortKeys).toBase64()
                        : null;

                switch (request.getTotalPagedResultsPolicy()) {
                case NONE:
                    return newResultPromise(newQueryResponse(nextCookie));
                case EXACT:
                case ESTIMATE:
                    return newResultPromise(newQueryResponse(nextCookie, CountPolicy.EXACT, resultCount));
                default:
                    throw new UnsupportedOperationException("totalPagedResultsPolicy: "
                            + request.getTotalPagedResultsPolicy().toString() + " not supported");
                }
            } else {
                return newResultPromise(newQueryResponse());
            }
        }
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public Promise<ResourceResponse, ResourceException> readInstance(final Context context, final String id,
            final ReadRequest request) {
        try {
            final ResourceResponse resource = resources.get(id);
            if (resource == null) {
                throw new NotFoundException("The resource with ID '" + id
                        + "' could not be read because it does not exist");
            }
            return newResultPromise(resource);
        } catch (final ResourceException e) {
            return newExceptionPromise(e);
        }
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public Promise<ResourceResponse, ResourceException> updateInstance(final Context context, final String id,
            final UpdateRequest request) {
        final String rev = request.getRevision();
        try {
            final ResourceResponse resource;
            synchronized (writeLock) {
                final ResourceResponse existingResource = getResourceForUpdate(id, rev);
                final String newRev = getNextRevision(existingResource.getRevision());
                resource = newResourceResponse(id, newRev, request.getContent());
                addIdAndRevision(resource);
                resources.put(id, resource);
            }
            return newResultPromise(resource);
        } catch (final ResourceException e) {
            return newExceptionPromise(e);
        }
    }

    /*
     * Add the ID and revision to the JSON content so that they are included
     * with subsequent responses. We shouldn't really update the passed in
     * content in case it is shared by other components, but we'll do it here
     * anyway for simplicity.
     */
    private void addIdAndRevision(final ResourceResponse resource) throws ResourceException {
        final JsonValue content = resource.getContent();
        try {
            content.asMap().put(ResourceResponse.FIELD_CONTENT_ID, resource.getId());
            content.asMap().put(ResourceResponse.FIELD_CONTENT_REVISION, resource.getRevision());
        } catch (final JsonValueException e) {
            throw new BadRequestException(
                    "The request could not be processed because the provided "
                            + "content is not a JSON object");
        }
    }

    private String getNextRevision(final String rev) throws ResourceException {
        try {
            return String.valueOf(Integer.parseInt(rev) + 1);
        } catch (final NumberFormatException e) {
            throw new InternalServerErrorException("Malformed revision number '" + rev
                    + "' encountered while updating a resource");
        }
    }

    private ResourceResponse getResourceForUpdate(final String id, final String rev)
            throws NotFoundException, PreconditionFailedException {
        final ResourceResponse existingResource = resources.get(id);
        if (existingResource == null) {
            throw new NotFoundException("The resource with ID '" + id
                    + "' could not be updated because it does not exist");
        } else if (rev != null && !existingResource.getRevision().equals(rev)) {
            throw new PreconditionFailedException("The resource with ID '" + id
                    + "' could not be updated because " + "it does not have the required version");
        }
        return existingResource;
    }

    private Object increment(final PatchOperation operation, final Object object,
            final Number amount) throws BadRequestException {
        if (object instanceof Long) {
            return ((Long) object) + amount.longValue();
        } else if (object instanceof Integer) {
            return ((Integer) object) + amount.intValue();
        } else if (object instanceof Float) {
            return ((Float) object) + amount.floatValue();
        } else if (object instanceof Double) {
            return ((Double) object) + amount.doubleValue();
        } else {
            throw new BadRequestException("The field '" + operation.getField()
                    + "' is not a number");
        }
    }
}