diff --git a/docs/reference/server.md b/docs/reference/server.md index e65abc4456..61b619b642 100644 --- a/docs/reference/server.md +++ b/docs/reference/server.md @@ -44,7 +44,7 @@ Server.serve(routes).provide( ) ``` -:::note +:::note[Handling Interrupt Signals (Ctrl+C) in SBT] Sometimes we may want to have more control over installation of the http application into the server. In such cases, we may want to use the `Server.install` method. This method only installs the `Routes` into the server, and the lifecycle of the server can be managed separately. ::: @@ -566,6 +566,12 @@ printSource("zio-http-example/src/main/scala/example/GracefulShutdown.scala") This approach ensures that clients receive appropriate responses for their requests, rather than encountering errors or abrupt disconnections. It helps maintain the integrity of the communication between clients and the server, providing a smoother experience for users and preventing potential data loss or corruption. +:::Note +When running a server through SBT, pressing Ctrl+C doesn't cleanly shut down the application. Instead, SBT intercepts the signal and throws a `java.lang.InterruptedException`, bypassing any custom shutdown handlers you may have implemented. + +However, if you run the same server directly from a packaged JAR file using `java -jar`, Ctrl+C will trigger the expected graceful shutdown sequence, allowing your application to clean up resources properly before terminating. +::: + ## Idle Timeout Configuration The idle timeout is a mechanism by which the server automatically terminates an inactive connection after a certain period of inactivity. When a client connects to the server, it establishes a connection to request and receive responses. However, there may be instances where the client becomes slow, inactive, or unresponsive, and the server needs to reclaim resources associated with idle connections to optimize server performance and resource utilization. diff --git a/zio-http-gen/src/main/scala/zio/http/gen/openapi/Config.scala b/zio-http-gen/src/main/scala/zio/http/gen/openapi/Config.scala index e4d90db78b..f9e62eda04 100644 --- a/zio-http-gen/src/main/scala/zio/http/gen/openapi/Config.scala +++ b/zio-http-gen/src/main/scala/zio/http/gen/openapi/Config.scala @@ -33,6 +33,7 @@ final case class Config( commonFieldsOnSuperType: Boolean, generateSafeTypeAliases: Boolean, fieldNamesNormalization: NormalizeFields, + stringFormatTypes: Map[String, String], ) object Config { @@ -73,11 +74,15 @@ object Config { enableAutomatic = false, manualOverrides = Map.empty, ), + stringFormatTypes = Map.empty, ) def config: zio.Config[Config] = ( zio.Config.boolean("common-fields-on-super-type").withDefault(Config.default.commonFieldsOnSuperType) ++ zio.Config.boolean("generate-safe-type-aliases").withDefault(Config.default.generateSafeTypeAliases) ++ - NormalizeFields.config.nested("fields-normalization") + NormalizeFields.config.nested("fields-normalization") ++ zio.Config.table( + "string-format-types", + zio.Config.string, + ) ).to[Config] } diff --git a/zio-http-gen/src/main/scala/zio/http/gen/openapi/EndpointGen.scala b/zio-http-gen/src/main/scala/zio/http/gen/openapi/EndpointGen.scala index f92bdb144d..d1cb7a85a7 100644 --- a/zio-http-gen/src/main/scala/zio/http/gen/openapi/EndpointGen.scala +++ b/zio-http-gen/src/main/scala/zio/http/gen/openapi/EndpointGen.scala @@ -685,6 +685,14 @@ final case class EndpointGen(config: Config) { 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 => @@ -719,6 +727,14 @@ final case class EndpointGen(config: Config) { 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(_, _, _, _) => @@ -1237,9 +1253,19 @@ final case class EndpointGen(config: Config) { val annotations = addNumericValidations[Long](exclusiveMin, exclusiveMax) Some(Code.Field(name, Code.Primitive.ScalaLong, annotations, config.fieldNamesNormalization)) + case JsonSchema.String(Some(format), _, _, _) if config.stringFormatTypes.contains(format.value) => + Some(Code.Field(name, Code.TypeRef(config.stringFormatTypes(format.value)), config.fieldNamesNormalization)) case JsonSchema.String(Some(JsonSchema.StringFormat.UUID), _, maxLength, minLength) => val annotations = addStringValidations(minLength, maxLength) Some(Code.Field(name, Code.Primitive.ScalaUUID, annotations, config.fieldNamesNormalization)) + case JsonSchema.String(Some(JsonSchema.StringFormat.Date), _, _, _) => + Some(Code.Field(name, Code.Primitive.ScalaLocalDate, config.fieldNamesNormalization)) + case JsonSchema.String(Some(JsonSchema.StringFormat.DateTime), _, _, _) => + Some(Code.Field(name, Code.Primitive.ScalaInstant, config.fieldNamesNormalization)) + case JsonSchema.String(Some(JsonSchema.StringFormat.Time), _, _, _) => + Some(Code.Field(name, Code.Primitive.ScalaTime, config.fieldNamesNormalization)) + case JsonSchema.String(Some(JsonSchema.StringFormat.Duration), _, _, _) => + Some(Code.Field(name, Code.Primitive.ScalaDuration, config.fieldNamesNormalization)) case JsonSchema.String(_, _, maxLength, minLength) => val annotations = addStringValidations(minLength, maxLength) Some(Code.Field(name, Code.Primitive.ScalaString, annotations, config.fieldNamesNormalization)) diff --git a/zio-http-gen/src/main/scala/zio/http/gen/scala/Code.scala b/zio-http-gen/src/main/scala/zio/http/gen/scala/Code.scala index 1777c4b44c..aa8827d8e5 100644 --- a/zio-http-gen/src/main/scala/zio/http/gen/scala/Code.scala +++ b/zio-http-gen/src/main/scala/zio/http/gen/scala/Code.scala @@ -217,17 +217,21 @@ object Code { sealed trait Primitive extends ScalaType object Primitive { - case object ScalaInt extends Primitive - case object ScalaLong extends Primitive - case object ScalaDouble extends Primitive - case object ScalaFloat extends Primitive - case object ScalaChar extends Primitive - case object ScalaByte extends Primitive - case object ScalaShort extends Primitive - case object ScalaBoolean extends Primitive - case object ScalaUnit extends Primitive - case object ScalaUUID extends Primitive - case object ScalaString extends Primitive + case object ScalaInt extends Primitive + case object ScalaLong extends Primitive + case object ScalaDouble extends Primitive + case object ScalaFloat extends Primitive + case object ScalaChar extends Primitive + case object ScalaByte extends Primitive + case object ScalaShort extends Primitive + case object ScalaBoolean extends Primitive + case object ScalaUnit extends Primitive + case object ScalaUUID extends Primitive + case object ScalaLocalDate extends Primitive + case object ScalaInstant extends Primitive + case object ScalaTime extends Primitive + case object ScalaDuration extends Primitive + case object ScalaString extends Primitive } final case class EndpointCode( @@ -253,6 +257,10 @@ object Code { case object Long extends CodecType case object String extends CodecType case object UUID extends CodecType + case object LocalDate extends CodecType + case object LocalTime extends CodecType + case object Duration extends CodecType + case object Instant extends CodecType case class Aliased(underlying: CodecType, newtypeName: String) extends CodecType } final case class QueryParamCode(name: String, queryType: CodecType) diff --git a/zio-http-gen/src/main/scala/zio/http/gen/scala/CodeGen.scala b/zio-http-gen/src/main/scala/zio/http/gen/scala/CodeGen.scala index 4a797b83f7..6b582ca7d9 100644 --- a/zio-http-gen/src/main/scala/zio/http/gen/scala/CodeGen.scala +++ b/zio-http-gen/src/main/scala/zio/http/gen/scala/CodeGen.scala @@ -4,6 +4,8 @@ import java.nio.charset.StandardCharsets import java.nio.file.StandardOpenOption._ import java.nio.file._ +import scala.util.matching.Regex + object CodeGen { private val EndpointImports = @@ -230,18 +232,21 @@ object CodeGen { val multipleAnnotationsAboveContent = if (annotationValues.size > 1) "\n" + content else content allImports -> annotationValues.mkString("", "\n", multipleAnnotationsAboveContent) - case Code.Primitive.ScalaBoolean => Nil -> "Boolean" - case Code.Primitive.ScalaByte => Nil -> "Byte" - case Code.Primitive.ScalaChar => Nil -> "Char" - case Code.Primitive.ScalaDouble => Nil -> "Double" - case Code.Primitive.ScalaFloat => Nil -> "Float" - case Code.Primitive.ScalaInt => Nil -> "Int" - case Code.Primitive.ScalaLong => Nil -> "Long" - case Code.Primitive.ScalaShort => Nil -> "Short" - case Code.Primitive.ScalaString => Nil -> "String" - case Code.Primitive.ScalaUnit => Nil -> "Unit" - case Code.Primitive.ScalaUUID => List(Code.Import("java.util.UUID")) -> "UUID" - case Code.ScalaType.Inferred => Nil -> "" + case Code.Primitive.ScalaBoolean => Nil -> "Boolean" + case Code.Primitive.ScalaByte => Nil -> "Byte" + case Code.Primitive.ScalaChar => Nil -> "Char" + case Code.Primitive.ScalaDouble => Nil -> "Double" + case Code.Primitive.ScalaFloat => Nil -> "Float" + case Code.Primitive.ScalaInt => Nil -> "Int" + case Code.Primitive.ScalaLong => Nil -> "Long" + case Code.Primitive.ScalaShort => Nil -> "Short" + case Code.Primitive.ScalaString => Nil -> "String" + case Code.Primitive.ScalaUnit => Nil -> "Unit" + case Code.Primitive.ScalaUUID => List(Code.Import("java.util.UUID")) -> "UUID" + case Code.Primitive.ScalaLocalDate => List(Code.Import("java.time.LocalDate")) -> "LocalDate" + case Code.Primitive.ScalaInstant => List(Code.Import("java.time.Instant")) -> "Instant" + case Code.Primitive.ScalaTime => List(Code.Import("java.time.LocalTime")) -> "LocalTime" + case Code.ScalaType.Inferred => Nil -> "" case Code.EndpointCode(method, pathPatternCode, queryParamsCode, headersCode, inCode, outCodes, errorsCode) => val (queryImports, queryContent) = queryParamsCode.map(renderQueryCode).unzip @@ -266,12 +271,16 @@ object CodeGen { def renderSegmentType(name: String, segmentType: Code.CodecType): (String, List[Code.Import]) = segmentType match { - case Code.CodecType.Boolean => s"""bool("$name")""" -> Nil - case Code.CodecType.Int => s"""int("$name")""" -> Nil - case Code.CodecType.Long => s"""long("$name")""" -> Nil - case Code.CodecType.String => s"""string("$name")""" -> Nil - case Code.CodecType.UUID => s"""uuid("$name")""" -> Nil - case Code.CodecType.Literal => s""""$name"""" -> Nil + case Code.CodecType.Boolean => s"""bool("$name")""" -> Nil + case Code.CodecType.Int => s"""int("$name")""" -> Nil + case Code.CodecType.Long => s"""long("$name")""" -> Nil + case Code.CodecType.String => s"""string("$name")""" -> Nil + case Code.CodecType.UUID => s"""uuid("$name")""" -> Nil + case Code.CodecType.LocalDate => s"""date("$name")""" -> Nil + case Code.CodecType.LocalTime => s"""time("$name")""" -> Nil + case Code.CodecType.Instant => s"""date-time("$name")""" -> Nil + case Code.CodecType.Duration => s"""duration("$name")""" -> Nil + case Code.CodecType.Literal => s""""$name"""" -> Nil case Code.CodecType.Aliased(underlying, newtypeName) => val sb = new StringBuilder() val (code, imports) = renderSegmentType(name, underlying) @@ -379,12 +388,16 @@ object CodeGen { def renderQueryCode(queryCode: Code.QueryParamCode): (List[Code.Import], String) = queryCode match { case Code.QueryParamCode(name, queryType) => val (imports, tpe) = queryType match { - case Code.CodecType.Boolean => Nil -> "Boolean" - case Code.CodecType.Int => Nil -> "Int" - case Code.CodecType.Long => Nil -> "Long" - case Code.CodecType.String => Nil -> "String" - case Code.CodecType.UUID => List(Code.Import("java.util.UUID")) -> "UUID" - case Code.CodecType.Literal => throw new Exception("Literal query params are not supported") + case Code.CodecType.Boolean => Nil -> "Boolean" + case Code.CodecType.Int => Nil -> "Int" + case Code.CodecType.Long => Nil -> "Long" + case Code.CodecType.String => Nil -> "String" + case Code.CodecType.UUID => List(Code.Import("java.util.UUID")) -> "UUID" + case Code.CodecType.LocalDate => List(Code.Import("java.time.LocalDate")) -> "LocalDate" + case Code.CodecType.LocalTime => List(Code.Import("java.time.LocalTime")) -> "LocalTime" + case Code.CodecType.Instant => List(Code.Import("java.time.Instant")) -> "Instant" + case Code.CodecType.Duration => List(Code.Import("java.time.Duration")) -> "Duration" + case Code.CodecType.Literal => throw new Exception("Literal query params are not supported") case Code.CodecType.Aliased(underlying, newtypeName) => val (imports, _) = renderQueryCode(Code.QueryParamCode(name, underlying)) (Code.Import.FromBase(s"components.$newtypeName") :: imports) -> (newtypeName + ".Type") diff --git a/zio-http-gen/src/test/resources/EndpointWithRequestResponseBodyInlineMinMaxLength.scala b/zio-http-gen/src/test/resources/EndpointWithRequestResponseBodyInlineMinMaxLength.scala new file mode 100644 index 0000000000..2d69fa2a4a --- /dev/null +++ b/zio-http-gen/src/test/resources/EndpointWithRequestResponseBodyInlineMinMaxLength.scala @@ -0,0 +1,41 @@ +package test.api.v1 + +import test.component._ +import zio.schema._ + +object Entries { + import zio.http._ + import zio.http.endpoint._ + import zio.http.codec._ + val post = Endpoint(Method.POST / "api" / "v1" / "entries") + .in[POST.RequestBody] + .out[POST.ResponseBody](status = Status.Ok) + + object POST { + import zio.schema.annotation.validate + import zio.schema.validation.Validation + import java.util.UUID + import java.time.Instant + import java.time.LocalTime + import java.time.LocalDate + + case class RequestBody( + id: Int, + @validate[String](Validation.maxLength(255) && Validation.minLength(1)) name: String, + ) + object RequestBody { + implicit val codec: Schema[RequestBody] = DeriveSchema.gen[RequestBody] + } + case class ResponseBody( + @validate[String](Validation.maxLength(255) && Validation.minLength(1)) name: String, + uuid: Option[UUID], + deadline: Option[Instant], + id: Int, + time: Option[LocalTime], + day: LocalDate, + ) + object ResponseBody { + implicit val codec: Schema[ResponseBody] = DeriveSchema.gen[ResponseBody] + } + } +} diff --git a/zio-http-gen/src/test/resources/ValidatedData.scala b/zio-http-gen/src/test/resources/ValidatedData.scala index 99a34b2588..95d28f3e21 100644 --- a/zio-http-gen/src/test/resources/ValidatedData.scala +++ b/zio-http-gen/src/test/resources/ValidatedData.scala @@ -5,7 +5,7 @@ import zio.schema.annotation.validate import zio.schema.validation.Validation case class ValidatedData( - @validate[String](Validation.minLength(10)) name: String, + @validate[String](Validation.maxLength(10)) name: String, @validate[Int](Validation.greaterThan(0) && Validation.lessThan(100)) age: Int, ) object ValidatedData { diff --git a/zio-http-gen/src/test/resources/inline_schema_minmaxlength.json b/zio-http-gen/src/test/resources/inline_schema_minmaxlength.json new file mode 100644 index 0000000000..9f59708189 --- /dev/null +++ b/zio-http-gen/src/test/resources/inline_schema_minmaxlength.json @@ -0,0 +1,101 @@ +{ + "openapi" : "3.1.0", + "info" : { + "title" : "", + "version" : "" + }, + "paths" : { + "/api/v1/entries" : { + "post" : { + "requestBody" : + { + "content" : { + "application/json" : { + "schema" : + { + "type" : + "object", + "properties" : { + "id" : { + "type" : + "integer", + "format" : "int32" + }, + "name" : { + "type" : + "string", + "minLength" : 1, + "maxLength" : 255 + } + }, + "additionalProperties" : + true, + "required" : [ + "id", + "name" + ] + } + + } + }, + "required" : true + }, + "responses" : { + "200" : + { + "description" : "", + "content" : { + "application/json" : { + "schema" : + { + "type" : + "object", + "properties" : { + "id" : { + "type" : + "integer", + "format" : "int32" + }, + "name" : { + "type" : + "string", + "minLength" : 1, + "maxLength" : 255 + }, + "day" : { + "type" : + "string", + "format": "date" + }, + "deadline": { + "type": "string", + "format": "date-time" + }, + "time": { + "type": "string", + "format": "time" + }, + "uuid" : { + "type" : + "string", + "format" : "uuid" + } + }, + "additionalProperties" : + true, + "required" : [ + "id", + "name", + "day" + ] + } + + } + } + } + }, + "deprecated" : false + } + } + } +} diff --git a/zio-http-gen/src/test/scala/zio/http/gen/scala/CodeGenSpec.scala b/zio-http-gen/src/test/scala/zio/http/gen/scala/CodeGenSpec.scala index 16ba55d5d4..fe0aa3f375 100644 --- a/zio-http-gen/src/test/scala/zio/http/gen/scala/CodeGenSpec.scala +++ b/zio-http-gen/src/test/scala/zio/http/gen/scala/CodeGenSpec.scala @@ -198,6 +198,16 @@ object CodeGenSpec extends ZIOSpecDefault { } } } @@ TestAspect.exceptScala3, // for some reason, the temp dir is empty in Scala 3 + test("OpenAPI spec with inline schema request and response body with minLength and maxLength") { + val openAPIString = stringFromResource("/inline_schema_minmaxlength.json") + + openApiFromJsonString(openAPIString) { openAPI => + codeGenFromOpenAPI(openAPI) { testDir => + fileShouldBe(testDir, "api/v1/Entries.scala", "/EndpointWithRequestResponseBodyInlineMinMaxLength.scala") + } + } + } @@ TestAspect.exceptScala3, // for some reason, the temp dir is empty in Scala 3 + test("OpenAPI spec with inline schema request and response body, with nested object schema") { val openAPIString = stringFromResource("/inline_schema_nested.json") diff --git a/zio-http/shared/src/main/scala/zio/http/codec/internal/AtomizedCodecs.scala b/zio-http/shared/src/main/scala/zio/http/codec/internal/AtomizedCodecs.scala index e8dfb11302..52cce2c84a 100644 --- a/zio-http/shared/src/main/scala/zio/http/codec/internal/AtomizedCodecs.scala +++ b/zio-http/shared/src/main/scala/zio/http/codec/internal/AtomizedCodecs.scala @@ -44,28 +44,36 @@ private[http] final case class AtomizedCodecs( def makeInputsBuilder(): Mechanic.InputsBuilder = { Atomized( - Array.ofDim(method.length), - Array.ofDim(status.length), - Array.ofDim(path.length), - Array.ofDim(query.length), - Array.ofDim(header.length), - Array.ofDim(content.length), + method = Array.ofDim(method.length), + path = Array.ofDim(path.length), + query = Array.ofDim(query.length), + header = Array.ofDim(header.length), + content = Array.ofDim(content.length), + status = Array.ofDim(status.length), ) } def optimize: AtomizedCodecs = AtomizedCodecs( - method.materialize, - path.materialize, - query.materialize, - header.materialize, - content.materialize, - status.materialize, + method = method.materialize, + path = path.materialize, + query = query.materialize, + header = header.materialize, + content = content.materialize, + status = status.materialize, ) } private[http] object AtomizedCodecs { - val empty = AtomizedCodecs(Chunk.empty, Chunk.empty, Chunk.empty, Chunk.empty, Chunk.empty, Chunk.empty) + val empty: AtomizedCodecs = + AtomizedCodecs( + method = Chunk.empty, + path = Chunk.empty, + query = Chunk.empty, + header = Chunk.empty, + content = Chunk.empty, + status = Chunk.empty, + ) def flatten[R, A](in: HttpCodec[R, A]): AtomizedCodecs = { val atoms = flattenedAtoms(in) @@ -80,7 +88,7 @@ private[http] object AtomizedCodecs { private def flattenedAtoms[R, A](in: HttpCodec[R, A]): Chunk[Atom[_, _]] = in match { case Combine(left, right, _) => flattenedAtoms(left) ++ flattenedAtoms(right) - case atom: Atom[_, _] => Chunk(atom) + case atom: Atom[_, _] => Chunk.single(atom) case map: TransformOrFail[_, _, _] => flattenedAtoms(map.api) case Annotated(api, _) => flattenedAtoms(api) case Empty => Chunk.empty diff --git a/zio-http/shared/src/main/scala/zio/http/endpoint/openapi/JsonSchema.scala b/zio-http/shared/src/main/scala/zio/http/endpoint/openapi/JsonSchema.scala index 0dc9d674ce..3ccac01aa8 100644 --- a/zio-http/shared/src/main/scala/zio/http/endpoint/openapi/JsonSchema.scala +++ b/zio-http/shared/src/main/scala/zio/http/endpoint/openapi/JsonSchema.scala @@ -293,8 +293,8 @@ object JsonSchema { JsonSchema.String( schema.format.map(StringFormat.fromString), schema.pattern.map(Pattern.apply), - schema.minLength, schema.maxLength, + schema.minLength, ) case schema if schema.schemaType.contains(TypeOrTypes.Type("boolean")) => JsonSchema.Boolean