com.google.cloud.dns.testing.LocalDnsHelper.java Source code

Java tutorial

Introduction

Here is the source code for com.google.cloud.dns.testing.LocalDnsHelper.java

Source

/*
 * Copyright 2016 Google Inc. All Rights Reserved.
 *
 * 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 com.google.cloud.dns.testing;

import static com.google.common.net.InetAddresses.isInetAddress;
import static java.net.HttpURLConnection.HTTP_NO_CONTENT;
import static java.net.HttpURLConnection.HTTP_OK;

import com.google.api.client.http.HttpMediaType;
import com.google.api.client.json.JsonFactory;
import com.google.api.client.json.jackson.JacksonFactory;
import com.google.api.services.dns.model.Change;
import com.google.api.services.dns.model.ManagedZone;
import com.google.api.services.dns.model.Project;
import com.google.api.services.dns.model.Quota;
import com.google.api.services.dns.model.ResourceRecordSet;
import com.google.cloud.NoCredentials;
import com.google.cloud.dns.DnsOptions;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Joiner;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.ImmutableSortedMap;
import com.google.common.collect.Lists;
import com.google.common.collect.Sets;
import com.google.common.io.ByteStreams;

import com.sun.net.httpserver.Headers;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import com.sun.net.httpserver.HttpServer;

import org.apache.commons.fileupload.MultipartStream;
import org.joda.time.format.ISODateTimeFormat;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.math.BigInteger;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.NavigableMap;
import java.util.NavigableSet;
import java.util.Random;
import java.util.Scanner;
import java.util.Set;
import java.util.SortedMap;
import java.util.TreeMap;
import java.util.TreeSet;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.ConcurrentNavigableMap;
import java.util.concurrent.ConcurrentSkipListMap;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.regex.Pattern;
import java.util.zip.GZIPInputStream;

/**
 * A local Google Cloud DNS mock.
 *
 * <p>The mock runs in a separate thread, listening for HTTP requests on the local machine at an
 * ephemeral port.
 *
 * <p>While the mock attempts to simulate the service, there are some differences in the behaviour.
 * The mock will accept any project ID and never returns a notFound or another error because of
 * project ID. It assumes that all project IDs exist and that the user has all the necessary
 * privileges to manipulate any project. Similarly, the local simulation does not require
 * verification of domain name ownership. Any request for creating a managed zone will be approved.
 * The mock does not track quota and will allow the user to exceed it. The mock provides only basic
 * validation of the DNS data for record sets of type A and AAAA. It does not validate any other
 * record set types.
 */
public class LocalDnsHelper {

    private final ConcurrentSkipListMap<String, ProjectContainer> projects = new ConcurrentSkipListMap<>();
    private static final URI BASE_CONTEXT;
    private static final Logger log = Logger.getLogger(LocalDnsHelper.class.getName());
    private static final JsonFactory jsonFactory = new JacksonFactory();
    private static final Random ID_GENERATOR = new Random();
    private static final String VERSION = "v1";
    private static final String CONTEXT = "/dns/" + VERSION + "/projects";
    private static final Set<String> ENCODINGS = ImmutableSet.of("gzip", "x-gzip");
    private static final List<String> TYPES = ImmutableList.of("A", "AAAA", "CNAME", "MX", "NAPTR", "NS", "PTR",
            "SOA", "SPF", "SRV", "TXT");
    private static final TreeSet<String> FORBIDDEN = Sets
            .newTreeSet(ImmutableList.of("google.com.", "com.", "example.com.", "net.", "org."));
    private static final Pattern ZONE_NAME_RE = Pattern.compile("[a-z][a-z0-9-]*");
    private static final ScheduledExecutorService EXECUTORS = Executors.newScheduledThreadPool(2,
            Executors.defaultThreadFactory());
    private static final String PROJECT_ID = "dummyprojectid";
    private static final String RESPONSE_BOUNDARY = "____THIS_IS_HELPERS_BOUNDARY____";
    private static final String RESPONSE_SEPARATOR = "--" + RESPONSE_BOUNDARY + "\r\n";
    private static final String RESPONSE_END = "--" + RESPONSE_BOUNDARY + "--\r\n\r\n";

    static {
        try {
            BASE_CONTEXT = new URI(CONTEXT);
        } catch (URISyntaxException e) {
            throw new IllegalArgumentException("Could not initialize LocalDnsHelper due to URISyntaxException.", e);
        }
    }

    private final long delayChange;
    private final HttpServer server;
    private final int port;

    /**
     * For matching URLs to operations.
     */
    private enum CallRegex {
        CHANGE_CREATE("POST", CONTEXT + "/[^/]+/managedZones/[^/]+/changes"), CHANGE_GET("GET",
                CONTEXT + "/[^/]+/managedZones/[^/]+/changes/[^/]+"), CHANGE_LIST("GET",
                        CONTEXT + "/[^/]+/managedZones/[^/]+/changes"), ZONE_CREATE("POST",
                                CONTEXT + "/[^/]+/managedZones"), ZONE_DELETE("DELETE",
                                        CONTEXT + "/[^/]+/managedZones/[^/]+"), ZONE_GET("GET",
                                                CONTEXT + "/[^/]+/managedZones/[^/]+"), ZONE_LIST("GET",
                                                        CONTEXT + "/[^/]+/managedZones"), PROJECT_GET("GET",
                                                                CONTEXT + "/[^/]+"), RECORD_LIST("GET", CONTEXT
                                                                        + "/[^/]+/managedZones/[^/]+/rrsets"), BATCH(
                                                                                "POST", "/batch");

        private final String method;
        private final String pathRegex;

        CallRegex(String method, String pathRegex) {
            this.pathRegex = pathRegex;
            this.method = method;
        }
    }

    /**
     * Associates a project with a collection of ManagedZones.
     */
    static class ProjectContainer {
        private final Project project;
        private final ConcurrentSkipListMap<String, ZoneContainer> zones = new ConcurrentSkipListMap<>();

        ProjectContainer(Project project) {
            this.project = project;
        }

        Project project() {
            return project;
        }

        ConcurrentSkipListMap<String, ZoneContainer> zones() {
            return zones;
        }
    }

    /**
     * Associates a zone with a collection of changes and dns records.
     */
    static class ZoneContainer {
        private final ManagedZone zone;
        private final AtomicReference<ImmutableSortedMap<String, ResourceRecordSet>> dnsRecords = new AtomicReference<>(
                ImmutableSortedMap.<String, ResourceRecordSet>of());
        private final ConcurrentLinkedQueue<Change> changes = new ConcurrentLinkedQueue<>();

