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

Improve OpenAPI model; Add OpenAPI generator for EndpointAPI (#1498) #2470

Merged
merged 19 commits into from
Nov 29, 2023

Conversation

987Nabil
Copy link
Contributor

@987Nabil 987Nabil commented Oct 5, 2023

Todos before this is no longer draft:

  • Add more tests
  • More methods to call the OpenAPI generator that allows to hand over more meta infos and config params
  • tidy up the code

fixes #1498
/claim #1498

@jdegoes
Copy link
Member

jdegoes commented Oct 6, 2023

@987Nabil Can you fix build failures? 🙏

@987Nabil 987Nabil force-pushed the generate-open-api branch 3 times, most recently from 0198828 to 8c2f923 Compare October 10, 2023 14:06
@codecov-commenter
Copy link

codecov-commenter commented Oct 13, 2023

Codecov Report

Attention: 431 lines in your changes are missing coverage. Please review.

Comparison is base (88730b0) 64.95% compared to head (7852bc8) 64.27%.
Report is 19 commits behind head on main.

Files Patch % Lines
...n/scala/zio/http/endpoint/openapi/JsonSchema.scala 44.70% 214 Missing ⚠️
...main/scala/zio/http/endpoint/openapi/OpenAPI.scala 40.67% 105 Missing ⚠️
...n/scala/zio/http/endpoint/openapi/OpenAPIGen.scala 76.80% 87 Missing ⚠️
...p/src/main/scala/zio/http/codec/SegmentCodec.scala 52.17% 11 Missing ⚠️
...http/src/main/scala/zio/http/codec/PathCodec.scala 65.21% 8 Missing ⚠️
zio-http/src/main/scala/zio/http/codec/Doc.scala 66.66% 2 Missing ⚠️
...http/src/main/scala/zio/http/codec/HttpCodec.scala 50.00% 2 Missing ⚠️
zio-http/src/main/scala/zio/http/Status.scala 0.00% 1 Missing ⚠️
...tp/src/main/scala/zio/http/endpoint/Endpoint.scala 66.66% 1 Missing ⚠️

❗ Your organization needs to install the Codecov GitHub app to enable full functionality.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #2470      +/-   ##
==========================================
- Coverage   64.95%   64.27%   -0.69%     
==========================================
  Files         136      138       +2     
  Lines        7197     7977     +780     
  Branches     1296     1483     +187     
==========================================
+ Hits         4675     5127     +452     
- Misses       2522     2850     +328     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

@987Nabil 987Nabil force-pushed the generate-open-api branch 5 times, most recently from ba87eb1 to f0820eb Compare October 14, 2023 20:05
@987Nabil
Copy link
Contributor Author

Scala 3 build is failing due to a bug in the Schema derivation macro. I'll check this. There is already a known bug that might be related.

@987Nabil 987Nabil marked this pull request as ready for review October 22, 2023 00:22
987Nabil and others added 7 commits October 24, 2023 05:34
* Add a test of a middleware providing a context to a `Routes`

* Add a test of a middleware providing a context to a `Routes`

* scalafmt

* scalafmt
* Update sbt-github-actions to 0.18.0

* Regenerate GitHub Actions workflow

Executed command: sbt githubWorkflowGenerate
},
)

def fromZSchema(schema: Schema[_], refType: SchemaStyle = SchemaStyle.Inline): JsonSchema =
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it be beneficial to support new zio.schema.annotation.description here like #2499 ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd leave this PR as is and add it later.

