softwaremill / tapir

Rapid development of self-documenting APIs
https://tapir.softwaremill.com
Apache License 2.0
1.32k stars 403 forks source link

[BUG] incorrect names for newtyped map schemas #3835

Open lgmyrek opened 1 month ago

lgmyrek commented 1 month ago

Tapir version: 1.10.8

Scala version: 2.13.14

Describe the bug both Keys and Values are incorrectly named in map schemas - most probably also in other cases

resulting docs for code example:

openapi: 3.1.0
info:
  title: test
  version: v1
paths:
  /test/1:
    put:
      operationId: test1
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/Map_Type_Type'
        required: true
      responses:
        '200':
          description: ''
        '400':
          description: 'Invalid value for: body'
          content:
            text/plain:
              schema:
                type: string
  /test/2:
    put:
      operationId: test2
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/Map_Type_Type1'
        required: true
      responses:
        '200':
          description: ''
        '400':
          description: 'Invalid value for: body'
          content:
            text/plain:
              schema:
                type: string
  /test/3:
    put:
      operationId: test3
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/Map_Type_Type2'
        required: true
      responses:
        '200':
          description: ''
        '400':
          description: 'Invalid value for: body'
          content:
            text/plain:
              schema:
                type: string
components:
  schemas:
    Map_Type_Type:
      title: Map_Type_Type
      type: object
      additionalProperties:
        type: integer
        format: int32
    Map_Type_Type1:
      title: Map_Type_Type
      type: object
      additionalProperties:
        type: integer
        format: int32
    Map_Type_Type2:
      title: Map_Type_Type
      type: object
      additionalProperties:
        type: integer
        format: int32

How to reproduce?


package com.test

import com.test.Defs._
import eu.timepit.refined.types.numeric.PosInt
import eu.timepit.refined.types.string.NonEmptyString
import io.circe.refined._
import io.circe.{Decoder, Encoder, KeyDecoder, KeyEncoder}
import io.estatico.newtype.Coercible
import io.estatico.newtype.macros.newtype
import sttp.apispec.openapi.circe.yaml.RichOpenAPI
import sttp.tapir._
import sttp.tapir.codec.newtype.TapirCodecNewType
import sttp.tapir.codec.refined.TapirCodecRefined
import sttp.tapir.docs.openapi.OpenAPIDocsInterpreter
import sttp.tapir.json.circe.jsonBody

object Test extends App  with TapirCodecRefined with TapirCodecNewType  with CirceCodecRefined with CirceNewtype {

  lazy implicit val map1Schema: Schema[Map[UserId, VeryImportantCount]] = Schema.schemaForMap[UserId, VeryImportantCount](_.value.value)
  lazy implicit val map2Schema: Schema[Map[AdminId, VeryImportantCount]] = Schema.schemaForMap[AdminId, VeryImportantCount](_.value.value)
  lazy implicit val map3Schema: Schema[Map[IntId, VeryImportantCount]] = Schema.schemaForMap[IntId, VeryImportantCount](_.value.value.toString)

  val endpoint1 = endpoint
    .put
    .name("test1")
    .in("test" / "1")
    .in(jsonBody[Map[UserId, VeryImportantCount]])

  val endpoint2 = endpoint
    .put
    .name("test2")
    .in("test" / "2")
    .in(jsonBody[Map[AdminId, VeryImportantCount]])

  val endpoint3 = endpoint
    .put
    .name("test3")
    .in("test" / "3")
    .in(jsonBody[Map[IntId, VeryImportantCount]])

  val endpoints = List(endpoint1, endpoint2, endpoint3)

  val yamlString: String = OpenAPIDocsInterpreter()
    .toOpenAPI(endpoints, "test", "v1")
    .toYaml

  println(yamlString)

}

object Defs {
  @newtype final case class UserId(value: NonEmptyString)
  @newtype final case class AdminId(value: NonEmptyString)
  @newtype final case class IntId(value: PosInt)

  @newtype final case class VeryImportantCount(value: Int)
}

trait CirceNewtype {
  implicit def coerceDecoder[R, N](
    implicit
    ev: Coercible[Decoder[R], Decoder[N]],
    R: Decoder[R],
  ): Decoder[N] = ev(R)

  implicit def coerceEncoder[R, N](
    implicit
    ev: Coercible[Encoder[R], Encoder[N]],
    R: Encoder[R],
  ): Encoder[N] = ev(R)

  implicit def coerceKeyDecoder[R, N](
    implicit
    ev: Coercible[KeyDecoder[R], KeyDecoder[N]],
    R: KeyDecoder[R],
  ): KeyDecoder[N] = ev(R)

  implicit def coerceKeyEncoder[R, N](
    implicit
    ev: Coercible[KeyEncoder[R], KeyEncoder[N]],
    R: KeyEncoder[R],
  ): KeyEncoder[N] = ev(R)

  implicit def coerceAsObjectEncoder[A, B](
    implicit
    ev: Coercible[Encoder.AsObject[A], Encoder.AsObject[B]],
    encA: Encoder.AsObject[A],
  ): Encoder.AsObject[B] = ev(Encoder.AsObject[A])
}

Additional information

kciesielski commented 3 weeks ago

@lgmyrek thanks for reporting. This one is tricky to tackle. The @newtype macro generates code like

package object Defs {
  type UserId = UserId.Type
  object UserId {
    type Repr = String
    type Base = Any { type UserId$newtype }
    trait Tag extends Any
    type Type <: Base with Tag

    def apply(x: String): UserId = x.asInstanceOf[UserId]

    implicit final class Ops$newtype(val $this$: Type) extends AnyVal {
      def value: String = $this$.asInstanceOf[String]
    }
  }
}

I simplified the inner type to String but it doesn't change the fact, that the resolved type name will be UserId.Type, which in the end results in Map_Type_Type. I tried to find a way to work around this, but so far without success. Maybe it's the newtype library that could change the Type type to something like UserIdType :thinking:

lgmyrek commented 3 weeks ago

You could try to always name the schemas for new types eg:

  implicit def namedSchemaForNewType[A, B: WeakTypeTag](
    implicit
    ev: Coercible[Schema[A], Schema[B]],
    schema: Schema[A],
  ): Schema[B] = {
    val s = symbolOf[B]

    ev(schema).name(SName(s.fullName))
  }

tho this surely needs some work to also handle newtypes with generic params and I do not know the implications of always naming those for compatibility and other schema interactions