        ZoneContainer(ManagedZone zone) {
            this.zone = zone;
            this.dnsRecords.set(ImmutableSortedMap.<String, ResourceRecordSet>of());
        }

        ManagedZone zone() {
            return zone;
        }

        AtomicReference<ImmutableSortedMap<String, ResourceRecordSet>> dnsRecords() {
            return dnsRecords;
        }

        ConcurrentLinkedQueue<Change> changes() {
            return changes;
        }

        Change findChange(String changeId) {
            for (Change current : changes) {
                if (changeId.equals(current.getId())) {
                    return current;
                }
            }
            return null;
        }
    }

    static class Response {
        private final int code;
        private final String body;

        Response(int code, String body) {
            this.code = code;
            this.body = body;
        }

        int code() {
            return code;
        }

        String body() {
            return body;
        }
    }

    private enum Error {
        REQUIRED(400, "global", "required", "REQUIRED"), INTERNAL_ERROR(500, "global", "internalError",
                "INTERNAL_ERROR"), BAD_REQUEST(400, "global", "badRequest", "BAD_REQUEST"), INVALID(400, "global",
                        "invalid", "INVALID"), CONTAINER_NOT_EMPTY(400, "global", "containerNotEmpty",
                                "CONTAINER_NOT_EMPTY"), NOT_AVAILABLE(400, "global",
                                        "managedZoneDnsNameNotAvailable", "NOT_AVAILABLE"), NOT_FOUND(404, "global",
                                                "notFound", "NOT_FOUND"), ALREADY_EXISTS(409, "global",
                                                        "alreadyExists", "ALREADY_EXISTS"), CONDITION_NOT_MET(412,
                                                                "global", "conditionNotMet",
                                                                "CONDITION_NOT_MET"), INVALID_ZONE_APEX(400,
                                                                        "global", "invalidZoneApex",
                                                                        "INVALID_ZONE_APEX");

        private final int code;
        private final String domain;
        private final String reason;
        private final String status;

        Error(int code, String domain, String reason, String status) {
            this.code = code;
            this.domain = domain;
            this.reason = reason;
            this.status = status;
        }

        Response response(String message) {
            try {
                return new Response(code, toJson(message));
            } catch (IOException e) {
                return Error.INTERNAL_ERROR.response("Error when generating JSON error response.");
            }
        }

        private String toJson(String message) throws IOException {
            Map<String, Object> errors = new HashMap<>();
            errors.put("domain", domain);
            errors.put("message", message);
            errors.put("reason", reason);
            Map<String, Object> args = new HashMap<>();
            args.put("errors", ImmutableList.of(errors));
            args.put("code", code);
            args.put("message", message);
            args.put("status", status);
            return jsonFactory.toString(ImmutableMap.of("error", args));
        }
    }

    private class RequestHandler implements HttpHandler {

        private Response pickHandler(HttpExchange exchange, CallRegex regex) {
            URI relative = BASE_CONTEXT.relativize(exchange.getRequestURI());
            String path = relative.getPath();
            String[] tokens = path.split("/");
            String projectId = tokens.length > 0 ? tokens[0] : null;
            String zoneName = tokens.length > 2 ? tokens[2] : null;
            String changeId = tokens.length > 4 ? tokens[4] : null;
            String query = relative.getQuery();
            switch (regex) {
            case CHANGE_GET:
                return getChange(projectId, zoneName, changeId, query);
            case CHANGE_LIST:
                return listChanges(projectId, zoneName, query);
            case ZONE_GET:
                return getZone(projectId, zoneName, query);
            case ZONE_DELETE:
                return deleteZone(projectId, zoneName);
            case ZONE_LIST:
                return listZones(projectId, query);
            case PROJECT_GET:
                return getProject(projectId, query);
            case RECORD_LIST:
                return listDnsRecords(projectId, zoneName, query);
            case ZONE_CREATE:
                try {
                    return handleZoneCreate(exchange, projectId, query);
                } catch (IOException ex) {
                    return Error.BAD_REQUEST.response(ex.getMessage());
                }
            case CHANGE_CREATE:
                try {
                    return handleChangeCreate(exchange, projectId, zoneName, query);
                } catch (IOException ex) {
                    return Error.BAD_REQUEST.response(ex.getMessage());
                }
            case BATCH:
                try {
                    return handleBatch(exchange);
                } catch (IOException | URISyntaxException ex) {
                    return Error.BAD_REQUEST.response(ex.getMessage());
                }
            default:
                return Error.INTERNAL_ERROR.response("Operation without a handler.");
            }
        }

        @Override
        public void handle(HttpExchange exchange) throws IOException {
            String requestMethod = exchange.getRequestMethod();
            String rawPath = exchange.getRequestURI().getRawPath();
            for (CallRegex regex : CallRegex.values()) {
                if (requestMethod.equals(regex.method) && rawPath.matches(regex.pathRegex)) {
                    Response response = pickHandler(exchange, regex);
                    if (response != null) {
                        /* null response is returned by batch request, because it handles writing
                           the response on its own */
                        writeResponse(exchange, response);
                    }
                    return;
                }
            }
            writeResponse(exchange,
                    Error.NOT_FOUND.response(String.format("The url %s for %s method does not match any API call.",
                            requestMethod, exchange.getRequestURI())));
        }

