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) &mdash; 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 &mdash; 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}