Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Polymorphic fields in contracts #530

Open
henrist opened this issue Nov 18, 2020 · 7 comments
Open

Polymorphic fields in contracts #530

henrist opened this issue Nov 18, 2020 · 7 comments

Comments

@henrist
Copy link

henrist commented Nov 18, 2020

As far as I can understand the current implementation of contracts has these limitations:

We have a use case where we use sealed classes to represent different models for a given field. But this cannot be used in the API since we cannot produce a correct contract for it. Our current workaround is to implement it as different fields, but this causes confusing example data since in reality the example is not a valid model (businesswise) - as the fields are exclusive.

It would be nice if http4k contracts could supported polymorphic fields, at least via sealed classes, by using oneOf/anyOf from the OpenAPI spec.

Example of what I want to achieve:

import com.fasterxml.jackson.annotation.JsonTypeName
import org.http4k.contract.ContractRoute
import org.http4k.contract.contract
import org.http4k.contract.meta
import org.http4k.contract.openapi.ApiInfo
import org.http4k.contract.openapi.v3.OpenApi3
import org.http4k.core.Body
import org.http4k.core.Method
import org.http4k.core.Response
import org.http4k.core.Status
import org.http4k.core.with
import org.http4k.format.Jackson.auto
import org.http4k.server.Jetty
import org.http4k.server.asServer

data class Building(
  val owner: BuildingOwner
)

// org.http4k.contract.openapi.v3.NoFieldFound: Could not find type in Company(companyName=Abc)
// @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type")
sealed class BuildingOwner {
  @JsonTypeName("BuildingOwnerPerson")
  data class Person(
    val personName: String
  ) : BuildingOwner()

  @JsonTypeName("BuildingOwnerCompany")
  data class Company(
    val companyName: String
  ) : BuildingOwner()
}

fun buildingRoute(): ContractRoute {
  val body = Body.auto<List<Building>>().toLens()

  val variants = listOf(
    Building(
      owner = BuildingOwner.Company(companyName = "Abc")
    ),
    Building(
      owner = BuildingOwner.Person(personName = "John Doe")
    ),
  )

  val spec = "/buildings" meta {
    summary = "List of buildings"
    returning(Status.OK, body to variants)
  } bindContract Method.GET

  return spec to {
    Response(Status.OK).with(body of variants)
  }
}

val contract = contract {
  renderer = OpenApi3(
    ApiInfo("my api", "current")
  )
  descriptionPath
  routes  = buildingRoute()
}

fun main() {
  contract.asServer(Jetty()).start()
}

The example also uses JsonTypeInfo to produce a discriminator field named "type", which is not supported by http4k. Ideally it should be mapped as a discriminator field in the schema.

The schema created by the current example under 3.277 (which incorrectly only models Company as the owner type):

Expand
{
  "info": { "title": "my api", "version": "current", "description": null },
  "tags": [],
  "paths": {
    "/buildings": {
      "get": {
        "summary": "List of buildings",
        "description": null,
        "tags": [""],
        "parameters": [],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "example": [
                  { "owner": { "companyName": "Abc" } },
                  { "owner": { "personName": "John Doe" } }
                ],
                "schema": {
                  "items": {
                    "oneOf": [{ "$ref": "#/components/schemas/Building" }]
                  },
                  "example": [
                    { "owner": { "companyName": "Abc" } },
                    { "owner": { "personName": "John Doe" } }
                  ],
                  "description": null,
                  "type": "array"
                }
              }
            }
          }
        },
        "security": [],
        "operationId": "getBuildings",
        "deprecated": false
      }
    }
  },
  "components": {
    "schemas": {
      "Building": {
        "properties": {
          "owner": {
            "$ref": "#/components/schemas/Company",
            "description": null,
            "example": null
          }
        },
        "example": { "owner": { "companyName": "Abc" } },
        "description": null,
        "type": "object",
        "required": ["owner"]
      },
      "Company": {
        "properties": {
          "companyName": {
            "example": "Abc",
            "description": null,
            "type": "string"
          }
        },
        "example": { "companyName": "Abc" },
        "description": null,
        "type": "object",
        "required": ["companyName"]
      },
      "Person": {
        "properties": {
          "personName": {
            "example": "John Doe",
            "description": null,
            "type": "string"
          }
        },
        "example": { "personName": "John Doe" },
        "description": null,
        "type": "object",
        "required": ["personName"]
      }
    },
    "securitySchemes": {}
  },
  "openapi": "3.0.0"
}
@daviddenton
Copy link
Member

To enable a "oneOf" type functionality in the docs you can just register "returning" with each variant in turn.

@daviddenton
Copy link
Member

See: OpenApi3AutoTest.renders as expected.approved to see this tested

@henrist
Copy link
Author

henrist commented Nov 18, 2020

The "oneOf implementation" only works on the root level, as in a fully different object returned from the handler. My example provided shows a different use case, as in a field being polymorphic. If I'm missing anything that would solve my example I'd be happy to hear.

@daviddenton
Copy link
Member

Ah - sorry. There might be a way to implement this with some type of annotation (on the field) which then gets picked up and processed by the renderer. But it's going to be pretty complex to write - you'd need to reimplement AutoJsonToJsonSchema.

@daviddenton
Copy link
Member

As an update, we now have the ability to discriminate between multiple examples by providing a "prefix". This is limited in that it adds this prefix to all objects created in this tree (and hence there are duplicates in the schema - one for each prefixed type tree), but it does allow some expression of polymorphic examples in the generated APIs

@vojkny
Copy link
Contributor

vojkny commented Apr 10, 2023

@henrist did you resolve this somehow? would you have a full example how you solved this?

@henrist
Copy link
Author

henrist commented Apr 11, 2023

@henrist did you resolve this somehow? would you have a full example how you solved this?

I don't think we did anything specifically to solve this other than just adhering to the constraints and limiting our schema. Unfortunately I no longer have access to the code base that had this case.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

3 participants