Repository: victools/jsonschema-generator (java)

Issue:
Kotlin data class boolean properties with "is" prefix are ignored
When generating a schema for following Kotlin data class, no definition is created for the `isBool` attribute.
```kotlin
  data class TestIsBoolean(val isBool: Boolean)
```

I suspect that it has to do with some Kotlin-specific naming logic that removes the prefix in compile time.
It's still not clear to me why the attribute is ignored completely rather than defined without the prefix.
Of course, the expected behavior, in this case, would be to have a schema property called `isBool`.

I'll be happy to contribute a fix, but not sure where to start. Any idea what the root cause is and how to approach fixing it?


Pull request description:
feat: support boolean getter with method name matching field name
Closes #363

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c101815..980d06d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 ## [Unreleased]
+### `jsonschema-generator`
+#### Changed
+- consider JavaBeans API specification in getter naming convention for field names with the second character being uppercase (e.g., a field `xIndex` has the getter `getxIndex()` according to the specification)
+- allow for field names starting with `is` to have getter of the same name (e.g., a field `isBool` may have the getter `isBool()`)
+
 ### `jsonschema-module-jackson`
 #### Added
 - elevate nested properties to the parent type where members are annotated with `@JsonUnwrapped`
diff --git a/jsonschema-generator/src/main/java/com/github/victools/jsonschema/generator/FieldScope.java b/jsonschema-generator/src/main/java/com/github/victools/jsonschema/generator/FieldScope.java
index 121813f..4ac79dc 100644
--- a/jsonschema-generator/src/main/java/com/github/victools/jsonschema/generator/FieldScope.java
+++ b/jsonschema-generator/src/main/java/com/github/victools/jsonschema/generator/FieldScope.java
@@ -24,6 +24,8 @@ import com.github.victools.jsonschema.generator.impl.LazyValue;
 import java.lang.annotation.Annotation;
 import java.lang.reflect.AnnotatedType;
 import java.lang.reflect.Field;
+import java.util.HashSet;
+import java.util.Set;
 import java.util.function.Predicate;
 import java.util.stream.Stream;
 