        private Response handleBatch(final HttpExchange exchange) throws IOException, URISyntaxException {
            String contentType = exchange.getRequestHeaders().getFirst("Content-type");
            if (contentType != null) {
                HttpMediaType httpMediaType = new HttpMediaType(contentType);
                String boundary = httpMediaType.getParameter("boundary");
                MultipartStream multipartStream = new MultipartStream(exchange.getRequestBody(),
                        boundary.getBytes(), 1024, null);
                ByteArrayOutputStream out = new ByteArrayOutputStream();
                byte[] bytes = new byte[1024];
                boolean nextPart = multipartStream.skipPreamble();
                while (nextPart) {
                    String line;
                    String contentId = null;
                    String headers = multipartStream.readHeaders();
                    Scanner scanner = new Scanner(headers);
                    while (scanner.hasNextLine()) {
                        line = scanner.nextLine();
                        if (line.toLowerCase().startsWith("content-id")) {
                            contentId = line.split(":")[1].trim();
                        }
                    }
                    // TODO: remove and write directly to socket once api client provides a complete
                    // location line (e.g. GET /aaa/bbb HTTP/1.0)
                    // and uses a request path for location instead of a complete URL.
                    ByteArrayOutputStream bouts = new ByteArrayOutputStream();
                    multipartStream.readBodyData(bouts);
                    byte[] contentBytes = bouts.toByteArray();
                    int indexOfCr = -1;
                    for (int i = 0; i < contentBytes.length; i++) {
                        if (contentBytes[i] == '\r') {
                            indexOfCr = i;
                            break;
                        }
                    }
                    Socket socket = new Socket("127.0.0.1", server.getAddress().getPort());
                    OutputStream socketOutput = socket.getOutputStream();
                    InputStream socketInput = socket.getInputStream();
                    //multipartStream.readBodyData(socketOutput);
                    if (indexOfCr < 0) {
                        socketOutput.write(contentBytes);
                    } else {
                        String[] requestLine = new String(contentBytes, 0, indexOfCr, StandardCharsets.UTF_8)
                                .split(" ");
                        socketOutput.write(requestLine[0].getBytes());
                        socketOutput.write(' ');
                        URI uri = new URI(requestLine[1]);
                        socketOutput.write(uri.getRawPath().getBytes());
                        if (uri.getRawQuery() != null) {
                            socketOutput.write('?');
                            socketOutput.write(uri.getRawQuery().getBytes());
                        }
                        if (uri.getRawFragment() != null) {
                            socketOutput.write('#');
                            socketOutput.write(uri.getRawFragment().getBytes());
                        }
                        socketOutput.write(" HTTP/1.0".getBytes());
                        socketOutput.write(contentBytes, indexOfCr, contentBytes.length - indexOfCr);
                    }
                    socketOutput.flush();
                    out.write(RESPONSE_SEPARATOR.getBytes());
                    out.write("Content-Type: application/http\r\n".getBytes());
                    out.write(("Content-ID: " + contentId + "\r\n\r\n").getBytes());
                    int length;
                    while ((length = socketInput.read(bytes)) != -1) {
                        out.write(bytes, 0, length);
                    }
                    socket.close();
                    nextPart = multipartStream.skipPreamble();
                }
                out.write(RESPONSE_END.getBytes());
                writeBatchResponse(exchange, out);
            } else {
                return Error.BAD_REQUEST.response("Content-type header was not provided for batch.");
            }
            return null;
        }

        /**
         * @throws IOException if the request cannot be parsed.
         */
        private Response handleChangeCreate(HttpExchange exchange, String projectId, String zoneName, String query)
                throws IOException {
            String requestBody = decodeContent(exchange.getRequestHeaders(), exchange.getRequestBody());
            Change change;
            try {
                change = jsonFactory.fromString(requestBody, Change.class);
            } catch (IllegalArgumentException ex) {
                return Error.REQUIRED.response("The 'entity.change' parameter is required but was missing.");
            }
            String[] fields = OptionParsers.parseGetOptions(query);
            return createChange(projectId, zoneName, change, fields);
        }

        /**
         * @throws IOException if the request cannot be parsed.
         */
        private Response handleZoneCreate(HttpExchange exchange, String projectId, String query)
                throws IOException {
            String requestBody = decodeContent(exchange.getRequestHeaders(), exchange.getRequestBody());
            ManagedZone zone;
            try {
                zone = jsonFactory.fromString(requestBody, ManagedZone.class);
            } catch (IllegalArgumentException ex) {
                return Error.REQUIRED.response("The 'entity.managedZone' parameter is required but was missing.");
            }
            String[] options = OptionParsers.parseGetOptions(query);
            return createZone(projectId, zone, options);
        }
    }

    private LocalDnsHelper(long delay) {
        this.delayChange = delay;
        try {
            server = HttpServer.create(new InetSocketAddress(0), 0);
            port = server.getAddress().getPort();
            server.setExecutor(Executors.newCachedThreadPool());
            server.createContext("/", new RequestHandler());
        } catch (IOException e) {
            throw new RuntimeException("Could not bind the mock DNS server.", e);
        }
    }

    /**
     * Accessor for testing purposes.
     */
    ConcurrentSkipListMap<String, ProjectContainer> projects() {
        return projects;
    }

    /**
     * Creates new {@code LocalDnsHelper} instance that listens to requests on the local machine. This
     * instance processes changes in separate thread. The parameter determines how long a thread
     * should wait before processing a change. If it is set to 0, the threading is turned off and the
     * mock will behave synchronously.
     *
     * @param delay delay for processing changes in ms or 0 for synchronous processing
     */
    public static LocalDnsHelper create(Long delay) {
        return new LocalDnsHelper(delay);
    }

    /**
     * Returns a {@link DnsOptions} instance that sets the host to use the mock server.
     */
    @Deprecated
    public DnsOptions options() {
        return getOptions();
    }

    /**
     * Returns a {@link DnsOptions} instance that sets the host to use the mock server.
     */
    public DnsOptions getOptions() {
        return DnsOptions.newBuilder().setProjectId(PROJECT_ID).setHost("http://localhost:" + port)
                .setCredentials(NoCredentials.getInstance()).build();
    }

    /**
     * Starts the thread that runs the local DNS server.
     */
    public void start() {
        server.start();
    }

    /**
     * Stops the thread that runs the mock DNS server.
     */
    public void stop() {
        server.stop(1);
    }

    private static void writeResponse(HttpExchange exchange, Response response) {
        exchange.getResponseHeaders().set("Content-type", "application/json; charset=UTF-8");
        OutputStream outputStream = exchange.getResponseBody();
        try {
            exchange.getResponseHeaders().add("Connection", "close");
            exchange.sendResponseHeaders(response.code(), response.body().length());
            if (response.code() != 204) {
                // the server automatically sends headers and closes output stream when 204 is returned
                outputStream.write(response.body().getBytes(StandardCharsets.UTF_8));
            }
            outputStream.close();
        } catch (IOException e) {
            log.log(Level.WARNING, "IOException encountered when sending response.", e);
        }
    }

    private static void writeBatchResponse(HttpExchange exchange, ByteArrayOutputStream output) {
        exchange.getResponseHeaders().set("Content-type", "multipart/mixed; boundary=" + RESPONSE_BOUNDARY);
        try {
            exchange.getResponseHeaders().add("Connection", "close");
            exchange.sendResponseHeaders(200, output.toByteArray().length);
            OutputStream responseBody = exchange.getResponseBody();
            output.writeTo(responseBody);
            responseBody.close();
        } catch (IOException e) {
            log.log(Level.WARNING, "IOException encountered when sending response.", e);
        }
    }

