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