001/*
002 * Licensed to the Apache Software Foundation (ASF) under one or more
003 * contributor license agreements.  See the NOTICE file distributed with
004 * this work for additional information regarding copyright ownership.
005 * The ASF licenses this file to You under the Apache License, Version 2.0
006 * (the "License"); you may not use this file except in compliance with
007 * the License.  You may obtain a copy of the License at
008 *
009 *      http://www.apache.org/licenses/LICENSE-2.0
010 *
011 * Unless required by applicable law or agreed to in writing, software
012 * distributed under the License is distributed on an "AS IS" BASIS,
013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
014 * See the License for the specific language governing permissions and
015 * limitations under the License.
016 */
017package org.apache.juneau.marshall.jsonschema;
018
019import static org.apache.juneau.commons.utils.CollectionUtils.*;
020import static org.apache.juneau.commons.utils.Shorts.*;
021import static org.apache.juneau.marshall.jsonschema.TypeCategory.*;
022
023import java.lang.reflect.*;
024import java.util.*;
025import java.util.function.*;
026import java.util.regex.*;
027
028import org.apache.juneau.commons.*;
029import org.apache.juneau.commons.bean.*;
030import org.apache.juneau.commons.utils.*;
031import org.apache.juneau.marshall.*;
032import org.apache.juneau.marshall.collections.*;
033import org.apache.juneau.marshall.json.*;
034import org.apache.juneau.marshall.marshaller.*;
035import org.apache.juneau.marshall.parser.*;
036import org.apache.juneau.marshall.serializer.*;
037
038/**
039 * Session object that lives for the duration of a single use of {@link JsonSchemaSerializer}.
040 *
041 * <h5 class='section'>Notes:</h5><ul>
042 *    <li class='warn'>This class is not thread safe and is typically discarded after one use.
043 * </ul>
044 *
045 * <h5 class='section'>See Also:</h5><ul>
046 *    <li class='link'><a class="doclink" href="https://juneau.apache.org/docs/topics/JsonSchemaDetails">JSON-Schema Support</a>
047 * </ul>
048 */
049@SuppressWarnings({
050   "java:S115", // Constants use UPPER_snakeCase convention
051   "java:S1192", // Duplicated literals (argument/property names) read more clearly inline than as constants
052   "unchecked" // The (Class<Enum<?>>) cast in getEnums() is safe because it is only reached for enum types.
053})
054public class JsonSchemaGeneratorSession extends MarshallingTraverseSession {
055
056   /**
057    * Builder class.
058    */
059   public static class Builder extends MarshallingTraverseSession.Builder<Builder> {
060
061      private final JsonSchemaGenerator ctx;
062
063      /**
064       * Constructor
065       *
066       * @param ctx The context creating this session.
067       *    <br>Cannot be <jk>null</jk>.
068       */
069      protected Builder(JsonSchemaGenerator ctx) {
070         super(reqnn("ctx", ctx));
071         this.ctx = ctx;
072      }
073
074      @Override
075      public JsonSchemaGeneratorSession build() {
076         return new JsonSchemaGeneratorSession(this);
077      }
078
079   }
080
081   /**
082    * Creates a new builder for this object.
083    *
084    * @param ctx The context creating this session.
085    *    <br>Cannot be <jk>null</jk>.
086    * @return A new builder.
087    */
088   public static Builder create(JsonSchemaGenerator ctx) {
089      return new Builder(reqnn("ctx", ctx));
090   }
091
092   private final JsonSchemaGenerator ctx;
093   private final Map<String,JsonMap> defs;
094   private JsonParserSession jpSession;
095   private JsonSerializerSession jsSession;
096
097   /**
098    * Constructor.
099    *
100    * @param builder The builder for this object.  Cannot be <jk>null</jk>.
101    */
102   protected JsonSchemaGeneratorSession(Builder builder) {
103      super(builder);
104      ctx = builder.ctx;
105      defs = isUseBeanDefs() ? new TreeMap<>() : null;
106   }
107
108   /**
109    * Adds a schema definition to this session.
110    *
111    * @param id The definition ID.
112    *    <br>Cannot be <jk>null</jk>.
113    * @param def The definition schema.
114    *    <br>Cannot be <jk>null</jk>.
115    * @return This object.
116    */
117   public JsonSchemaGeneratorSession addBeanDef(String id, JsonMap def) {
118      if (nn(defs))
119         defs.put(reqnn("id", id), reqnn("def", def));
120      return this;
121   }
122
123   /**
124    * Returns the definition ID for the specified class.
125    *
126    * @param cm The class to get the definition ID of.  Must not be <jk>null</jk>.
127    * @return The definition ID for the specified class.
128    */
129   public String getBeanDefId(ClassMeta<?> cm) {
130      return getBeanDefMapper().getId(cm);
131   }
132
133   /**
134    * Returns the definitions that were gathered during this session.
135    *
136    * <p>
137    * This map is modifiable and affects the map in the session.
138    *
139    * @return
140    *    The definitions that were gathered during this session, or <jk>null</jk> if {@link JsonSchemaGenerator.Builder#useBeanDefs()} was not enabled.
141    */
142   public Map<String,JsonMap> getBeanDefs() { return defs; }
143
144   /**
145    * Returns the definition URI for the specified class.
146    *
147    * @param cm The class to get the definition URI of.  Must not be <jk>null</jk>.
148    * @return The definition URI for the specified class.
149    */
150   public java.net.URI getBeanDefUri(ClassMeta<?> cm) {
151      return getBeanDefMapper().getURI(cm);
152   }
153
154   /**
155    * Returns the definition URI for the specified class.
156    *
157    * @param id The definition ID to get the definition URI of.
158    * @return The definition URI for the specified class.
159    */
160   public java.net.URI getBeanDefUri(String id) {
161      return getBeanDefMapper().getURI(id);
162   }
163
164   /**
165    * Returns the language-specific metadata on the specified bean property.
166    *
167    * @param bpm The bean property to return the metadata on.  Must not be <jk>null</jk>.
168    * @return The metadata.
169    */
170   public JsonSchemaBeanPropertyMeta getJsonSchemaBeanPropertyMeta(BeanPropertyMeta bpm) {
171      return ctx.getJsonSchemaBeanPropertyMeta(bpm);
172   }
173
174   /**
175    * Returns the language-specific metadata on the specified class.
176    *
177    * @param cm The class to return the metadata on.  Must not be <jk>null</jk>.
178    * @return The metadata.
179    */
180   public JsonSchemaClassMeta getJsonSchemaClassMeta(ClassMeta<?> cm) {
181      return ctx.getJsonSchemaClassMeta(cm);
182   }
183
184   /**
185    * Returns the JSON-schema for the specified type.
186    *
187    * @param cm The object type.
188    * @return The schema for the type, or <jk>null</jk> if the type is ignored (see {@link JsonSchemaGenerator.Builder#ignoreTypes(String...)}).
189    * @throws MarshallingRecursionException Bean recursion occurred.
190    * @throws SerializeException Error occurred.
191    */
192   public JsonMap getSchema(ClassMeta<?> cm) throws MarshallingRecursionException, SerializeException {
193      return getSchema(cm, "root", false, false, null);
194   }
195
196   /**
197    * Returns the JSON-schema for the specified object.
198    *
199    * @param o
200    *    The object.
201    *    <br>Can either be a POJO or a <c>Class</c>/<c>Type</c>.
202    * @return The schema for the type, or <jk>null</jk> if the type is ignored (see {@link JsonSchemaGenerator.Builder#ignoreTypes(String...)}).
203    * @throws MarshallingRecursionException Bean recursion occurred.
204    * @throws SerializeException Error occurred.
205    */
206   public JsonMap getSchema(Object o) throws MarshallingRecursionException, SerializeException {
207      return getSchema(toClassMeta(o), "root", false, false, null);
208   }
209
210   /**
211    * Returns the JSON-schema for the specified type.
212    *
213    * @param type The object type.
214    * @return The schema for the type, or <jk>null</jk> if the type is ignored (see {@link JsonSchemaGenerator.Builder#ignoreTypes(String...)}).
215    * @throws MarshallingRecursionException Bean recursion occurred.
216    * @throws SerializeException Error occurred.
217    */
218   public JsonMap getSchema(Type type) throws MarshallingRecursionException, SerializeException {
219      return getSchema(getClassMeta(type), "root", false, false, null);
220   }
221
222   private Object getDescription(ClassMeta<?> sType, TypeCategory t, boolean descriptionAdded) {
223      var canAdd = isAllowNestedDescriptions() || ! descriptionAdded;
224      if (canAdd && (getAddDescriptionsTo().contains(t) || getAddDescriptionsTo().contains(ANY)))
225         return sType.toString();
226      return null;
227   }
228
229   private static List<String> getEnums(ClassMeta<?> cm) {
230      List<String> l = list();
231      for (var e : ((Class<Enum<?>>)cm.inner()).getEnumConstants())
232         l.add(cm.toString(e));
233      return l;
234   }
235
236   private Object getExample(ClassMeta<?> sType, TypeCategory t, boolean exampleAdded) throws SerializeException {
237      var canAdd = isAllowNestedExamples() || ! exampleAdded;
238      if (canAdd && (getAddExamplesTo().contains(t) || getAddExamplesTo().contains(ANY))) {
239         var example = sType.getExample(this, jpSession());
240         if (nn(example)) {
241            try {
242               return Json5.to(toJson(example), Object.class);
243            } catch (ParseException e) {
244               throw new SerializeException(e);
245            }
246         }
247      }
248      return null;
249   }
250
251   @SuppressWarnings({
252      "java:S1168", // Intentional null when type is ignored — signals "no schema" to callers; null is filtered by JsonMap serialization
253      "java:S3776", // Cognitive complexity acceptable for schema generation over type categories
254      "java:S6541", // Brain Method — unified getSchema branching over serializers/types is intentional
255      "rawtypes" // Raw types necessary for generic type handling
256   })
257   private JsonMap getSchema(ClassMeta<?> eType, String attrName, boolean exampleAdded, boolean descriptionAdded, JsonSchemaBeanPropertyMeta jsbpm)
258      throws MarshallingRecursionException, SerializeException {
259
260      if (ctx.isIgnoredType(eType))
261         return null;
262
263      var out = new JsonMap();
264
265      if (eType == null)
266         eType = object();
267
268      ClassMeta<?> aType = null;       // The actual type (will be null if recursion occurs)
269      ClassMeta<?> sType = null;       // The serialized type
270      var objectSwap = eType.getSwap(this);
271
272      aType = push(attrName, eType, null);
273
274      sType = eType.getSerializedClassMeta(this);
275
276      String type = null;
277      String format = null;
278      Object example = null;
279      Object description = null;
280
281      boolean useDef = isUseBeanDefs() && sType.isBean();
282
283      if (useDef) {
284         exampleAdded = false;
285         descriptionAdded = false;
286      }
287
288      if (useDef && defs.containsKey(getBeanDefId(sType))) {
289         pop();
290         return new JsonMap().append("$ref", getBeanDefUri(sType));
291      }
292
293      JsonSchemaClassMeta jscm = null;
294      var objectSwapCM = objectSwap == null ? null : getClassMeta(objectSwap.getClass());
295      if (nn(objectSwapCM) && getAnnotationProvider().has(Schema.class, objectSwapCM))
296         jscm = getJsonSchemaClassMeta(objectSwapCM);
297      if (jscm == null)
298         jscm = getJsonSchemaClassMeta(sType);
299
300      TypeCategory tc = null;
301
302      if (sType.isNumber()) {
303         tc = NUMBER;
304         if (sType.isDecimal()) {
305            type = "number";
306            if (sType.isFloat()) {
307               format = "float";
308            } else if (sType.isDouble()) {
309               format = "double";
310            }
311         } else {
312            type = "integer";
313            if (sType.isShort()) {
314               format = "int16";
315            } else if (sType.isInteger()) {
316               format = "int32";
317            } else if (sType.isLong()) {
318               format = "int64";
319            }
320         }
321      } else if (sType.isBoolean()) {
322         tc = BOOLEAN;
323         type = "boolean";
324      } else if (sType.isMap()) {
325         tc = MAP;
326         type = "object";
327      } else if (sType.isBean()) {
328         tc = BEAN;
329         type = "object";
330      } else if (sType.isCollection()) {
331         tc = COLLECTION;
332         type = "array";
333      } else if (sType.isArray()) {
334         tc = ARRAY;
335         type = "array";
336      } else if (sType.isEnum()) {
337         tc = ENUM;
338         type = "string";
339      } else {
340         tc = STRING;
341         type = "string";
342         // Check both the declared and serialized types so that JDK URI value types keep their "uri" format
343         // even when a default string swap (e.g. UrlSwap for java.net.URL) has rewritten the serialized type to String.
344         if (sType.isUri() || eType.isUri())
345            format = "uri";
346      }
347
348      // Add info from @Schema on bean property.
349      if (nn(jsbpm)) {
350         out.append(jsbpm.getSchema());
351      }
352
353      out.append(jscm.getSchema());
354
355      Predicate<String> ne = Shorts::ine;
356      out.appendIfAbsentIf(ne, "type", type);
357      out.appendIfAbsentIf(ne, "format", format);
358
359      if (nn(aType)) {
360
361         example = getExample(sType, tc, exampleAdded);
362         description = getDescription(sType, tc, descriptionAdded);
363         exampleAdded |= nn(example);
364         descriptionAdded |= nn(description);
365
366         if (tc == BEAN) {
367            var properties = new JsonMap();
368            BeanMeta bm = getBeanMeta(sType.inner());
369            for (Iterator<BeanPropertyMeta> i = bm.getProperties().values().iterator(); i.hasNext();) {
370               BeanPropertyMeta p = i.next();
371               if (p.canRead() || p.isWriteOnly()) {
372                  var ps = getSchema((ClassMeta<?>) p.getBeanInfo(), p.getName(), exampleAdded, descriptionAdded, getJsonSchemaBeanPropertyMeta(p));
373                  // Write-only properties are advertised so clients know they can be sent, but never read back.
374                  // Copy first: the returned map may be shared (e.g. a cached or referenced definition).
375                  if (p.isWriteOnly())
376                     ps = new JsonMap().append(ps).append("writeOnly", true);
377                  properties.put(p.getName(), ps);
378               }
379            }
380            out.put("properties", properties);
381
382         } else if (tc == COLLECTION) {
383            ClassMeta et = sType.getElementType();
384            if (sType.isCollection() && sType.isAssignableTo(Set.class))
385               out.put("uniqueItems", true);
386            out.put("items", getSchema(et, "items", exampleAdded, descriptionAdded, null));
387
388         } else if (tc == ARRAY) {
389            ClassMeta et = sType.getElementType();
390            if (sType.isCollection() && sType.isAssignableTo(Set.class))
391               out.put("uniqueItems", true);
392            out.put("items", getSchema(et, "items", exampleAdded, descriptionAdded, null));
393
394         } else if (tc == ENUM) {
395            out.put("enum", getEnums(sType));
396
397         } else if (tc == MAP) {
398            var om = getSchema(sType.getValueType(), "additionalProperties", exampleAdded, descriptionAdded, null);
399            if (om != null && ! om.isEmpty())
400               out.put("additionalProperties", om);
401
402         }
403      }
404
405      out.append(jscm.getSchema());
406
407      Predicate<Object> neo = Shorts::ine;
408      out.appendIfAbsentIf(neo, "description", description);
409      out.appendIfAbsentIf(neo, "example", example);
410
411      if (useDef) {
412         defs.put(getBeanDefId(sType), out);
413         out = JsonMap.of("$ref", getBeanDefUri(sType));
414      }
415
416      pop();
417
418      return out;
419   }
420
421   private JsonParserSession jpSession() {
422      if (jpSession == null)
423         jpSession = ctx.getJsonParser().getSession();
424      return jpSession;
425   }
426
427   private ClassMeta<?> toClassMeta(Object o) {
428      if (o instanceof Type o2)
429         return getClassMeta(o2);
430      return getClassMetaForObject(o);
431   }
432
433   private String toJson(Object o) throws SerializeException {
434      if (jsSession == null)
435         jsSession = ctx.getJsonSerializer().getSession();
436      return jsSession.writeToString(o);
437   }
438
439   /**
440    * Add descriptions to types.
441    *
442    * @see JsonSchemaGenerator.Builder#addDescriptionsTo(TypeCategory...)
443    * @return
444    *    Set of categories of types that descriptions should be automatically added to generated schemas.
445    */
446   protected final Set<TypeCategory> getAddDescriptionsTo() { return ctx.getAddDescriptionsTo(); }
447
448   /**
449    * Add examples.
450    *
451    * @see JsonSchemaGenerator.Builder#addExamplesTo(TypeCategory...)
452    * @return
453    *    Set of categories of types that examples should be automatically added to generated schemas.
454    */
455   protected final Set<TypeCategory> getAddExamplesTo() { return ctx.getAddExamplesTo(); }
456
457   /**
458    * Bean schema definition mapper.
459    *
460    * @see JsonSchemaGenerator.Builder#beanDefMapper(Class)
461    * @return
462    *    Interface to use for converting Bean classes to definition IDs and URIs.
463    */
464   protected final MarshallingDefMapper getBeanDefMapper() { return ctx.getBeanDefMapper(); }
465
466   /**
467    * Ignore types from schema definitions.
468    *
469    * @see JsonSchemaGenerator.Builder#ignoreTypes(String...)
470    * @return
471    *    Custom schema information for particular class types.
472    */
473   protected final List<Pattern> getIgnoreTypes() { return ctx.getIgnoreTypes(); }
474
475   /**
476    * Allow nested descriptions.
477    *
478    * @see JsonSchemaGenerator.Builder#allowNestedDescriptions()
479    * @return
480    *    <jk>true</jk> if nested descriptions are allowed in schema definitions.
481    */
482   protected final boolean isAllowNestedDescriptions() { return ctx.isAllowNestedDescriptions(); }
483
484   /**
485    * Allow nested examples.
486    *
487    * @see JsonSchemaGenerator.Builder#allowNestedExamples()
488    * @return
489    *    <jk>true</jk> if nested examples are allowed in schema definitions.
490    */
491   protected final boolean isAllowNestedExamples() { return ctx.isAllowNestedExamples(); }
492
493   /**
494    * Use bean definitions.
495    *
496    * @see JsonSchemaGenerator.Builder#useBeanDefs()
497    * @return
498    *    <jk>true</jk> if schemas on beans will be serialized with <js>'$ref'</js> tags.
499    */
500   protected final boolean isUseBeanDefs() { return ctx.isUseBeanDefs(); }
501}