001/*
002 * SPDX-FileCopyrightText: none
003 * SPDX-License-Identifier: CC0-1.0
004 */
005
006package gov.nist.secauto.metaschema.databind.io;
007
008import com.ctc.wstx.stax.WstxInputFactory;
009import com.fasterxml.jackson.core.JsonParser;
010import com.fasterxml.jackson.core.JsonToken;
011import com.fasterxml.jackson.core.io.MergedStream;
012import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
013
014import gov.nist.secauto.metaschema.core.configuration.DefaultConfiguration;
015import gov.nist.secauto.metaschema.core.configuration.IConfiguration;
016import gov.nist.secauto.metaschema.core.model.IBoundObject;
017import gov.nist.secauto.metaschema.core.model.util.JsonUtil;
018import gov.nist.secauto.metaschema.core.util.ObjectUtils;
019import gov.nist.secauto.metaschema.databind.IBindingContext;
020import gov.nist.secauto.metaschema.databind.io.json.JsonFactoryFactory;
021import gov.nist.secauto.metaschema.databind.io.yaml.impl.YamlFactoryFactory;
022
023import org.codehaus.stax2.XMLEventReader2;
024import org.codehaus.stax2.XMLInputFactory2;
025import org.eclipse.jdt.annotation.NotOwning;
026import org.eclipse.jdt.annotation.Owning;
027
028import java.io.ByteArrayInputStream;
029import java.io.Closeable;
030import java.io.IOException;
031import java.io.InputStream;
032import java.io.InputStreamReader;
033import java.io.Reader;
034import java.nio.charset.Charset;
035
036import javax.xml.namespace.QName;
037import javax.xml.stream.XMLInputFactory;
038import javax.xml.stream.XMLStreamException;
039import javax.xml.stream.events.StartElement;
040
041import edu.umd.cs.findbugs.annotations.NonNull;
042import edu.umd.cs.findbugs.annotations.Nullable;
043
044/**
045 * Provides a means to analyze content to determine what type of bound data it
046 * contains.
047 */
048public class ModelDetector {
049  @NonNull
050  private final IBindingContext bindingContext;
051  @NonNull
052  private final IConfiguration<DeserializationFeature<?>> configuration;
053
054  /**
055   * Construct a new format detector using the default configuration.
056   *
057   * @param bindingContext
058   *          information about how Java classes are bound to Module definitions
059   */
060  public ModelDetector(
061      @NonNull IBindingContext bindingContext) {
062    this(bindingContext, new DefaultConfiguration<>());
063  }
064
065  /**
066   * Construct a new format detector using the provided {@code configuration}.
067   *
068   * @param bindingContext
069   *          information about how Java classes are bound to Module definitions
070   * @param configuration
071   *          the deserialization configuration
072   */
073  public ModelDetector(
074      @NonNull IBindingContext bindingContext,
075      @NonNull IConfiguration<DeserializationFeature<?>> configuration) {
076    this.bindingContext = bindingContext;
077    this.configuration = configuration;
078  }
079
080  private int getLookaheadLimit() {
081    return configuration.get(DeserializationFeature.FORMAT_DETECTION_LOOKAHEAD_LIMIT);
082  }
083
084  @NonNull
085  private IBindingContext getBindingContext() {
086    return bindingContext;
087  }
088
089  @NonNull
090  private IConfiguration<DeserializationFeature<?>> getConfiguration() {
091    return configuration;
092  }
093
094  /**
095   * Analyzes the data from the provided {@code inputStream} to determine it's
096   * model.
097   *
098   * @param inputStream
099   *          the resource stream to analyze
100   * @param format
101   *          the expected format of the data to read
102   * @return the analysis result
103   * @throws IOException
104   *           if an error occurred while reading the resource
105   */
106  @NonNull
107  @Owning
108  public Result detect(@NonNull @NotOwning InputStream inputStream, @NonNull Format format)
109      throws IOException {
110    byte[] buf = ObjectUtils.notNull(inputStream.readNBytes(getLookaheadLimit()));
111
112    Class<? extends IBoundObject> clazz;
113    try (InputStream bis = new ByteArrayInputStream(buf)) {
114      assert bis != null;
115      switch (format) {
116      case JSON:
117        try (JsonParser parser = JsonFactoryFactory.instance().createParser(bis)) {
118          assert parser != null;
119          clazz = detectModelJsonClass(parser);
120        }
121        break;
122      case YAML:
123        YAMLFactory factory = YamlFactoryFactory.newParserFactoryInstance(getConfiguration());
124        try (JsonParser parser = factory.createParser(bis)) {
125          assert parser != null;
126          clazz = detectModelJsonClass(parser);
127        }
128        break;
129      case XML:
130        clazz = detectModelXmlClass(bis);
131        break;
132      default:
133        throw new UnsupportedOperationException(
134            String.format("The format '%s' dataStream not supported", format));
135      }
136    }
137
138    if (clazz == null) {
139      throw new IllegalStateException(
140          String.format("Detected format '%s', but unable to detect the bound data type", format.name()));
141    }
142
143    return new Result(clazz, inputStream, buf);
144  }
145
146  @NonNull
147  private Class<? extends IBoundObject> detectModelXmlClass(@NonNull InputStream is) throws IOException {
148    QName startElementQName;
149    try {
150      XMLInputFactory2 xmlInputFactory = (XMLInputFactory2) XMLInputFactory.newInstance();
151      assert xmlInputFactory instanceof WstxInputFactory;
152      xmlInputFactory.configureForXmlConformance();
153      xmlInputFactory.setProperty(XMLInputFactory.IS_COALESCING, false);
154
155      Reader reader = new InputStreamReader(is, Charset.forName("UTF8"));
156      XMLEventReader2 eventReader = (XMLEventReader2) xmlInputFactory.createXMLEventReader(reader);
157      while (eventReader.hasNext() && !eventReader.peek().isStartElement()) {
158        eventReader.nextEvent();
159      }
160
161      if (!eventReader.peek().isStartElement()) {
162        throw new IOException("Unable to detect a start element");
163      }
164
165      StartElement start = eventReader.nextEvent().asStartElement();
166      startElementQName = ObjectUtils.notNull(start.getName());
167    } catch (XMLStreamException ex) {
168      throw new IOException(ex);
169    }
170
171    Class<? extends IBoundObject> clazz = getBindingContext().getBoundClassForRootXmlQName(startElementQName);
172    if (clazz == null) {
173      throw new IOException("Unrecognized element name: " + startElementQName.toString());
174    }
175    return clazz;
176  }
177
178  @Nullable
179  private Class<? extends IBoundObject> detectModelJsonClass(@NonNull JsonParser parser) throws IOException {
180    Class<? extends IBoundObject> retval = null;
181    JsonUtil.advanceAndAssert(parser, JsonToken.START_OBJECT);
182    outer: while (JsonToken.FIELD_NAME.equals(parser.nextToken())) {
183      String name = ObjectUtils.notNull(parser.currentName());
184      if (!"$schema".equals(name)) {
185        IBindingContext bindingContext = getBindingContext();
186        retval = bindingContext.getBoundClassForRootJsonName(name);
187        if (retval == null) {
188          throw new IOException("Unrecognized JSON field name: " + name);
189        }
190        break outer;
191      }
192      // do nothing
193      parser.nextToken();
194      // JsonUtil.skipNextValue(parser);
195    }
196    return retval;
197  }
198
199  public static final class Result implements Closeable {
200    @NonNull
201    private final Class<? extends IBoundObject> boundClass;
202    @Owning
203    private InputStream dataStream;
204
205    private Result(
206        @NonNull Class<? extends IBoundObject> clazz,
207        @NonNull InputStream is,
208        @NonNull byte[] buf) {
209      this.boundClass = clazz;
210      this.dataStream = new MergedStream(null, is, buf, 0, buf.length);
211    }
212
213    /**
214     * Get the Java class representing the detected bound object.
215     *
216     * @return the Java class
217     */
218    @NonNull
219    public Class<? extends IBoundObject> getBoundClass() {
220      return boundClass;
221    }
222
223    /**
224     * Get an {@link InputStream} that can be used to read the analyzed data from
225     * the start.
226     *
227     * @return the stream
228     */
229    @NonNull
230    @Owning
231    public InputStream getDataStream() {
232      return ObjectUtils.requireNonNull(dataStream, "data stream already closed");
233    }
234
235    @SuppressWarnings("PMD.NullAssignment")
236    @Override
237    public void close() throws IOException {
238      this.dataStream.close();
239      this.dataStream = null;
240    }
241  }
242}