1   /*
2    * SPDX-FileCopyrightText: none
3    * SPDX-License-Identifier: CC0-1.0
4    */
5   
6   package dev.metaschema.databind.io.json;
7   
8   import com.fasterxml.jackson.core.JsonGenerator;
9   import com.fasterxml.jackson.databind.JsonNode;
10  import com.fasterxml.jackson.databind.node.ObjectNode;
11  
12  import org.eclipse.jdt.annotation.NotOwning;
13  
14  import java.io.IOException;
15  import java.util.Iterator;
16  import java.util.List;
17  import java.util.Map;
18  
19  import dev.metaschema.core.model.IAnyContent;
20  import dev.metaschema.core.model.IAnyInstance;
21  import dev.metaschema.core.model.IBoundObject;
22  import dev.metaschema.core.model.JsonGroupAsBehavior;
23  import dev.metaschema.databind.model.IBoundDefinitionModelAssembly;
24  import dev.metaschema.databind.model.IBoundDefinitionModelComplex;
25  import dev.metaschema.databind.model.IBoundDefinitionModelFieldComplex;
26  import dev.metaschema.databind.model.IBoundFieldValue;
27  import dev.metaschema.databind.model.IBoundInstance;
28  import dev.metaschema.databind.model.IBoundInstanceFlag;
29  import dev.metaschema.databind.model.IBoundInstanceModel;
30  import dev.metaschema.databind.model.IBoundInstanceModelAny;
31  import dev.metaschema.databind.model.IBoundInstanceModelAssembly;
32  import dev.metaschema.databind.model.IBoundInstanceModelChoiceGroup;
33  import dev.metaschema.databind.model.IBoundInstanceModelFieldComplex;
34  import dev.metaschema.databind.model.IBoundInstanceModelFieldScalar;
35  import dev.metaschema.databind.model.IBoundInstanceModelGroupedAssembly;
36  import dev.metaschema.databind.model.IBoundInstanceModelGroupedField;
37  import dev.metaschema.databind.model.IBoundInstanceModelGroupedNamed;
38  import dev.metaschema.databind.model.IBoundProperty;
39  import dev.metaschema.databind.model.info.AbstractModelInstanceWriteHandler;
40  import dev.metaschema.databind.model.info.IFeatureComplexItemValueHandler;
41  import dev.metaschema.databind.model.info.IFeatureScalarItemValueHandler;
42  import dev.metaschema.databind.model.info.IItemWriteHandler;
43  import dev.metaschema.databind.model.info.IModelInstanceCollectionInfo;
44  import edu.umd.cs.findbugs.annotations.NonNull;
45  
46  /**
47   * Provides support for writing Metaschema-bound Java objects to JSON format.
48   * <p>
49   * This class implements the {@link IItemWriteHandler} interface to serialize
50   * bound objects to JSON using Jackson's {@link JsonGenerator}. It handles
51   * flags, fields, assemblies, and choice groups according to the Metaschema JSON
52   * serialization rules.
53   *
54   * @see IJsonWritingContext
55   * @see JsonGenerator
56   */
57  @SuppressWarnings("PMD.CouplingBetweenObjects")
58  public class MetaschemaJsonWriter implements IJsonWritingContext, IItemWriteHandler {
59    @NonNull
60    @NotOwning
61    private final JsonGenerator generator;
62  
63    /**
64     * Construct a new Module-aware JSON writer.
65     *
66     * @param generator
67     *          the JSON generator to write with. The caller retains ownership of
68     *          this generator and is responsible for closing it.
69     * @see DefaultJsonProblemHandler
70     */
71    public MetaschemaJsonWriter(@NonNull @NotOwning JsonGenerator generator) {
72      this.generator = generator;
73    }
74  
75    @Override
76    public JsonGenerator getWriter() {
77      return generator;
78    }
79  
80    // =====================================
81    // Entry point for top-level-definitions
82    // =====================================
83  
84    @Override
85    public void write(
86        @NonNull IBoundDefinitionModelComplex definition,
87        @NonNull IBoundObject item) throws IOException {
88      definition.writeItem(item, this);
89    }
90  
91    // ================
92    // Instance writers
93    // ================
94  
95    private <T> void writeInstance(
96        @NonNull IBoundProperty<T> instance,
97        @NonNull IBoundObject parentItem) throws IOException {
98      @SuppressWarnings("unchecked")
99      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 }