@@ -112,14 +114,25 @@ public class FieldScope extends MemberScope<ResolvedField, Field> {
      * @return public getter from within the field's declaring class
      */
     private MethodScope doFindGetter() {
-        String capitalisedFieldName = this.getDeclaredName().substring(0, 1).toUpperCase() + this.getDeclaredName().substring(1);
-        String getterName1 = "get" + capitalisedFieldName;
-        String getterName2 = "is" + capitalisedFieldName;
+        String declaredName = this.getDeclaredName();
+        Set<String> possibleGetterNames = new HashSet<>(5);
+        // @since 4.32.0 - for a field like "xIndex" also consider "getxIndex()" as getter method (according to JavaBeans specification)
+        if (declaredName.length() > 1 && Character.isUpperCase(declaredName.charAt(1))) {
+            possibleGetterNames.add("get" + declaredName);
+            possibleGetterNames.add("is" + declaredName);
+        }
+        // common naming convention: capitalise first character and leave the rest as-is
+        String capitalisedFieldName = declaredName.substring(0, 1).toUpperCase() + declaredName.substring(1);
+        possibleGetterNames.add("get" + capitalisedFieldName);
+        possibleGetterNames.add("is" + capitalisedFieldName);
+        // @since 4.32.0 - for a field like "isBool" also consider "isBool()" as potential getter method
+        if (declaredName.startsWith("is") && declaredName.length() > 2 && Character.isUpperCase(declaredName.charAt(2))) {
+            possibleGetterNames.add(declaredName);
+        }
         ResolvedMethod[] methods = this.getDeclaringTypeMembers().getMemberMethods();
         return Stream.of(methods)
-                .filter(method -> method.getRawMember().getParameterCount() == 0)
-                .filter(ResolvedMethod::isPublic)
-                .filter(method -> method.getName().equals(getterName1) || method.getName().equals(getterName2))
+                .filter(method -> method.isPublic() && method.getRawMember().getParameterCount() == 0)
+                .filter(method -> possibleGetterNames.contains(method.getName()))
                 .findFirst()
                 .map(method -> this.getContext().createMethodScope(method, this.getDeclaringTypeMembers()))
                 .orElse(null);
diff --git a/jsonschema-generator/src/main/java/com/github/victools/jsonschema/generator/MethodScope.java b/jsonschema-generator/src/main/java/com/github/victools/jsonschema/generator/MethodScope.java
index bdc9b03..0d92bdf 100644
--- a/jsonschema-generator/src/main/java/com/github/victools/jsonschema/generator/MethodScope.java
+++ b/jsonschema-generator/src/main/java/com/github/victools/jsonschema/generator/MethodScope.java
@@ -24,7 +24,9 @@ import java.lang.annotation.Annotation;
 import java.lang.reflect.AnnotatedType;
 import java.lang.reflect.Method;
 import java.util.Arrays;
+import java.util.HashSet;
 import java.util.List;
+import java.util.Set;
 import java.util.function.Predicate;
 import java.util.stream.Collectors;
 import java.util.stream.IntStream;
@@ -129,24 +131,36 @@ public class MethodScope extends MemberScope<ResolvedMethod, Method> {
             return null;
         }
         String methodName = this.getDeclaredName();
-        String fieldName;
-        if (methodName.startsWith("get") && methodName.length() > 3 && Character.isUpperCase(methodName.charAt(3))) {
-            // ensure that the variable starts with a lower-case letter
-            fieldName = methodName.substring(3, 4).toLowerCase() + methodName.substring(4);
-        } else if (methodName.startsWith("is") && methodName.length() > 2 && Character.isUpperCase(methodName.charAt(2))) {
-            // ensure that the variable starts with a lower-case letter
-            fieldName = methodName.substring(2, 3).toLowerCase() + methodName.substring(3);
-        } else {
-            // method name does not fall into getter conventions
-            fieldName = null;
+        Set<String> possibleFieldNames = new HashSet<>(3);
+        if (methodName.startsWith("get")) {
+            if (methodName.length() > 3 && Character.isUpperCase(methodName.charAt(3))) {
+                // ensure that the variable starts with a lower-case letter
+                possibleFieldNames.add(methodName.substring(3, 4).toLowerCase() + methodName.substring(4));
+            }
+            // @since 4.32.0 - conforming with JavaBeans API specification edge case when second character in field name is in uppercase
+            if (methodName.length() > 4 && Character.isUpperCase(methodName.charAt(4))) {
+                possibleFieldNames.add(methodName.substring(3));
+            }
+        } else if (methodName.startsWith("is")) {
+            if (methodName.length() > 2 && Character.isUpperCase(methodName.charAt(2))) {
+                // ensure that the variable starts with a lower-case letter
+                possibleFieldNames.add(methodName.substring(2, 3).toLowerCase() + methodName.substring(3));
+                // since 4.32.0: a method "isBool()" is considered a possible getter for a field "isBool" as well as for "bool"
+                possibleFieldNames.add(methodName);
+            }
+            // @since 4.32.0 - conforming with JavaBeans API specification edge case when second character in field name is in uppercase
+            if (methodName.length() > 3 && Character.isUpperCase(methodName.charAt(3))) {
+                possibleFieldNames.add(methodName.substring(2));
+            }
         }
-        if (fieldName == null) {
+        if (possibleFieldNames.isEmpty()) {
+            // method name does not fall into getter conventions
             return null;
         }
         // method name matched getter conventions
         // check whether a matching field exists
         return Stream.of(this.getDeclaringTypeMembers().getMemberFields())
-                .filter(memberField -> memberField.getName().equals(fieldName))
+                .filter(memberField -> possibleFieldNames.contains(memberField.getName()))
                 .findFirst()
                 .map(field -> this.getContext().createFieldScope(field, this.getDeclaringTypeMembers()))
                 .orElse(null);


Test patch:
diff --git a/jsonschema-generator/src/test/java/com/github/victools/jsonschema/generator/FieldScopeTest.java b/jsonschema-generator/src/test/java/com/github/victools/jsonschema/generator/FieldScopeTest.java
index d3e1be9..e417403 100644
--- a/jsonschema-generator/src/test/java/com/github/victools/jsonschema/generator/FieldScopeTest.java
+++ b/jsonschema-generator/src/test/java/com/github/victools/jsonschema/generator/FieldScopeTest.java
@@ -49,7 +49,13 @@ public class FieldScopeTest extends AbstractTypeAwareTest {
             Arguments.of("fieldWithPrivateGetter", null, null),
             Arguments.of("fieldWithPublicGetter", null, "getFieldWithPublicGetter"),
             Arguments.of("fieldWithPublicGetter", "fieldWithoutGetter", "getFieldWithPublicGetter"),
-            Arguments.of("fieldWithPublicBooleanGetter", null, "isFieldWithPublicBooleanGetter")
+            Arguments.of("fieldWithPublicBooleanGetter", null, "isFieldWithPublicBooleanGetter"),
+            Arguments.of("isFieldWithMatchingGetter", null, "isFieldWithMatchingGetter"),
+            Arguments.of("isFieldWithMatchingGetter", "fieldWithPublicGetter", "isFieldWithMatchingGetter"),
+            Arguments.of("aFieldWithJavaBeansConformingGetter", null, "getaFieldWithJavaBeansConformingGetter"),
+            Arguments.of("aFieldWithJavaBeansNonConformingGetter", null, "getAFieldWithJavaBeansNonConformingGetter"),
+            Arguments.of("aFieldWithJavaBeansConformingBooleanGetter", null, "isaFieldWithJavaBeansConformingBooleanGetter"),
+            Arguments.of("aFieldWithJavaBeansNonConformingBooleanGetter", null, "isAFieldWithJavaBeansNonConformingBooleanGetter")
         );
     }
 
@@ -107,6 +113,11 @@ public class FieldScopeTest extends AbstractTypeAwareTest {
         private int fieldWithPrivateGetter;
         private long fieldWithPublicGetter;
         private boolean fieldWithPublicBooleanGetter;
+        private boolean isFieldWithMatchingGetter;
+        private int aFieldWithJavaBeansConformingGetter;
+        private long aFieldWithJavaBeansNonConformingGetter;
+        private boolean aFieldWithJavaBeansConformingBooleanGetter;
+        private boolean aFieldWithJavaBeansNonConformingBooleanGetter;
 
         private int getFieldWithPrivateGetter() {
             return this.fieldWithPrivateGetter;
@@ -120,6 +131,26 @@ public class FieldScopeTest extends AbstractTypeAwareTest {
         public boolean isFieldWithPublicBooleanGetter() {
             return this.fieldWithPublicBooleanGetter;
         }
+
+        public boolean isFieldWithMatchingGetter() {
+            return this.isFieldWithMatchingGetter;
+        }
+
+        public int getaFieldWithJavaBeansConformingGetter() {
+            return this.aFieldWithJavaBeansConformingGetter;
+        }
+
+        public long getAFieldWithJavaBeansNonConformingGetter() {
+            return this.aFieldWithJavaBeansNonConformingGetter;
+        }
+
+        public boolean isaFieldWithJavaBeansConformingBooleanGetter() {
+            return this.aFieldWithJavaBeansConformingBooleanGetter;
+        }
+
+        public boolean isAFieldWithJavaBeansNonConformingBooleanGetter() {
+            return this.aFieldWithJavaBeansNonConformingBooleanGetter;
+        }
     }
 
     @Retention(RetentionPolicy.RUNTIME)
diff --git a/jsonschema-generator/src/test/java/com/github/victools/jsonschema/generator/MethodScopeTest.java b/jsonschema-generator/src/test/java/com/github/victools/jsonschema/generator/MethodScopeTest.java
index 51de57d..fa91e40 100644
--- a/jsonschema-generator/src/test/java/com/github/victools/jsonschema/generator/MethodScopeTest.java
+++ b/jsonschema-generator/src/test/java/com/github/victools/jsonschema/generator/MethodScopeTest.java
@@ -50,6 +50,12 @@ public class MethodScopeTest extends AbstractTypeAwareTest {
             Arguments.of("getFieldWithPublicGetter", "getFieldWithPrivateGetter", "fieldWithPublicGetter"),
             Arguments.of("isFieldWithPublicBooleanGetter", null, "fieldWithPublicBooleanGetter"),
             Arguments.of("isFieldWithPublicBooleanGetter", "isBehavingSomehow", "fieldWithPublicBooleanGetter"),
+            Arguments.of("isFieldWithMatchingPublicGetter", null, "isFieldWithMatchingPublicGetter"),
+            Arguments.of("isFieldWithMatchingPublicGetter", "isFieldWithPublicBooleanGetter", "isFieldWithMatchingPublicGetter"),
+            Arguments.of("getaFieldWithJavaBeansConformingGetter", null, "aFieldWithJavaBeansConformingGetter"),
+            Arguments.of("getAFieldWithJavaBeansNonConformingGetter", null, "aFieldWithJavaBeansNonConformingGetter"),
+            Arguments.of("isaFieldWithJavaBeansConformingBooleanGetter", null, "aFieldWithJavaBeansConformingBooleanGetter"),
+            Arguments.of("isAFieldWithJavaBeansNonConformingBooleanGetter", null, "aFieldWithJavaBeansNonConformingBooleanGetter"),
             Arguments.of("getCalculatedValue", null, null),
             Arguments.of("isBehavingSomehow", null, null),
             Arguments.of("isBehavingSomehow", "isFieldWithPublicBooleanGetter", null),
@@ -116,6 +122,11 @@ public class MethodScopeTest extends AbstractTypeAwareTest {
         private long fieldWithPublicGetter;
         @TestAnnotation
         private boolean fieldWithPublicBooleanGetter;
+        private boolean isFieldWithMatchingPublicGetter;
+        private int aFieldWithJavaBeansConformingGetter;
+        private long aFieldWithJavaBeansNonConformingGetter;
+        private boolean aFieldWithJavaBeansConformingBooleanGetter;
+        private boolean aFieldWithJavaBeansNonConformingBooleanGetter;
 
         @TestAnnotation
         private int getFieldWithPrivateGetter() {
@@ -130,6 +141,26 @@ public class MethodScopeTest extends AbstractTypeAwareTest {
             return this.fieldWithPublicBooleanGetter;
         }
 
+        public boolean isFieldWithMatchingPublicGetter() {
+            return this.isFieldWithMatchingPublicGetter;
+        }
+
+        public int getaFieldWithJavaBeansConformingGetter() {
+            return this.aFieldWithJavaBeansConformingGetter;
+        }
+
+        public long getAFieldWithJavaBeansNonConformingGetter() {
+            return this.aFieldWithJavaBeansNonConformingGetter;
+        }
+
+        public boolean isaFieldWithJavaBeansConformingBooleanGetter() {
+            return this.aFieldWithJavaBeansConformingBooleanGetter;
+        }
+
+        public boolean isAFieldWithJavaBeansNonConformingBooleanGetter() {
+            return this.aFieldWithJavaBeansNonConformingBooleanGetter;
+        }
+
         public double getCalculatedValue() {
             return 42.;
         }