    private static String decodeContent(Headers headers, InputStream inputStream) throws IOException {
        List<String> contentEncoding = headers.get("Content-encoding");
        InputStream input = inputStream;
        try {
            if (contentEncoding != null && !contentEncoding.isEmpty()) {
                String encoding = contentEncoding.get(0);
                if (ENCODINGS.contains(encoding)) {
                    input = new GZIPInputStream(inputStream);
                } else if (!"identity".equals(encoding)) {
                    throw new IOException(
                            "The request has the following unsupported HTTP content encoding: " + encoding);
                }
            }
            return new String(ByteStreams.toByteArray(input), StandardCharsets.UTF_8);
        } catch (IOException e) {
            throw new IOException("Exception encountered when decoding request content.", e);
        }
    }

    /**
     * Generates a JSON response.
     *
     * @param context managedZones | projects | rrsets | changes
     */
    @VisibleForTesting
    static Response toListResponse(List<String> serializedObjects, String context, String pageToken,
            boolean includePageToken) {
        StringBuilder responseBody = new StringBuilder();
        responseBody.append("{\"").append(context).append("\": [");
        Joiner.on(",").appendTo(responseBody, serializedObjects);
        responseBody.append(']');
        // add page token only if it exists and is asked for
        if (pageToken != null && includePageToken) {
            responseBody.append(",\"nextPageToken\": \"").append(pageToken).append('"');
        }
        responseBody.append('}');
        return new Response(HTTP_OK, responseBody.toString());
    }

    /**
     * Prepares record sets that are created by default for each zone.
     */
    private static ImmutableSortedMap<String, ResourceRecordSet> defaultRecords(ManagedZone zone) {
        ResourceRecordSet soa = new ResourceRecordSet();
        soa.setTtl(21600);
        soa.setName(zone.getDnsName());
        soa.setRrdatas(ImmutableList.of(
                // taken from the service
                "ns-cloud-c1.googledomains.com. cloud-dns-hostmaster.google.com. 0 21600 3600 1209600 312"));
        soa.setType("SOA");
        ResourceRecordSet ns = new ResourceRecordSet();
        ns.setTtl(21600);
        ns.setName(zone.getDnsName());
        ns.setRrdatas(zone.getNameServers());
        ns.setType("NS");
        String nsId = getUniqueId(ImmutableSet.<String>of());
        String soaId = getUniqueId(ImmutableSet.of(nsId));
        return ImmutableSortedMap.of(nsId, ns, soaId, soa);
    }

    /**
     * Returns a list of four nameservers randomly chosen from the predefined set.
     */
    @VisibleForTesting
    static List<String> randomNameservers() {
        ArrayList<String> nameservers = Lists.newArrayList("dns1.googlecloud.com", "dns2.googlecloud.com",
                "dns3.googlecloud.com", "dns4.googlecloud.com", "dns5.googlecloud.com", "dns6.googlecloud.com");
        while (nameservers.size() != 4) {
            int index = ID_GENERATOR.nextInt(nameservers.size());
            nameservers.remove(index);
        }
        return nameservers;
    }

    /**
     * Returns a hex string id (used for a record set) unique within the set of ids.
     */
    @VisibleForTesting
    static String getUniqueId(Set<String> ids) {
        String id;
        do {
            id = Long.toHexString(System.currentTimeMillis()) + Long.toHexString(Math.abs(ID_GENERATOR.nextLong()));
        } while (ids.contains(id));
        return id;
    }

    /**
     * Tests if a record set matches name and type (if provided). Used for filtering.
     */
    @VisibleForTesting
    static boolean matchesCriteria(ResourceRecordSet recordSet, String name, String type) {
        if (type != null && !recordSet.getType().equals(type)) {
            return false;
        }
        return name == null || recordSet.getName().equals(name);
    }

    /**
     * Returns a project container. Never returns {@code null} because we assume that all projects
     * exists.
     */
    private ProjectContainer findProject(String projectId) {
        ProjectContainer defaultProject = createProject(projectId);
        projects.putIfAbsent(projectId, defaultProject);
        return projects.get(projectId);
    }

    /**
     * Returns a zone container. Returns {@code null} if zone does not exist within project.
     */
    @VisibleForTesting
    ZoneContainer findZone(String projectId, String zoneName) {
        ProjectContainer projectContainer = findProject(projectId); // never null
        return projectContainer.zones().get(zoneName);
    }

    /**
     * Returns a change found by its id. Returns {@code null} if such a change does not exist.
     */
    @VisibleForTesting
    Change findChange(String projectId, String zoneName, String changeId) {
        ZoneContainer wrapper = findZone(projectId, zoneName);
        return wrapper == null ? null : wrapper.findChange(changeId);
    }

    /**
     * Returns a response to getChange service call.
     */
    @VisibleForTesting
    Response getChange(String projectId, String zoneName, String changeId, String query) {
        Change change = findChange(projectId, zoneName, changeId);
        if (change == null) {
            ZoneContainer zone = findZone(projectId, zoneName);
            if (zone == null) {
                return Error.NOT_FOUND.response(String
                        .format("The 'parameters.managedZone' resource named '%s' does not exist.", zoneName));
            }
            return Error.NOT_FOUND.response(
                    String.format("The 'parameters.changeId' resource named '%s' does not exist.", changeId));
        }
        String[] fields = OptionParsers.parseGetOptions(query);
        Change result = OptionParsers.extractFields(change, fields);
        try {
            return new Response(HTTP_OK, jsonFactory.toString(result));
        } catch (IOException e) {
            return Error.INTERNAL_ERROR
                    .response(String.format("Error when serializing change %s in managed zone %s in project %s.",
                            changeId, zoneName, projectId));
        }
    }

    /**
     * Returns a response to getZone service call.
     */
    @VisibleForTesting
    Response getZone(String projectId, String zoneName, String query) {
        ZoneContainer container = findZone(projectId, zoneName);
        if (container == null) {
            return Error.NOT_FOUND.response(
                    String.format("The 'parameters.managedZone' resource named '%s' does not exist.", zoneName));
        }
        String[] fields = OptionParsers.parseGetOptions(query);
        ManagedZone result = OptionParsers.extractFields(container.zone(), fields);
        try {
            return new Response(HTTP_OK, jsonFactory.toString(result));
        } catch (IOException e) {
            return Error.INTERNAL_ERROR.response(
                    String.format("Error when serializing managed zone %s in project %s.", zoneName, projectId));
        }
    }

    /**
     * We assume that every project exists. If we do not have it in the collection yet, we just create
     * a new default project instance with default quota.
     */
    @VisibleForTesting
    Response getProject(String projectId, String query) {
        String[] fields = OptionParsers.parseGetOptions(query);
        Project project = findProject(projectId).project(); // creates project if needed
        Project result = OptionParsers.extractFields(project, fields);
        try {
            return new Response(HTTP_OK, jsonFactory.toString(result));
        } catch (IOException e) {
            return Error.INTERNAL_ERROR.response(String.format("Error when serializing project %s.", projectId));
        }
    }

