diff --git a/spring-ai-model/src/main/java/org/springframework/ai/util/json/schema/JsonSchemaGenerator.java b/spring-ai-model/src/main/java/org/springframework/ai/util/json/schema/JsonSchemaGenerator.java index 036fca1216..e02c1b7df8 100644 --- a/spring-ai-model/src/main/java/org/springframework/ai/util/json/schema/JsonSchemaGenerator.java +++ b/spring-ai-model/src/main/java/org/springframework/ai/util/json/schema/JsonSchemaGenerator.java @@ -147,6 +147,7 @@ public static String generateForMethodInput(Method method, SchemaOption... schem if (StringUtils.hasText(parameterDescription)) { parameterNode.put("description", parameterDescription); } + applySchemaConstraints(method.getParameters()[i], parameterNode); properties.set(parameterName, parameterNode); } @@ -256,6 +257,61 @@ private static boolean isMethodParameterRequired(Method method, int index) { return null; } + /** + * Applies constraint attributes from a parameter-level {@code @Schema} annotation + * (e.g. minimum, maximum, pattern, example, allowableValues) to the generated schema + * node. Description and required/requiredMode are handled separately and are not + * re-applied here. + */ + private static void applySchemaConstraints(Parameter parameter, ObjectNode parameterNode) { + var schemaAnnotation = parameter.getAnnotation(Schema.class); + if (schemaAnnotation == null) { + return; + } + if (StringUtils.hasText(schemaAnnotation.minimum())) { + tryParseDouble(schemaAnnotation.minimum(), parameterNode, "minimum"); + } + if (StringUtils.hasText(schemaAnnotation.maximum())) { + tryParseDouble(schemaAnnotation.maximum(), parameterNode, "maximum"); + } + if (schemaAnnotation.exclusiveMinimum()) { + parameterNode.put("exclusiveMinimum", true); + } + if (schemaAnnotation.exclusiveMaximum()) { + parameterNode.put("exclusiveMaximum", true); + } + if (schemaAnnotation.minLength() > 0) { + parameterNode.put("minLength", schemaAnnotation.minLength()); + } + if (schemaAnnotation.maxLength() < Integer.MAX_VALUE) { + parameterNode.put("maxLength", schemaAnnotation.maxLength()); + } + if (StringUtils.hasText(schemaAnnotation.pattern())) { + parameterNode.put("pattern", schemaAnnotation.pattern()); + } + if (StringUtils.hasText(schemaAnnotation.example())) { + parameterNode.put("example", schemaAnnotation.example()); + } + if (schemaAnnotation.allowableValues().length > 0) { + var enumArray = parameterNode.putArray("enum"); + for (String value : schemaAnnotation.allowableValues()) { + enumArray.add(value); + } + } + if (schemaAnnotation.multipleOf() > 0) { + parameterNode.put("multipleOf", schemaAnnotation.multipleOf()); + } + } + + private static void tryParseDouble(String value, ObjectNode node, String fieldName) { + try { + node.put(fieldName, Double.parseDouble(value)); + } + catch (NumberFormatException ignored) { + // If the value isn't a valid number, skip it. + } + } + // Based on the method in ModelOptionsUtils. public static void convertTypeValuesToUpperCase(ObjectNode node) { if (node.isObject()) { diff --git a/spring-ai-model/src/test/java/org/springframework/ai/util/json/JsonSchemaGeneratorTests.java b/spring-ai-model/src/test/java/org/springframework/ai/util/json/JsonSchemaGeneratorTests.java index 4bedc5677e..f0cc320b8c 100644 --- a/spring-ai-model/src/test/java/org/springframework/ai/util/json/JsonSchemaGeneratorTests.java +++ b/spring-ai-model/src/test/java/org/springframework/ai/util/json/JsonSchemaGeneratorTests.java @@ -160,6 +160,32 @@ void generateSchemaForMethodWithOpenApiSchemaAnnotations() throws Exception { assertThat(schema).isEqualToIgnoringWhitespace(expectedJsonSchema); } + @Test + void generateSchemaForMethodWithOpenApiSchemaConstraints() throws Exception { + Method method = TestMethods.class.getDeclaredMethod("schemaConstraintsMethod", int.class, String.class, + String.class); + + String schema = JsonSchemaGenerator.generateForMethodInput(method); + JsonNode schemaNode = JsonParser.getJsonMapper().readTree(schema); + JsonNode properties = schemaNode.get("properties"); + + JsonNode chanceNode = properties.get("chance"); + assertThat(chanceNode.get("description").asText()).isEqualTo("Chance percentage"); + assertThat(chanceNode.get("minimum").asDouble()).isEqualTo(0.0); + assertThat(chanceNode.get("maximum").asDouble()).isEqualTo(100.0); + + JsonNode codeNode = properties.get("code"); + assertThat(codeNode.get("minLength").asInt()).isEqualTo(3); + assertThat(codeNode.get("maxLength").asInt()).isEqualTo(10); + assertThat(codeNode.get("pattern").asText()).isEqualTo("[A-Z]+"); + + JsonNode statusNode = properties.get("status"); + assertThat(statusNode.get("enum")).isNotNull(); + List enumValues = new java.util.ArrayList<>(); + statusNode.get("enum").forEach(n -> enumValues.add(n.asText())); + assertThat(enumValues).containsExactly("ACTIVE", "INACTIVE", "PENDING"); + } + @Test void generateSchemaForMethodWithObjectParam() throws Exception { Method method = TestMethods.class.getDeclaredMethod("objectParamMethod", Object.class); @@ -730,6 +756,12 @@ public void jacksonMethod( public void nullableMethod(@Nullable String username, String password) { } + public void schemaConstraintsMethod( + @Schema(description = "Chance percentage", minimum = "0", maximum = "100") int chance, + @Schema(minLength = 3, maxLength = 10, pattern = "[A-Z]+") String code, + @Schema(allowableValues = { "ACTIVE", "INACTIVE", "PENDING" }) String status) { + } + public void complexMethod(List items, TestData data, MoreTestData moreData) { }