001/*
002 * SPDX-FileCopyrightText: none
003 * SPDX-License-Identifier: CC0-1.0
004 */
005
006package dev.metaschema.databind.io.json;
007
008import com.fasterxml.jackson.core.JsonGenerator;
009
010import org.eclipse.jdt.annotation.NotOwning;
011
012import java.io.IOException;
013import java.util.List;
014
015import dev.metaschema.core.model.IBoundObject;
016import dev.metaschema.core.model.JsonGroupAsBehavior;
017import dev.metaschema.databind.model.IBoundDefinitionModelAssembly;
018import dev.metaschema.databind.model.IBoundDefinitionModelComplex;
019import dev.metaschema.databind.model.IBoundDefinitionModelFieldComplex;
020import dev.metaschema.databind.model.IBoundFieldValue;
021import dev.metaschema.databind.model.IBoundInstance;
022import dev.metaschema.databind.model.IBoundInstanceFlag;
023import dev.metaschema.databind.model.IBoundInstanceModel;
024import dev.metaschema.databind.model.IBoundInstanceModelAssembly;
025import dev.metaschema.databind.model.IBoundInstanceModelChoiceGroup;
026import dev.metaschema.databind.model.IBoundInstanceModelFieldComplex;
027import dev.metaschema.databind.model.IBoundInstanceModelFieldScalar;
028import dev.metaschema.databind.model.IBoundInstanceModelGroupedAssembly;
029import dev.metaschema.databind.model.IBoundInstanceModelGroupedField;
030import dev.metaschema.databind.model.IBoundInstanceModelGroupedNamed;
031import dev.metaschema.databind.model.IBoundProperty;
032import dev.metaschema.databind.model.info.AbstractModelInstanceWriteHandler;
033import dev.metaschema.databind.model.info.IFeatureComplexItemValueHandler;
034import dev.metaschema.databind.model.info.IFeatureScalarItemValueHandler;
035import dev.metaschema.databind.model.info.IItemWriteHandler;
036import dev.metaschema.databind.model.info.IModelInstanceCollectionInfo;
037import edu.umd.cs.findbugs.annotations.NonNull;
038
039/**
040 * Provides support for writing Metaschema-bound Java objects to JSON format.
041 * <p>
042 * This class implements the {@link IItemWriteHandler} interface to serialize
043 * bound objects to JSON using Jackson's {@link JsonGenerator}. It handles
044 * flags, fields, assemblies, and choice groups according to the Metaschema JSON
045 * serialization rules.
046 *
047 * @see IJsonWritingContext
048 * @see JsonGenerator
049 */
050@SuppressWarnings("PMD.CouplingBetweenObjects")
051public class MetaschemaJsonWriter implements IJsonWritingContext, IItemWriteHandler {
052  @NonNull
053  @NotOwning
054  private final JsonGenerator generator;
055
056  /**
057   * Construct a new Module-aware JSON writer.
058   *
059   * @param generator
060   *          the JSON generator to write with. The caller retains ownership of
061   *          this generator and is responsible for closing it.
062   * @see DefaultJsonProblemHandler
063   */
064  public MetaschemaJsonWriter(@NonNull @NotOwning JsonGenerator generator) {
065    this.generator = generator;
066  }
067
068  @Override
069  public JsonGenerator getWriter() {
070    return generator;
071  }
072
073  // =====================================
074  // Entry point for top-level-definitions
075  // =====================================
076
077  @Override
078  public void write(
079      @NonNull IBoundDefinitionModelComplex definition,
080      @NonNull IBoundObject item) throws IOException {
081    definition.writeItem(item, this);
082  }
083
084  // ================
085  // Instance writers
086  // ================
087
088  private <T> void writeInstance(
089      @NonNull IBoundProperty<T> instance,
090      @NonNull IBoundObject parentItem) throws IOException {
091    @SuppressWarnings("unchecked")
092    T value = (T) instance.getValue(parentItem);
093    if (value != null && !value.equals(instance.getResolvedDefaultValue())) {
094      generator.writeFieldName(instance.getJsonName());
095      instance.writeItem(value, this);
096    }
097  }
098
099  private <T> void writeModelInstance(
100      @NonNull IBoundInstanceModel<T> instance,
101      @NonNull Object parentItem) throws IOException {
102    Object value = instance.getValue(parentItem);
103    if (value != null) {
104      // this if is not strictly needed, since isEmpty will return false on a null
105      // value
106      // checking null here potentially avoids the expensive operation of instatiating
107      IModelInstanceCollectionInfo<T> collectionInfo = instance.getCollectionInfo();
108      if (!collectionInfo.isEmpty(value)) {
109        generator.writeFieldName(instance.getJsonName());
110        collectionInfo.writeItems(new ModelInstanceWriteHandler<>(instance), value);
111      }
112    }
113  }
114
115  @SuppressWarnings("PMD.NullAssignment")
116  private void writeFieldValue(@NonNull IBoundFieldValue fieldValue, @NonNull Object parentItem) throws IOException {
117    Object item = fieldValue.getValue(parentItem);
118
119    // handle json value key
120    IBoundInstanceFlag jsonValueKey = fieldValue.getParentFieldDefinition().getJsonValueKeyFlagInstance();
121    if (item == null) {
122      if (jsonValueKey != null) {
123        item = fieldValue.getDefaultValue();
124      }
125    } else if (item.equals(fieldValue.getResolvedDefaultValue())) {
126      // same as default
127      item = null;
128    }
129
130    if (item != null) {
131      // There are two modes:
132      // 1) use of a JSON value key, or
133      // 2) a simple value named "value"
134
135      String valueKeyName;
136      if (jsonValueKey != null) {
137        Object keyValue = jsonValueKey.getValue(parentItem);
138        if (keyValue == null) {
139          throw new IOException(String.format("Null value for json-value-key for definition '%s'",
140              jsonValueKey.getContainingDefinition().toCoordinates()));
141        }
142        try {
143          // this is the JSON value key case
144          valueKeyName = jsonValueKey.getJavaTypeAdapter().asString(keyValue);
145        } catch (IllegalArgumentException ex) {
146          throw new IOException(
147              String.format("Invalid value '%s' for json-value-key for definition '%s'",
148                  keyValue,
149                  jsonValueKey.getContainingDefinition().toCoordinates()),
150              ex);
151        }
152      } else {
153        valueKeyName = fieldValue.getParentFieldDefinition().getEffectiveJsonValueKeyName();
154      }
155      generator.writeFieldName(valueKeyName);
156      // LOGGER.info("FIELD: {}", valueKeyName);
157
158      writeItemFieldValue(item, fieldValue);
159    }
160  }
161
162  @Override
163  public void writeItemFlag(Object item, IBoundInstanceFlag instance) throws IOException {
164    writeScalarItem(item, instance);
165  }
166
167  @Override
168  public void writeItemField(Object item, IBoundInstanceModelFieldScalar instance) throws IOException {
169    writeScalarItem(item, instance);
170  }
171
172  @Override
173  public void writeItemField(IBoundObject item, IBoundInstanceModelFieldComplex instance) throws IOException {
174    writeModelObject(
175        instance,
176        item,
177        this::writeObjectProperties);
178  }
179
180  @Override
181  public void writeItemField(IBoundObject item, IBoundInstanceModelGroupedField instance) throws IOException {
182    writeGroupedModelObject(
183        instance,
184        item,
185        (parent, handler) -> {
186          writeDiscriminatorProperty(handler);
187          writeObjectProperties(parent, handler);
188        });
189  }
190
191  @Override
192  public void writeItemField(IBoundObject item, IBoundDefinitionModelFieldComplex definition) throws IOException {
193    writeDefinitionObject(
194        definition,
195        item,
196        (ObjectWriter<IBoundDefinitionModelFieldComplex>) this::writeObjectProperties);
197  }
198
199  @Override
200  public void writeItemFieldValue(Object item, IBoundFieldValue fieldValue) throws IOException {
201    fieldValue.getJavaTypeAdapter().writeJsonValue(item, generator);
202  }
203
204  @Override
205  public void writeItemAssembly(IBoundObject item, IBoundInstanceModelAssembly instance) throws IOException {
206    writeModelObject(instance, item, this::writeObjectProperties);
207  }
208
209  @Override
210  public void writeItemAssembly(IBoundObject item, IBoundInstanceModelGroupedAssembly instance) throws IOException {
211    writeGroupedModelObject(
212        instance,
213        item,
214        (parent, handler) -> {
215          writeDiscriminatorProperty(handler);
216          writeObjectProperties(parent, handler);
217        });
218  }
219
220  @Override
221  public void writeItemAssembly(IBoundObject item, IBoundDefinitionModelAssembly definition) throws IOException {
222    writeDefinitionObject(definition, item, this::writeObjectProperties);
223  }
224
225  @Override
226  public void writeChoiceGroupItem(IBoundObject item, IBoundInstanceModelChoiceGroup instance) throws IOException {
227    IBoundInstanceModelGroupedNamed actualInstance = instance.getItemInstance(item);
228    assert actualInstance != null;
229    actualInstance.writeItem(item, this);
230  }
231
232  /**
233   * Writes a scalar item.
234   *
235   * @param item
236   *          the item to write
237   * @param handler
238   *          the value handler
239   * @throws IOException
240   *           if an error occurred while writing the scalar value
241   */
242  private void writeScalarItem(@NonNull Object item, @NonNull IFeatureScalarItemValueHandler handler)
243      throws IOException {
244    handler.getJavaTypeAdapter().writeJsonValue(item, generator);
245  }
246
247  private <T extends IBoundInstanceModelGroupedNamed> void writeDiscriminatorProperty(
248      @NonNull T instance) throws IOException {
249
250    IBoundInstanceModelChoiceGroup choiceGroup = instance.getParentContainer();
251
252    // write JSON object discriminator
253    String discriminatorProperty = choiceGroup.getJsonDiscriminatorProperty();
254    String discriminatorValue = instance.getEffectiveDisciminatorValue();
255
256    generator.writeStringField(discriminatorProperty, discriminatorValue);
257  }
258
259  private <T extends IFeatureComplexItemValueHandler> void writeObjectProperties(
260      @NonNull IBoundObject parent,
261      @NonNull T handler) throws IOException {
262    for (IBoundProperty<?> property : handler.getJsonProperties().values()) {
263      assert property != null;
264
265      if (property instanceof IBoundInstanceModel) {
266        writeModelInstance((IBoundInstanceModel<?>) property, parent);
267      } else if (property instanceof IBoundInstance) {
268        writeInstance(property, parent);
269      } else { // IBoundFieldValue
270        writeFieldValue((IBoundFieldValue) property, parent);
271      }
272    }
273  }
274
275  private <T extends IFeatureComplexItemValueHandler> void writeDefinitionObject(
276      @NonNull T handler,
277      @NonNull IBoundObject parent,
278      @NonNull ObjectWriter<T> propertyWriter) throws IOException {
279    generator.writeStartObject();
280
281    propertyWriter.accept(parent, handler);
282    generator.writeEndObject();
283  }
284
285  private <T extends IFeatureComplexItemValueHandler & IBoundInstanceModel<IBoundObject>>
286      void writeModelObject(
287          @NonNull T handler,
288          @NonNull IBoundObject parent,
289          @NonNull ObjectWriter<T> propertyWriter) throws IOException {
290    generator.writeStartObject();
291
292    IBoundInstanceFlag jsonKey = handler.getItemJsonKey(parent);
293    if (jsonKey != null) {
294      Object keyValue = jsonKey.getValue(parent);
295      if (keyValue == null) {
296        throw new IOException(
297            String.format("Null value for json-key for definition '%s'",
298                jsonKey.getContainingDefinition().toCoordinates()));
299      }
300
301      // the field will be the JSON key value
302      String key;
303      try {
304        key = jsonKey.getJavaTypeAdapter().asString(keyValue);
305      } catch (IllegalArgumentException ex) {
306        throw new IOException(
307            String.format("Illegal value '%s' for json-key for definition '%s'",
308                keyValue,
309                jsonKey.getContainingDefinition().toCoordinates()),
310            ex);
311      }
312      generator.writeFieldName(key);
313
314      // next the value will be a start object
315      generator.writeStartObject();
316    }
317
318    propertyWriter.accept(parent, handler);
319
320    if (jsonKey != null) {
321      // next the value will be a start object
322      generator.writeEndObject();
323    }
324    generator.writeEndObject();
325  }
326
327  private <T extends IFeatureComplexItemValueHandler & IBoundInstanceModelGroupedNamed> void writeGroupedModelObject(
328      @NonNull T handler,
329      @NonNull IBoundObject parent,
330      @NonNull ObjectWriter<T> propertyWriter) throws IOException {
331    generator.writeStartObject();
332
333    IBoundInstanceModelChoiceGroup choiceGroup = handler.getParentContainer();
334    IBoundInstanceFlag jsonKey = choiceGroup.getItemJsonKey(parent);
335    if (jsonKey != null) {
336      Object keyValue = jsonKey.getValue(parent);
337      if (keyValue == null) {
338        throw new IOException(String.format("Null value for json-key for definition '%s'",
339            jsonKey.getContainingDefinition().toCoordinates()));
340      }
341
342      // the field will be the JSON key value
343      String key;
344      try {
345        key = jsonKey.getJavaTypeAdapter().asString(keyValue);
346      } catch (IllegalArgumentException ex) {
347        throw new IOException(
348            String.format("Invalid value '%s' for json-key for definition '%s'",
349                keyValue,
350                jsonKey.getContainingDefinition().toCoordinates()),
351            ex);
352      }
353      generator.writeFieldName(key);
354
355      // next the value will be a start object
356      generator.writeStartObject();
357    }
358
359    propertyWriter.accept(parent, handler);
360
361    if (jsonKey != null) {
362      // next the value will be a start object
363      generator.writeEndObject();
364    }
365    generator.writeEndObject();
366  }
367
368  /**
369   * Supports writing items that are {@link IBoundInstanceModel}-based.
370   *
371   * @param <ITEM>
372   *          the Java type of the item
373   */
374  private class ModelInstanceWriteHandler<ITEM>
375      extends AbstractModelInstanceWriteHandler<ITEM> {
376    public ModelInstanceWriteHandler(
377        @NonNull IBoundInstanceModel<ITEM> instance) {
378      super(instance);
379    }
380
381    @Override
382    public void writeList(List<ITEM> items) throws IOException {
383      IBoundInstanceModel<ITEM> instance = getCollectionInfo().getInstance();
384
385      boolean writeArray = false;
386      if (JsonGroupAsBehavior.LIST.equals(instance.getJsonGroupAsBehavior())
387          || JsonGroupAsBehavior.SINGLETON_OR_LIST.equals(instance.getJsonGroupAsBehavior())
388              && items.size() > 1) {
389        // write array, then items
390        writeArray = true;
391        generator.writeStartArray();
392      } // only other option is a singleton value, write item
393
394      super.writeList(items);
395
396      if (writeArray) {
397        // write the end array
398        generator.writeEndArray();
399      }
400    }
401
402    @Override
403    public void writeItem(ITEM item) throws IOException {
404      IBoundInstanceModel<ITEM> instance = getInstance();
405      instance.writeItem(item, MetaschemaJsonWriter.this);
406    }
407  }
408}