    /**
     * Creates a project. It generates a project number randomly.
     */
    private ProjectContainer createProject(String projectId) {
        Quota quota = new Quota();
        quota.setManagedZones(10000);
        quota.setRrsetsPerManagedZone(10000);
        quota.setRrsetAdditionsPerChange(100);
        quota.setRrsetDeletionsPerChange(100);
        quota.setTotalRrdataSizePerChange(10000);
        quota.setResourceRecordsPerRrset(100);
        Project project = new Project();
        project.setId(projectId);
        project.setNumber(new BigInteger(String.valueOf(Math.abs(ID_GENERATOR.nextLong() % Long.MAX_VALUE))));
        project.setQuota(quota);
        return new ProjectContainer(project);
    }

    @VisibleForTesting
    Response deleteZone(String projectId, String zoneName) {
        ZoneContainer zone = findZone(projectId, zoneName);
        ImmutableSortedMap<String, ResourceRecordSet> rrsets = zone == null
                ? ImmutableSortedMap.<String, ResourceRecordSet>of()
                : zone.dnsRecords().get();
        ImmutableList<String> defaults = ImmutableList.of("NS", "SOA");
        for (ResourceRecordSet current : rrsets.values()) {
            if (!defaults.contains(current.getType())) {
                return Error.CONTAINER_NOT_EMPTY.response(String
                        .format("The resource named '%s' cannot be deleted because it is not empty", zoneName));
            }
        }
        ProjectContainer projectContainer = projects.get(projectId);
        ZoneContainer previous = projectContainer.zones.remove(zoneName);
        return previous == null
                ? Error.NOT_FOUND.response(
                        String.format("The 'parameters.managedZone' resource named '%s' does not exist.", zoneName))
                : new Response(HTTP_NO_CONTENT, "{}");
    }

    /**
     * Creates new managed zone and stores it in the collection. Assumes that project exists.
     */
    @VisibleForTesting
    Response createZone(String projectId, ManagedZone zone, String... fields) {
        Response errorResponse = checkZone(zone);
        if (errorResponse != null) {
            return errorResponse;
        }
        ManagedZone completeZone = new ManagedZone();
        completeZone.setName(zone.getName());
        completeZone.setDnsName(zone.getDnsName());
        completeZone.setDescription(zone.getDescription());
        completeZone.setNameServerSet(zone.getNameServerSet());
        completeZone.setCreationTime(ISODateTimeFormat.dateTime().withZoneUTC().print(System.currentTimeMillis()));
        completeZone.setId(BigInteger.valueOf(Math.abs(ID_GENERATOR.nextLong() % Long.MAX_VALUE)));
        completeZone.setNameServers(randomNameservers());
        ZoneContainer zoneContainer = new ZoneContainer(completeZone);
        ImmutableSortedMap<String, ResourceRecordSet> defaultsRecords = defaultRecords(completeZone);
        zoneContainer.dnsRecords().set(defaultsRecords);
        Change change = new Change();
        change.setAdditions(ImmutableList.copyOf(defaultsRecords.values()));
        change.setStatus("done");
        change.setId("0");
        change.setStartTime(ISODateTimeFormat.dateTime().withZoneUTC().print(System.currentTimeMillis()));
        zoneContainer.changes().add(change);
        ProjectContainer projectContainer = findProject(projectId);
        ZoneContainer oldValue = projectContainer.zones().putIfAbsent(completeZone.getName(), zoneContainer);
        if (oldValue != null) {
            return Error.ALREADY_EXISTS.response(String
                    .format("The resource 'entity.managedZone' named '%s' already exists", completeZone.getName()));
        }
        ManagedZone result = OptionParsers.extractFields(completeZone, fields);
        try {
            return new Response(HTTP_OK, jsonFactory.toString(result));
        } catch (IOException e) {
            return Error.INTERNAL_ERROR
                    .response(String.format("Error when serializing managed zone %s.", result.getName()));
        }
    }

    /**
     * Creates a new change, stores it, and if delayChange > 0, invokes processing in a new thread.
     */
    Response createChange(String projectId, String zoneName, Change change, String... fields) {
        ZoneContainer zoneContainer = findZone(projectId, zoneName);
        if (zoneContainer == null) {
            return Error.NOT_FOUND.response(
                    String.format("The 'parameters.managedZone' resource named %s does not exist.", zoneName));
        }
        Response response = checkChange(change, zoneContainer);
        if (response != null) {
            return response;
        }
        Change completeChange = new Change();
        if (change.getAdditions() != null) {
            completeChange.setAdditions(ImmutableList.copyOf(change.getAdditions()));
        }
        if (change.getDeletions() != null) {
            completeChange.setDeletions(ImmutableList.copyOf(change.getDeletions()));
        }
        /* We need to set ID for the change. We are working in concurrent environment. We know that the
        element fell on an index between 1 and maxId (index 0 is the default change which creates SOA
        and NS), so we will reset all IDs between 0 and maxId (all of them are valid for the respective
        objects). */
        ConcurrentLinkedQueue<Change> changeSequence = zoneContainer.changes();
        changeSequence.add(completeChange);
        int maxId = changeSequence.size();
        int index = 0;
        for (Change c : changeSequence) {
            if (index == maxId) {
                break;
            }
            c.setId(String.valueOf(index++));
        }
        completeChange.setStatus("pending");
        completeChange.setStartTime(ISODateTimeFormat.dateTime().withZoneUTC().print(System.currentTimeMillis()));
        invokeChange(projectId, zoneName, completeChange.getId());
        Change result = OptionParsers.extractFields(completeChange, fields);
        try {
            return new Response(HTTP_OK, jsonFactory.toString(result));
        } catch (IOException e) {
            return Error.INTERNAL_ERROR
                    .response(String.format("Error when serializing change %s in managed zone %s in project %s.",
                            result.getId(), zoneName, projectId));
        }
    }

    /**
     * Applies change. Uses a different pooled thread which applies the change only if {@code
     * delayChange} is > 0.
     */
    private void invokeChange(final String projectId, final String zoneName, final String changeId) {
        if (delayChange > 0) {
            EXECUTORS.schedule(new Runnable() {
                @Override
                public void run() {
                    applyExistingChange(projectId, zoneName, changeId);
                }
            }, delayChange, TimeUnit.MILLISECONDS);
        } else {
            applyExistingChange(projectId, zoneName, changeId);
        }
    }

