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.bean.mcp.v20260728; 018 019import static org.apache.juneau.commons.utils.CollectionUtils.*; 020import static org.apache.juneau.commons.utils.Shorts.*; 021 022import java.util.*; 023 024import org.apache.juneau.marshall.collections.*; 025 026/** 027 * Fluent builder for a restricted elicitation JSON Schema (MCP {@code 2026-07-28} SEP-2322), producing the 028 * {@link JsonMap} consumed by {@link ElicitRequest#setRequestedSchema(java.util.Map)}. 029 * 030 * <p> 031 * The elicitation restricted schema permits only primitive-typed, non-nested top-level properties. There is 032 * deliberately no {@code objectField}/{@code arrayField} method, so a nested/object/array-typed field is a 033 * compile-time impossibility, never a runtime-rejected one. {@link #build()} additionally rejects a handful of 034 * structurally-invalid per-field modifier combinations (for example {@link #format(String)} on a non-string 035 * field) with an {@link IllegalStateException}, so a handler author gets an immediate, in-process failure rather 036 * than a wire-level schema-validation error discovered by the client much later. 037 * 038 * <p> 039 * Each {@code xxxField(name)} call starts a new field and becomes the target of every subsequent modifier call 040 * ({@link #title(String)}, {@link #description(String)}, etc.) until the next {@code xxxField(...)} call. 041 * Re-declaring a field under a {@code name} already used by an earlier {@code xxxField(...)} call replaces that 042 * field's definition entirely, silently discarding any per-field modifiers already applied to it 043 * (last-field-wins). {@link #minLength(int)}/{@link #maxLength(int)} are additional string-only per-field 044 * modifiers, alongside {@link #format(String)}. {@link #required(String...)} is a separate, builder-level (not 045 * per-field) call that marks one or more field names required, emitting a top-level {@code "required":[...]} 046 * array; unlike the per-field modifiers, it is not tied to the most-recently-started field, is order-independent 047 * with respect to {@code xxxField(...)} (a name may be marked required before or after the field it names is 048 * (re)declared, and a redeclaration of that field does not discard the marking), and may be called any time, 049 * with an unknown name validated only at {@link #build()} time. 050 * 051 * <h5 class='section'>Example:</h5> 052 * <p class='bjava'> 053 * JsonMap <jv>schema</jv> = ElicitSchema.<jsm>create</jsm>() 054 * .booleanField(<js>"confirm"</js>).title(<js>"Confirm"</js>) 055 * .build(); 056 * </p> 057 */ 058@SuppressWarnings({ 059 "java:S1192" // Duplicated literals (property names) read more clearly inline than as constants 060}) 061public class ElicitSchema { 062 063 private static final Set<String> NUMERIC_TYPES = Set.of("number", "integer"); 064 065 private final Map<String,JsonMap> properties = map(); 066 private final Set<String> required = new LinkedHashSet<>(); 067 private String currentField; 068 069 private ElicitSchema() {} 070 071 /** 072 * Creates a new, empty schema builder. 073 * 074 * @return A new builder. Never <jk>null</jk>. 075 */ 076 public static ElicitSchema create() { 077 return new ElicitSchema(); 078 } 079 080 /** 081 * Starts a {@code string}-typed field. 082 * 083 * @param name The property name. Must not be <jk>null</jk> or blank. 084 * @return This object (for method chaining). 085 * @throws IllegalArgumentException If {@code name} is <jk>null</jk> or blank. 086 */ 087 public ElicitSchema stringField(String name) { 088 return field(name, "string"); 089 } 090 091 /** 092 * Starts a {@code number}-typed (floating-point) field. 093 * 094 * @param name The property name. Must not be <jk>null</jk> or blank. 095 * @return This object (for method chaining). 096 * @throws IllegalArgumentException If {@code name} is <jk>null</jk> or blank. 097 */ 098 public ElicitSchema numberField(String name) { 099 return field(name, "number"); 100 } 101 102 /** 103 * Starts an {@code integer}-typed field. 104 * 105 * @param name The property name. Must not be <jk>null</jk> or blank. 106 * @return This object (for method chaining). 107 * @throws IllegalArgumentException If {@code name} is <jk>null</jk> or blank. 108 */ 109 public ElicitSchema integerField(String name) { 110 return field(name, "integer"); 111 } 112 113 /** 114 * Starts a {@code boolean}-typed field. 115 * 116 * @param name The property name. Must not be <jk>null</jk> or blank. 117 * @return This object (for method chaining). 118 * @throws IllegalArgumentException If {@code name} is <jk>null</jk> or blank. 119 */ 120 public ElicitSchema booleanField(String name) { 121 return field(name, "boolean"); 122 } 123 124 /** 125 * Starts a closed-choice {@code string}-typed field restricted to {@code values} (wire shape: 126 * {@code type:"string", enum:[...]}). Pair with {@link #enumNames(String...)} for parallel display labels. 127 * 128 * @param name The property name. Must not be <jk>null</jk> or blank. 129 * @param values The allowed values. Must not be <jk>null</jk> or empty, and must not contain a 130 * <jk>null</jk> element. 131 * @return This object (for method chaining). 132 * @throws IllegalArgumentException If {@code name} is <jk>null</jk> or blank, or {@code values} is 133 * <jk>null</jk>, empty, or contains a <jk>null</jk> element. 134 */ 135 public ElicitSchema enumField(String name, String...values) { 136 reqnb("name", name); 137 if (values == null || values.length == 0) 138 throw iaex("Field ''%s'': enumField values must not be null or empty", name); 139 for (var value : values) 140 if (value == null) 141 throw iaex("Field ''%s'': enumField values must not contain a null element", name); 142 field(name, "string"); 143 properties.get(name).put("enum", List.of(values)); 144 return this; 145 } 146 147 private ElicitSchema field(String name, String type) { 148 reqnb("name", name); 149 var p = new JsonMap(); 150 p.put("type", type); 151 // Last-field-wins: a redeclared field discards its prior per-field modifiers (see class Javadoc). 152 // required(...) marking is a separate, builder-level set and deliberately survives a redeclaration. 153 properties.put(name, p); 154 currentField = name; 155 return this; 156 } 157 158 /** 159 * Sets the display title of the most-recently-started field. 160 * 161 * @param value The title. Must not be <jk>null</jk>. 162 * @return This object (for method chaining). 163 * @throws IllegalArgumentException If {@code value} is <jk>null</jk>. 164 */ 165 public ElicitSchema title(String value) { 166 currentProperty().put("title", reqnn("value", value)); 167 return this; 168 } 169 170 /** 171 * Sets the description of the most-recently-started field. 172 * 173 * @param value The description. Must not be <jk>null</jk>. 174 * @return This object (for method chaining). 175 * @throws IllegalArgumentException If {@code value} is <jk>null</jk>. 176 */ 177 public ElicitSchema description(String value) { 178 currentProperty().put("description", reqnn("value", value)); 179 return this; 180 } 181 182 /** 183 * Sets the string-subtype format of the most-recently-started field (for example {@code email}, {@code uri}, 184 * {@code date}, {@code date-time}). Applying this to a non-string field is rejected at {@link #build()} time. 185 * 186 * @param value The format token. Must not be <jk>null</jk>. 187 * @return This object (for method chaining). 188 * @throws IllegalArgumentException If {@code value} is <jk>null</jk>. 189 */ 190 public ElicitSchema format(String value) { 191 currentProperty().put("format", reqnn("value", value)); 192 return this; 193 } 194 195 /** 196 * Sets the minimum UTF-16 code-unit length of the most-recently-started string field. Applying this to a 197 * non-string field, to an {@link #enumField(String, String...)} (a closed-choice field, where a length 198 * constraint is meaningless), or a value that is negative or exceeds a previously-/subsequently-set 199 * {@link #maxLength(int)}, is rejected at {@link #build()} time. 200 * 201 * @param value The minimum length. 202 * @return This object (for method chaining). 203 */ 204 public ElicitSchema minLength(int value) { 205 currentProperty().put("minLength", value); 206 return this; 207 } 208 209 /** 210 * Sets the maximum UTF-16 code-unit length of the most-recently-started string field. Applying this to a 211 * non-string field, to an {@link #enumField(String, String...)} (a closed-choice field, where a length 212 * constraint is meaningless), or a value that is negative or less than a previously-/subsequently-set 213 * {@link #minLength(int)}, is rejected at {@link #build()} time. 214 * 215 * @param value The maximum length. 216 * @return This object (for method chaining). 217 */ 218 public ElicitSchema maxLength(int value) { 219 currentProperty().put("maxLength", value); 220 return this; 221 } 222 223 /** 224 * Sets the minimum value of the most-recently-started number/integer field. Applying this to a field of any 225 * other type is rejected at {@link #build()} time. 226 * 227 * @param value The minimum. Must not be <jk>null</jk>. 228 * @return This object (for method chaining). 229 * @throws IllegalArgumentException If {@code value} is <jk>null</jk>. 230 */ 231 public ElicitSchema min(Number value) { 232 currentProperty().put("minimum", reqnn("value", value)); 233 return this; 234 } 235 236 /** 237 * Sets the maximum value of the most-recently-started number/integer field. Applying this to a field of any 238 * other type is rejected at {@link #build()} time. 239 * 240 * @param value The maximum. Must not be <jk>null</jk>. 241 * @return This object (for method chaining). 242 * @throws IllegalArgumentException If {@code value} is <jk>null</jk>. 243 */ 244 public ElicitSchema max(Number value) { 245 currentProperty().put("maximum", reqnn("value", value)); 246 return this; 247 } 248 249 /** 250 * Sets the default value of the most-recently-started field. 251 * 252 * <p> 253 * Named {@code defaultValue}, not {@code default} (a reserved Java keyword) — the wire schema property 254 * this writes is still literally {@code "default"}. 255 * 256 * @param value The default value. Can be <jk>null</jk>. 257 * @return This object (for method chaining). 258 */ 259 public ElicitSchema defaultValue(Object value) { 260 currentProperty().put("default", value); 261 return this; 262 } 263 264 /** 265 * Sets parallel display labels for the most-recently-started {@link #enumField(String, String...)}'s values. 266 * Applying this to a field with no {@code enum} values is rejected at {@link #build()} time. 267 * 268 * @param values The display labels, parallel in order to the field's {@code enum} values. Must not be 269 * <jk>null</jk> or empty, must not contain a <jk>null</jk> element, and (when the current field already 270 * carries {@code enum} values) must be the same length as those values. 271 * @return This object (for method chaining). 272 * @throws IllegalArgumentException If {@code values} is <jk>null</jk>, empty, or contains a <jk>null</jk> 273 * element, or if its length does not match the current field's existing {@code enum} values' length. 274 */ 275 public ElicitSchema enumNames(String...values) { 276 if (values == null || values.length == 0) 277 throw iaex("enumNames values must not be null or empty"); 278 for (var value : values) 279 if (value == null) 280 throw iaex("enumNames values must not contain a null element"); 281 var p = currentProperty(); 282 var enumValues = p.getList("enum"); 283 if (enumValues != null && enumValues.size() != values.length) 284 throw iaex("enumNames length (%s) must match enum length (%s)", values.length, enumValues.size()); 285 p.put("enumNames", List.of(values)); 286 return this; 287 } 288 289 /** 290 * Marks one or more already-added field names required, contributing to the built schema's top-level 291 * {@code "required":[...]} array. 292 * 293 * <p> 294 * Unlike the per-field modifiers ({@link #title(String)}, etc.), this is a builder-level, not per-field, 295 * call — it is not tied to the most-recently-started field and may name any field(s) already added via 296 * an earlier {@code xxxField(...)} call, in any order. May be called more than once; each call adds to the 297 * previously-marked names (not replaced). Naming a field not (yet) added is rejected at {@link #build()} 298 * time, not here, since a later {@code xxxField(...)} call may still add it. 299 * 300 * @param names The field names to mark required. Must not be <jk>null</jk> or empty, and must not contain 301 * a <jk>null</jk> element. 302 * @return This object (for method chaining). 303 * @throws IllegalArgumentException If {@code names} is <jk>null</jk>, empty, or contains a <jk>null</jk> 304 * element. 305 */ 306 public ElicitSchema required(String...names) { 307 if (names == null || names.length == 0) 308 throw iaex("required field names must not be null or empty"); 309 for (var name : names) 310 if (name == null) 311 throw iaex("required field names must not contain a null element"); 312 Collections.addAll(required, names); 313 return this; 314 } 315 316 private JsonMap currentProperty() { 317 if (currentField == null) 318 throw isex("No field has been started yet; call stringField(...)/numberField(...)/etc. first"); 319 return properties.get(currentField); 320 } 321 322 /** 323 * Validates every field's modifier combination (and the builder-level {@link #required(String...)} 324 * marking) and builds the final restricted-schema {@link JsonMap} 325 * ({@code {"type":"object","properties":{...},"required":[...]}}, the last member present only if 326 * {@link #required(String...)} was called at least once). 327 * 328 * @return The built schema, ready for {@link ElicitRequest#setRequestedSchema(java.util.Map)}. 329 * @throws IllegalStateException If no field was ever added, if any field carries a structurally-invalid 330 * modifier combination, or if {@link #required(String...)} named a field that was never added. 331 */ 332 public JsonMap build() { 333 if (properties.isEmpty()) 334 throw isex("ElicitSchema requires at least one field (an empty requestedSchema is unanswerable)"); 335 properties.forEach(this::validateField); 336 required.forEach(this::validateRequiredName); 337 var schema = new JsonMap(); 338 schema.put("type", "object"); 339 // Deep-copy each per-field map so a caller who keeps the builder and applies further modifiers after 340 // build() cannot mutate an already-returned schema. 341 var propsCopy = new JsonMap(); 342 properties.forEach((name, p) -> propsCopy.put(name, new JsonMap(p))); 343 schema.put("properties", propsCopy); 344 if (! required.isEmpty()) 345 schema.put("required", List.copyOf(required)); 346 return schema; 347 } 348 349 /** 350 * Validates a single field's modifier combination. 351 * 352 * @param name The field name (used only for the exception message). 353 * @param p The field's per-property {@link JsonMap}. 354 * @throws IllegalStateException If the field carries a structurally-invalid modifier combination. 355 */ 356 private void validateField(String name, JsonMap p) { 357 var type = (String)p.get("type"); 358 validateFormat(name, p, type); 359 validateMinMax(name, p, type); 360 validateLength(name, p, type); 361 validateEnumNames(name, p); 362 } 363 364 private void validateFormat(String name, JsonMap p, String type) { 365 if (p.containsKey("format") && neq(type, "string")) 366 throw isex("Field ''%s'': format is only valid on string fields", name); 367 } 368 369 private void validateMinMax(String name, JsonMap p, String type) { 370 if ((p.containsKey("minimum") || p.containsKey("maximum")) && ! NUMERIC_TYPES.contains(type)) 371 throw isex("Field ''%s'': min/max are only valid on number/integer fields", name); 372 } 373 374 private void validateLength(String name, JsonMap p, String type) { 375 if ((p.containsKey("minLength") || p.containsKey("maxLength")) && neq(type, "string")) 376 throw isex("Field ''%s'': minLength/maxLength are only valid on string fields", name); 377 if ((p.containsKey("minLength") || p.containsKey("maxLength")) && p.containsKey("enum")) 378 throw isex("Field ''%s'': minLength/maxLength are not valid on an enum (closed-choice) field", name); 379 var minLength = p.getInt("minLength"); 380 var maxLength = p.getInt("maxLength"); 381 if (nn(minLength) && minLength < 0) 382 throw isex("Field ''%s'': minLength must not be negative", name); 383 if (nn(maxLength) && maxLength < 0) 384 throw isex("Field ''%s'': maxLength must not be negative", name); 385 if (nn(minLength) && nn(maxLength) && minLength > maxLength) 386 throw isex("Field ''%s'': minLength must not exceed maxLength", name); 387 } 388 389 private void validateEnumNames(String name, JsonMap p) { 390 if (p.containsKey("enumNames") && ! p.containsKey("enum")) 391 throw isex("Field ''%s'': enumNames requires enum values (use enumField(...))", name); 392 } 393 394 private void validateRequiredName(String name) { 395 if (! properties.containsKey(name)) 396 throw isex("required(...) names field ''%s'', which was never added", name); 397 } 398}