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}