    /**
     * Applies changes to a zone. Repeatedly tries until succeeds. Thread safe and deadlock safe.
     */
    private void applyExistingChange(String projectId, String zoneName, String changeId) {
        Change change = findChange(projectId, zoneName, changeId);
        if (change == null) {
            return; // no such change exists, nothing to do
        }
        ZoneContainer wrapper = findZone(projectId, zoneName);
        if (wrapper == null) {
            return; // no such zone exists; it might have been deleted by another thread
        }
        AtomicReference<ImmutableSortedMap<String, ResourceRecordSet>> dnsRecords = wrapper.dnsRecords();
        while (true) {
            // managed zone must have a set of records which is not null
            ImmutableSortedMap<String, ResourceRecordSet> original = dnsRecords.get();
            // the copy will be populated when handling deletions
            SortedMap<String, ResourceRecordSet> copy = new TreeMap<>();
            // apply deletions first
            List<ResourceRecordSet> deletions = change.getDeletions();
            if (deletions != null) {
                for (Map.Entry<String, ResourceRecordSet> entry : original.entrySet()) {
                    if (!deletions.contains(entry.getValue())) {
                        copy.put(entry.getKey(), entry.getValue());
                    }
                }
            } else {
                copy.putAll(original);
            }
            // apply additions
            List<ResourceRecordSet> additions = change.getAdditions();
            if (additions != null) {
                for (ResourceRecordSet addition : additions) {
                    ResourceRecordSet rrset = new ResourceRecordSet();
                    rrset.setName(addition.getName());
                    rrset.setRrdatas(ImmutableList.copyOf(addition.getRrdatas()));
                    rrset.setTtl(addition.getTtl());
                    rrset.setType(addition.getType());
                    String id = getUniqueId(copy.keySet());
                    copy.put(id, rrset);
                }
            }
            boolean success = dnsRecords.compareAndSet(original, ImmutableSortedMap.copyOf(copy));
            if (success) {
                break; // success if no other thread modified the value in the meantime
            }
        }
        change.setStatus("done");
    }

    /**
     * Lists zones. Next page token is the last listed zone name and is returned only of there is more
     * to list and if the user does not exclude nextPageToken from field options.
     */
    @VisibleForTesting
    Response listZones(String projectId, String query) {
        Map<String, Object> options = OptionParsers.parseListZonesOptions(query);
        Response response = checkListOptions(options);
        if (response != null) {
            return response;
        }
        ConcurrentSkipListMap<String, ZoneContainer> containers = findProject(projectId).zones();
        String[] fields = (String[]) options.get("fields");
        String dnsName = (String) options.get("dnsName");
        String pageToken = (String) options.get("pageToken");
        Integer maxResults = options.get("maxResults") == null ? null
                : Integer.valueOf((String) options.get("maxResults"));
        boolean sizeReached = false;
        boolean hasMorePages = false;
        LinkedList<String> serializedZones = new LinkedList<>();
        String lastZoneName = null;
        ConcurrentNavigableMap<String, ZoneContainer> fragment = pageToken != null
                ? containers.tailMap(pageToken, false)
                : containers;
        for (ZoneContainer zoneContainer : fragment.values()) {
            ManagedZone zone = zoneContainer.zone();
            if (dnsName == null || zone.getDnsName().equals(dnsName)) {
                if (sizeReached) {
                    // we do not add this, just note that there would be more and there should be a token
                    hasMorePages = true;
                    break;
                } else {
                    try {
                        lastZoneName = zone.getName();
                        serializedZones.addLast(jsonFactory.toString(OptionParsers.extractFields(zone, fields)));
                    } catch (IOException e) {
                        return Error.INTERNAL_ERROR.response(String.format(
                                "Error when serializing managed zone %s in project %s", lastZoneName, projectId));
                    }
                }
            }
            sizeReached = maxResults != null && maxResults.equals(serializedZones.size());
        }
        boolean includePageToken = hasMorePages
                && (fields == null || Arrays.asList(fields).contains("nextPageToken"));
        return toListResponse(serializedZones, "managedZones", lastZoneName, includePageToken);
    }

    /**
     * Lists record sets for a zone. Next page token is the ID of the last record listed.
     */
    @VisibleForTesting
    Response listDnsRecords(String projectId, String zoneName, String query) {
        Map<String, Object> options = OptionParsers.parseListDnsRecordsOptions(query);
        Response response = checkListOptions(options);
        if (response != null) {
            return response;
        }
        ZoneContainer zoneContainer = findZone(projectId, zoneName);
        if (zoneContainer == null) {
            return Error.NOT_FOUND.response(
                    String.format("The 'parameters.managedZone' resource named '%s' does not exist.", zoneName));
        }
        ImmutableSortedMap<String, ResourceRecordSet> dnsRecords = zoneContainer.dnsRecords().get();
        String[] fields = (String[]) options.get("fields");
        String name = (String) options.get("name");
        String type = (String) options.get("type");
        String pageToken = (String) options.get("pageToken");
        ImmutableSortedMap<String, ResourceRecordSet> fragment = pageToken != null
                ? dnsRecords.tailMap(pageToken, false)
                : dnsRecords;
        Integer maxResults = options.get("maxResults") == null ? null
                : Integer.valueOf((String) options.get("maxResults"));
        boolean sizeReached = false;
        boolean hasMorePages = false;
        LinkedList<String> serializedRrsets = new LinkedList<>();
        String lastRecordId = null;
        for (String recordSetId : fragment.keySet()) {
            ResourceRecordSet recordSet = fragment.get(recordSetId);
            if (matchesCriteria(recordSet, name, type)) {
                if (sizeReached) {
                    // we do not add this, just note that there would be more and there should be a token
                    hasMorePages = true;
                    break;
                } else {
                    lastRecordId = recordSetId;
                    try {
                        serializedRrsets
                                .addLast(jsonFactory.toString(OptionParsers.extractFields(recordSet, fields)));
                    } catch (IOException e) {
                        return Error.INTERNAL_ERROR.response(String.format(
                                "Error when serializing resource record set in managed zone %s in project %s",
                                zoneName, projectId));
                    }
                }
            }
            sizeReached = maxResults != null && maxResults.equals(serializedRrsets.size());
        }
        boolean includePageToken = hasMorePages
                && (fields == null || Arrays.asList(fields).contains("nextPageToken"));
        return toListResponse(serializedRrsets, "rrsets", lastRecordId, includePageToken);
    }

