Skip to content

Commit

Permalink
Merge branch 'main' into fix-openapi-docgen
Browse files Browse the repository at this point in the history
  • Loading branch information
mobley-trent authored Jan 7, 2025
2 parents d152cf6 + 6d9dce1 commit c255b83
Show file tree
Hide file tree
Showing 9 changed files with 352 additions and 169 deletions.
75 changes: 75 additions & 0 deletions docs/reference/openapi-gen-sbt-plugin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
---
id: openapi-gen-sbt-plugin
title: OpenAPI codegen sbt plugin
---

This plugin allows to easily generate scala source code with zio-http Endpoints from OpenAPI spec files.

## How to use

The plugin offers 2 modes of operation that can be mixed and used together:
- Generating from unmanaged static OpenAPI spec files
- Generating from managed dynamic OpenAPI spec files

in `project/plugins.sbt` add the following line:
```scala
addSbtPlugin("dev.zio" % "zio-http-sbt-codegen" % "@VERSION@") // make sure the version of the sbt plugin
// matches the version of zio-http you are using
```

in `build.sbt` enable the plugin by adding:
```scala
enablePlugins(ZioHttpCodegen)
```

### 1. Generating from unmanaged static OpenAPI spec files
Place your manually curated OpenAPI spec files (`.yml`, `.yaml`, or `.json`) in `src/main/oapi/<path as package>/<openapi spec file>`.\
That's it. No other configuration is needed for basic usage. \
Once you `compile` your project, the `zioHttpCodegenMake` task is automatically invoked, and the generated code will be placed under `target/scala-<scala_binary_version>/src_managed/main/scala`.

