diff --git a/build.sbt b/build.sbt index cc48774a24..e2ad47a064 100644 --- a/build.sbt +++ b/build.sbt @@ -12,6 +12,8 @@ val _ = sys.props += ("ZIOHttpLogLevel" -> Debug.ZIOHttpLogLevel) ThisBuild / githubWorkflowEnv += ("JDK_JAVA_OPTIONS" -> "-Xms4G -Xmx8G -XX:+UseG1GC -Xss10M -XX:ReservedCodeCacheSize=1G -XX:NonProfiledCodeHeapSize=512m -Dfile.encoding=UTF-8") ThisBuild / githubWorkflowEnv += ("SBT_OPTS" -> "-Xms4G -Xmx8G -XX:+UseG1GC -Xss10M -XX:ReservedCodeCacheSize=1G -XX:NonProfiledCodeHeapSize=512m -Dfile.encoding=UTF-8") +ThisBuild / resolvers ++= Resolver.sonatypeOssRepos("snapshots") + ThisBuild / githubWorkflowJavaVersions := Seq( JavaSpec.graalvm(Graalvm.Distribution("graalvm"), "17"), JavaSpec.graalvm(Graalvm.Distribution("graalvm"), "21"), diff --git a/docs/reference/handler.md b/docs/reference/handler.md index 52bddff4e4..f4b43aedf5 100644 --- a/docs/reference/handler.md +++ b/docs/reference/handler.md @@ -612,6 +612,17 @@ In this example, the type of the handler before applying the `sandbox` operator Without the `sandbox` operator, the compiler would complain about the unhandled `Throwable` error. +By default, sandboxed errors will result in a `500 Internal Server Error` response without a body. If you want to have all information about the error in the response body you can use a different (`ErrorResponseConfig`)[response/response.md#failure-responses-with-details] like `ErrorResponseConfig.debug`: + +```scala mdoc:compile-only +import zio.http._ +import java.nio.file._ + +Routes( + Method.GET / "file" -> + Handler.fromFile(Paths.get("file.txt").toFile).sandbox, + ) @@ ErrorResponseConfig.debug +``` ### Converting a `Handler` to an `Routes` The `Handler#toRoutes` operator, converts a handler to an `Routes` to be served by the `Server`. The following example, shows an HTTP application that serves a simple "Hello, World!" response for all types of incoming requests: diff --git a/docs/reference/response/response.md b/docs/reference/response/response.md index cb6a3ce842..f5d0dec5dc 100644 --- a/docs/reference/response/response.md +++ b/docs/reference/response/response.md @@ -188,6 +188,56 @@ val failedHandler = Handler.fail(new IOException()) failedHandler.mapErrorCause(Response.fromCause) ``` +#### Failure Responses with Details + +By default, the `Response.fromThrowable` and `Response.fromCause` methods create a response with a status code only. If we want to include additional details in the response, we have to hand over a `ErrorResponseConfig`. + +```scala +/** + * Configuration for the response generation + * + * @param withErrorBody + * if true, includes the error message in the response body + * @param withStackTrace + * if true, includes the stack trace in the response body + * @param maxStackTraceDepth + * maximum number of stack trace lines to include in the response body. Set to + * 0 to include all lines. + * @param errorFormat + * the preferred format for the error response. + * If the context in which the response is created has access to an Accept header, + * the header will be used preferably to determine the format. + */ +final case class ErrorResponseConfig( + withErrorBody: Boolean = false, + withStackTrace: Boolean = false, + maxStackTraceDepth: Int = 10, + errorFormat: ErrorResponseConfig.ErrorFormat = ErrorResponseConfig.ErrorFormat.Html, +) +``` + +This config can not only be used directly, but can also configure how ZIO-HTTP internally converts a `Cause` or `Throwable` to a `Response`. +You can configure error responses globally by providing a custom `ErrorResponseConfig` via layer for example in the bootstrap of your application. +Or you can apply the config locally to some routes via middleware. + +```scala mdoc +import zio.http._ + +object MyHttpApp extends ZIOAppDefault { + // Provide a custom ErrorResponseConfig via layer + // Equivalent to: val bootstrap = ErrorResponseConfig.configLayer(ErrorResponseConfig.debugConfig) + override val bootstrap: ZLayer[ZIOAppArgs, Any, Any] = ErrorResponseConfig.debugLayer + + // Apply the ErrorResponseConfig.debug middleware to routes + // Equivalent to: val myRoutes = Handler.ok.toRoutes @@ ErrorResponseConfig.withConfig(ErrorResponseConfig.debugConfig) + val myRoutes = Handler.ok.toRoutes @@ ErrorResponseConfig.debug + + override def run = ??? +} +``` + +The debug config will include the error message and full stack trace in the response body. + :::note In many cases, it is more convenient to use the `sandbox` method to automatically convert all failures into a corresponding `Response`. But in some cases, to have more granular control over the error handling, we may want to use `Response.fromCause` and `Response.fromThrowable` directly. ::: diff --git a/project/Dependencies.scala b/project/Dependencies.scala index abca59886e..65007e599b 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -9,7 +9,7 @@ object Dependencies { val ZioCliVersion = "0.5.0" val ZioJsonVersion = "0.7.1" val ZioParserVersion = "0.1.10" - val ZioSchemaVersion = "1.4.1" + val ZioSchemaVersion = "1.5.0" val SttpVersion = "3.3.18" val ZioConfigVersion = "4.0.2" 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 6c9209b6b0..f92bdb144d 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 @@ -449,7 +449,7 @@ final case class EndpointGen(config: Config) { caseClasses = code.caseClasses, enums = code.enums, ) - Nil -> s"$method.${Inline.RequestBodyType}" + code.imports -> s"$method.${Inline.RequestBodyType}" } case OpenAPI.ReferenceOr.Reference(SchemaRef(ref), _, _) => Nil -> ref case other => throw new Exception(s"Unexpected request body schema: $other") @@ -460,7 +460,7 @@ final case class EndpointGen(config: Config) { val (outImports: Iterable[List[Code.Import]], outCodes: Iterable[Code.OutCode]) = // TODO: ignore default for now. Not sure how to handle it - op.responses.collect { + op.responses.toSeq.collect { case (OpenAPI.StatusOrDefault.StatusValue(status), OpenAPI.ReferenceOr.Reference(ResponseRef(key), _, _)) => val response = resolveResponseRef(openAPI, key) val (imports, code) = diff --git a/zio-http-gen/src/test/resources/ComponentAliasKey.scala b/zio-http-gen/src/test/resources/ComponentAliasKey.scala new file mode 100644 index 0000000000..a9e41fa76c --- /dev/null +++ b/zio-http-gen/src/test/resources/ComponentAliasKey.scala @@ -0,0 +1,9 @@ +package test.component + +import zio.prelude.Newtype +import zio.schema.Schema +import java.util.UUID + +object Key extends Newtype[UUID] { + implicit val schema: Schema[Key.Type] = Schema.primitive[UUID].transform(wrap, unwrap) +} \ No newline at end of file diff --git a/zio-http-gen/src/test/resources/EndpointWithRequestResponseBodyInline.scala b/zio-http-gen/src/test/resources/EndpointWithRequestResponseBodyInline.scala index 524fc5c9e4..18592aa56a 100644 --- a/zio-http-gen/src/test/resources/EndpointWithRequestResponseBodyInline.scala +++ b/zio-http-gen/src/test/resources/EndpointWithRequestResponseBodyInline.scala @@ -1,6 +1,7 @@ package test.api.v1 import test.component._ +import zio.schema._ object Users { import zio.http._ diff --git a/zio-http-gen/src/test/resources/EndpointWithRequestResponseBodyInlineNested.scala b/zio-http-gen/src/test/resources/EndpointWithRequestResponseBodyInlineNested.scala index cc2dce0223..03a94ca498 100644 --- a/zio-http-gen/src/test/resources/EndpointWithRequestResponseBodyInlineNested.scala +++ b/zio-http-gen/src/test/resources/EndpointWithRequestResponseBodyInlineNested.scala @@ -1,6 +1,7 @@ package test.api.v1 import test.component._ +import zio.schema._ object Users { import zio.http._ diff --git a/zio-http-gen/src/test/resources/EndpointWithRequestResponseBodyWithKeywordsInline.scala b/zio-http-gen/src/test/resources/EndpointWithRequestResponseBodyWithKeywordsInline.scala index 496f1c9284..0cfa8c3dd3 100644 --- a/zio-http-gen/src/test/resources/EndpointWithRequestResponseBodyWithKeywordsInline.scala +++ b/zio-http-gen/src/test/resources/EndpointWithRequestResponseBodyWithKeywordsInline.scala @@ -1,6 +1,7 @@ package test.api.v1 import test.component._ +import zio.schema._ object Keywords { import zio.http._ diff --git a/zio-http-gen/src/test/resources/inline_schema_alias_only_as_key_schema.yaml b/zio-http-gen/src/test/resources/inline_schema_alias_only_as_key_schema.yaml new file mode 100644 index 0000000000..6c23c0cf2e --- /dev/null +++ b/zio-http-gen/src/test/resources/inline_schema_alias_only_as_key_schema.yaml @@ -0,0 +1,36 @@ +info: + title: Dummy Service + version: 0.0.1 +servers: + - url: http://127.0.0.1:5000/ +tags: + - name: Dummy_API +paths: + /api/text_by_key: + get: + operationId: text_by_key + description: Get a dictionary mapping keys to text + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/ObjectWithDictionary' + description: OK +openapi: 3.0.3 +components: + schemas: + Key: + type: string + format: uuid + ObjectWithDictionary: + type: object + required: + - dict + properties: + dict: + type: object + additionalProperties: + type: string + x-string-key-schema: + $ref: '#/components/schemas/Key' diff --git a/zio-http-gen/src/test/scala/zio/http/gen/openapi/EndpointGenSpec.scala b/zio-http-gen/src/test/scala/zio/http/gen/openapi/EndpointGenSpec.scala index b0a4bd028d..e445fd7a17 100644 --- a/zio-http-gen/src/test/scala/zio/http/gen/openapi/EndpointGenSpec.scala +++ b/zio-http-gen/src/test/scala/zio/http/gen/openapi/EndpointGenSpec.scala @@ -910,7 +910,7 @@ object EndpointGenSpec extends ZIOSpecDefault { val expected = Code.File( List("api", "v1", "Users.scala"), pkgPath = List("api", "v1"), - imports = List(Code.Import.FromBase(path = "component._")), + imports = List(Code.Import.FromBase(path = "component._"), Code.Import.Absolute("zio.schema._")), objects = List( Code.Object( "Users", @@ -1026,7 +1026,7 @@ object EndpointGenSpec extends ZIOSpecDefault { val expected = Code.File( List("api", "v1", "Users.scala"), pkgPath = List("api", "v1"), - imports = List(Code.Import.FromBase(path = "component._")), + imports = List(Code.Import.FromBase(path = "component._"), Code.Import.Absolute("zio.schema._")), objects = List( Code.Object( "Users", 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 df89ae8bec..16ba55d5d4 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 @@ -3,16 +3,17 @@ package zio.http.gen.scala import java.nio.file._ import scala.annotation.nowarn +import scala.collection.immutable.ListMap import scala.jdk.CollectionConverters._ import scala.meta._ import scala.meta.parsers._ import scala.util.{Failure, Success, Try} -import zio.Scope import zio.json.JsonDecoder import zio.test.Assertion.{hasSameElements, isFailure, isSuccess} import zio.test.TestAspect.{blocking, flaky} import zio.test._ +import zio.{Chunk, Scope} import zio.schema.annotation.validate import zio.schema.codec.JsonCodec @@ -22,7 +23,7 @@ import zio.schema.{DeriveSchema, Schema} import zio.http._ import zio.http.codec._ import zio.http.endpoint.Endpoint -import zio.http.endpoint.openapi.{OpenAPI, OpenAPIGen} +import zio.http.endpoint.openapi.{JsonSchema, OpenAPI, OpenAPIGen} import zio.http.gen.model._ import zio.http.gen.openapi.Config.NormalizeFields import zio.http.gen.openapi.{Config, EndpointGen} @@ -445,7 +446,8 @@ object CodeGenSpec extends ZIOSpecDefault { } } @@ TestAspect.exceptScala3, // for some reason, the temp dir is empty in Scala 3 test( - "OpenAPI spec with inline schema response body of sum-type with multiple contradicting reusable fields and super type members", + "OpenAPI spec with inline schema response body of sum-type with multiple contradicting reusable fields and " + + "super type members", ) { val openAPIString = stringFromResource("/inline_schema_sumtype_with_multiple_contradicting_reusable_fields.yaml") @@ -457,7 +459,8 @@ object CodeGenSpec extends ZIOSpecDefault { } } @@ TestAspect.exceptScala3, // for some reason, the temp dir is empty in Scala 3 test( - "OpenAPI spec with inline schema response body of sum-type with multiple non-contradicting reusable fields and super type members", + "OpenAPI spec with inline schema response body of sum-type with multiple non-contradicting reusable fields " + + "and super type members", ) { val openAPIString = stringFromResource("/inline_schema_sumtype_with_multiple_non_contradicting_reusable_fields.yaml") @@ -928,7 +931,8 @@ object CodeGenSpec extends ZIOSpecDefault { assertTrue( Try( codeGenFromOpenAPI(oapi)(_ => TestResult(TestArrow.succeed(true))), - ).failed.get.getMessage == "x-string-key-schema must reference a string schema, but got: {\"type\":\"integer\",\"format\":\"int32\"}", + ).failed.get.getMessage == "x-string-key-schema must reference a string schema, but " + + "got: {\"type\":\"integer\",\"format\":\"int32\"}", ) } }, @@ -975,5 +979,208 @@ object CodeGenSpec extends ZIOSpecDefault { } } } @@ TestAspect.exceptScala3, + test("Schema with newtype only referenced as dictionary key") { + val openAPIString = stringFromResource("/inline_schema_alias_only_as_key_schema.yaml") + + openApiFromYamlString(openAPIString) { oapi => + codeGenFromOpenAPI( + oapi, + Config.default.copy(generateSafeTypeAliases = true), + ) { testDir => + allFilesShouldBe( + testDir.toFile, + List( + "api/Text_by_key.scala", + "component/Key.scala", + "component/ObjectWithDictionary.scala", + ), + ) && fileShouldBe( + testDir, + "component/Key.scala", + "/ComponentAliasKey.scala", + ) + } + } + } @@ TestAspect.exceptScala3, + test("Generate all responses") { + val oapi = + OpenAPI( + openapi = "3.0.0", + info = OpenAPI.Info( + title = "XXX", + description = None, + termsOfService = None, + contact = None, + license = None, + version = "1.0.0", + ), + paths = ListMap( + OpenAPI.Path + .fromString(name = "/api/a/b") + .map { path => + path -> OpenAPI.PathItem( + ref = None, + summary = None, + description = None, + get = None, + put = None, + post = Some( + OpenAPI.Operation( + summary = None, + description = None, + externalDocs = None, + operationId = None, + requestBody = None, + responses = Map( + OpenAPI.StatusOrDefault.StatusValue(status = Status.Ok) -> + OpenAPI.ReferenceOr.Or(value = OpenAPI.Response()), + OpenAPI.StatusOrDefault.StatusValue(Status.BadRequest) -> + OpenAPI.ReferenceOr.Or(OpenAPI.Response()), + OpenAPI.StatusOrDefault.StatusValue(Status.Unauthorized) -> + OpenAPI.ReferenceOr.Or(OpenAPI.Response()), + ), + ), + ), + delete = None, + options = None, + head = None, + patch = None, + trace = None, + ) + } + .toSeq: _*, + ), + components = None, + externalDocs = None, + ) + + val maybeEndpointCode = + EndpointGen + .fromOpenAPI(oapi, Config.default) + .files + .flatMap(_.objects) + .flatMap(_.endpoints) + .collectFirst { + case (field, code) if field.name == "post" => code + } + + assertTrue( + maybeEndpointCode.is(_.some).outCodes.length == 1 && + maybeEndpointCode.is(_.some).errorsCode.length == 2, + ) + }, + test("schema import is added when needed") { + val oapi = + OpenAPI( + openapi = "3.0.0", + info = OpenAPI.Info( + title = "XXX", + description = None, + termsOfService = None, + contact = None, + license = None, + version = "1.0.0", + ), + paths = ListMap( + OpenAPI.Path + .fromString(name = "/api/a/b") + .map { path => + path -> OpenAPI.PathItem( + ref = None, + summary = None, + description = None, + get = None, + put = None, + post = Some( + OpenAPI.Operation( + summary = None, + description = None, + externalDocs = None, + operationId = None, + requestBody = Some( + OpenAPI.ReferenceOr.Or( + OpenAPI.RequestBody( + content = Map( + "application/json" -> OpenAPI.MediaType( + OpenAPI.ReferenceOr.Or( + JsonSchema.Object( + properties = Map( + "h" -> JsonSchema.String(None, None), + "i" -> JsonSchema.String(None, None), + "j" -> JsonSchema.String(None, None), + "k" -> JsonSchema.String(None, None), + ), + additionalProperties = Left(true), + required = Chunk("h", "i", "j", "k"), + ), + ), + ), + ), + ), + ), + ), + ), + ), + delete = None, + options = None, + head = None, + patch = None, + trace = None, + ) + } + .toSeq: _*, + ), + components = None, + externalDocs = None, + ) + + def suspendAssertion[A](assertion: => Assertion[A]) = + Assertion( + TestArrow.suspend((a: A) => TestArrow.succeed(a) >>> assertion.arrow), + ) + + val importsZioSchema: Assertion[Code.File] = + Assertion.hasField("imports", (_: Code.File).imports, Assertion.contains(Code.Import("zio.schema._"))) + val objectsOwnSchemaIsNone = Assertion.hasField("schema", (_: Code.Object).schema, Assertion.isNone) + lazy val objectCaseClassesContainNoSchema: Assertion[Code.Object] = + Assertion.hasField( + "caseClasses", + (_: Code.Object).caseClasses, + Assertion.forall(caseClassCompanionsContainNoSchema), + ) + lazy val caseClassCompanionsContainNoSchema: Assertion[Code.CaseClass] = + Assertion.hasField( + "companionObject", + (_: Code.CaseClass).companionObject, + Assertion.isNone || Assertion.isSome(objectContainsNoSchema), + ) + lazy val objectObjectsContainNoSchema: Assertion[Code.Object] = + Assertion.hasField( + "objects", + (_: Code.Object).objects, + Assertion.forall(objectContainsNoSchema), + ) + lazy val objectContainsNoSchema: Assertion[Code.Object] = + suspendAssertion { + objectsOwnSchemaIsNone && + objectObjectsContainNoSchema && + objectCaseClassesContainNoSchema + } + val fileObjectsContainNoSchema: Assertion[Code.File] = + Assertion.hasField("objects", (_: Code.File).objects, Assertion.forall(objectContainsNoSchema)) + val fileCaseClassCompanionsContainNoSchema = + Assertion.hasField( + "caseClasses", + (_: Code.File).caseClasses, + Assertion.forall(caseClassCompanionsContainNoSchema), + ) + val fileContainsNoSchema: Assertion[Code.File] = + fileObjectsContainNoSchema && + fileCaseClassCompanionsContainNoSchema + + assert(EndpointGen.fromOpenAPI(oapi, Config.default).files) { + Assertion.forall(importsZioSchema || fileContainsNoSchema) + } + }, ) @@ java11OrNewer @@ flaky @@ blocking // Downloading scalafmt on CI is flaky } diff --git a/zio-http/js/src/main/scala/zio/http/codec/PathCodecPlatformSpecific.scala b/zio-http/js/src/main/scala/zio/http/codec/PathCodecPlatformSpecific.scala index a5c599958c..e3d8dcf977 100644 --- a/zio-http/js/src/main/scala/zio/http/codec/PathCodecPlatformSpecific.scala +++ b/zio-http/js/src/main/scala/zio/http/codec/PathCodecPlatformSpecific.scala @@ -1,11 +1,10 @@ package zio.http.codec -import java.util.Objects - trait PathCodecPlatformSpecific { private[codec] def parseLong(s: CharSequence, beginIndex: Int, endIndex: Int, radix: Int): Long = { - Objects.requireNonNull(s) - Objects.checkFromToIndex(beginIndex, endIndex, s.length) + require(s != null, "CharSequence cannot be null") + checkFromToIndex(beginIndex, endIndex, s.length) + if (radix < Character.MIN_RADIX) throw new NumberFormatException("radix " + radix + " less than Character.MIN_RADIX") if (radix > Character.MAX_RADIX) @@ -42,8 +41,9 @@ trait PathCodecPlatformSpecific { } private[codec] def parseInt(s: CharSequence, beginIndex: Int, endIndex: Int, radix: Int): Int = { - Objects.requireNonNull(s) - Objects.checkFromToIndex(beginIndex, endIndex, s.length) + require(s != null, "CharSequence cannot be null") + checkFromToIndex(beginIndex, endIndex, s.length) + if (radix < Character.MIN_RADIX) throw new NumberFormatException("radix " + radix + " less than Character.MIN_RADIX") if (radix > Character.MAX_RADIX) @@ -88,4 +88,10 @@ trait PathCodecPlatformSpecific { "For input string: \"" + s + "\"" + (if (radix == 10) "" else " under radix " + radix), ) + + private def checkFromToIndex(from: Int, to: Int, length: Int): Unit = { + if (from < 0 || to > length || from > to) { + throw new IndexOutOfBoundsException(s"Range [$from, $to) out of bounds for length $length") + } + } } diff --git a/zio-http/js/src/test/scala/zio/http/PathCodecPlatformSpecificSpec.scala b/zio-http/js/src/test/scala/zio/http/PathCodecPlatformSpecificSpec.scala new file mode 100644 index 0000000000..a41c202dd1 --- /dev/null +++ b/zio-http/js/src/test/scala/zio/http/PathCodecPlatformSpecificSpec.scala @@ -0,0 +1,35 @@ +package zio.http.codec + +import zio._ +import zio.test.Assertion._ +import zio.test._ + +object PathCodecPlatformSpecificSpec extends ZIOSpecDefault { + + def spec = suite("PathCodecJSPlatformSpecificSpec")( + test("parseInt should correctly parse a valid integer from a CharSequence") { + val charSequence = "12345" + val result = new PathCodecPlatformSpecific {}.parseInt(charSequence, 0, charSequence.length, 10) + assert(result)(equalTo(12345)) + }, + test("parseInt should throw an error for an invalid radix") { + val charSequence = "12345" + val result = ZIO.attempt { + new PathCodecPlatformSpecific {}.parseInt(charSequence, 0, charSequence.length, Character.MAX_RADIX + 1) + }.either + assertZIO(result)(isLeft(hasMessage(containsString("radix")))) + }, + test("parseLong should correctly parse a valid long from a CharSequence") { + val charSequence = "123456789012345" + val result = new PathCodecPlatformSpecific {}.parseLong(charSequence, 0, charSequence.length, 10) + assert(result)(equalTo(123456789012345L)) + }, + test("parseLong should throw an error for an invalid input") { + val charSequence = "invalid123" + val result = ZIO.attempt { + new PathCodecPlatformSpecific {}.parseLong(charSequence, 0, charSequence.length, 10) + }.either + assertZIO(result)(isLeft(hasMessage(containsString("Error at index")))) + }, + ) +} diff --git a/zio-http/jvm/src/main/scala/zio/http/netty/client/ClientSSLConverter.scala b/zio-http/jvm/src/main/scala/zio/http/netty/client/ClientSSLConverter.scala index 6df29da1db..7a3b3e0c94 100644 --- a/zio-http/jvm/src/main/scala/zio/http/netty/client/ClientSSLConverter.scala +++ b/zio-http/jvm/src/main/scala/zio/http/netty/client/ClientSSLConverter.scala @@ -18,7 +18,7 @@ package zio.http.netty.client import java.io.{File, FileInputStream, InputStream} import java.security.KeyStore -import javax.net.ssl.TrustManagerFactory +import javax.net.ssl.{KeyManagerFactory, TrustManagerFactory} import scala.util.Using @@ -31,6 +31,39 @@ import zio.http.ClientSSLConfig import io.netty.handler.ssl.util.InsecureTrustManagerFactory import io.netty.handler.ssl.{SslContext, SslContextBuilder} private[netty] object ClientSSLConverter { + private def keyManagerTrustManagerToSslContext( + keyManagerInfo: Option[(String, InputStream, Option[Secret])], + trustManagerInfo: Option[(String, InputStream, Option[Secret])], + sslContextBuilder: SslContextBuilder, + ): SslContextBuilder = { + val mkeyManagerFactory = + keyManagerInfo.map { case (keyStoreType, inputStream, maybePassword) => + val keyStore = KeyStore.getInstance(keyStoreType) + val keyManagerFactory = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm) + val password = maybePassword.map(_.value.toArray).orNull + + keyStore.load(inputStream, password) + keyManagerFactory.init(keyStore, password) + keyManagerFactory + } + + val mtrustManagerFactory = + trustManagerInfo.map { case (keyStoreType, inputStream, maybePassword) => + val keyStore = KeyStore.getInstance(keyStoreType) + val trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm) + val password = maybePassword.map(_.value.toArray).orNull + + keyStore.load(inputStream, password) + trustManagerFactory.init(keyStore) + trustManagerFactory + } + + var bldr = SslContextBuilder.forClient() + mkeyManagerFactory.foreach(kmf => bldr = bldr.keyManager(kmf)) + mtrustManagerFactory.foreach(tmf => bldr = bldr.trustManager(tmf)) + bldr + } + private def trustStoreToSslContext( trustStoreStream: InputStream, trustStorePassword: Secret, @@ -78,6 +111,41 @@ private[netty] object ClientSSLConverter { case ClientSSLConfig.FromTrustStoreFile(trustStorePath, trustStorePassword) => val trustStoreStream = new FileInputStream(trustStorePath) trustStoreToSslContext(trustStoreStream, trustStorePassword, sslContextBuilder) + case ClientSSLConfig.FromJavaxNetSsl( + keyManagerKeyStoreType, + keyManagerSource, + keyManagerPassword, + trustManagerKeyStoreType, + trustManagerSource, + trustManagerPassword, + ) => + val keyManagerInfo = + keyManagerSource match { + case ClientSSLConfig.FromJavaxNetSsl.File(path) => + Option(new FileInputStream(path)).map(inputStream => + (keyManagerKeyStoreType, inputStream, keyManagerPassword), + ) + case ClientSSLConfig.FromJavaxNetSsl.Resource(path) => + Option(getClass.getClassLoader.getResourceAsStream(path)).map(inputStream => + (keyManagerKeyStoreType, inputStream, keyManagerPassword), + ) + case ClientSSLConfig.FromJavaxNetSsl.Empty => None + } + + val trustManagerInfo = + trustManagerSource match { + case ClientSSLConfig.FromJavaxNetSsl.File(path) => + Option(new FileInputStream(path)).map(inputStream => + (trustManagerKeyStoreType, inputStream, trustManagerPassword), + ) + case ClientSSLConfig.FromJavaxNetSsl.Resource(path) => + Option(getClass.getClassLoader.getResourceAsStream(path)).map(inputStream => + (trustManagerKeyStoreType, inputStream, trustManagerPassword), + ) + case ClientSSLConfig.FromJavaxNetSsl.Empty => None + } + + keyManagerTrustManagerToSslContext(keyManagerInfo, trustManagerInfo, sslContextBuilder) } def toNettySSLContext(sslConfig: ClientSSLConfig): SslContext = { diff --git a/zio-http/jvm/src/test/scala/zio/http/BodySchemaOpsSpec.scala b/zio-http/jvm/src/test/scala/zio/http/BodySchemaOpsSpec.scala index 8dee92abb6..5bbc0a915d 100644 --- a/zio-http/jvm/src/test/scala/zio/http/BodySchemaOpsSpec.scala +++ b/zio-http/jvm/src/test/scala/zio/http/BodySchemaOpsSpec.scala @@ -39,7 +39,9 @@ object BodySchemaOpsSpec extends ZIOHttpSpec { }, test("Body.fromStream") { val body = Body.fromStream(persons) - val expected = """{"name":"John","age":42}{"name":"Jane","age":43}""" + val expected = + """{"name":"John","age":42} + |{"name":"Jane","age":43}""".stripMargin body.asString.map(s => assertTrue(s == expected)) }, test("Body#to") { diff --git a/zio-http/jvm/src/test/scala/zio/http/ClientHttpsSpec.scala b/zio-http/jvm/src/test/scala/zio/http/ClientHttpsSpec.scala index 97d5810d7c..fcdb1842e2 100644 --- a/zio-http/jvm/src/test/scala/zio/http/ClientHttpsSpec.scala +++ b/zio-http/jvm/src/test/scala/zio/http/ClientHttpsSpec.scala @@ -18,18 +18,14 @@ package zio.http import zio._ import zio.test.Assertion._ -import zio.test.TestAspect.{ignore, nonFlaky} +import zio.test.TestAspect.nonFlaky import zio.test.{TestAspect, assertZIO} import zio.http.netty.NettyConfig import zio.http.netty.client.NettyClientDriver -object ClientHttpsSpec extends ZIOHttpSpec { - - val sslConfig = ClientSSLConfig.FromTrustStoreResource( - trustStorePath = "truststore.jks", - trustStorePassword = "changeit", - ) +abstract class ClientHttpsSpecBase extends ZIOHttpSpec { + val sslConfig: ClientSSLConfig val zioDev = URL.decode("https://zio.dev").toOption.get @@ -37,7 +33,7 @@ object ClientHttpsSpec extends ZIOHttpSpec { val badRequest = URL .decode( - "https://www.whatissslcertificate.com/google-has-made-the-list-of-untrusted-providers-of-digital-certificates/", + "https://httpbin.org/status/400", ) .toOption .get @@ -57,7 +53,7 @@ object ClientHttpsSpec extends ZIOHttpSpec { test("should respond as Bad Request") { val actual = Client.batched(Request.get(badRequest)).map(_.status) assertZIO(actual)(equalTo(Status.BadRequest)) - } @@ ignore, + }, test("should throw DecoderException for handshake failure") { val actual = Client.batched(Request.get(untrusted)).exit assertZIO(actual)( @@ -69,7 +65,7 @@ object ClientHttpsSpec extends ZIOHttpSpec { ), ), ) - } @@ nonFlaky(20) @@ ignore, + } @@ nonFlaky(20), ) .provideShared( ZLayer.succeed(ZClient.Config.default.ssl(sslConfig)), @@ -83,3 +79,20 @@ object ClientHttpsSpec extends ZIOHttpSpec { ZLayer.succeed(NettyConfig.defaultWithFastShutdown), ) } + +object ClientHttpsSpec extends ClientHttpsSpecBase { + + val sslConfig = ClientSSLConfig.FromTrustStoreResource( + trustStorePath = "truststore.jks", + trustStorePassword = "changeit", + ) +} + +object ClientHttpsFromJavaxNetSslSpec extends ClientHttpsSpecBase { + + val sslConfig = + ClientSSLConfig.FromJavaxNetSsl + .builderWithTrustManagerResource("trustStore.jks") + .trustManagerPassword("changeit") + .build() +} diff --git a/zio-http/jvm/src/test/scala/zio/http/ClientSpec.scala b/zio-http/jvm/src/test/scala/zio/http/ClientSpec.scala index 0bd5350539..51695c4c0d 100644 --- a/zio-http/jvm/src/test/scala/zio/http/ClientSpec.scala +++ b/zio-http/jvm/src/test/scala/zio/http/ClientSpec.scala @@ -105,7 +105,7 @@ object ClientSpec extends RoutesRunnableSpec { val url = URL.decode("https://test.com").toOption.get val resp = ZClient.batched(Request.get(url)).timeout(500.millis) assertZIO(resp)(isNone) - } @@ timeout(5.seconds) @@ flaky(20), + } @@ timeout(5.seconds) @@ flaky(20) @@ TestAspect.ignore, // annoying in CI test("authorization header without scheme") { val app = Handler diff --git a/zio-http/jvm/src/test/scala/zio/http/codec/PathCodecSpec.scala b/zio-http/jvm/src/test/scala/zio/http/codec/PathCodecSpec.scala index 8c00e783ba..fc5026061b 100644 --- a/zio-http/jvm/src/test/scala/zio/http/codec/PathCodecSpec.scala +++ b/zio-http/jvm/src/test/scala/zio/http/codec/PathCodecSpec.scala @@ -194,17 +194,14 @@ object PathCodecSpec extends ZIOHttpSpec { test("/users") { val codec = PathCodec.empty / PathCodec.literal("users") - assertTrue( - codec.segments == - Chunk(SegmentCodec.empty, SegmentCodec.literal("users")), - ) + assertTrue(codec.segments == Chunk(SegmentCodec.literal("users"))) }, ), suite("render")( test("empty") { val codec = PathCodec.empty - assertTrue(codec.render == "") + assertTrue(codec.render == "/") }, test("/users") { val codec = PathCodec.empty / PathCodec.literal("users") diff --git a/zio-http/jvm/src/test/scala/zio/http/endpoint/AuthSpec.scala b/zio-http/jvm/src/test/scala/zio/http/endpoint/AuthSpec.scala index 6b60f20c0a..4d93dff8cf 100644 --- a/zio-http/jvm/src/test/scala/zio/http/endpoint/AuthSpec.scala +++ b/zio-http/jvm/src/test/scala/zio/http/endpoint/AuthSpec.scala @@ -154,7 +154,7 @@ object AuthSpec extends ZIOSpecDefault { }, test("Auth basic or bearer with context and endpoint client") { val endpoint = - Endpoint(Method.GET / "test" / "multiAuth") + Endpoint(Method.GET / "multiAuth") .out[String](MediaType.text.`plain`) .auth(AuthType.Basic | AuthType.Bearer) val routes = @@ -188,7 +188,7 @@ object AuthSpec extends ZIOSpecDefault { } yield assertTrue(responseBasic == "admin" && responseBearer == "bearer-admin") }, test("Auth from query parameter with context and endpoint client") { - val endpoint = Endpoint(Method.GET / "test" / "query") + val endpoint = Endpoint(Method.GET / "query") .out[String](MediaType.text.`plain`) .auth(AuthType.Custom(HttpCodec.query[String]("token"))) val routes = @@ -214,7 +214,7 @@ object AuthSpec extends ZIOSpecDefault { }, test("Auth with context and endpoint client with path parameter") { val endpoint = - Endpoint(Method.GET / "test" / int("a")).out[String](MediaType.text.`plain`).auth(AuthType.Basic) + Endpoint(Method.GET / int("a")).out[String](MediaType.text.`plain`).auth(AuthType.Basic) val routes = Routes( endpoint.implementHandler(handler((_: Int) => withContext((ctx: AuthContext) => ctx.value))), diff --git a/zio-http/jvm/src/test/scala/zio/http/endpoint/RoundtripSpec.scala b/zio-http/jvm/src/test/scala/zio/http/endpoint/RoundtripSpec.scala index 00062ede9a..3b6fa237f6 100644 --- a/zio-http/jvm/src/test/scala/zio/http/endpoint/RoundtripSpec.scala +++ b/zio-http/jvm/src/test/scala/zio/http/endpoint/RoundtripSpec.scala @@ -369,6 +369,43 @@ object RoundtripSpec extends ZIOHttpSpec { (stream: ZStream[Any, Nothing, Byte]) => stream.runCount.map(c => assert(c)(equalTo(1024L * 1024L))), ) }, + test("string stream output") { + val api = Endpoint(GET / "download").query(HttpCodec.query[Int]("count")).outStream[String] + val route = api.implementHandler { + Handler.fromFunctionZIO { count => + ZIO.succeed(ZStream.fromIterable((0 until count).map(_.toString))) + } + } + + testEndpointZIO( + api, + Routes(route), + 1024 * 1024, + (stream: ZStream[Any, Nothing, String]) => + stream.zipWithIndex + .runFold((true, 0)) { case ((allOk, count), (str, idx)) => + (allOk && str == idx.toString, count + 1) + } + .map { case (allOk, c) => + assertTrue(allOk && c == 1024 * 1024) + }, + ) + }, + test("string output") { + val api = Endpoint(GET / "download").query(HttpCodec.query[String]("param")).out[String] + val route = api.implementHandler { + Handler.fromFunctionZIO { param => + ZIO.succeed(param) + } + } + + testEndpointZIO( + api, + Routes(route), + "test", + (str: String) => assertTrue(str == "test"), + ) + }, test("multi-part input") { val api = Endpoint(POST / "test") .in[String]("name") diff --git a/zio-http/jvm/src/test/scala/zio/http/endpoint/openapi/OpenAPIGenSpec.scala b/zio-http/jvm/src/test/scala/zio/http/endpoint/openapi/OpenAPIGenSpec.scala index 6636f6533c..35a2442bc9 100644 --- a/zio-http/jvm/src/test/scala/zio/http/endpoint/openapi/OpenAPIGenSpec.scala +++ b/zio-http/jvm/src/test/scala/zio/http/endpoint/openapi/OpenAPIGenSpec.scala @@ -10,8 +10,8 @@ import zio.schema.{DeriveSchema, Schema} import zio.http.Method.{GET, POST} import zio.http._ -import zio.http.codec.PathCodec.string -import zio.http.codec.{ContentCodec, Doc, HttpCodec} +import zio.http.codec.PathCodec.{empty, string} +import zio.http.codec._ import zio.http.endpoint._ object OpenAPIGenSpec extends ZIOSpecDefault { @@ -180,6 +180,13 @@ object OpenAPIGenSpec extends ZIOSpecDefault { .out[SimpleOutputBody] .outError[NotFoundError](Status.NotFound) + private val queryParamOptEndpoint = + Endpoint(GET / "withQuery") + .in[SimpleInputBody] + .query(HttpCodec.query[String]("query").optional) + .out[SimpleOutputBody] + .outError[NotFoundError](Status.NotFound) + private val queryParamCollectionEndpoint = Endpoint(GET / "withQuery") .in[SimpleInputBody] @@ -194,6 +201,19 @@ object OpenAPIGenSpec extends ZIOSpecDefault { .out[SimpleOutputBody] .outError[NotFoundError](Status.NotFound) + private val optionalHeaderEndpoint = + Endpoint(GET / "withHeader") + .in[SimpleInputBody] + .header(HttpCodec.contentType.optional) + .out[SimpleOutputBody] + .outError[NotFoundError](Status.NotFound) + + private val optionalPayloadEndpoint = + Endpoint(GET / "withPayload") + .inCodec(HttpCodec.content[Payload].optional) + .out[SimpleOutputBody] + .outError[NotFoundError](Status.NotFound) + private val alternativeInputEndpoint = Endpoint(GET / "inputAlternative") .inCodec( @@ -211,6 +231,25 @@ object OpenAPIGenSpec extends ZIOSpecDefault { override def spec: Spec[TestEnvironment with Scope, Any] = suite("OpenAPIGenSpec")( + test("root endpoint to OpenAPI") { + val rootEndpoint = Endpoint(Method.GET / empty) + val generated = OpenAPIGen.fromEndpoints("Root Endpoint", "1.0", rootEndpoint) + val json = toJsonAst(generated) + val expectedJson = """|{ + | "openapi" : "3.1.0", + | "info" : { + | "title" : "Root Endpoint", + | "version" : "1.0" + | }, + | "paths" : { + | "/" : { + | "get" : {} + | } + | }, + | "components" : {} + |}""".stripMargin + assertTrue(json == toJsonAst(expectedJson)) + }, test("simple endpoint to OpenAPI") { val generated = OpenAPIGen.fromEndpoints( "Simple Endpoint", @@ -478,6 +517,134 @@ object OpenAPIGenSpec extends ZIOSpecDefault { |}""".stripMargin assertTrue(json == toJsonAst(expectedJson)) }, + test("with optional query parameter") { + val generated = OpenAPIGen.fromEndpoints("Simple Endpoint", "1.0", queryParamOptEndpoint) + val json = toJsonAst(generated) + val expectedJson = """{ + | "openapi" : "3.1.0", + | "info" : { + | "title" : "Simple Endpoint", + | "version" : "1.0" + | }, + | "paths" : { + | "/withQuery" : { + | "get" : { + | "parameters" : [ + | + | { + | "name" : "query", + | "in" : "query", + | "schema" : + | { + | "type" :[ + | "string", + | "null" + | ] + | }, + | "allowReserved" : false, + | "style" : "form" + | } + | ], + | "requestBody" : + | { + | "content" : { + | "application/json" : { + | "schema" : + | { + | "$ref" : "#/components/schemas/SimpleInputBody" + | } + | } + | }, + | "required" : true + | }, + | "responses" : { + | "200" : + | { + | "content" : { + | "application/json" : { + | "schema" : + | { + | "$ref" : "#/components/schemas/SimpleOutputBody" + | } + | } + | } + | }, + | "404" : + | { + | "content" : { + | "application/json" : { + | "schema" : + | { + | "$ref" : "#/components/schemas/NotFoundError" + | } + | } + | } + | } + | } + | } + | } + | }, + | "components" : { + | "schemas" : { + | "NotFoundError" : + | { + | "type" : + | "object", + | "properties" : { + | "message" : { + | "type" : + | "string" + | } + | }, + | "required" : [ + | "message" + | ] + | }, + | "SimpleInputBody" : + | { + | "type" : + | "object", + | "properties" : { + | "name" : { + | "type" : + | "string" + | }, + | "age" : { + | "type" : + | "integer", + | "format" : "int32" + | } + | }, + | "required" : [ + | "name", + | "age" + | ] + | }, + | "SimpleOutputBody" : + | { + | "type" : + | "object", + | "properties" : { + | "userName" : { + | "type" : + | "string" + | }, + | "score" : { + | "type" : + | "integer", + | "format" : "int32" + | } + | }, + | "required" : [ + | "userName", + | "score" + | ] + | } + | } + | } + |}""".stripMargin + assertTrue(json == toJsonAst(expectedJson)) + }, test("with query parameter with multiple values") { val generated = OpenAPIGen.fromEndpoints("Simple Endpoint", "1.0", queryParamCollectionEndpoint) val json = toJsonAst(generated) @@ -734,6 +901,214 @@ object OpenAPIGenSpec extends ZIOSpecDefault { |}""".stripMargin assertTrue(json == toJsonAst(expectedJson)) }, + test("optional header") { + val generated = OpenAPIGen.fromEndpoints("Simple Endpoint", "1.0", optionalHeaderEndpoint) + val json = toJsonAst(generated) + val expectedJson = """{ + | "openapi" : "3.1.0", + | "info" : { + | "title" : "Simple Endpoint", + | "version" : "1.0" + | }, + | "paths" : { + | "/withHeader" : { + | "get" : { + | "parameters" : [ + | { + | "name" : "content-type", + | "in" : "header", + | "schema" : { + | "type" : [ + | "string", + | "null" + | ] + | }, + | "style" : "simple" + | } + | ], + | "requestBody" : { + | "content" : { + | "application/json" : { + | "schema" : { + | "$ref" : "#/components/schemas/SimpleInputBody", + | "description" : "" + | } + | } + | }, + | "required" : true + | }, + | "responses" : { + | "200" : { + | "content" : { + | "application/json" : { + | "schema" : { + | "$ref" : "#/components/schemas/SimpleOutputBody" + | } + | } + | } + | }, + | "404" : { + | "content" : { + | "application/json" : { + | "schema" : { + | "$ref" : "#/components/schemas/NotFoundError" + | } + | } + | } + | } + | } + | } + | } + | }, + | "components" : { + | "schemas" : { + | "NotFoundError" : { + | "type" : "object", + | "properties" : { + | "message" : { + | "type" : "string" + | } + | }, + | "required" : [ + | "message" + | ] + | }, + | "SimpleInputBody" : { + | "type" : "object", + | "properties" : { + | "name" : { + | "type" : "string" + | }, + | "age" : { + | "type" : "integer", + | "format" : "int32" + | } + | }, + | "required" : [ + | "name", + | "age" + | ] + | }, + | "SimpleOutputBody" : { + | "type" : "object", + | "properties" : { + | "userName" : { + | "type" : "string" + | }, + | "score" : { + | "type" : "integer", + | "format" : "int32" + | } + | }, + | "required" : [ + | "userName", + | "score" + | ] + | } + | } + | } + |}""".stripMargin + assertTrue(json == toJsonAst(expectedJson)) + }, + test("optional payload") { + val generated = OpenAPIGen.fromEndpoints("Simple Endpoint", "1.0", optionalPayloadEndpoint) + val json = toJsonAst(generated) + val expected = """{ + | "openapi" : "3.1.0", + | "info" : { + | "title" : "Simple Endpoint", + | "version" : "1.0" + | }, + | "paths" : { + | "/withPayload" : { + | "get" : { + | "requestBody" : { + | "content" : { + | "application/json" : { + | "schema" : { + | "anyOf" : [ + | { + | "type" : "null" + | }, + | { + | "$ref" : "#/components/schemas/Payload" + | } + | ], + | "description" : "" + | } + | } + | }, + | "required" : false + | }, + | "responses" : { + | "200" : { + | "content" : { + | "application/json" : { + | "schema" : { + | "$ref" : "#/components/schemas/SimpleOutputBody" + | } + | } + | } + | }, + | "404" : { + | "content" : { + | "application/json" : { + | "schema" : { + | "$ref" : "#/components/schemas/NotFoundError" + | } + | } + | } + | } + | } + | } + | } + | }, + | "components" : { + | "schemas" : { + | "NotFoundError" : { + | "type" : "object", + | "properties" : { + | "message" : { + | "type" : "string" + | } + | }, + | "required" : [ + | "message" + | ] + | }, + | "Payload" : { + | "type" : "object", + | "properties" : { + | "content" : { + | "type" : "string" + | } + | }, + | "required" : [ + | "content" + | ], + | "description" : "A simple payload" + | }, + | "SimpleOutputBody" : { + | "type" : "object", + | "properties" : { + | "userName" : { + | "type" : "string" + | }, + | "score" : { + | "type" : "integer", + | "format" : "int32" + | } + | }, + | "required" : [ + | "userName", + | "score" + | ] + | } + | } + | } + |}""".stripMargin + assertTrue(json == toJsonAst(expected)) + }, test("alternative input") { val generated = OpenAPIGen.fromEndpoints("Simple Endpoint", "1.0", alternativeInputEndpoint) val json = toJsonAst(generated) @@ -1383,8 +1758,7 @@ object OpenAPIGenSpec extends ZIOSpecDefault { | "width", | "height", | "metadata" - | ], - | "description" : "Test doc\n\n" + | ] | } | } | } @@ -2858,6 +3232,119 @@ object OpenAPIGenSpec extends ZIOSpecDefault { |""".stripMargin assertTrue(json == toJsonAst(expectedJson)) }, + test("Stream schema") { + val endpoint = Endpoint(RoutePattern.POST / "folder") + .outStream[Int] + val openApi = OpenAPIGen.fromEndpoints(endpoint) + val json = toJsonAst(openApi) + val expectedJson = + """ + |{ + | "openapi" : "3.1.0", + | "info" : { + | "title" : "", + | "version" : "" + | }, + | "paths" : { + | "/folder" : { + | "post" : { + | "responses" : { + | "200" : + | { + | "content" : { + | "application/json" : { + | "schema" : + | { + | "type" : + | "array", + | "items" : { + | "type" : + | "integer", + | "format" : "int32" + | } + | } + | } + | } + | } + | } + | } + | } + | }, + | "components" : { + | + | } + |} + |""".stripMargin + assertTrue(json == toJsonAst(expectedJson)) + }, + test("Stream schema multipart") { + val endpoint = Endpoint(RoutePattern.POST / "folder") + .outCodec( + HttpCodec.contentStream[String]("strings") ++ + HttpCodec.contentStream[Int]("ints"), + ) + val openApi = OpenAPIGen.fromEndpoints(endpoint) + val json = toJsonAst(openApi) + val expectedJson = + """ + |{ + | "openapi" : "3.1.0", + | "info" : { + | "title" : "", + | "version" : "" + | }, + | "paths" : { + | "/folder" : { + | "post" : { + | "responses" : { + | "default" : + | { + | "content" : { + | "multipart/form-data" : { + | "schema" : + | { + | "type" : + | "object", + | "properties" : { + | "strings" : { + | "type" : + | "array", + | "items" : { + | "type" : + | "string" + | } + | }, + | "ints" : { + | "type" : + | "array", + | "items" : { + | "type" : + | "integer", + | "format" : "int32" + | } + | } + | }, + | "additionalProperties" : + | false, + | "required" : [ + | "strings", + | "ints" + | ] + | } + | } + | } + | } + | } + | } + | } + | }, + | "components" : { + | + | } + |} + |""".stripMargin + assertTrue(json == toJsonAst(expectedJson)) + }, test("Lazy schema") { val endpoint = Endpoint(RoutePattern.POST / "lazy") .in[Lazy.A] diff --git a/zio-http/jvm/src/test/scala/zio/http/headers/ForwardedSpec.scala b/zio-http/jvm/src/test/scala/zio/http/headers/ForwardedSpec.scala index c979b79095..61726d5459 100644 --- a/zio-http/jvm/src/test/scala/zio/http/headers/ForwardedSpec.scala +++ b/zio-http/jvm/src/test/scala/zio/http/headers/ForwardedSpec.scala @@ -19,7 +19,7 @@ package zio.http.headers import zio.Scope import zio.test._ -import zio.http.{Header, ZIOHttpSpec} +import zio.http.{Header, Headers, Request, ZIOHttpSpec} object ForwardedSpec extends ZIOHttpSpec { override def spec: Spec[TestEnvironment with Scope, Any] = suite("Forwarded suite")( @@ -58,5 +58,22 @@ object ForwardedSpec extends ZIOHttpSpec { ) assertTrue(Header.Forwarded.parse(headerValue) == Right(header)) }, + test("render Forwarded produces a valid raw header value") { + val gen = for { + by <- Gen.option(Gen.const("by.host")) + forv <- Gen.listOf( + Gen.elements("127.0.0.1", "localhost", "0.0.0.0"), + ) + host <- Gen.option(Gen.const("host.com")) + proto <- Gen.option(Gen.const("http")) + } yield (by, forv, host, proto) + + check(gen) { case (by, forv, host, proto) => + val expected = Header.Forwarded(by = by, forValues = forv, host = host, proto = proto) + val raw = Header.Forwarded.render(expected) + val actual = Header.Forwarded.parse(raw) + assertTrue(actual.is(_.right) == expected).label(s"Rendering result > '${raw}'") + } + } @@ TestAspect.shrinks(0), ) } diff --git a/zio-http/shared/src/main/scala/zio/http/ClientSSLConfig.scala b/zio-http/shared/src/main/scala/zio/http/ClientSSLConfig.scala index caed30430d..a7a4ab8f7f 100644 --- a/zio-http/shared/src/main/scala/zio/http/ClientSSLConfig.scala +++ b/zio-http/shared/src/main/scala/zio/http/ClientSSLConfig.scala @@ -28,6 +28,15 @@ object ClientSSLConfig { val trustStorePath = Config.string("trust-store-path") val trustStorePassword = Config.secret("trust-store-password") + val keyManagerKeyStoreType = Config.string("keyManagerKeyStoreType") + val keyManagerFile = Config.string("keyManagerFile") + val keyManagerResource = Config.string("keyManagerResource") + val keyManagerPassword = Config.secret("keyManagerPassword") + val trustManagerKeyStoreType = Config.string("trustManagerKeyStoreType") + val trustManagerFile = Config.string("trustManagerFile") + val trustManagerResource = Config.string("trustManagerResource") + val trustManagerPassword = Config.secret("trustManagerPassword") + val default = Config.succeed(Default) val fromCertFile = certPath.map(FromCertFile(_)) val fromCertResource = certPath.map(FromCertResource(_)) @@ -39,6 +48,45 @@ object ClientSSLConfig { serverCertConfig.zipWith(clientCertConfig)(FromClientAndServerCert(_, _)) } + val fromJavaxNetSsl = { + keyManagerKeyStoreType.optional + .zip(keyManagerFile.optional) + .zip(keyManagerResource.optional) + .zip(keyManagerPassword.optional) + .zip(trustManagerKeyStoreType.optional) + .zip( + trustManagerFile.optional + .zip(trustManagerResource.optional) + .validate("must supply trustManagerFile or trustManagerResource")(pair => + pair._1.isDefined || pair._2.isDefined, + ), + ) + .zip(trustManagerPassword.optional) + .map { case (kmkst, kmf, kmr, kmpass, tmkst, (tmf, tmr), tmpass) => + val bldr0 = + List[(Option[String], FromJavaxNetSsl => String => FromJavaxNetSsl)]( + (kmkst, b => b.keyManagerKeyStoreType(_)), + (kmf, b => b.keyManagerFile), + (kmr, b => b.keyManagerResource), + (tmkst, b => b.trustManagerKeyStoreType(_)), + (tmf, b => b.trustManagerFile), + (tmr, b => b.trustManagerResource), + ) + .foldLeft(FromJavaxNetSsl()) { case (bldr, (maybe, lens)) => + maybe.fold(bldr)(s => lens(bldr)(s)) + } + + List[(Option[Secret], FromJavaxNetSsl => Secret => FromJavaxNetSsl)]( + (kmpass, b => b.keyManagerPassword(_)), + (tmpass, b => b.trustManagerPassword(_)), + ) + .foldLeft(bldr0) { case (bldr, (maybe, lens)) => + maybe.fold(bldr)(s => lens(bldr)(s)) + } + .build() + } + } + tpe.switch( "Default" -> default, "FromCertFile" -> fromCertFile, @@ -58,6 +106,55 @@ object ClientSSLConfig { clientCertConfig: ClientSSLCertConfig, ) extends ClientSSLConfig + final case class FromJavaxNetSsl( + keyManagerKeyStoreType: String = "JKS", + keyManagerSource: FromJavaxNetSsl.Source = FromJavaxNetSsl.Empty, + keyManagerPassword: Option[Secret] = None, + trustManagerKeyStoreType: String = "JKS", + trustManagerSource: FromJavaxNetSsl.Source = FromJavaxNetSsl.Empty, + trustManagerPassword: Option[Secret] = None, + ) extends ClientSSLConfig { self => + + def isValidBuild: Boolean = trustManagerSource != FromJavaxNetSsl.Empty + def isInvalidBuild: Boolean = !isValidBuild + def build(): FromJavaxNetSsl = this + + def keyManagerKeyStoreType(tpe: String): FromJavaxNetSsl = self.copy(keyManagerKeyStoreType = tpe) + def keyManagerFile(file: String): FromJavaxNetSsl = + keyManagerSource match { + case FromJavaxNetSsl.Resource(_) => this + case _ => self.copy(keyManagerSource = FromJavaxNetSsl.File(file)) + } + def keyManagerResource(path: String): FromJavaxNetSsl = self.copy(keyManagerSource = FromJavaxNetSsl.Resource(path)) + def keyManagerPassword(password: Secret): FromJavaxNetSsl = self.copy(keyManagerPassword = Some(password)) + def keyManagerPassword(password: String): FromJavaxNetSsl = keyManagerPassword(Secret(password)) + + def trustManagerKeyStoreType(tpe: String): FromJavaxNetSsl = self.copy(trustManagerKeyStoreType = tpe) + def trustManagerFile(file: String): FromJavaxNetSsl = + trustManagerSource match { + case FromJavaxNetSsl.Resource(_) => this + case _ => self.copy(trustManagerSource = FromJavaxNetSsl.File(file)) + } + def trustManagerResource(path: String): FromJavaxNetSsl = + self.copy(trustManagerSource = FromJavaxNetSsl.Resource(path)) + def trustManagerPassword(password: Secret): FromJavaxNetSsl = self.copy(trustManagerPassword = Some(password)) + def trustManagerPassword(password: String): FromJavaxNetSsl = trustManagerPassword(Secret(password)) + } + + object FromJavaxNetSsl { + + sealed trait Source extends Product with Serializable + case object Empty extends Source + final case class File(file: String) extends Source + final case class Resource(resource: String) extends Source + + def builderWithTrustManagerFile(file: String): FromJavaxNetSsl = + FromJavaxNetSsl().trustManagerFile(file) + + def builderWithTrustManagerResource(resource: String): FromJavaxNetSsl = + FromJavaxNetSsl().trustManagerResource(resource) + } + object FromTrustStoreResource { def apply(trustStorePath: String, trustStorePassword: String): FromTrustStoreResource = FromTrustStoreResource(trustStorePath, Secret(trustStorePassword)) diff --git a/zio-http/shared/src/main/scala/zio/http/ErrorResponseConfig.scala b/zio-http/shared/src/main/scala/zio/http/ErrorResponseConfig.scala index bc9a3fa8d5..10237403a8 100644 --- a/zio-http/shared/src/main/scala/zio/http/ErrorResponseConfig.scala +++ b/zio-http/shared/src/main/scala/zio/http/ErrorResponseConfig.scala @@ -12,6 +12,10 @@ import zio._ * @param maxStackTraceDepth * maximum number of stack trace lines to include in the response body. Set to * 0 to include all lines. + * @param errorFormat + * the preferred format for the error response. If the context in which the + * response is created has access to an Accept header, the header will be used + * preferably to determine the format. */ final case class ErrorResponseConfig( withErrorBody: Boolean = false, @@ -38,6 +42,9 @@ object ErrorResponseConfig { val debug: HandlerAspect[Any, Unit] = Middleware.runBefore(setConfig(debugConfig)) + val debugLayer: ULayer[Unit] = + ZLayer(setConfig(debugConfig)) + def withConfig(config: ErrorResponseConfig): HandlerAspect[Any, Unit] = Middleware.runBefore(setConfig(config)) diff --git a/zio-http/shared/src/main/scala/zio/http/Header.scala b/zio-http/shared/src/main/scala/zio/http/Header.scala index fca8e9d09f..48a87bb8ad 100644 --- a/zio-http/shared/src/main/scala/zio/http/Header.scala +++ b/zio-http/shared/src/main/scala/zio/http/Header.scala @@ -2856,8 +2856,14 @@ object Header { Right(Forwarded(by, forValue, host, proto)) } - def render(forwarded: Forwarded): String = - s"${forwarded.by}; ${forwarded.forValues.map(v => s"for=$v").mkString(",")}; ${forwarded.host}; ${forwarded.proto}" + def render(forwarded: Forwarded): String = { + def formatDirective(directiveName: String, directiveValue: Option[String]) = + directiveValue.map(v => s"${directiveName}=${v};").getOrElse("") + + val forValues = if (forwarded.forValues.nonEmpty) forwarded.forValues.mkString("for=", ",for=", ";") else "" + + s"${formatDirective("by", forwarded.by)}${forValues}${formatDirective("host", forwarded.host)}${formatDirective("proto", forwarded.proto)}" + } } /** From header value. */ diff --git a/zio-http/shared/src/main/scala/zio/http/Method.scala b/zio-http/shared/src/main/scala/zio/http/Method.scala index e367719e89..93083a86bf 100644 --- a/zio-http/shared/src/main/scala/zio/http/Method.scala +++ b/zio-http/shared/src/main/scala/zio/http/Method.scala @@ -31,7 +31,9 @@ sealed trait Method { self => if (that == Method.ANY) self else that - def /[A](that: PathCodec[A]): RoutePattern[A] = RoutePattern.fromMethod(self) / that + def /[A](that: PathCodec[A]): RoutePattern[A] = + if (that == PathCodec.empty) RoutePattern.fromMethod(self).asInstanceOf[RoutePattern[A]] + else RoutePattern.fromMethod(self) / that def matches(that: Method): Boolean = if (self == Method.ANY) true diff --git a/zio-http/shared/src/main/scala/zio/http/RoutePattern.scala b/zio-http/shared/src/main/scala/zio/http/RoutePattern.scala index b73f5b09b4..c42739408b 100644 --- a/zio-http/shared/src/main/scala/zio/http/RoutePattern.scala +++ b/zio-http/shared/src/main/scala/zio/http/RoutePattern.scala @@ -53,7 +53,9 @@ final case class RoutePattern[A](method: Method, pathCodec: PathCodec[A]) { self * Returns a new pattern that is extended with the specified segment pattern. */ def /[B](that: PathCodec[B])(implicit combiner: Combiner[A, B]): RoutePattern[combiner.Out] = - copy(pathCodec = pathCodec ++ that) + if (that == PathCodec.empty) self.asInstanceOf[RoutePattern[combiner.Out]] + else if (pathCodec == PathCodec.empty) copy(pathCodec = that.asInstanceOf[PathCodec[combiner.Out]]) + else copy(pathCodec = pathCodec ++ that) /** * Creates a route from this pattern and the specified handler. diff --git a/zio-http/shared/src/main/scala/zio/http/codec/HttpCodec.scala b/zio-http/shared/src/main/scala/zio/http/codec/HttpCodec.scala index df546bd0da..8a2fe790a8 100644 --- a/zio-http/shared/src/main/scala/zio/http/codec/HttpCodec.scala +++ b/zio-http/shared/src/main/scala/zio/http/codec/HttpCodec.scala @@ -566,6 +566,19 @@ object HttpCodec extends ContentCodecs with HeaderCodecs with MethodCodecs with def tag: AtomTag = AtomTag.Content def index(index: Int): Content[A] = copy(index = index) + + /** + * Returns a new codec, where the value produced by this one is optional. + */ + override def optional: HttpCodec[HttpCodecType.Content, Option[A]] = + Annotated( + Content( + codec.optional, + name, + index, + ), + Metadata.Optional(), + ) } private[http] final case class ContentStream[A]( codec: HttpContentCodec[A], diff --git a/zio-http/shared/src/main/scala/zio/http/codec/HttpContentCodec.scala b/zio-http/shared/src/main/scala/zio/http/codec/HttpContentCodec.scala index 0c92a45d89..3f8a3f29a2 100644 --- a/zio-http/shared/src/main/scala/zio/http/codec/HttpContentCodec.scala +++ b/zio-http/shared/src/main/scala/zio/http/codec/HttpContentCodec.scala @@ -1,11 +1,15 @@ package zio.http.codec +import java.nio.charset.StandardCharsets + import scala.collection.immutable.ListMap import zio._ -import zio.stream.ZPipeline +import zio.stream.{ZChannel, ZPipeline} +import zio.schema.codec.DecodeError.ReadError +import zio.schema.codec.JsonCodec.{JsonDecoder, JsonEncoder} import zio.schema.codec._ import zio.schema.{DeriveSchema, Schema} @@ -144,6 +148,36 @@ sealed trait HttpContentCodec[A] { self => choices.headOption.map(_._2).getOrElse { throw new IllegalArgumentException(s"No codec defined") } + + def optional: HttpContentCodec[Option[A]] = + self match { + case HttpContentCodec.Choices(choices) => + HttpContentCodec.Choices( + choices.map { case (mediaType, BinaryCodecWithSchema(fromConfig, schema)) => + mediaType -> BinaryCodecWithSchema(fromConfig.andThen(optBinaryCodec), schema.optional) + }, + ) + case HttpContentCodec.Filtered(codec, mediaType) => + HttpContentCodec.Filtered(codec.optional, mediaType) + } + + private def optBinaryCodec(bc: BinaryCodec[A]): BinaryCodec[Option[A]] = new BinaryCodec[Option[A]] { + override def encode(value: Option[A]): Chunk[Byte] = value match { + case Some(a) => bc.encode(a) + case None => Chunk.empty + } + + override def decode(bytes: Chunk[Byte]): Either[DecodeError, Option[A]] = + if (bytes.isEmpty) Right(None) + else bc.decode(bytes).map(Some(_)) + + override def streamDecoder: ZPipeline[Any, DecodeError, Byte, Option[A]] = + ZPipeline.chunks[Byte].map(bc.decode).map(_.toOption) + + override def streamEncoder: ZPipeline[Any, Nothing, Option[A], Byte] = + ZPipeline.identity[Option[A]].map(_.fold(Chunk.empty[Byte])(bc.encode)).flattenChunks + } + } object HttpContentCodec { @@ -273,6 +307,7 @@ object HttpContentCodec { } object json { + private var jsonCodecCache: Map[Schema[_], HttpContentCodec[_]] = Map.empty def only[A](implicit schema: Schema[A]): HttpContentCodec[A] = if (jsonCodecCache.contains(schema)) { @@ -282,10 +317,12 @@ object HttpContentCodec { ListMap( MediaType.application.`json` -> BinaryCodecWithSchema( - config => - JsonCodec.schemaBasedBinaryCodec[A]( - JsonCodec.Config(ignoreEmptyCollections = config.ignoreEmptyCollections), - )(schema), + config => { + JsonCodec.schemaBasedBinaryCodec( + JsonCodec + .Config(ignoreEmptyCollections = config.ignoreEmptyCollections, treatStreamsAsArrays = true), + )(schema) + }, schema, ), ), diff --git a/zio-http/shared/src/main/scala/zio/http/codec/PathCodec.scala b/zio-http/shared/src/main/scala/zio/http/codec/PathCodec.scala index ead97b5da7..75c7e34140 100644 --- a/zio-http/shared/src/main/scala/zio/http/codec/PathCodec.scala +++ b/zio-http/shared/src/main/scala/zio/http/codec/PathCodec.scala @@ -48,8 +48,11 @@ sealed trait PathCodec[A] extends codec.PathCodecPlatformSpecific { self => final def ++[B](that: PathCodec[B])(implicit combiner: Combiner[A, B]): PathCodec[combiner.Out] = PathCodec.Concat(self, that, combiner) - final def /[B](that: PathCodec[B])(implicit combiner: Combiner[A, B]): PathCodec[combiner.Out] = - self ++ that + final def /[B](that: PathCodec[B])(implicit combiner: Combiner[A, B]): PathCodec[combiner.Out] = { + if (self == PathCodec.empty) that.asInstanceOf[PathCodec[combiner.Out]] + else if (that == PathCodec.empty) self.asInstanceOf[PathCodec[combiner.Out]] + else self ++ that + } final def /[Env, Err](routes: Routes[Env, Err])(implicit ev: PathCodec[A] <:< PathCodec[Unit], diff --git a/zio-http/shared/src/main/scala/zio/http/codec/SegmentCodec.scala b/zio-http/shared/src/main/scala/zio/http/codec/SegmentCodec.scala index a8077c6b64..e60ea41f8c 100644 --- a/zio-http/shared/src/main/scala/zio/http/codec/SegmentCodec.scala +++ b/zio-http/shared/src/main/scala/zio/http/codec/SegmentCodec.scala @@ -114,6 +114,7 @@ sealed trait SegmentCodec[A] { self => } if (self ne SegmentCodec.Empty) b.append('/') loop(self.asInstanceOf[SegmentCodec[_]]) + if (b.isEmpty) b.appendAll("/") b.result() } 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 abd59326c0..cdc38d22d2 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 @@ -117,8 +117,8 @@ private[openapi] object BoolOrSchema { private[openapi] sealed trait TypeOrTypes { self => def add(value: String): TypeOrTypes = self match { - case TypeOrTypes.Type(string) => TypeOrTypes.Types(Chunk(string, value)) - case TypeOrTypes.Types(chunk) => TypeOrTypes.Types(chunk :+ value) + case TypeOrTypes.Type(string) => TypeOrTypes.Types(Chunk(string, value).distinct) + case TypeOrTypes.Types(chunk) => TypeOrTypes.Types((chunk :+ value).distinct) } } @@ -253,7 +253,7 @@ object JsonSchema { .toOption .get - private def fromSerializableSchema(schema: SerializableJsonSchema): JsonSchema = { + private[openapi] def fromSerializableSchema(schema: SerializableJsonSchema): JsonSchema = { val additionalProperties = schema.additionalProperties match { case Some(BoolOrSchema.BooleanWrapper(bool)) => Left(bool) case Some(BoolOrSchema.SchemaWrapper(schema)) => diff --git a/zio-http/shared/src/main/scala/zio/http/endpoint/openapi/OpenAPIGen.scala b/zio-http/shared/src/main/scala/zio/http/endpoint/openapi/OpenAPIGen.scala index b6d848e78f..41f0232a46 100644 --- a/zio-http/shared/src/main/scala/zio/http/endpoint/openapi/OpenAPIGen.scala +++ b/zio-http/shared/src/main/scala/zio/http/endpoint/openapi/OpenAPIGen.scala @@ -314,7 +314,17 @@ object OpenAPIGen { findName(metadata).orElse(maybeName).getOrElse(throw new Exception("Multipart content without name")) JsonSchema.obj( name -> JsonSchema - .fromZSchema(codec.lookup(mediaType).map(_._2.schema).getOrElse(codec.defaultSchema), referenceType) + .ArrayType( + Some( + JsonSchema + .fromZSchema( + codec.lookup(mediaType).map(_._2.schema).getOrElse(codec.defaultSchema), + referenceType, + ), + ), + None, + uniqueItems = false, + ) .description(descriptionFromMeta) .deprecated(deprecated(metadata)) .nullable(optional(metadata)), @@ -327,7 +337,14 @@ object OpenAPIGen { .nullable(optional(metadata)) case HttpCodec.ContentStream(codec, _, _) => JsonSchema - .fromZSchema(codec.lookup(mediaType).map(_._2.schema).getOrElse(codec.defaultSchema), referenceType) + .ArrayType( + Some( + JsonSchema + .fromZSchema(codec.lookup(mediaType).map(_._2.schema).getOrElse(codec.defaultSchema), referenceType), + ), + None, + uniqueItems = false, + ) .description(descriptionFromMeta) .deprecated(deprecated(metadata)) .nullable(optional(metadata)) @@ -617,7 +634,7 @@ object OpenAPIGen { def queryParams: Set[OpenAPI.ReferenceOr[OpenAPI.Parameter]] = { inAtoms.query.collect { - case mc @ MetaCodec(HttpCodec.Query(HttpCodec.Query.QueryType.Primitive(name, codec), _), _) => + case mc @ MetaCodec(q @ HttpCodec.Query(HttpCodec.Query.QueryType.Primitive(name, codec), _), _) => OpenAPI.ReferenceOr.Or( OpenAPI.Parameter.queryParameter( name = name, @@ -630,10 +647,10 @@ object OpenAPIGen { examples = mc.examples.map { case (name, value) => name -> OpenAPI.ReferenceOr.Or(OpenAPI.Example(value = Json.Str(value.toString))) }, - required = mc.required, + required = mc.required && !q.isOptional, ), ) :: Nil - case mc @ MetaCodec(HttpCodec.Query(record @ HttpCodec.Query.QueryType.Record(schema), _), _) => + case mc @ MetaCodec(HttpCodec.Query(record @ HttpCodec.Query.QueryType.Record(schema), _), _) => val recordSchema = (schema match { case schema if schema.isInstanceOf[Schema.Optional[_]] => schema.asInstanceOf[Schema.Optional[_]].schema case _ => schema @@ -691,7 +708,7 @@ object OpenAPIGen { examples = mc.examples.map { case (exName, value) => exName -> OpenAPI.ReferenceOr.Or(OpenAPI.Example(value = Json.Str(value.toString))) }, - required = !optional, + required = mc.required && !optional, ), ) :: Nil } @@ -722,7 +739,8 @@ object OpenAPIGen { OpenAPI.Parameter.headerParameter( name = mc.name.getOrElse(codec.name), description = mc.docsOpt, - definition = Some(OpenAPI.ReferenceOr.Or(JsonSchema.fromTextCodec(codec.textCodec))), + definition = + Some(OpenAPI.ReferenceOr.Or(JsonSchema.fromTextCodec(codec.textCodec).nullable(!mc.required))), deprecated = mc.deprecated, examples = mc.examples.map { case (name, value) => name -> OpenAPI.ReferenceOr.Or(OpenAPI.Example(codec.textCodec.encode(value).toJsonAST.toOption.get)) @@ -922,9 +940,13 @@ object OpenAPIGen { case (mediaType, values) => val combinedAtomized: AtomizedMetaCodecs = values.map(_._1).reduce(_ ++ _) val combinedContentDoc = combinedAtomized.contentDocs.toCommonMark + val vals = + if (values.forall(v => v._2.isNullable || v._2 == JsonSchema.Null)) + values.map(_._2).filter(_ != JsonSchema.Null) + else values.map(_._2) val alternativesSchema = { JsonSchema - .AnyOfSchema(values.map { case (_, schema) => + .AnyOfSchema(vals.map { schema => schema.description match { case Some(value) => schema.description(value.replace(combinedContentDoc, "")) case None => schema