    /**
     * Lists changes. Next page token is the ID of the last change listed.
     */
    @VisibleForTesting
    Response listChanges(String projectId, String zoneName, String query) {
        Map<String, Object> options = OptionParsers.parseListChangesOptions(query);
        Response response = checkListOptions(options);
        if (response != null) {
            return response;
        }
        ZoneContainer zoneContainer = findZone(projectId, zoneName);
        if (zoneContainer == null) {
            return Error.NOT_FOUND.response(
                    String.format("The 'parameters.managedZone' resource named '%s' does not exist", zoneName));
        }
        // take a sorted snapshot of the current change list
        NavigableMap<Integer, Change> changes = new TreeMap<>();
        for (Change c : zoneContainer.changes()) {
            if (c.getId() != null) {
                changes.put(Integer.valueOf(c.getId()), c);
            }
        }
        String[] fields = (String[]) options.get("fields");
        String sortOrder = (String) options.get("sortOrder");
        String pageToken = (String) options.get("pageToken");
        Integer maxResults = options.get("maxResults") == null ? null
                : Integer.valueOf((String) options.get("maxResults"));
        // as the only supported field is change sequence, we are not reading sortBy
        NavigableSet<Integer> keys;
        if ("descending".equals(sortOrder)) {
            keys = changes.descendingKeySet();
        } else {
            keys = changes.navigableKeySet();
        }
        Integer from = null;
        try {
            from = Integer.valueOf(pageToken);
        } catch (NumberFormatException ex) {
            // ignore page token
        }
        keys = from != null ? keys.tailSet(from, false) : keys;
        NavigableMap<Integer, Change> fragment = from != null && changes.containsKey(from)
                ? changes.tailMap(from, false)
                : changes;
        boolean sizeReached = false;
        boolean hasMorePages = false;
        LinkedList<String> serializedResults = new LinkedList<>();
        String lastChangeId = null;
        for (Integer key : keys) {
            Change change = fragment.get(key);
            if (sizeReached) {
                // we do not add this, just note that there would be more and there should be a token
                hasMorePages = true;
                break;
            } else {
                lastChangeId = change.getId();
                try {
                    serializedResults.addLast(jsonFactory.toString(OptionParsers.extractFields(change, fields)));
                } catch (IOException e) {
                    return Error.INTERNAL_ERROR.response(
                            String.format("Error when serializing change %s in managed zone %s in project %s",
                                    lastChangeId, zoneName, projectId));
                }
            }
            sizeReached = maxResults != null && maxResults.equals(serializedResults.size());
        }
        boolean includePageToken = hasMorePages
                && (fields == null || Arrays.asList(fields).contains("nextPageToken"));
        return toListResponse(serializedResults, "changes", lastChangeId, includePageToken);
    }

    /**
     * Validates a zone to be created.
     */
    private static Response checkZone(ManagedZone zone) {
        if (zone.getName() == null) {
            return Error.REQUIRED.response("The 'entity.managedZone.name' parameter is required but was missing.");
        }
        if (zone.getDnsName() == null) {
            return Error.REQUIRED
                    .response("The 'entity.managedZone.dnsName' parameter is required but was missing.");
        }
        if (zone.getDescription() == null) {
            return Error.REQUIRED
                    .response("The 'entity.managedZone.description' parameter is required but was missing.");
        }
        try {
            int number = Integer.valueOf(zone.getName());
            return Error.INVALID
                    .response(String.format("Invalid value for 'entity.managedZone.name': '%s'", number));
        } catch (NumberFormatException ex) {
            // expected
        }
        if (zone.getName().isEmpty() || zone.getName().length() > 32
                || !ZONE_NAME_RE.matcher(zone.getName()).matches()) {
            return Error.INVALID
                    .response(String.format("Invalid value for 'entity.managedZone.name': '%s'", zone.getName()));
        }
        if (zone.getDnsName().isEmpty() || !zone.getDnsName().endsWith(".")) {
            return Error.INVALID.response(
                    String.format("Invalid value for 'entity.managedZone.dnsName': '%s'", zone.getDnsName()));
        }
        if (FORBIDDEN.contains(zone.getDnsName())) {
            return Error.NOT_AVAILABLE.response(
                    String.format("The '%s' managed zone is not available to be created.", zone.getDnsName()));
        }
        return null;
    }

    /**
     * Validates a change to be created.
     */
    @VisibleForTesting
    static Response checkChange(Change change, ZoneContainer zone) {
        if ((change.getDeletions() == null || change.getDeletions().size() <= 0)
                && (change.getAdditions() == null || change.getAdditions().size() <= 0)) {
            return Error.REQUIRED.response("The 'entity.change' parameter is required but was missing.");
        }
        if (change.getAdditions() != null) {
            int counter = 0;
            for (ResourceRecordSet addition : change.getAdditions()) {
                Response response = checkRrset(addition, zone, counter, "additions");
                if (response != null) {
                    return response;
                }
                counter++;
            }
        }
        if (change.getDeletions() != null) {
            int counter = 0;
            for (ResourceRecordSet deletion : change.getDeletions()) {
                Response response = checkRrset(deletion, zone, counter, "deletions");
                if (response != null) {
                    return response;
                }
                counter++;
            }
        }
        return checkAdditionsDeletions(change.getAdditions(), change.getDeletions(), zone);
        // null if everything is ok
    }

    /**
     * Checks a rrset within a change.
     *
     * @param type [additions|deletions]
     * @param index the index or addition or deletion in the list
     * @param zone the zone that this change is applied to
     */
    @VisibleForTesting
    static Response checkRrset(ResourceRecordSet rrset, ZoneContainer zone, int index, String type) {
        if (rrset.getName() == null || !rrset.getName().endsWith(zone.zone().getDnsName())) {
            return Error.INVALID.response(String.format("Invalid value for 'entity.change.%s[%s].name': '%s'", type,
                    index, rrset.getName()));
        }
        if (rrset.getType() == null || !TYPES.contains(rrset.getType())) {
            return Error.INVALID.response(String.format("Invalid value for 'entity.change.%s[%s].type': '%s'", type,
                    index, rrset.getType()));
        }
        if (rrset.getTtl() != null && rrset.getTtl() != 0 && rrset.getTtl() < 0) {
            return Error.INVALID.response(String.format("Invalid value for 'entity.change.%s[%s].ttl': '%s'", type,
                    index, rrset.getTtl()));
        }
        if (rrset.getRrdatas() == null || rrset.isEmpty()) {
            return Error.INVALID.response(
                    String.format("Invalid value for 'entity.change.%s[%s].rrdata': '%s'", type, index, "<empty>"));
        }
        int counter = 0;
        for (String record : rrset.getRrdatas()) {
            if (!checkRrData(record, rrset.getType())) {
                return Error.INVALID.response(String.format(
                        "Invalid value for 'entity.change.%s[%s].rrdata[%s]': '%s'", type, index, counter, record));
            }
            counter++;
        }
        if ("deletions".equals(type)) {
            // check that deletion has a match by name and type
            boolean found = false;
            for (ResourceRecordSet wrappedRrset : zone.dnsRecords().get().values()) {
                if (rrset.getName().equals(wrappedRrset.getName())
                        && rrset.getType().equals(wrappedRrset.getType())) {
                    found = true;
                    break;
                }
            }
            if (!found) {
                return Error.NOT_FOUND.response(
                        String.format("The 'entity.change.deletions[%s]' resource named '%s (%s)' does not exist.",
                                index, rrset.getName(), rrset.getType()));
            }
            // if found, we still need an exact match
            if ("deletions".equals(type) && !zone.dnsRecords().get().containsValue(rrset)) {
                // such a record does not exist
                return Error.CONDITION_NOT_MET
                        .response(String.format("Precondition not met for 'entity.change.deletions[%s]", index));
            }
        }
        return null;
    }

