Repository: cjbooms/fabrikt (kotlin)

Issue:
allOf with single $ref leads to Any
Code generation for the following example is correct (return types correctly typed as `Result`):

```yml
openapi: 3.0.0
paths:
  /test:
    get:
      operationId: test
      responses:
        '200':
          description: Operation successful
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Result'
components:
  schemas:
    Result:
      allOf:
        - $ref: '#/components/schemas/Base'
        - type: object
          properties:
            bar:
              type: string
    Base:
      properties:
        foo:
          type: string
```

However, when `Result` is reduced to just this, the return type in both the client and the controller is suddenly `Any`:

```yml
    Result:
      allOf:
        - $ref: '#/components/schemas/Base'
```

I think an `allOf` with a single item should be treated the same as a direct `$ref`.

Pull request description:
Force type to be object if any properties are defined
Closes #121

Patch:
diff --git a/src/main/kotlin/com/cjbooms/fabrikt/model/SourceApi.kt b/src/main/kotlin/com/cjbooms/fabrikt/model/SourceApi.kt
index 8153fce5..5d6d82ef 100644
--- a/src/main/kotlin/com/cjbooms/fabrikt/model/SourceApi.kt
+++ b/src/main/kotlin/com/cjbooms/fabrikt/model/SourceApi.kt
@@ -8,6 +8,7 @@ import com.reprezen.kaizen.oasparser.model3.OpenApi3
 import com.reprezen.kaizen.oasparser.model3.Schema
 import java.nio.file.Path
 import java.nio.file.Paths
