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}