    /**
     * Checks against duplicate additions (for each record set to be added that already exists, we
     * must have a matching deletion. Furthermore, check that mandatory SOA and NS records stay.
     */
    static Response checkAdditionsDeletions(List<ResourceRecordSet> additions, List<ResourceRecordSet> deletions,
            ZoneContainer zone) {
        if (additions != null) {
            int index = 0;
            for (ResourceRecordSet rrset : additions) {
                for (ResourceRecordSet wrappedRrset : zone.dnsRecords().get().values()) {
                    if (rrset.getName().equals(wrappedRrset.getName())
                            && rrset.getType().equals(wrappedRrset.getType())
                            // such a record set exists and we must have a deletion
                            && (deletions == null || !deletions.contains(wrappedRrset))) {
                        return Error.ALREADY_EXISTS.response(String.format(
                                "The 'entity.change.additions[%s]' resource named '%s (%s)' already exists.", index,
                                rrset.getName(), rrset.getType()));
                    }
                }
                if (rrset.getType().equals("SOA") && findByNameAndType(deletions, null, "SOA") == null) {
                    return Error.INVALID_ZONE_APEX.response(String.format("The resource record set 'entity"
                            + ".change.additions[%s]' is invalid because a zone must contain exactly one resource"
                            + " record set of type 'SOA' at the apex.", index));
                }
                if (rrset.getType().equals("NS") && findByNameAndType(deletions, null, "NS") == null) {
                    return Error.INVALID_ZONE_APEX.response(String.format("The resource record set 'entity"
                            + ".change.additions[%s]' is invalid because a zone must contain exactly one resource"
                            + " record set of type 'NS' at the apex.", index));
                }
                index++;
            }
        }
        if (deletions != null) {
            int index = 0;
            for (ResourceRecordSet rrset : deletions) {
                if (rrset.getType().equals("SOA") && findByNameAndType(additions, null, "SOA") == null) {
                    return Error.INVALID_ZONE_APEX.response(String.format("The resource record set 'entity"
                            + ".change.deletions[%s]' is invalid because a zone must contain exactly one resource"
                            + " record set of type 'SOA' at the apex.", index));
                }
                if (rrset.getType().equals("NS") && findByNameAndType(additions, null, "NS") == null) {
                    return Error.INVALID_ZONE_APEX.response(String.format("The resource record set 'entity"
                            + ".change.deletions[%s]' is invalid because a zone must contain exactly one resource"
                            + " record set of type 'NS' at the apex.", index));
                }
                index++;
            }
        }
        return null;
    }

    /**
     * Helper for searching rrsets in a collection.
     */
    private static ResourceRecordSet findByNameAndType(Iterable<ResourceRecordSet> recordSets, String name,
            String type) {
        if (recordSets != null) {
            for (ResourceRecordSet rrset : recordSets) {
                if ((name == null || name.equals(rrset.getName()))
                        && (type == null || type.equals(rrset.getType()))) {
                    return rrset;
                }
            }
        }
        return null;
    }

    /**
     * We only provide the most basic validation for A and AAAA record sets.
     */
    static boolean checkRrData(String data, String type) {
        switch (type) {
        case "A":
            return !data.contains(":") && isInetAddress(data);
        case "AAAA":
            return data.contains(":") && isInetAddress(data);
        default:
            return true;
        }
    }

    /**
     * Check supplied listing options.
     */
    @VisibleForTesting
    static Response checkListOptions(Map<String, Object> options) {
        // for general listing
        String maxResultsString = (String) options.get("maxResults");
        if (maxResultsString != null) {
            Integer maxResults;
            try {
                maxResults = Integer.valueOf(maxResultsString);
            } catch (NumberFormatException ex) {
                return Error.INVALID.response(String.format("Invalid integer value': '%s'.", maxResultsString));
            }
            if (maxResults <= 0) {
                return Error.INVALID
                        .response(String.format("Invalid value for 'parameters.maxResults': '%s'", maxResults));
            }
        }
        String dnsName = (String) options.get("dnsName");
        if (dnsName != null && !dnsName.endsWith(".")) {
            return Error.INVALID.response(String.format("Invalid value for 'parameters.dnsName': '%s'", dnsName));
        }
        // for listing record sets, name must be fully qualified
        String name = (String) options.get("name");
        if (name != null && !name.endsWith(".")) {
            return Error.INVALID.response(String.format("Invalid value for 'parameters.name': '%s'", name));
        }
        String type = (String) options.get("type"); // must be provided with name
        if (type != null) {
            if (name == null) {
                return Error.INVALID.response("Invalid value for 'parameters.name': '' "
                        + "(name must be specified if type is specified)");
            }
            if (!TYPES.contains(type)) {
                return Error.INVALID.response(String.format("Invalid value for 'parameters.type': '%s'", type));
            }
        }
        // for listing changes
        String order = (String) options.get("sortOrder");
        if (order != null && !"ascending".equals(order) && !"descending".equals(order)) {
            return Error.INVALID.response(String.format("Invalid value for 'parameters.sortOrder': '%s'", order));
        }
        String sortBy = (String) options.get("sortBy"); // case insensitive
        if (sortBy != null && !"changesequence".equals(sortBy.toLowerCase())) {
            return Error.INVALID.response(
                    String.format("Invalid string value: '%s'. Allowed values: [changesequence]", sortBy));
        }
        return null;
    }
}