Comment on lines 298 to 303
def fromZSchema(schema: Schema[_], refType: SchemaStyle = SchemaStyle.Inline): JsonSchema =
schema match {
case enum0: Schema.Enum[_] if refType != SchemaStyle.Inline && nominal(enum0).isDefined =>
JsonSchema.RefSchema(nominal(enum0, refType).get)
case enum0: Schema.Enum[_] =>
JsonSchema.Enum(enum0.cases.map(c => EnumValue.Str(c.id)))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ZIO Schema expresses ADTs as Enums, not just lists of possible values. If one of enum0 cases is not a literal / case object, then this needs to be translated into oneOf.

The id of the case should respect any caseName annotations on the schema.

Suggested change
def fromZSchema(schema: Schema[_], refType: SchemaStyle = SchemaStyle.Inline): JsonSchema =
schema match {
case enum0: Schema.Enum[_] if refType != SchemaStyle.Inline && nominal(enum0).isDefined =>
JsonSchema.RefSchema(nominal(enum0, refType).get)
case enum0: Schema.Enum[_] =>
JsonSchema.Enum(enum0.cases.map(c => EnumValue.Str(c.id)))
private def allPrimitive(enum: Schema.Enum[_]): Boolean =
enum.cases.forall(case_ => allPrimitive_(case_.schema))
@tailrec
private def allPrimitive_(schema: Schema[_]): Boolean =
schema match {
case _: Schema.Primitive[_] => true
case _: Schema.CaseClass0[_] => true
case Schema.Transform(schema, _, _, _, _) => allPrimitive_(schema)
case _ => false
}
private def enumCaseId(schema: Schema.Case[_, _]): String =
schema.annotations.collectFirst { case caseName(name) => name }.getOrElse(schema.id)
def fromZSchema(schema: Schema[_], refType: SchemaStyle = SchemaStyle.Inline): JsonSchema =
schema match {
case enum0: Schema.Enum[_] if refType != SchemaStyle.Inline && nominal(enum0).isDefined =>
JsonSchema.RefSchema(nominal(enum0, refType).get)
case enum0: Schema.Enum[_] if allPrimitive(enum0) =>
JsonSchema.Enum(enum0.cases.map { c => EnumValue.Str(enumCaseId(c)) })
case enum0: Schema.Enum[_] =>
JsonSchema
.OneOfSchema(enum0.cases.flatMap { case_ =>
case_.schema match {
case Schema.Lazy(schema0) => ; None
case schema => Some(fromZSchema(schema, refType))
}
})
.deprecated(deprecated(enum0))
// .discriminator(enum0.annotations.collectFirst { case discriminatorName(discriminator) =>
// OpenAPI.Discriminator(discriminator)
// })

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are right. I remember I had this on my list, but must have missed it. Also I missed a test for this. Idk if I will take your your code, but I'll tackle this.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@runtologist Can you tell me your reasoning behind case _: Schema.Primitive[_] => true ?
I think this should be false, since primitive schemas still accept a set of values and a fixed value.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you are right.

field.name ->
fromZSchema(field.schema, refType).deprecated(deprecated(field.schema))
})
.required(record.fields.filterNot(_.schema.isInstanceOf[Schema.Optional[_]]).map(_.name))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fields annotated as optional are still modeled as required in the openapi.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

True. I missed that one. Also transient fields should actually be filtered out. Thanks.

},
)

def fromZSchema(schema: Schema[_], refType: SchemaStyle = SchemaStyle.Inline): JsonSchema =
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO fromZSchema should be able to accumulate component schemas. For example oneOf, allOf cases should be named. If they are not named, this prohibits proper use of discriminators. Also schemas that are used in multiple places should not be expanded multiple times.

One possible approach would be to allow fromZSchema to return multiple JsonSchemas, but I'm sure there are other approaches.

Copy link
Contributor Author

@987Nabil 987Nabil Nov 7, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The case of expanding multiple times was intentionally ignored. As it does not change meaning, but is just an optimization. And also right now, we build schemas per endpoint and then accumulate. In this case, we can't be very efficient, since we don't know all Endpoints. Also I am not sure how efficient this needs to be, since I don't expect that building OpenAPI is happening more than once per application.
Actually, we could probably generate it at compile time 🤔
Your argument with the naming I don't get. Could you please give me more details? Or an example?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am referring to this section https://swagger.io/specification/#discriminator-object, where I am not sure the example for oneOf with discriminator can be modelled without naming cases. One can probably get pretty far without, but I have seen it in the past.

My concern with duplication is not about efficiency, but ergonomics. Usually the oeverall openapi spec is not for a single endpoint, but for a more complex API, where lots of types appear in multiple places. A human reader of the API will need to read a very long documents with lots of repetitions. A client that is generated from the spec will have basically the same datatype repeated lots of times under different names. If everything was expanded, such types could be generated exactly once.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I get what you mean. You want the schemas not to be inlined, but part of components, so you can use a discriminator to reference the schema. I'll impl. this

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exactly. Wonderful. :)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@runtologist Thanks for the helpful review. Maybe you could check the changes 🙂

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure. This is really a lot better! Using discriminator + references works really well. I love it. There are a few things I've noticed:

After the change caseNames on enums are not applied any more. All enum values are the name of the class/object.

Optional fields and fields with defaults are no longer listed as required, but their type is

type:
  - OriginalType
  - 'null'

AFAIU it should just be OriginalType.

Enums are still inlined instead of referenced.

Element types of arrays are still inlined instead of referenced.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Enums are still inlined instead of referenced

Element types of arrays are still inlined instead of referenced.

In the test I see something else. Maybe matter of what SchemaStyle is used. Do you have an example?

AFAIU it should just be OriginalType.

I am not so sure about that. I'll try to read specs again and think about what implications that has. Anyhow I do not see this as a show stopper, since it is not wrong. Option[Value] in json is null | Value.

After the change caseNames on enums are not applied any more. All enum values are the name of the class/object.

I'll check this

OpenAPI gen support for default values, optional and transient fields
JsonSchemas(
JsonSchema.ArrayType(Some(nested.root)),
ref,
nested.children + (nested.rootRef.get -> nested.root),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This crashes with a None.get for me.

Suggested change
nested.children + (nested.rootRef.get -> nested.root),
nested.children ++ nested.rootRef.map(_ -> nested.root),

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you give me a reproducer?

@jdegoes jdegoes merged commit 58e0594 into zio:main Nov 29, 2023
14 checks passed
@jdegoes
Copy link
Member

jdegoes commented Nov 29, 2023

@987Nabil Please submit followups in a separate PR. Thanks for all your hard work on this PR!

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

Successfully merging this pull request may close these issues.

Generate OpenAPI documentation from EndpointSpec data type
8 participants