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.jsonschema;
018
019import static org.apache.juneau.commons.utils.AssertionUtils.*;
020import static org.apache.juneau.commons.utils.CollectionUtils.*;
021import static org.apache.juneau.commons.utils.Utils.*;
022import static org.apache.juneau.jsonschema.TypeCategory.*;
023
024import java.lang.reflect.*;
025import java.util.*;
026import java.util.function.*;
027import java.util.regex.*;
028
029import org.apache.juneau.*;
030import org.apache.juneau.annotation.*;
031import org.apache.juneau.collections.*;
032import org.apache.juneau.commons.utils.*;
033import org.apache.juneau.json.*;
034import org.apache.juneau.parser.*;
035import org.apache.juneau.serializer.*;
036
037/**
038 * Session object that lives for the duration of a single use of {@link JsonSchemaSerializer}.
039 *
040 * <h5 class='section'>Notes:</h5><ul>
041 *    <li class='warn'>This class is not thread safe and is typically discarded after one use.
042 * </ul>
043 *
044 * <h5 class='section'>See Also:</h5><ul>
045 *    <li class='link'><a class="doclink" href="https://juneau.apache.org/docs/topics/JsonSchemaDetails">JSON-Schema Support</a>
046 * </ul>
047 */
048public class JsonSchemaGeneratorSession extends BeanTraverseSession {
049   /**
050    * Builder class.
051    */
052   public static class Builder extends BeanTraverseSession.Builder {
053
054      private JsonSchemaGenerator ctx;
055
056      /**
057       * Constructor
058       *
059       * @param ctx The context creating this session.
060       *    <br>Cannot be <jk>null</jk>.
061       */
062      protected Builder(JsonSchemaGenerator ctx) {
063         super(assertArgNotNull("ctx", ctx));
064         this.ctx = ctx;
065      }
066
067      @Override /* Overridden from Builder */
068      public <T> Builder apply(Class<T> type, Consumer<T> apply) {
069         super.apply(type, apply);
070         return this;
071      }
072
073      @Override
074      public JsonSchemaGeneratorSession build() {
075         return new JsonSchemaGeneratorSession(this);
076      }
077
078      @Override /* Overridden from Builder */
079      public Builder debug(Boolean value) {
080         super.debug(value);
081         return this;
082      }
083
084      @Override /* Overridden from Builder */
085      public Builder locale(Locale value) {
086         super.locale(value);
087         return this;
088      }
089
090      @Override /* Overridden from Builder */
091      public Builder mediaType(MediaType value) {
092         super.mediaType(value);
093         return this;
094      }
095
096      @Override /* Overridden from Builder */
097      public Builder mediaTypeDefault(MediaType value) {
098         super.mediaTypeDefault(value);
099         return this;
100      }
101
102      @Override /* Overridden from Builder */
103      public Builder properties(Map<String,Object> value) {
104         super.properties(value);
105         return this;
106      }
107
108      @Override /* Overridden from Builder */
109      public Builder property(String key, Object value) {
110         super.property(key, value);
111         return this;
112      }
113
114      @Override /* Overridden from Builder */
115      public Builder timeZone(TimeZone value) {
116         super.timeZone(value);
117         return this;
118      }
119
120      @Override /* Overridden from Builder */
121      public Builder timeZoneDefault(TimeZone value) {
122         super.timeZoneDefault(value);
123         return this;
124      }
125
126      @Override /* Overridden from Builder */
127      public Builder unmodifiable() {
128         super.unmodifiable();
129         return this;
130      }
131   }
132
133   /**
134    * Creates a new builder for this object.
135    *
136    * @param ctx The context creating this session.
137    *    <br>Cannot be <jk>null</jk>.
138    * @return A new builder.
139    */
140   public static Builder create(JsonSchemaGenerator ctx) {
141      return new Builder(assertArgNotNull("ctx", ctx));
142   }
143
144   private final JsonSchemaGenerator ctx;
145   private final Map<String,JsonMap> defs;
146   private JsonParserSession jpSession;
147   private JsonSerializerSession jsSession;
148
149   /**
150    * Constructor.
151    *
152    * @param builder The builder for this object.
153    */
154   protected JsonSchemaGeneratorSession(Builder builder) {
155      super(builder);
156      ctx = builder.ctx;
157      defs = isUseBeanDefs() ? new TreeMap<>() : null;
158   }
159
160   /**
161    * Adds a schema definition to this session.
162    *
163    * @param id The definition ID.
164    *    <br>Cannot be <jk>null</jk>.
165    * @param def The definition schema.
166    *    <br>Cannot be <jk>null</jk>.
167    * @return This object.
168    */
169   public JsonSchemaGeneratorSession addBeanDef(String id, JsonMap def) {
170      if (nn(defs))
171         defs.put(assertArgNotNull("id", id), assertArgNotNull("def", def));
172      return this;
173   }
174
175   /**
176    * Returns the definition ID for the specified class.
177    *
178    * @param cm The class to get the definition ID of.
179    * @return The definition ID for the specified class.
180    */
181   public String getBeanDefId(ClassMeta<?> cm) {
182      return getBeanDefMapper().getId(cm);
183   }
184
185   /**
186    * Returns the definitions that were gathered during this session.
187    *
188    * <p>
189    * This map is modifiable and affects the map in the session.
190    *
191    * @return
192    *    The definitions that were gathered during this session, or <jk>null</jk> if {@link JsonSchemaGenerator.Builder#useBeanDefs()} was not enabled.
193    */
194   public Map<String,JsonMap> getBeanDefs() { return defs; }
195
196   /**
197    * Returns the definition URI for the specified class.
198    *
199    * @param cm The class to get the definition URI of.
200    * @return The definition URI for the specified class.
201    */
202   public java.net.URI getBeanDefUri(ClassMeta<?> cm) {
203      return getBeanDefMapper().getURI(cm);
204   }
205
206   /**
207    * Returns the definition URI for the specified class.
208    *
209    * @param id The definition ID to get the definition URI of.
210    * @return The definition URI for the specified class.
211    */
212   public java.net.URI getBeanDefUri(String id) {
213      return getBeanDefMapper().getURI(id);
214   }
215
216   /**
217    * Returns the language-specific metadata on the specified bean property.
218    *
219    * @param bpm The bean property to return the metadata on.
220    * @return The metadata.
221    */
222   public JsonSchemaBeanPropertyMeta getJsonSchemaBeanPropertyMeta(BeanPropertyMeta bpm) {
223      return ctx.getJsonSchemaBeanPropertyMeta(bpm);
224   }
225
226   /**
227    * Returns the language-specific metadata on the specified class.
228    *
229    * @param cm The class to return the metadata on.
230    * @return The metadata.
231    */
232   public JsonSchemaClassMeta getJsonSchemaClassMeta(ClassMeta<?> cm) {
233      return ctx.getJsonSchemaClassMeta(cm);
234   }
235
236   /**
237    * Returns the JSON-schema for the specified type.
238    *
239    * @param cm The object type.
240    * @return The schema for the type.
241    * @throws BeanRecursionException Bean recursion occurred.
242    * @throws SerializeException Error occurred.
243    */
244   public JsonMap getSchema(ClassMeta<?> cm) throws BeanRecursionException, SerializeException {
245      return getSchema(cm, "root", null, false, false, null);
246   }
247
248   /**
249    * Returns the JSON-schema for the specified object.
250    *
251    * @param o
252    *    The object.
253    *    <br>Can either be a POJO or a <c>Class</c>/<c>Type</c>.
254    * @return The schema for the type.
255    * @throws BeanRecursionException Bean recursion occurred.
256    * @throws SerializeException Error occurred.
257    */
258   public JsonMap getSchema(Object o) throws BeanRecursionException, SerializeException {
259      return getSchema(toClassMeta(o), "root", null, false, false, null);
260   }
261
262   /**
263    * Returns the JSON-schema for the specified type.
264    *
265    * @param type The object type.
266    * @return The schema for the type.
267    * @throws BeanRecursionException Bean recursion occurred.
268    * @throws SerializeException Error occurred.
269    */
270   public JsonMap getSchema(Type type) throws BeanRecursionException, SerializeException {
271      return getSchema(getClassMeta(type), "root", null, false, false, null);
272   }
273
274   private Object getDescription(ClassMeta<?> sType, TypeCategory t, boolean descriptionAdded) {
275      var canAdd = isAllowNestedDescriptions() || ! descriptionAdded;
276      if (canAdd && (getAddDescriptionsTo().contains(t) || getAddDescriptionsTo().contains(ANY)))
277         return sType.toString();
278      return null;
279   }
280
281   @SuppressWarnings("unchecked")
282   private static List<String> getEnums(ClassMeta<?> cm) {
283      List<String> l = list();
284      for (var e : ((Class<Enum<?>>)cm.inner()).getEnumConstants())
285         l.add(cm.toString(e));
286      return l;
287   }
288
289   private Object getExample(ClassMeta<?> sType, TypeCategory t, boolean exampleAdded) throws SerializeException {
290      var canAdd = isAllowNestedExamples() || ! exampleAdded;
291      if (canAdd && (getAddExamplesTo().contains(t) || getAddExamplesTo().contains(ANY))) {
292         var example = sType.getExample(this, jpSession());
293         if (nn(example)) {
294            try {
295               return JsonParser.DEFAULT.parse(toJson(example), Object.class);
296            } catch (ParseException e) {
297               throw new SerializeException(e);
298            }
299         }
300      }
301      return null;
302   }
303
304   @SuppressWarnings({ "unchecked", "rawtypes" })
305   private JsonMap getSchema(ClassMeta<?> eType, String attrName, List<String> pNames, boolean exampleAdded, boolean descriptionAdded, JsonSchemaBeanPropertyMeta jsbpm)
306      throws BeanRecursionException, SerializeException {
307
308      if (ctx.isIgnoredType(eType))
309         return null;
310
311      var out = new JsonMap();
312
313      if (eType == null)
314         eType = object();
315
316      var aType = (ClassMeta<?>)null;        // The actual type (will be null if recursion occurs)
317      var sType = (ClassMeta<?>)null;        // The serialized type
318      var objectSwap = eType.getSwap(this);
319
320      aType = push(attrName, eType, null);
321
322      sType = eType.getSerializedClassMeta(this);
323
324      var type = (String)null;
325      var format = (String)null;
326      var example = (Object)null;
327      var description = (Object)null;
328
329      boolean useDef = isUseBeanDefs() && sType.isBean() && pNames == null;
330
331      if (useDef) {
332         exampleAdded = false;
333         descriptionAdded = false;
334      }
335
336      if (useDef && defs.containsKey(getBeanDefId(sType))) {
337         pop();
338         return new JsonMap().append("$ref", getBeanDefUri(sType));
339      }
340
341      var jscm = (JsonSchemaClassMeta)null;
342      var objectSwapCM = objectSwap == null ? null : getClassMeta(objectSwap.getClass());
343      if (nn(objectSwapCM) && getAnnotationProvider().has(Schema.class, objectSwapCM))
344         jscm = getJsonSchemaClassMeta(objectSwapCM);
345      if (jscm == null)
346         jscm = getJsonSchemaClassMeta(sType);
347
348      var tc = (TypeCategory)null;
349
350      if (sType.isNumber()) {
351         tc = NUMBER;
352         if (sType.isDecimal()) {
353            type = "number";
354            if (sType.isFloat()) {
355               format = "float";
356            } else if (sType.isDouble()) {
357               format = "double";
358            }
359         } else {
360            type = "integer";
361            if (sType.isShort()) {
362               format = "int16";
363            } else if (sType.isInteger()) {
364               format = "int32";
365            } else if (sType.isLong()) {
366               format = "int64";
367            }
368         }
369      } else if (sType.isBoolean()) {
370         tc = BOOLEAN;
371         type = "boolean";
372      } else if (sType.isMap()) {
373         tc = MAP;
374         type = "object";
375      } else if (sType.isBean()) {
376         tc = BEAN;
377         type = "object";
378      } else if (sType.isCollection()) {
379         tc = COLLECTION;
380         type = "array";
381      } else if (sType.isArray()) {
382         tc = ARRAY;
383         type = "array";
384      } else if (sType.isEnum()) {
385         tc = ENUM;
386         type = "string";
387      } else if (sType.isCharSequence() || sType.isChar()) {
388         tc = STRING;
389         type = "string";
390      } else if (sType.isUri()) {
391         tc = STRING;
392         type = "string";
393         format = "uri";
394      } else {
395         tc = STRING;
396         type = "string";
397      }
398
399      // Add info from @Schema on bean property.
400      if (nn(jsbpm)) {
401         out.append(jsbpm.getSchema());
402      }
403
404      out.append(jscm.getSchema());
405
406      Predicate<String> ne = Utils::ne;
407      out.appendIfAbsentIf(ne, "type", type);
408      out.appendIfAbsentIf(ne, "format", format);
409
410      if (nn(aType)) {
411
412         example = getExample(sType, tc, exampleAdded);
413         description = getDescription(sType, tc, descriptionAdded);
414         exampleAdded |= nn(example);
415         descriptionAdded |= nn(description);
416
417         if (tc == BEAN) {
418            var properties = new JsonMap();
419            BeanMeta bm = getBeanMeta(sType.inner());
420            if (nn(pNames))
421               bm = new BeanMetaFiltered(bm, pNames);
422            for (Iterator<BeanPropertyMeta> i = bm.getProperties().values().iterator(); i.hasNext();) {
423               BeanPropertyMeta p = i.next();
424               if (p.canRead()) {
425                  var pProps = p.getProperties();
426                  properties.put(p.getName(), getSchema(p.getClassMeta(), p.getName(), pProps, exampleAdded, descriptionAdded, getJsonSchemaBeanPropertyMeta(p)));
427               }
428            }
429            out.put("properties", properties);
430
431         } else if (tc == COLLECTION) {
432            ClassMeta et = sType.getElementType();
433            if (sType.isCollection() && sType.isChildOf(Set.class))
434               out.put("uniqueItems", true);
435            out.put("items", getSchema(et, "items", pNames, exampleAdded, descriptionAdded, null));
436
437         } else if (tc == ARRAY) {
438            ClassMeta et = sType.getElementType();
439            if (sType.isCollection() && sType.isChildOf(Set.class))
440               out.put("uniqueItems", true);
441            out.put("items", getSchema(et, "items", pNames, exampleAdded, descriptionAdded, null));
442
443         } else if (tc == ENUM) {
444            out.put("enum", getEnums(sType));
445
446         } else if (tc == MAP) {
447            var om = getSchema(sType.getValueType(), "additionalProperties", null, exampleAdded, descriptionAdded, null);
448            if (! om.isEmpty())
449               out.put("additionalProperties", om);
450
451         }
452      }
453
454      out.append(jscm.getSchema());
455
456      Predicate<Object> neo = Utils::ne;
457      out.appendIfAbsentIf(neo, "description", description);
458      out.appendIfAbsentIf(neo, "example", example);
459
460      if (useDef) {
461         defs.put(getBeanDefId(sType), out);
462         out = JsonMap.of("$ref", getBeanDefUri(sType));
463      }
464
465      pop();
466
467      return out;
468   }
469
470   private JsonParserSession jpSession() {
471      if (jpSession == null)
472         jpSession = ctx.getJsonParser().getSession();
473      return jpSession;
474   }
475
476   private ClassMeta<?> toClassMeta(Object o) {
477      if (o instanceof Type o2)
478         return getClassMeta(o2);
479      return getClassMetaForObject(o);
480   }
481
482   private String toJson(Object o) throws SerializeException {
483      if (jsSession == null)
484         jsSession = ctx.getJsonSerializer().getSession();
485      return jsSession.serializeToString(o);
486   }
487
488   /**
489    * Add descriptions to types.
490    *
491    * @see JsonSchemaGenerator.Builder#addDescriptionsTo(TypeCategory...)
492    * @return
493    *    Set of categories of types that descriptions should be automatically added to generated schemas.
494    */
495   protected final Set<TypeCategory> getAddDescriptionsTo() { return ctx.getAddDescriptionsTo(); }
496
497   /**
498    * Add examples.
499    *
500    * @see JsonSchemaGenerator.Builder#addExamplesTo(TypeCategory...)
501    * @return
502    *    Set of categories of types that examples should be automatically added to generated schemas.
503    */
504   protected final Set<TypeCategory> getAddExamplesTo() { return ctx.getAddExamplesTo(); }
505
506   /**
507    * Bean schema definition mapper.
508    *
509    * @see JsonSchemaGenerator.Builder#beanDefMapper(Class)
510    * @return
511    *    Interface to use for converting Bean classes to definition IDs and URIs.
512    */
513   protected final BeanDefMapper getBeanDefMapper() { return ctx.getBeanDefMapper(); }
514
515   /**
516    * Ignore types from schema definitions.
517    *
518    * @see JsonSchemaGenerator.Builder#ignoreTypes(String...)
519    * @return
520    *    Custom schema information for particular class types.
521    */
522   protected final List<Pattern> getIgnoreTypes() { return ctx.getIgnoreTypes(); }
523
524   /**
525    * Allow nested descriptions.
526    *
527    * @see JsonSchemaGenerator.Builder#allowNestedDescriptions()
528    * @return
529    *    <jk>true</jk> if nested descriptions are allowed in schema definitions.
530    */
531   protected final boolean isAllowNestedDescriptions() { return ctx.isAllowNestedDescriptions(); }
532
533   /**
534    * Allow nested examples.
535    *
536    * @see JsonSchemaGenerator.Builder#allowNestedExamples()
537    * @return
538    *    <jk>true</jk> if nested examples are allowed in schema definitions.
539    */
540   protected final boolean isAllowNestedExamples() { return ctx.isAllowNestedExamples(); }
541
542   /**
543    * Use bean definitions.
544    *
545    * @see JsonSchemaGenerator.Builder#useBeanDefs()
546    * @return
547    *    <jk>true</jk> if schemas on beans will be serialized with <js>'$ref'</js> tags.
548    */
549   protected final boolean isUseBeanDefs() { return ctx.isUseBeanDefs(); }
550}