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