All Downloads are FREE. Search and download functionalities are using the official Maven repository.

io.hyperfoil.core.handlers.JsonParser Maven / Gradle / Ivy

There is a newer version: 0.27.1
Show newest version
package io.hyperfoil.core.handlers;

import java.io.Serializable;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.function.Function;

import io.hyperfoil.api.config.BenchmarkDefinitionException;
import io.hyperfoil.api.config.InitFromParam;
import io.hyperfoil.api.config.Locator;
import io.hyperfoil.api.processor.Processor;
import io.hyperfoil.api.processor.Transformer;
import io.hyperfoil.api.session.ResourceUtilizer;
import io.hyperfoil.api.session.Session;
import io.hyperfoil.core.builders.ServiceLoadedBuilderProvider;
import io.hyperfoil.core.data.DataFormat;
import io.hyperfoil.core.generators.Pattern;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.PooledByteBufAllocator;
import io.vertx.core.logging.Logger;
import io.vertx.core.logging.LoggerFactory;

public abstract class JsonParser implements Serializable, ResourceUtilizer {
   protected static final Logger log = LoggerFactory.getLogger(JsonParser.class);
   protected static final int MAX_PARTS = 16;

   protected final String query;
   protected final boolean delete;
   protected final Transformer replace;
   protected final Processor processor;
   private final JsonParser.Selector[] selectors;
   private StreamQueue.Consumer record = JsonParser.this::record;

   public JsonParser(String query, boolean delete, Transformer replace, Processor processor) {
      this.query = query;
      this.delete = delete;
      this.replace = replace;
      this.processor = processor;

      byte[] queryBytes = query.getBytes(StandardCharsets.UTF_8);
      if (queryBytes.length == 0 || queryBytes[0] != '.') {
         throw new BenchmarkDefinitionException("Path should start with '.'");
      }
      ArrayList selectors = new ArrayList<>();
      int next = 1;
      for (int i = 1; i < queryBytes.length; ++i) {
         if (queryBytes[i] == '[' || queryBytes[i] == '.' && next < i) {
            while (queryBytes[next] == '.') ++next;
            if (next != i) {
               selectors.add(new AttribSelector(Arrays.copyOfRange(queryBytes, next, i)));
            }
            next = i + 1;
         }
         if (queryBytes[i] == '[') {
            ArraySelector arraySelector = new ArraySelector();
            ++i;
            int startIndex = i, endIndex = i;
            for (; i < queryBytes.length; ++i) {
               if (queryBytes[i] == ']') {
                  if (endIndex < i) {
                     arraySelector.rangeEnd = bytesToInt(queryBytes, startIndex, i);
                     if (startIndex == endIndex) {
                        arraySelector.rangeStart = arraySelector.rangeEnd;
                     }
                  }
                  selectors.add(arraySelector);
                  next = i + 1;
                  break;
               } else if (queryBytes[i] == ':') {
                  if (startIndex < i) {
                     arraySelector.rangeStart = bytesToInt(queryBytes, startIndex, i);
                  }
                  endIndex = i + 1;
               }
            }
         }
      }
      if (next < queryBytes.length) {
         while (queryBytes[next] == '.') ++next;
         selectors.add(new AttribSelector(Arrays.copyOfRange(queryBytes, next, queryBytes.length)));
      }
      this.selectors = selectors.toArray(new JsonParser.Selector[0]);
   }

   protected abstract void record(Context context, Session session, ByteStream data, int offset, int length, boolean isLastPart);

   private static int bytesToInt(byte[] bytes, int start, int end) {
      int value = 0;
      for (; ; ) {
         if (bytes[start] < '0' || bytes[start] > '9') {
            throw new BenchmarkDefinitionException("Invalid range specification: " + new String(bytes));
         }
         value += bytes[start] - '0';
         if (++start >= end) {
            return value;
         } else {
            value *= 10;
         }
      }
   }

   @Override
   public void reserve(Session session) {
      ResourceUtilizer.reserve(session, processor, replace);
   }

   interface Selector extends Serializable {
      Context newContext();

      interface Context {
         void reset();
      }
   }

   private static class AttribSelector implements JsonParser.Selector {
      byte[] name;

      AttribSelector(byte[] name) {
         this.name = name;
      }

      boolean match(StreamQueue stream, int start, int end) {
         assert start <= end;
         // TODO: move this to StreamQueue and optimize access
         for (int i = 0; i < name.length && i < end - start; ++i) {
            if (name[i] != stream.getByte(start + i)) return false;
         }
         return true;
      }

      @Override
      public Context newContext() {
         return null;
      }
   }

