zio / zio-http

A next-generation Scala framework for building scalable, correct, and efficient HTTP clients and servers
https://zio.dev/zio-http
Apache License 2.0
787 stars 396 forks source link

[gen] add support for type aliases (newtype / opaque type) when generating code from spec #2962

Closed hochgi closed 1 month ago

hochgi commented 2 months ago

Is your feature request related to a problem? Please describe.

In business logic code we want to use Newtype to make sure we don't mix up different fields of the same type. Generated code is great, but all primitives can still be mixed up. This is especially true when we don't have a "stable source", e.g. when an openapi.yaml spec is retrieved during build from a live service, which can change.

consider a simple example like:

components:
  schemas:
    Person:
      type: object
      properties:
        firstName:
          type: string
        lastName:
          type: string

generated class would be:

case class Person(
  firstName: String,
  lastName: String
)

and example code usage might be:

val johnDoe = Person("John", "Doe")

but a simple change in the order of fields in yaml, or a field rename may result in the generated class to look different, e.g:

case class Person(
  surname: String,
  firstName: String
)

which will cause our code to still compile, but be wrong:

val johnDoe = Person("John", "Doe")

code didn't change, but now "John" is the surname, and "Doe" is the first name.

Describe the solution you'd like

Ideally, we control the OpenAPI spec, and can define it "safer", in a way that enables generated code to use safe type aliases. e.g. by defining aliases directly, and using refs , e.g:

components:
  schemas:
    FirstName:
      type: string
    LastName:
      type: string
    Person:
      type: object
      properties:
        firstName:
          $ref: '#/components/schemas/FirstName'
        lastName:
          $ref: '#/components/schemas/LastName'

such definition can be picked up by zio-http-gen to render FirstName & LastName as e.g. prelude Newtypes:

// file FirstName.scala
object FirstName extends Newtype[String] {
  given schema: ZSchema[FirstName] = derive[ZSchema]
}
type FirstName = FirstName.Type
// file LastName.scala
object LastName extends Newtype[String] {
  given schema: ZSchema[LastName] = derive[ZSchema]
}
type LastName = LastName.Type
// file Person.scala
case class Person(
  firstName: FirstName,
  lastName: LastName
)

Alternatively, we can consider some x-codegen annotations as described here. e.g:

components:
  schemas:
    Person:
      type: object
      properties:
        firstName:
          type: string
          x-codegen-type-alias: FirstName
        lastName:
          type: string
          x-codegen-type-alias: LastName

to achieve a similar goal.

Describe alternatives you've considered

What's currently possible, if we're being very meticulous, we can still avoid the issue of silent breakage if we keep a "manual" safe copy of the classes with safe Newtype aliases. We then can use Schema.migrate to help us transform all safe instances to the generated ones, and vice versa. While this works (if we are careful to always use named fields - thus code won't compile when changes like the one mentioned above are introduced), it is still inferior to an ideal solution when we generate safe code to begin with.

Additional context

Using a made up annotation like x-codegen-type-alias would work, but is "local" to the component using it. If we have several components that "need" the same semantics over a primitive, it makes sense to make it "global". Relying on "same named type" might be ambiguous. Consider a type alias like ID. It can mean a person ID number, or order ID, etc'… using a ref makes it clear different components use the SAME semantics of the field. Perhaps an annotation like x-codegen-type-alias can be considered in the future for non-opaque type aliases?

Perhaps the feature would be configurable. I think that by default primitives should generate Newtype aliases. The configuration can control whether to generate prelude Newtypes, regular scala 3 opaque types, opt-out aliasing altogether to render the primitive type directly, or anything else we might want to enable (e.g. 3rd party support for libs like neotype)

jdegoes commented 2 months ago

/bounty $250

algora-pbc[bot] commented 2 months ago

💎 $250 bounty • ZIO

Steps to solve:

  1. Start working: Comment /attempt #2962 with your implementation plan
  2. Submit work: Create a pull request including /claim #2962 in the PR body to claim the bounty
  3. Receive payment: 100% of the bounty is received 2-5 days post-reward. Make sure you are eligible for payouts

Thank you for contributing to zio/zio-http!

Add a bounty • Share on socials

Attempt Started (GMT+0) Solution
🟢 @hochgi Jul 31, 2024, 6:49:43 PM #3002
hochgi commented 2 months ago

/attempt #2962

I'll give this a go. Going for alias via ref (the more "global" approach)

algora-pbc[bot] commented 2 months ago

💡 @hochgi submitted a pull request that claims the bounty. You can visit your bounty board to reward.

algora-pbc[bot] commented 1 month ago

🎉🎈 @hochgi has been awarded $250! 🎈🎊