tapir icon indicating copy to clipboard operation
tapir copied to clipboard

[Feature] deriving encodedNames from an ordered list

Open decoursin opened this issue 1 year ago • 8 comments

I think it would be nice if we can do something like this:

case class SomeThing(data: String, typeStr: String, uid: String, )

implicit lazy val sSomeThing: Schema[SomeThing] = Schema.derivedWithEncodedTypes3("data", "type", "id")

Similar is being done by Circe here:

import io.circe.{ Decoder, Encoder }

case class User(id: Long, firstName: String, lastName: String)

implicit val decodeUser: Decoder[User] = Decoder.forProduct3("id", "first_name", "last_name")(User.apply)
implicit val encodeUser: Encoder[User] = Encoder.forProduct3("id", "first_name", "last_name")(u => (u.id, u.firstName, u.lastName))

Alternatively, using a list, instead of having to specify the number of arguments, would be even better, like this:

val SomeThingList = List("data", "type", "id")

implicit lazy val sSomeThing: Schema[SomeThing] = Schema.derivedWithEncodedTypes3("data", "type", "id")

Perhaps something like this, which seems to work for me:

def makeSchema[T](schema: Schema[T], list: List[String]) = {
  val s = schema.schemaType match {
    case s @ SProduct(_) =>
      s.copy(fields = s.fields.mapWithIndex { case (field, i) => SProductField[T, field.FieldType](field.name.copy(encodedName = list.get(i).get), field.schema, field.get) })
    case x => x
  }
  schema.copy(schemaType = s)
}

and used like this:

makeSchema(Schema.derived[SomeThing], List("data", "id", "type")

decoursin avatar Jun 06 '23 09:06 decoursin

Hi @decoursin, thank your for submitting the idea. I think Circe uses specific number of arguments, because they don't want to allow unsafe executions. The list.get(i).get call can throw an exception, and we also would like to avoid it in tapir. Could you describe your use case, where you need such custom name derivation?

kciesielski avatar Jun 12 '23 06:06 kciesielski

BTW how about using @encodedName? https://tapir.softwaremill.com/en/latest/endpoint/schemas.html?highlight=encodedName#customising-derived-schemas

kciesielski avatar Jun 12 '23 06:06 kciesielski

Hi @kciesielski thanks for your comments. The problem essentially stems from the fact that derivations (or json conversions, however you want to call them) are being done twice.

When I'm writing a backend API, of course, I absolutely want my API documentation to be perfectly inline with the real json encoder/decoders. Any discrepancy can cause huge problems: imagine a 3rd party App Developer who is trying to build an App against your backend by only using the API documentation, and their's misalignment with the documentation and the real json encoding/decoding - that could cause serious problems.

#2923 would obviously be the ideal solution, but unfortunately both it's not completed yet and it would only pertain to one json library.

Imagine if I could do this:

implicit val sUser: Schema[User]      = Schema.deriveByType3("id", "first_name", "last_name")
implicit val decodeUser: Decoder[User] = Decoder.forProduct3("id", "first_name", "last_name")(User.apply)
implicit val encodeUser: Encoder[User] = Encoder.forProduct3("id", "first_name", "last_name")(u => (u.id, u.firstName, u.lastName))

When it's coded like that ^, I can immediately see with my eyes that the json-specific-library encoding/decodings are exactly the same as the Schema ones. This would be ideal, and only #2923 would be a better solution in my opinion.

The annotation @encodedName is also a valid solution, but it's inferior in my opinion because when you have hundreds of case classes with some having 20 parameters, it's far more tedious and uncertain to know if the Schema and json encoders/decoders are exactly the same, and this creates a much greater chance of mistake. Here below is for a visual comparison:

implicit val decodeUser: Decoder[User] = Decoder.forProduct3("id", "first_name", "last_name")(User.apply)
implicit val encodeUser: Encoder[User] = Encoder.forProduct3("id", "first_name", "last_name")(u => (u.id, u.firstName, u.lastName))
case class User(id: String, @encodedName("first_name") firstName: Option[String] = None, @encodedName("last_name") lastName: Option[String] = None)

and @encodedName intermixes business code with documentation - it's nice to have these separate, and ruins the nice, clean bare case class User(...).

decoursin avatar Jun 17 '23 08:06 decoursin

@decoursin I think that there's no reason why we couldn't support this as an additional option for customising schemas. I'm just thinking about naming - we would probably need a general private macro accepting a list of field names, plus arity-specific methods. We've got derived as the default name, so probably something along these lines. A verbose option, like derivedUsingFieldNames3 or derivedWithFieldNames3 or derivedProduct3, wdyt?

There's also an even more general option, to provide the metadata for each field (such as deprecation, default values, validators etc.). This could have the signature e.g. derivedUsing3(sa1: SchemaAnnotations[_], sa2: SchemaAnnotations[_], sa3: SchemaAnnotations[_]) or derivedProduct3(...). That way we could give a way to provide all of the available metadata externally. We would have to provide a nice way to construct the SchemaAnnotations instances.

Of course, this could still be achieved using schema customisation, as desribed here, so using .modify, so maybe it's not worth the trouble. Probably metadata other than custom names are comparatively rare so writing sth like Schema.derivedProduct3(...).modify(_.firstName)(_.description(...)) would be sufficient maybe?

adamw avatar Jun 22 '23 19:06 adamw

Of course, this could still be achieved using schema customisation, as desribed here, so using .modify, so maybe it's not worth the trouble. Probably metadata other than custom names are comparatively rare so writing sth like Schema.derivedProduct3(...).modify(.firstName)(.description(...)) would be sufficient maybe?

I would agree with that statement. I think the @encodedNames is the most important, and as long as there's any another way for users to add their additional schema modifications, like by using modify afterwards that would be plenty good.

I'm just thinking about naming - we would probably need a general private macro accepting a list of field names, plus arity-specific methods. We've got derived as the default name, so probably something along these lines. A verbose option, like derivedUsingFieldNames3 or derivedWithFieldNames3 or derivedProduct3, wdyt?

It's difficult for me to say, I would be happy with any naming choice you make. I would perhaps stay away from Product3 because it's a little be too vague for this scenario, but it's up to you. Perhaps even derivedWithName3 or else derivedWithFieldName3.

Thanks!

decoursin avatar Jun 24 '23 08:06 decoursin

Also, if I can help at all with this feature, please do let me know. The coding might be a little bit outside of my abilities, but I'm happy to try. I can definitely do the documentation if you'd like

decoursin avatar Jun 24 '23 11:06 decoursin

derivedWithFieldNames it is ;)

If you would like to try working on this, please do :) Macros aren't that scary, esp in Scala 3.

adamw avatar Jun 24 '23 13:06 adamw

I definitely don't have time to do Macros in Scala 3 lol, sorry. I haven't touched Scala 3 yet, and know nothing about Macros. That would take me too long given other things I need to get done. If it sits around though for a year or two I might know enough to do it then :)

decoursin avatar Jun 25 '23 16:06 decoursin