   private static class ArraySelector implements Selector {
      int rangeStart = 0;
      int rangeEnd = Integer.MAX_VALUE;

      @Override
      public Context newContext() {
         return new ArraySelectorContext();
      }

      boolean matches(ArraySelectorContext context) {
         return context.active && context.currentItem >= rangeStart && context.currentItem <= rangeEnd;
      }
   }

   private static class ArraySelectorContext implements Selector.Context {
      boolean active;
      int currentItem;

      @Override
      public void reset() {
         active = false;
         currentItem = 0;
      }
   }

   protected abstract class Context implements Session.Resource {
      Selector.Context[] selectorContext = new Selector.Context[selectors.length];
      int level;
      int selectorLevel;
      int selector;
      boolean inQuote;
      boolean inKey;
      boolean escaped;
      StreamQueue stream = new StreamQueue(MAX_PARTS);
      int keyStartIndex;
      int lastCharIndex; // end of key name
      int valueStartIndex;
      int lastOutputIndex; // last byte we have written out
      int safeOutputIndex; // last byte we could definitely write out
      ByteStream[] pool = new ByteStream[MAX_PARTS];
      protected ByteBuf replaceBuffer = PooledByteBufAllocator.DEFAULT.buffer();
      final StreamQueue.Consumer replaceConsumer = this::replaceConsumer;

      protected Context(Function byteStreamSupplier) {
         for (int i = 0; i < pool.length; ++i) {
            pool[i] = byteStreamSupplier.apply(this);
         }
         for (int i = 0; i < selectors.length; ++i) {
            selectorContext[i] = selectors[i].newContext();
         }
         reset();
      }

      public void reset() {
         for (Selector.Context ctx : selectorContext) {
            if (ctx != null) ctx.reset();
         }
         level = -1;
         selectorLevel = 0;
         selector = 0;
         inQuote = false;
         inKey = false;
         escaped = false;
         keyStartIndex = -1;
         lastCharIndex = -1;
         valueStartIndex = -1;
         lastOutputIndex = 0;
         safeOutputIndex = 0;
         stream.reset();
         replaceBuffer.clear();
      }

      private Selector.Context current() {
         return selectorContext[selector];
      }

      public void parse(ByteStream data, Session session, boolean isLast) {
         int readerIndex = stream.append(data);
         PARSING:
         while (true) {
            int b = stream.getByte(readerIndex++);
            switch (b) {
               case -1:
                  --readerIndex;
                  break PARSING;
               case ' ':
               case '\n':
               case '\t':
               case '\r':
                  // ignore whitespace
                  break;
               case '\\':
                  escaped = !escaped;
                  break;
               case '{':
                  if (!inQuote) {
                     ++level;
                     inKey = true;
                     if (valueStartIndex < 0) {
                        safeOutputIndex = readerIndex;
                     }
                     // TODO assert we have active attrib selector
                  }
                  break;
               case '}':
                  if (!inQuote) {
                     tryRecord(session, readerIndex);
                     if (level == selectorLevel) {
                        --selectorLevel;
                        --selector;
                     }
                     if (valueStartIndex < 0) {
                        safeOutputIndex = readerIndex;
                     }
                     --level;
                  }
                  break;
               case '"':
                  if (!escaped) {
                     inQuote = !inQuote;
                  }
                  break;
               case ':':
                  if (!inQuote) {
                     if (selectorLevel == level && keyStartIndex >= 0 && selector < selectors.length && selectors[selector] instanceof AttribSelector) {
                        AttribSelector selector = (AttribSelector) selectors[this.selector];
                        if (selector.match(stream, keyStartIndex, lastCharIndex)) {
                           if (onMatch(readerIndex) && (delete || replace != null)) {
                              // omit key's starting quote
                              int outputEnd = keyStartIndex - 1;
                              // remove possible comma before the key
                              LOOP:
                              while (true) {
                                 switch (stream.getByte(outputEnd - 1)) {
                                    case ' ':
                                    case '\n':
                                    case '\t':
                                    case '\r':
                                    case ',':
                                       --outputEnd;
                                       break;
                                    default:
                                       break LOOP;
                                 }
                              }
                              stream.consume(lastOutputIndex, outputEnd, record, this, session, false);
                              lastOutputIndex = outputEnd;
                           }
                        }
                     }
                     keyStartIndex = -1;
                     if (valueStartIndex < 0) {
                        safeOutputIndex = readerIndex;
                     }
                     inKey = false;
                  }
                  break;
               case ',':
                  if (!inQuote) {
                     inKey = true;
                     keyStartIndex = -1;
                     tryRecord(session, readerIndex);
                     if (selectorLevel == level && selector < selectors.length && current() instanceof ArraySelectorContext) {
                        ArraySelectorContext asc = (ArraySelectorContext) current();
                        if (asc.active) {
                           asc.currentItem++;
                        }
                        if (((ArraySelector) selectors[selector]).matches(asc)) {
                           if (onMatch(readerIndex) && (delete || replace != null)) {
                              // omit the ','
                              stream.consume(lastOutputIndex, readerIndex - 1, record, this, session, false);
                              lastOutputIndex = readerIndex - 1;
                           }
                        }
                     }
                  }
                  break;
               case '[':
                  if (!inQuote) {
                     if (valueStartIndex < 0) {
                        safeOutputIndex = readerIndex;
                     }
                     ++level;
                     if (selectorLevel == level && selector < selectors.length && selectors[selector] instanceof ArraySelector) {
                        ArraySelectorContext asc = (ArraySelectorContext) current();
                        asc.active = true;
                        if (((ArraySelector) selectors[selector]).matches(asc)) {
                           if (onMatch(readerIndex) && (delete || replace != null)) {
                              stream.consume(lastOutputIndex, readerIndex, record, this, session, false);
                              lastOutputIndex = readerIndex;
                           }
                        }
                     }
                  }
                  break;
               case ']':
                  if (!inQuote) {
                     tryRecord(session, readerIndex);
                     if (selectorLevel == level && selector < selectors.length && current() instanceof ArraySelectorContext) {
                        ArraySelectorContext asc = (ArraySelectorContext) current();
                        asc.active = false;
                        --selectorLevel;
                     }
                     if (valueStartIndex < 0) {
                        safeOutputIndex = readerIndex;
                     }
                     --level;
                  }
                  break;
               default:
                  lastCharIndex = readerIndex;
                  if (inKey && keyStartIndex < 0) {
                     keyStartIndex = readerIndex - 1;
                  }
            }
            if (b != '\\') {
               escaped = false;
            }
         }
         if (keyStartIndex >= 0 || valueStartIndex >= 0) {
            stream.release(Math.min(Math.min(keyStartIndex, valueStartIndex), safeOutputIndex));
            if (isLast) {
               throw new IllegalStateException("End of input while the JSON is not complete.");
            }
         } else {
            if ((delete || replace != null) && lastOutputIndex < safeOutputIndex) {
               stream.consume(lastOutputIndex, safeOutputIndex, record, this, session, isLast);
               lastOutputIndex = safeOutputIndex;
            }
            stream.release(readerIndex);
         }
      }