### 2. Generating from managed dynamic OpenAPI spec files
In this mode, you can hook into `ZIOpenApi / sourceGenerators` a task to generate OpenAPI spec file, exactly like you would do with regular `Compile / sourceGenerators` for scala source files.
You might have some OpenAPI spec files hosted on [swaggerhub](https://app.swaggerhub.com/) or a similar service,
or maybe you use services that expose OpenAPI specs via REST API, or perhaps you have a local project that can build its own spec and you want to run the spec generate command.
Whatever the scenario you're dealing with, it can be very handy to dynamically fetch/generate the latest most updated spec file, so the generated code stays up to date with any changes introduced.

Here's how you can do it:
```scala
import gigahorse.support.apachehttp.Gigahorse
import scala.concurrent.Await
import scala.concurrent.duration.DurationInt

ZIOpenApi / sourceGenerators += Def.task[Seq[File]] {
// we'll fetch a spec from https://www.petstore.dev/
// gigahorse comes builtin with sbt, but any other http client can be used
val http = Gigahorse.http(Gigahorse.config)
val request = Gigahorse.url("https://raw.githubusercontent.com/readmeio/oas-examples/main/3.0/yaml/response-http-behavior.yaml")
val response = http.run(request, Gigahorse.asString)
val content = Await.result(response, 1.minute)

// path under target/scala-<scala_bin_version>/src_managed/oapi/
// corresponds to the package where scala sources will be generated
val outFile = (ZIOpenApi / sourceManaged).value / "dev" / "petstore" / "http" / "test" / "api.yaml"
IO.write(outFile, content)

// as long the task yields a Seq[File] of valid OpenAPI spec files,
// and those files follow the path structure `src_managed/oapi/<path as package>/<openapi spec file>`,
// the plugin will pick it up, and generate the corresponding scala sources.
Seq(outFile)
}
```

## Configuration
The plugin offers a setting key which you can set to control how code is generated:
```scala
zioHttpCodegenConf := zio.http.gen.openapi.Config.default
```

## Caveats
The plugin allows you to provide multiple files.
Note that if you place multiple files in the same directory,
which means same package for the generated code - you must make sure there are no "collisions" between generated classes.
If the same class is going to be generated differently in different files, you probably want to have a different package for it.

Also, please note that the plugin relies on the file extension to determine how to parse it.
So files must have the correct extension (`.yml`, `.yaml`, or `.json`), and the content must be formatted accordingly.
114 changes: 44 additions & 70 deletions zio-http-gen/src/main/scala/zio/http/gen/openapi/EndpointGen.scala
Original file line number Diff line number Diff line change
Expand Up @@ -673,85 +673,44 @@ final case class EndpointGen(config: Config) {
} else None

@tailrec
private def schemaToPathCodec(schema: JsonSchema, openAPI: OpenAPI, name: String): Code.PathSegmentCode = {
private def schemaToPathCodec(schema: JsonSchema, openAPI: OpenAPI, name: String): Code.PathSegmentCode =
schema match {
case JsonSchema.AnnotatedSchema(s, _) => schemaToPathCodec(s, openAPI, name)
case JsonSchema.RefSchema(ref) => schemaToPathCodec(resolveSchemaRef(openAPI, ref), openAPI, name)
case JsonSchema.Integer(JsonSchema.IntegerFormat.Int32, _, _, _, _, _) =>
Code.PathSegmentCode(name = name, segmentType = Code.CodecType.Int)
case JsonSchema.Integer(JsonSchema.IntegerFormat.Int64, _, _, _, _, _) =>
Code.PathSegmentCode(name = name, segmentType = Code.CodecType.Long)
case JsonSchema.Integer(JsonSchema.IntegerFormat.Timestamp, _, _, _, _, _) =>
Code.PathSegmentCode(name = name, segmentType = Code.CodecType.Long)
case JsonSchema.String(Some(JsonSchema.StringFormat.UUID), _, _, _) =>
Code.PathSegmentCode(name = name, segmentType = Code.CodecType.UUID)
case JsonSchema.String(Some(JsonSchema.StringFormat.Date), _, _, _) =>
Code.PathSegmentCode(name = name, segmentType = Code.CodecType.LocalDate)
case JsonSchema.String(Some(JsonSchema.StringFormat.DateTime), _, _, _) =>
Code.PathSegmentCode(name = name, segmentType = Code.CodecType.Instant)
case JsonSchema.String(Some(JsonSchema.StringFormat.Time), _, _, _) =>
Code.PathSegmentCode(name = name, segmentType = Code.CodecType.LocalTime)
case JsonSchema.String(Some(JsonSchema.StringFormat.Duration), _, _, _) =>
Code.PathSegmentCode(name = name, segmentType = Code.CodecType.Duration)
case JsonSchema.String(_, _, _, _) =>
Code.PathSegmentCode(name = name, segmentType = Code.CodecType.String)
case JsonSchema.Boolean =>
Code.PathSegmentCode(name = name, segmentType = Code.CodecType.Boolean)
case JsonSchema.OneOfSchema(_) => throw new Exception("Alternative path variables are not supported")
case JsonSchema.AllOfSchema(_) => throw new Exception("Path variables must have exactly one schema")
case JsonSchema.AnyOfSchema(_) => throw new Exception("Path variables must have exactly one schema")
case JsonSchema.Number(_, _, _, _, _, _) =>
throw new Exception("Floating point path variables are currently not supported")
case JsonSchema.ArrayType(_, _, _) => throw new Exception("Array path variables are not supported")
case JsonSchema.Object(_, _, _) => throw new Exception("Object path variables are not supported")
case JsonSchema.Enum(_) => throw new Exception("Enum path variables are not supported")
case JsonSchema.Null => throw new Exception("Null path variables are not supported")
case JsonSchema.AnyJson => throw new Exception("AnyJson path variables are not supported")
case s: JsonSchema.Integer => Code.PathSegmentCode(name = name, segmentType = integerCodec(s.format))
case s: JsonSchema.String => Code.PathSegmentCode(name = name, segmentType = stringCodec(s.format))
case JsonSchema.Boolean => Code.PathSegmentCode(name = name, segmentType = Code.CodecType.Boolean)
case JsonSchema.OneOfSchema(_) => throw new Exception("Alternative path variables are not supported")
case JsonSchema.AllOfSchema(_) => throw new Exception("Path variables must have exactly one schema")
case JsonSchema.AnyOfSchema(_) => throw new Exception("Path variables must have exactly one schema")
case JsonSchema.ArrayType(_, _, _) => throw new Exception("Array path variables are not supported")
case JsonSchema.Object(_, _, _) => throw new Exception("Object path variables are not supported")
case JsonSchema.Enum(_) => throw new Exception("Enum path variables are not supported")
case JsonSchema.Null => throw new Exception("Null path variables are not supported")
case JsonSchema.AnyJson => throw new Exception("AnyJson path variables are not supported")
case _: JsonSchema.Number => throw new Exception("Floating point path variables are not supported")
}
}

@tailrec
private def schemaToQueryParamCodec(
schema: JsonSchema,
openAPI: OpenAPI,
name: String,
): Code.QueryParamCode = {
schema match {
case JsonSchema.AnnotatedSchema(s, _) =>
schemaToQueryParamCodec(s, openAPI, name)
case JsonSchema.RefSchema(ref) =>
schemaToQueryParamCodec(resolveSchemaRef(openAPI, ref), openAPI, name)
case JsonSchema.Integer(JsonSchema.IntegerFormat.Int32, _, _, _, _, _) =>
Code.QueryParamCode(name = name, queryType = Code.CodecType.Int)
case JsonSchema.Integer(JsonSchema.IntegerFormat.Int64, _, _, _, _, _) =>
Code.QueryParamCode(name = name, queryType = Code.CodecType.Long)
case JsonSchema.Integer(JsonSchema.IntegerFormat.Timestamp, _, _, _, _, _) =>
Code.QueryParamCode(name = name, queryType = Code.CodecType.Long)
case JsonSchema.String(Some(JsonSchema.StringFormat.Date), _, _, _) =>
Code.QueryParamCode(name = name, queryType = Code.CodecType.LocalDate)
case JsonSchema.String(Some(JsonSchema.StringFormat.DateTime), _, _, _) =>
Code.QueryParamCode(name = name, queryType = Code.CodecType.Instant)
case JsonSchema.String(Some(JsonSchema.StringFormat.Duration), _, _, _) =>
Code.QueryParamCode(name = name, queryType = Code.CodecType.Duration)
case JsonSchema.String(Some(JsonSchema.StringFormat.Time), _, _, _) =>
Code.QueryParamCode(name = name, queryType = Code.CodecType.LocalTime)
case JsonSchema.String(Some(JsonSchema.StringFormat.UUID), _, _, _) =>
Code.QueryParamCode(name = name, queryType = Code.CodecType.UUID)
case JsonSchema.String(_, _, _, _) =>
Code.QueryParamCode(name = name, queryType = Code.CodecType.String)
case JsonSchema.Boolean =>
Code.QueryParamCode(name = name, queryType = Code.CodecType.Boolean)
case JsonSchema.OneOfSchema(_) => throw new Exception("Alternative query parameters are not supported")
case JsonSchema.AllOfSchema(_) => throw new Exception("Query parameters must have exactly one schema")
case JsonSchema.AnyOfSchema(_) => throw new Exception("Query parameters must have exactly one schema")
case JsonSchema.Number(_, _, _, _, _, _) =>
throw new Exception("Floating point query parameters are currently not supported")
case JsonSchema.ArrayType(_, _, _) => throw new Exception("Array query parameters are not supported")
case JsonSchema.Object(_, _, _) => throw new Exception("Object query parameters are not supported")
case JsonSchema.Enum(_) => throw new Exception("Enum query parameters are not supported")
case JsonSchema.Null => throw new Exception("Null query parameters are not supported")
case JsonSchema.AnyJson => throw new Exception("AnyJson query parameters are not supported")
}
): Code.QueryParamCode = schema match {
case JsonSchema.AnnotatedSchema(s, _) => schemaToQueryParamCodec(s, openAPI, name)
case JsonSchema.RefSchema(ref) => schemaToQueryParamCodec(resolveSchemaRef(openAPI, ref), openAPI, name)
case JsonSchema.Boolean => Code.QueryParamCode(name = name, queryType = Code.CodecType.Boolean)
case s: JsonSchema.Integer => Code.QueryParamCode(name = name, queryType = integerCodec(s.format))
case s: JsonSchema.String => Code.QueryParamCode(name = name, queryType = stringCodec(s.format))
case _: JsonSchema.Number => throw new Exception("Floating point query parameters are not supported")
case JsonSchema.OneOfSchema(_) => throw new Exception("Alternative query parameters are not supported")
case JsonSchema.AllOfSchema(_) => throw new Exception("Query parameters must have exactly one schema")
case JsonSchema.AnyOfSchema(_) => throw new Exception("Query parameters must have exactly one schema")
case JsonSchema.ArrayType(_, _, _) => throw new Exception("Array query parameters are not supported")
case JsonSchema.Object(_, _, _) => throw new Exception("Object query parameters are not supported")
case JsonSchema.Enum(_) => throw new Exception("Enum query parameters are not supported")
case JsonSchema.Null => throw new Exception("Null query parameters are not supported")
case JsonSchema.AnyJson => throw new Exception("AnyJson query parameters are not supported")
}

private def fieldsOfObject(openAPI: OpenAPI, annotations: Chunk[JsonSchema.MetaData])(
Expand Down Expand Up @@ -1042,6 +1001,7 @@ final case class EndpointGen(config: Config) {
properties.map { case (name, schema) => name -> schema.withoutAnnotations }.collect {
case (name, schema)
if !schema.isInstanceOf[JsonSchema.RefSchema]
&& !(schema == JsonSchema.AnyJson)
&& !schema.isPrimitive
&& !schema.isCollection =>
schemaToCode(schema, openAPI, name.capitalize, Chunk.empty)
Expand Down Expand Up @@ -1093,7 +1053,7 @@ final case class EndpointGen(config: Config) {
),
)
case JsonSchema.Null => throw new Exception("Null query parameters are not supported")
case JsonSchema.AnyJson => throw new Exception("AnyJson query parameters are not supported")
case JsonSchema.AnyJson => None
}
}

Expand Down Expand Up @@ -1378,4 +1338,18 @@ final case class EndpointGen(config: Config) {
}
}

