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