      private boolean onMatch(int readerIndex) {
         ++selector;
         if (selector < selectors.length) {
            ++selectorLevel;
            return false;
         } else {
            valueStartIndex = readerIndex;
            return true;
         }
      }

      private void tryRecord(Session session, int readerIndex) {
         if (selectorLevel == level && valueStartIndex >= 0) {
            // valueStartIndex is always before quotes here
            LOOP:
            while (true) {
               switch (stream.getByte(valueStartIndex)) {
                  case ' ':
                  case '\n':
                  case '\r':
                  case '\t':
                     ++valueStartIndex;
                     break;
                  case -1:
                  default:
                     break LOOP;
               }
            }
            int end = readerIndex - 1;
            LOOP:
            while (end > valueStartIndex) {
               switch (stream.getByte(end - 1)) {
                  case ' ':
                  case '\n':
                  case '\r':
                  case '\t':
                     --end;
                     break;
                  default:
                     break LOOP;
               }
            }
            if (valueStartIndex == end) {
               // This happens when we try to select from a 0-length array
               // - as long as there are not quotes there's nothing to record.
               valueStartIndex = -1;
               --selector;
               return;
            }
            if (replace != null) {
               // The buffer cannot be overwritten as if the processor is caching input
               // (this happens when we're defragmenting) we would overwrite the underlying data
               replaceBuffer.readerIndex(replaceBuffer.writerIndex());
               stream.consume(valueStartIndex, end, replaceConsumer, null, session, true);
               // If the result is empty, don't write the key
               if (replaceBuffer.isReadable()) {
                  stream.consume(lastOutputIndex, valueStartIndex, record, this, session, false);
                  processor.process(session, replaceBuffer, replaceBuffer.readerIndex(), replaceBuffer.readableBytes(), false);
               }
            } else if (!delete) {
               stream.consume(valueStartIndex, end, record, this, session, true);
            }
            lastOutputIndex = end;
            valueStartIndex = -1;
            --selector;
         }
      }

      public ByteStream retain(ByteStream stream) {
         for (int i = 0; i < pool.length; ++i) {
            ByteStream pooled = pool[i];
            if (pooled != null) {
               pool[i] = null;
               stream.moveTo(pooled);
               return pooled;
            }
         }
         throw new IllegalStateException();
      }

