1   /*
2    * SPDX-FileCopyrightText: none
3    * SPDX-License-Identifier: CC0-1.0
4    */
5   
6   package dev.metaschema.schemagen.json.impl;
7   
8   import com.fasterxml.jackson.databind.JsonNode;
9   import com.fasterxml.jackson.databind.node.ArrayNode;
10  import com.fasterxml.jackson.databind.node.BigIntegerNode;
11  import com.fasterxml.jackson.databind.node.BooleanNode;
12  import com.fasterxml.jackson.databind.node.DecimalNode;
13  import com.fasterxml.jackson.databind.node.DoubleNode;
14  import com.fasterxml.jackson.databind.node.IntNode;
15  import com.fasterxml.jackson.databind.node.JsonNodeFactory;
16  import com.fasterxml.jackson.databind.node.LongNode;
17  import com.fasterxml.jackson.databind.node.ObjectNode;
18  import com.fasterxml.jackson.databind.node.TextNode;
19  
20  import java.math.BigDecimal;
21  import java.math.BigInteger;
22  import java.util.ArrayList;
23  import java.util.Collection;
24  import java.util.Comparator;
25  import java.util.LinkedList;
26  import java.util.List;
27  import java.util.Set;
28  import java.util.stream.Collectors;
29  import java.util.stream.Stream;
30  
31  import dev.metaschema.core.datatype.IDataTypeAdapter;
32  import dev.metaschema.core.datatype.markup.MarkupLine;
33  import dev.metaschema.core.datatype.markup.MarkupMultiline;
34  import dev.metaschema.core.model.IChoiceInstance;
35  import dev.metaschema.core.model.IContainerModelAbsolute;
36  import dev.metaschema.core.model.IFlagInstance;
37  import dev.metaschema.core.model.IModelDefinition;
38  import dev.metaschema.core.model.IModelElement;
39  import dev.metaschema.core.model.INamedModelElement;
40  import dev.metaschema.core.model.IValuedDefinition;
41  import dev.metaschema.core.model.IValuedInstance;
42  import dev.metaschema.core.qname.IEnhancedQName;
43  import dev.metaschema.core.util.CollectionUtil;
44  import dev.metaschema.core.util.ObjectUtils;
45  import edu.umd.cs.findbugs.annotations.NonNull;
46  import edu.umd.cs.findbugs.annotations.Nullable;
47  
48  /**
49   * Provides utility methods for generating JSON Schema elements from Metaschema
50   * model components.
51   * <p>
52   * This class contains helper methods for generating titles, descriptions,
53   * defaults, properties, and handling choice combinations in JSON Schema output.
54   */
55  public final class JsonSchemaHelper {
56    /**
57     * Supports comparison of named properties by their property name.
58     */
59    public static final Comparator<IJsonSchemaPropertyNamed> INSTANCE_NAME_COMPARATOR
60        = Comparator.comparing(IJsonSchemaPropertyNamed::getName);
61    /**
62     * Supports comparison of JSON schema definitions by their definition name.
63     */
64    public static final Comparator<IJsonSchemaDefinable> DEFINABLE_NAME_COMPARATOR
65        = Comparator.comparing(IJsonSchemaDefinable::getDefinitionName);
66  
67    /**
68     * Generates a title property in the JSON Schema from the element's formal name.
69     *
70     * @param named
71     *          the named model element to extract the title from
72     * @param obj
73     *          the object node to add the title property to
74     */
75    public static void generateTitle(
76        @NonNull INamedModelElement named,
77        @NonNull ObjectNode obj) {
78      String formalName = named.getEffectiveFormalName();
79      if (formalName != null) {
80        obj.put("title", formalName);
81      }
82    }
83  
84    /**
85     * Generates a description property in the JSON Schema from the element's
86     * description and remarks.
87     *
88     * @param <NAMED>
89     *          the type of the named model element
90     * @param named
91     *          the named model element to extract the description from
92     * @param obj
93     *          the object node to add the description property to
94     */
95    public static <NAMED extends INamedModelElement & IModelElement> void generateDescription(
96        @NonNull NAMED named,
97        @NonNull ObjectNode obj) {
98      MarkupLine description = named.getEffectiveDescription();
99  
100     StringBuilder retval = null;
101     if (description != null) {
102       retval = new StringBuilder().append(description.toMarkdown());
103     }
104 
105     MarkupMultiline remarks = named.getRemarks();
106     if (remarks != null) {
107       if (retval == null) {
108         retval = new StringBuilder();
109       } else {
110         retval.append("\n\n");
111       }
112       retval.append(remarks.toMarkdown());
113     }
114     if (retval != null) {
115       obj.put("description", retval.toString());
116     }
117   }
118 
119   /**
120    * Generates a default property in the JSON Schema from the instance's default
121    * value.
122    *
123    * @param instance
124    *          the valued instance to extract the default from
125    * @param obj
126    *          the object node to add the default property to
127    */
128   public static void generateDefault(
129       @NonNull IValuedInstance instance,
130       @NonNull ObjectNode obj) {
131     Object defaultValue = instance.getEffectiveDefaultValue();
132     if (defaultValue != null) {
133       IValuedDefinition definition = instance.getDefinition();
134       IDataTypeAdapter<?> adapter = definition.getJavaTypeAdapter();
135       obj.set("default", toJsonValue(defaultValue, adapter));
136     }
137   }
138 
139   private static JsonNode toJsonValue(
140       @Nullable Object defaultValue,
141       @NonNull IDataTypeAdapter<?> adapter) {
142     JsonNode retval = null; // use default conversion by default
143     switch (adapter.getJsonRawType()) {
144     case BOOLEAN:
145       if (defaultValue instanceof Boolean) {
146         retval = BooleanNode.valueOf((Boolean) defaultValue);
147       } // else use default conversion
148       break;
149     case INTEGER:
150       if (defaultValue instanceof BigInteger) {
151         retval = BigIntegerNode.valueOf((BigInteger) defaultValue);
152       } else if (defaultValue instanceof Integer) {
153         retval = IntNode.valueOf((Integer) defaultValue);
154       } else if (defaultValue instanceof Long) {
155         retval = LongNode.valueOf((Long) defaultValue);
156       } // else use default conversion
157       break;
158     case NUMBER:
159       if (defaultValue instanceof BigDecimal) {
160         retval = DecimalNode.valueOf((BigDecimal) defaultValue);
161       } else if (defaultValue instanceof Double) {
162         retval = DoubleNode.valueOf((Double) defaultValue);
163       } // else use default conversion
164       break;
165     case ANY:
166     case ARRAY:
167     case OBJECT:
168     case NULL:
169       throw new UnsupportedOperationException("Invalid type: " + adapter.getClass());
170     case STRING:
171       // use default conversion
172       break;
173     }
174 
175     if (retval == null && defaultValue != null) {
176       retval = TextNode.valueOf(adapter.asString(defaultValue));
177     }
178     return retval;
179   }
180 
181   /**
182    * Generate a JSON pointer expression that points to the provided JSON schema
183    * definition for use as a schema reference.
184    *
185    * @param schema
186    *          the JSON schema definition to generate the pointer for
187    * @return the JSON pointer
188    */
189   @NonNull
190   public static String generateDefinitionJsonPointer(@NonNull IJsonSchemaDefinable schema) {
191     return ObjectUtils.notNull(new StringBuilder()
192         .append("#/definitions/")
193         .append(schema.getDefinitionName())
194         .toString());
195   }
196 
197   /**
198    * Generates properties and required array for a JSON Schema object type.
199    *
200    * @param properties
201    *          the collection of properties to generate
202    * @param node
203    *          the object node to add the properties and required array to
204    * @param state
205    *          the JSON generation state
206    */
207   public static void generateProperties(
208       @NonNull Collection<IJsonSchemaPropertyNamed> properties,
209       @NonNull ObjectNode node,
210       @NonNull IJsonGenerationState state) {
211 
212     if (!properties.isEmpty()) {
213       ObjectNode propertiesNode = node.putObject("properties");
214 
215       List<String> required = new LinkedList<>();
216       properties.forEach(property -> {
217         ObjectNode propertyNode = ObjectUtils.notNull(state.getJsonNodeFactory().objectNode());
218         property.generate(propertyNode, state);
219 
220         String name = property.getName();
221         propertiesNode.set(name, propertyNode);
222 
223         if (property.isRequired()) {
224           required.add(name);
225         }
226       });
227 
228       if (!required.isEmpty()) {
229         ArrayNode requiredNode = node.putArray("required");
230         required.forEach(requiredNode::add);
231       }
232     }
233   }
234 
235   /**
236    * Builds a list of flag property schemas for the given definition.
237    * <p>
238    * Flags used as JSON keys are excluded from the returned list.
239    *
240    * @param definition
241    *          the model definition containing the flags
242    * @param jsonKeyFlagName
243    *          the name of the flag used as JSON key, or {@code null} if none
244    * @param state
245    *          the JSON generation state
246    * @return a list of flag property schemas, excluding the JSON key flag
247    * @throws IllegalArgumentException
248    *           if the specified JSON key flag name does not exist on the
249    *           definition
250    */
251   @NonNull
252   public static List<IJsonSchemaPropertyFlag> buildFlagProperties(
253       @NonNull IModelDefinition definition,
254       @Nullable IEnhancedQName jsonKeyFlagName,
255       @NonNull IJsonGenerationState state) {
256 
257     Stream<? extends IFlagInstance> flagStream = definition.getFlagInstances().stream();
258 
259     // determine the flag instances to generate
260     if (jsonKeyFlagName != null) {
261       IFlagInstance jsonKeyFlag = definition.getFlagInstanceByName(jsonKeyFlagName.getIndexPosition());
262       if (jsonKeyFlag == null) {
263         throw new IllegalArgumentException(
264             String.format("The referenced json-key flag-name '%s' does not exist on definition '%s'.",
265                 jsonKeyFlagName,
266                 definition.getName()));
267       }
268       flagStream = flagStream.filter(instance -> !jsonKeyFlag.equals(instance));
269     }
270 
271     return ObjectUtils.notNull(flagStream
272         .map(instance -> state.getJsonSchemaPropertyFlag(ObjectUtils.requireNonNull(instance)))
273         .collect(Collectors.toUnmodifiableList()));
274   }
275 
276   /**
277    * Builds a list of model property schemas for the given container definition.
278    * <p>
279    * Choice instances are excluded from the returned list as they are handled
280    * separately.
281    *
282    * @param definition
283    *          the container model definition containing the model instances
284    * @param state
285    *          the JSON generation state
286    * @return a list of model property schemas, excluding choice instances
287    */
288   @NonNull
289   public static List<IJsonSchemaPropertyNamed> buildModelProperties(
290       @NonNull IContainerModelAbsolute definition,
291       @NonNull IJsonGenerationState state) {
292     return ObjectUtils.notNull(definition.getModelInstances().stream()
293         // filter out choice instances, which will be handled separately
294         .filter(instance -> !(instance instanceof IChoiceInstance))
295         .map(instance -> state.getJsonSchemaPropertyModel(ObjectUtils.notNull(instance)))
296         .collect(Collectors.toUnmodifiableList()));
297   }
298 
299   /**
300    * Generates the body of a JSON Schema for a field definition.
301    * <p>
302    * For simple fields without non-value properties, generates a direct value
303    * reference. For complex fields with flags, generates an object type with
304    * properties.
305    *
306    * @param field
307    *          the field definition schema to generate body for
308    * @param node
309    *          the object node to add the schema to
310    * @param state
311    *          the JSON generation state
312    */
313   public static void generateFieldBody(
314       @NonNull IJsonSchemaDefinitionField field,
315       @NonNull ObjectNode node,
316       @NonNull IJsonGenerationState state) {
317     if (field.getNonValueProperties().isEmpty()) {
318       // simple case
319       field.getFieldValue().generateJsonSchemaOrDefinitionRef(node, state);
320     } else {
321       generateComplexFieldBody(field, node, state);
322     }
323   }
324 
325   private static void generateComplexFieldBody(
326       @NonNull IJsonSchemaDefinitionField field,
327       @NonNull ObjectNode node,
328       @NonNull IJsonGenerationState state) {
329     List<? extends IJsonSchemaPropertyNamed> nonValueProperties = field.getNonValueProperties();
330     node.put("type", "object");
331 
332     IFlagInstance jsonValueKeyFlag = field.getDefinition().getJsonValueKeyFlagInstance();
333 
334     Stream<? extends IJsonSchemaPropertyNamed> propertiesStream = nonValueProperties.stream();
335 
336     if (jsonValueKeyFlag == null) {
337       // make room for the value
338       propertiesStream = Stream.concat(propertiesStream, Stream.of(new FieldValueProperty(field)));
339     } else {
340       propertiesStream = propertiesStream.filter(property -> !(property instanceof IJsonSchemaPropertyFlag)
341           || !jsonValueKeyFlag.equals(((IJsonSchemaPropertyFlag) property).getInstance()));
342     }
343 
344     List<IJsonSchemaPropertyNamed> properties = ObjectUtils.notNull(propertiesStream
345         .sorted(INSTANCE_NAME_COMPARATOR)
346         .collect(Collectors.toUnmodifiableList()));
347     generateProperties(
348         properties,
349         node,
350         state);
351 
352     if (jsonValueKeyFlag == null) {
353       node.put("additionalProperties", false);
354     } else {
355       ObjectNode additionalPropertiesTypeNode;
356 
357       additionalPropertiesTypeNode = ObjectUtils.notNull(JsonNodeFactory.instance.objectNode());
358       // the type of the additional properties must be the datatype of the field value
359       field.getFieldValue().generateJsonSchemaOrDefinitionRef(additionalPropertiesTypeNode, state);
360 
361       ObjectNode additionalPropertiesNode = ObjectUtils.notNull(JsonNodeFactory.instance.objectNode());
362       ArrayNode allOf = additionalPropertiesNode.putArray("allOf");
363       allOf.add(additionalPropertiesTypeNode);
364       allOf.addObject()
365           .put(
366               "minProperties",
367               // increment by one to allow for the value key
368               properties.stream()
369                   .filter(IJsonSchemaPropertyNamed::isRequired)
370                   .count() + 1)
371           .put("maxProperties", properties.size() + 1);
372 
373       node.set("additionalProperties", additionalPropertiesNode);
374     }
375   }
376 
377   /**
378    * Generates the body of a JSON Schema for an assembly definition.
379    * <p>
380    * Handles choice combinations by generating either a single object type or an
381    * anyOf array when multiple choice combinations exist.
382    *
383    * @param assembly
384    *          the assembly definition schema to generate body for
385    * @param node
386    *          the object node to add the schema to
387    * @param state
388    *          the JSON generation state
389    */
390   public static void generateAssemblyBody(
391       @NonNull IJsonSchemaDefinitionAssembly assembly,
392       @NonNull ObjectNode node,
393       @NonNull IJsonGenerationState state) {
394     node.put("type", "object");
395 
396     List<JsonSchemaHelper.Choice> availableChoices = assembly.getChoices();
397 
398     if (availableChoices.size() == 1) {
399       generateProperties(
400           availableChoices.iterator().next().getCombinations(),
401           node,
402           state);
403       node.put("additionalProperties", false);
404     } else if (availableChoices.size() > 1) {
405       ArrayNode oneOf = node.putArray("anyOf");
406       availableChoices.forEach(choice -> {
407         ObjectNode schemaNode = ObjectUtils.notNull(oneOf.addObject());
408 
409         generateProperties(
410             choice.getCombinations(),
411             schemaNode,
412             state);
413         schemaNode.put("additionalProperties", false);
414       });
415     }
416   }
417 
418   private static class FieldValueProperty implements IJsonSchemaPropertyNamed {
419     private final IJsonSchemaDefinitionField field;
420 
421     public FieldValueProperty(IJsonSchemaDefinitionField field) {
422       this.field = field;
423     }
424 
425     @Override
426     public String getName() {
427       return field.getDefinition().getEffectiveJsonValueKeyName();
428     }
429 
430     @Override
431     public Stream<IJsonSchemaDefinable> collectDefinitions(
432         Set<IJsonSchemaDefinitionAssembly> visited,
433         IJsonGenerationState state) {
434       return ObjectUtils.notNull(Stream.empty());
435     }
436 
437     @Override
438     public void generate(ObjectNode node, IJsonGenerationState state) {
439       field.getFieldValue().generateJsonSchemaOrDefinitionRef(ObjectUtils.notNull(node.putObject(getName())), state);
440     }
441 
442     @Override
443     public boolean isRequired() {
444       return true;
445     }
446   }
447 
448   /**
449    * Expands a base choice into all possible combinations with choice instances.
450    * <p>
451    * Creates a Cartesian product of the base choice with all options from the
452    * provided choice instances.
453    *
454    * @param baseChoice
455    *          the base choice to expand
456    * @param choiceInstances
457    *          the choice instances to combine with
458    * @param state
459    *          the JSON generation state
460    * @return a stream of all possible choice combinations
461    */
462   @NonNull
463   public static Stream<Choice> explodeChoices(
464       @NonNull Choice baseChoice,
465       @NonNull List<? extends IChoiceInstance> choiceInstances,
466       @NonNull IJsonGenerationState state) {
467     Stream<Choice> retval = Stream.of(baseChoice);
468     for (IChoiceInstance choice : choiceInstances) {
469       List<IJsonSchemaPropertyNamed> newChoices = buildModelProperties(ObjectUtils.notNull(choice), state);
470       retval = retval.flatMap(oldChoice -> oldChoice.explode(newChoices));
471     }
472     return ObjectUtils.notNull(retval);
473   }
474 
475   /**
476    * Represents a single combination of properties in a choice group.
477    * <p>
478    * Choice objects are used to track different valid combinations of properties
479    * when generating JSON Schema for assemblies with choice elements.
480    */
481   public static final class Choice {
482     @NonNull
483     private final List<IJsonSchemaPropertyNamed> combinations;
484 
485     /**
486      * Constructs a new choice with the specified property combinations.
487      *
488      * @param combinations
489      *          the list of properties in this choice combination
490      */
491     public Choice(@NonNull List<IJsonSchemaPropertyNamed> combinations) {
492       this.combinations = combinations;
493     }
494 
495     /**
496      * Retrieves the properties in this choice combination.
497      *
498      * @return the list of property schemas
499      */
500     @NonNull
501     public List<IJsonSchemaPropertyNamed> getCombinations() {
502       return combinations;
503     }
504 
505     /**
506      * Creates new choice combinations by adding each new choice to this choice.
507      * <p>
508      * If newChoices is empty, returns a stream containing only this choice.
509      *
510      * @param newChoices
511      *          the new property options to combine with this choice
512      * @return a stream of new choices, each containing this choice's properties
513      *         plus one new property
514      */
515     @NonNull
516     public Stream<Choice> explode(@NonNull List<IJsonSchemaPropertyNamed> newChoices) {
517       return ObjectUtils.notNull(newChoices.isEmpty()
518           ? Stream.of(this)
519           : newChoices.stream()
520               .map(next -> {
521                 List<IJsonSchemaPropertyNamed> retval = new ArrayList<>(combinations.size() + 1);
522                 retval.addAll(combinations);
523                 retval.add(next);
524                 return new Choice(CollectionUtil.unmodifiableList(retval));
525               }));
526     }
527   }
528 
529   private JsonSchemaHelper() {
530     // disable construction
531   }
532 }