private static void visitMappingProperties(Deque<String> path,
                                             ObjectNode mapping, BiConsumer<String, String> consumer) {
    Objects.requireNonNull(mapping, "mapping");
    if (mapping.isMissingNode()) {
      return;
    }

    if (mapping.has("properties")) {
      // recurse
      visitMappingProperties(path, (ObjectNode) mapping.get("properties"), consumer);
      return;
    }

    if (mapping.has("type")) {
      // this is leaf (register field / type mapping)
      consumer.accept(String.join(".", path), mapping.get("type").asText());
      return;
    }

    // otherwise continue visiting mapping(s)
    Iterable<Map.Entry<String, JsonNode>> iter = mapping::fields;
    for (Map.Entry<String, JsonNode> entry : iter) {
      final String name = entry.getKey();
      final ObjectNode node = (ObjectNode) entry.getValue();
      path.add(name);
      visitMappingProperties(path, node, consumer);
      path.removeLast();
    }
  }
Visits Elasticsearch
<a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/mapping.html">mapping
properties</a> and calls consumer for each {@code field / type} pair.
Nested fields are represented as {@code foo.bar.qux}.
criteria/elasticsearch/src/org/immutables/criteria/elasticsearch/Json.java