private def integerCodec(format: JsonSchema.IntegerFormat): Code.CodecType = format match {
case JsonSchema.IntegerFormat.Int32 => Code.CodecType.Int
case JsonSchema.IntegerFormat.Int64 => Code.CodecType.Long
case JsonSchema.IntegerFormat.Timestamp => Code.CodecType.Long
}

private def stringCodec(format: Option[JsonSchema.StringFormat]): Code.CodecType = format match {
case Some(JsonSchema.StringFormat.Date) => Code.CodecType.LocalDate
case Some(JsonSchema.StringFormat.DateTime) => Code.CodecType.Instant
case Some(JsonSchema.StringFormat.Duration) => Code.CodecType.Duration
case Some(JsonSchema.StringFormat.Time) => Code.CodecType.LocalTime
case Some(JsonSchema.StringFormat.UUID) => Code.CodecType.UUID
case _ => Code.CodecType.String
}
}
5 changes: 3 additions & 2 deletions zio-http-gen/src/main/scala/zio/http/gen/scala/CodeGen.scala
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@ import java.nio.charset.StandardCharsets
import java.nio.file.StandardOpenOption._
import java.nio.file._

import scala.util.matching.Regex

object CodeGen {

private val EndpointImports =
Expand Down Expand Up @@ -265,6 +263,9 @@ object CodeGen {
case Code.TypeRef(name) =>
Nil -> name

case Code.ScalaType.JsonAST =>
List(Code.Import("zio.json.ast.Json")) -> "Json"

case scalaType =>
throw new Exception(s"Unknown ScalaType: $scalaType")
}
Expand Down
14 changes: 14 additions & 0 deletions zio-http-gen/src/test/resources/AnimalWithAny.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package test.component

import zio.json.ast.Json
import zio.schema._
import zio.schema.annotation.fieldName

case class Animal(
name: String,
eats: Json,
@fieldName("extra_attributes") extraAttributes: Map[String, Json],
)
object Animal {
implicit val codec: Schema[Animal] = DeriveSchema.gen[Animal]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
info:
title: Animals Service
version: 0.0.1
tags:
- name: Animals_API
paths:
/api/v1/zoo/{animal}:
get:
operationId: get_animal
parameters:
- in: path
name: animal
schema:
type: string
required: true
tags:
- Animals_API
description: Get animals by species name
responses:
"200":
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Animal'
description: OK
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/HttpError'
description: Internal Server Error
openapi: 3.0.3
components:
schemas:
Animal:
type: object
required:
- name
- eats
- extra_attributes
properties:
name:
type: string
eats: {}
extra_attributes:
type: object
additionalProperties: true
24 changes: 24 additions & 0 deletions zio-http-gen/src/test/scala/zio/http/gen/scala/CodeGenSpec.scala
Original file line number Diff line number Diff line change
Expand Up @@ -1012,6 +1012,30 @@ object CodeGenSpec extends ZIOSpecDefault {
}
}
} @@ TestAspect.exceptScala3,
test("Schema with any and any object") {
val openAPIString = stringFromResource("/inline_schema_any_and_any_object.yaml")

openApiFromYamlString(openAPIString) { oapi =>
codeGenFromOpenAPI(
oapi,
Config.default.copy(
fieldNamesNormalization = Config.default.fieldNamesNormalization.copy(enableAutomatic = true),
),
) { testDir =>
allFilesShouldBe(
testDir.toFile,
List(
"api/v1/zoo/Animal.scala",
"component/Animal.scala",
),
) && fileShouldBe(
testDir,
"component/Animal.scala",
"/AnimalWithAny.scala",
)
}
}
} @@ TestAspect.exceptScala3,
test("Generate all responses") {
val oapi =
OpenAPI(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,18 @@
package zio.http.headers

import java.net.URI
import java.util.Base64

import zio.Scope
import zio.test._

import zio.http.Header.Authorization
import zio.http.Header.Authorization.Digest
import zio.http.Header.Authorization.{Basic, Digest}
import zio.http.ZIOHttpSpec

object AuthorizationSpec extends ZIOHttpSpec {
private def encodeCredentials(username: String, password: String): String =
Base64.getEncoder.encodeToString(s"$username:$password".getBytes)

override def spec: Spec[TestEnvironment with Scope, Any] =
suite("Authorization header suite")(
Expand Down Expand Up @@ -104,5 +107,15 @@ object AuthorizationSpec extends ZIOHttpSpec {
val auth = Authorization.parse("Basic not-base64")
assertTrue(auth.isLeft)
},
test("should parse valid Basic Authorization header") {
val encodedHeader = encodeCredentials("user", "pass")
val result = Authorization.parse(s"Basic $encodedHeader")
assertTrue(result.isRight) && assertTrue(result.toOption.get == Basic("user", "pass"))
},
test("should parse header with multiple colons in password") {
val encodedHeader = encodeCredentials("user", "pass:with:colon")
val result = Authorization.parse(s"Basic $encodedHeader")
assertTrue(result.isRight) && assertTrue(result.toOption.get == Basic("user", "pass:with:colon"))
},
)
}
Loading

0 comments on commit c255b83

Please sign in to comment.