      public void release(ByteStream stream) {
         for (int i = 0; i < pool.length; ++i) {
            if (pool[i] == null) {
               pool[i] = stream;
               return;
            }
         }
         throw new IllegalStateException();
      }

      protected abstract void replaceConsumer(Void ignored, Session session, ByteStream data, int offset, int length, boolean lastFragment);
   }

   public abstract static class BaseBuilder> implements InitFromParam {
      protected Locator locator;
      protected String query;
      protected boolean unquote = true;
      protected Processor.Builder processor;
      protected DataFormat format = DataFormat.STRING;
      protected boolean delete;
      protected Transformer.Builder replace;

      /**
       * @param param Either query -> variable or variable <- query.
       * @return Self.
       */
      @Override
      public S init(String param) {
         String query;
         String var;
         if (param.contains("->")) {
            String[] parts = param.split("->");
            query = parts[0];
            var = parts[1];
         } else if (param.contains("<-")) {
            String[] parts = param.split("->");
            query = parts[1];
            var = parts[0];
         } else {
            throw new BenchmarkDefinitionException("Cannot parse json query specification: '" + param + "', use 'query -> var' or 'var <- query'");
         }
         return query(query.trim()).toVar(var.trim());
      }

      @SuppressWarnings("unchecked")
      protected S self() {
         return (S) this;
      }

      public S setLocator(Locator locator) {
         this.locator = locator;
         return self();
      }

      @SuppressWarnings("unchecked")
      public S copy(Locator locator) {
         S copy;
         try {
            copy = (S) getClass().newInstance();
         } catch (InstantiationException | IllegalAccessException e) {
            throw new IllegalStateException(e);
         }
         return copy.setLocator(locator).query(query).unquote(unquote).processor(processor);
      }

      /**
       * Query selecting the part of JSON.
       *
       * @param query Query.
       * @return Self.
       */
      public S query(String query) {
         this.query = query;
         return self();
      }

      /**
       * Automatically unquote and unescape the input values. By default true.
       *
       * @param unquote Do unquote and unescape?
       * @return Builder.
       */
      public S unquote(boolean unquote) {
         this.unquote = unquote;
         return self();
      }

      /**
       * If this is set to true, the selected key will be deleted from the JSON and the modified JSON will be passed
       * to the processor.
       *
       * @param delete Should the selected query be deleted?
       * @return Self.
       */
      public S delete(boolean delete) {
         this.delete = delete;
         return self();
      }

      /**
       * Custom transformation executed on the value of the selected item.
       * Note that the output value must contain quotes (if applicable) and be correctly escaped.
       *
       * @return Builder.
       */
      public ServiceLoadedBuilderProvider replace() {
         return new ServiceLoadedBuilderProvider<>(Transformer.Builder.class, locator, this::replace);
      }

      public S replace(Transformer.Builder replace) {
         if (replace == null) {
            throw new BenchmarkDefinitionException("Calling replace twice!");
         }
         this.replace = replace;
         return self();
      }

      /**
       * Replace value of selected item with value generated through a pattern.
       * Note that the result must contain quotes and be correctly escaped.
       *
       * @param pattern Pattern format.
       * @return Self.
       */
      public S replace(String pattern) {
         return replace(fragmented -> new Pattern(pattern, false)).unquote(false);
      }

      /**
       * Shortcut to store selected parts in an array in the session. Must follow the pattern variable[maxSize]
       *
       * @param varAndSize Array name.
       * @return Self.
       */
      public S toArray(String varAndSize) {
         return processor(new ArrayRecorder.Builder().init(varAndSize).format(format));
      }

      /**
       * Shortcut to store first match in given variable. Further matches are ignored.
       *
       * @param var Variable name.
       * @return Self.
       */
      public S toVar(String var) {
         return processor(new SimpleRecorder.Builder().toVar(var).format(format));
      }

      public S processor(Processor.Builder processor) {
         if (this.processor != null) {
            throw new BenchmarkDefinitionException("Processor already set!");
         }
         this.processor = processor;
         return self();
      }

      /**
       * Conversion to apply on the matching parts with 'toVar' or 'toArray' shortcuts.
       *
       * @param format Data format.
       * @return Self.
       */
      public S format(DataFormat format) {
         this.format = format;
         return self();
      }

      protected void validate() {
         if (query == null) {
            throw new BenchmarkDefinitionException("Missing 'query'");
         } else if (processor == null) {
            throw new BenchmarkDefinitionException("Missing processor - use 'processor', 'toVar' or 'toArray'");
         }
      }
   }
}