+import java.util.logging.Logger
 
 data class SchemaInfo(val name: String, val schema: Schema) {
     val typeInfo: KotlinTypeInfo = KotlinTypeInfo.from(schema, name)
@@ -18,6 +19,8 @@ data class SourceApi(
     val baseDir: Path = Paths.get("").toAbsolutePath()
 ) {
     companion object {
+        private val logger = Logger.getGlobal()
+
         fun create(
             baseApi: String,
             apiFragments: Collection<String>,
@@ -61,7 +64,10 @@ data class SourceApi(
                 errors + listOf(ValidationError("The $name object contains invalid use of both properties and `oneOf`."))
             else if (schema.type == OasType.Object.type && schema.oneOfSchemas?.isNotEmpty() == true)
                 errors + listOf(ValidationError("The $name object contains invalid use of both properties and `oneOf`."))
-            else errors
+            else if (schema.type == null && schema.properties?.isNotEmpty() == true) {
+                logger.warning("Schema '$name' has 'type: null' but defines properties. Assuming: 'type: object'")
+                errors
+            } else errors
         }
 
         return api.schemas.map { it.value.properties }.flatMap { it.entries }
diff --git a/src/main/kotlin/com/cjbooms/fabrikt/util/KaizenParserExtensions.kt b/src/main/kotlin/com/cjbooms/fabrikt/util/KaizenParserExtensions.kt
index 3a6d7b04..5b6199c3 100644
--- a/src/main/kotlin/com/cjbooms/fabrikt/util/KaizenParserExtensions.kt
+++ b/src/main/kotlin/com/cjbooms/fabrikt/util/KaizenParserExtensions.kt
@@ -175,14 +175,21 @@ object KaizenParserExtensions {
     fun Schema.safeType(): String? =
         when {
             type != null -> type
+            properties?.isNotEmpty() == true -> "object"
+            allOfSchemas.hasAnyDefinedProperties() -> "object"
             allOfSchemas?.firstOrNull { it.type != null } != null -> allOfSchemas.first { it.type != null }.type
+            oneOfSchemas.hasAnyDefinedProperties() -> "object"
             oneOfSchemas?.firstOrNull { it.type != null } != null -> oneOfSchemas.first { it.type != null }.type
+            anyOfSchemas.hasAnyDefinedProperties() -> "object"
             anyOfSchemas?.firstOrNull { it.type != null } != null -> anyOfSchemas.first { it.type != null }.type
             isOneOfPolymorphicTypes() -> "object"
             isUnknownAdditionalProperties("") -> "object"
             else -> null
         }
 
+    private fun List<Schema>?.hasAnyDefinedProperties(): Boolean =
+        this?.any { it.properties?.isNotEmpty() == true } == true
+
     fun Schema.isOneOfPolymorphicTypes() =
         this.oneOfSchemas?.firstOrNull()?.allOfSchemas?.firstOrNull() != null
 


Test patch:
diff --git a/src/test/kotlin/com/cjbooms/fabrikt/generators/ModelGeneratorTest.kt b/src/test/kotlin/com/cjbooms/fabrikt/generators/ModelGeneratorTest.kt
index beaba7d3..d3707386 100644
--- a/src/test/kotlin/com/cjbooms/fabrikt/generators/ModelGeneratorTest.kt
+++ b/src/test/kotlin/com/cjbooms/fabrikt/generators/ModelGeneratorTest.kt
@@ -42,7 +42,8 @@ class ModelGeneratorTest {
         "polymorphicModels",
         "requiredReadOnly",
         "validationAnnotations",
-        "wildCardTypes"
+        "wildCardTypes",
+        "singleAllOf"
     )
 
     @BeforeEach
diff --git a/src/test/kotlin/com/cjbooms/fabrikt/generators/SpringControllerGeneratorTest.kt b/src/test/kotlin/com/cjbooms/fabrikt/generators/SpringControllerGeneratorTest.kt
index fc13c008..2b44a066 100644
--- a/src/test/kotlin/com/cjbooms/fabrikt/generators/SpringControllerGeneratorTest.kt
+++ b/src/test/kotlin/com/cjbooms/fabrikt/generators/SpringControllerGeneratorTest.kt
@@ -29,7 +29,8 @@ class SpringControllerGeneratorTest {
 
     @Suppress("unused")
     private fun testCases(): Stream<String> = Stream.of(
-        "githubApi"
+        "githubApi",
+        "singleAllOf"
     )
 
     private fun setupGithubApiTestEnv() {
@@ -42,6 +43,24 @@ class SpringControllerGeneratorTest {
         MutableSettings.updateSettings(setOf(CodeGenerationType.CONTROLLERS), emptySet(), emptySet(), emptySet())
     }
 
+    // @Test
+    // fun `debug single test`() = `correct models are generated for different OpenApi Specifications`("singleAllOf")
+
+    @ParameterizedTest
+    @MethodSource("testCases")
+    fun `correct models are generated for different OpenApi Specifications`(testCaseName: String) {
+        val basePackage = "examples.$testCaseName"
+        val api = SourceApi(readTextResource("/examples/$testCaseName/api.yaml"))
+        val expectedControllers = readTextResource("/examples/$testCaseName/controllers/Controllers.kt")
+
+        val controllers = SpringControllerInterfaceGenerator(
+            Packages(basePackage),
+            api
+        ).generate().toSingleFile()
+
+        assertThat(controllers).isEqualTo(expectedControllers)
+    }
+
     @Test
     fun `should contain correct number of controller classes`() {
         setupGithubApiTestEnv()
@@ -139,21 +158,6 @@ class SpringControllerGeneratorTest {
         ).isTrue()
     }
 
-    @ParameterizedTest
-    @MethodSource("testCases")
-    fun `correct models are generated for different OpenApi Specifications`(testCaseName: String) {
-        val basePackage = "examples.$testCaseName"
-        val api = SourceApi(readTextResource("/examples/$testCaseName/api.yaml"))
-        val expectedControllers = readTextResource("/examples/$testCaseName/controllers/Controllers.kt")
-
-        val controllers = SpringControllerInterfaceGenerator(
-            Packages(basePackage),
-            api
-        ).generate().toSingleFile()
-
-        assertThat(controllers).isEqualTo(expectedControllers)
-    }
-
     private fun Controllers.toSingleFile(): String {
         val destPackage = if (controllers.isNotEmpty()) controllers.first().destinationPackage else ""
         val singleFileBuilder = FileSpec.builder(destPackage, "dummyFilename")
diff --git a/src/test/resources/examples/singleAllOf/api.yaml b/src/test/resources/examples/singleAllOf/api.yaml
new file mode 100644
index 00000000..3ed88e8a
--- /dev/null
+++ b/src/test/resources/examples/singleAllOf/api.yaml
@@ -0,0 +1,22 @@
+openapi: 3.0.0
+paths:
+  /test:
+    get:
+      operationId: test
+      responses:
+        '200':
+          description: Operation successful
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Result'
+components:
+  schemas:
+    Result:
+      allOf:
+        - $ref: '#/components/schemas/Base'
+
+    Base:
+      properties:
+        foo:
+          type: string
\ No newline at end of file
diff --git a/src/test/resources/examples/singleAllOf/controllers/Controllers.kt b/src/test/resources/examples/singleAllOf/controllers/Controllers.kt
new file mode 100644
index 00000000..906f5571
--- /dev/null
+++ b/src/test/resources/examples/singleAllOf/controllers/Controllers.kt
@@ -0,0 +1,23 @@
+package examples.singleAllOf.controllers
+
+import examples.singleAllOf.models.Result
+import org.springframework.http.ResponseEntity
+import org.springframework.stereotype.Controller
+import org.springframework.validation.annotation.Validated
+import org.springframework.web.bind.annotation.RequestMapping
+import org.springframework.web.bind.annotation.RequestMethod
+
+@Controller
+@Validated
+@RequestMapping("")
+interface TestController {
+    /**
+     *
+     */
+    @RequestMapping(
+        value = ["/test"],
+        produces = ["application/json"],
+        method = [RequestMethod.GET]
+    )
+    fun test(): ResponseEntity<Result>
+}
diff --git a/src/test/resources/examples/singleAllOf/models/Models.kt b/src/test/resources/examples/singleAllOf/models/Models.kt
new file mode 100644
index 00000000..62d7ee4e
--- /dev/null
+++ b/src/test/resources/examples/singleAllOf/models/Models.kt
@@ -0,0 +1,16 @@
+package examples.singleAllOf.models
+
+import com.fasterxml.jackson.annotation.JsonProperty
+import kotlin.String
+
+data class Base(
+    @param:JsonProperty("foo")
+    @get:JsonProperty("foo")
+    val foo: String? = null
+)
+
+data class Result(
+    @param:JsonProperty("foo")
+    @get:JsonProperty("foo")
+    val foo: String? = null
+)