From 56843eab80d8f81df3375d570f42510f8093b7c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Olivier=20M=C3=A9lois?= Date: Fri, 14 Jan 2022 12:39:13 +0100 Subject: [PATCH] Derive http endpoints from hints (#41) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Derive HttpEndpoint from Hint instead of relying on subtyping This derives the HttpEndpoint instance associated to an Endpoint, by searching smithy.api.Http in the the Endpoint's hints, and compiling the found data to the correct interface, by means of Schematic. The rationale is the following : 1. allow for dynamically instantiated service to have HttpService without any bespoke processing. 2. open the door for splitting the http package away from the core module Also : * AlsRemove uri-specific methods from core package object * Fix URIEncoderDecoder to actually do uri encoding Co-authored-by: Jakub Kozłowski --- build.sbt | 1 - .../src/smithy4s/aws/AwsSignature.scala | 2 +- .../src/smithy4s/codegen/Renderer.scala | 47 +----- .../core/src/smithy4s/http/HttpEndpoint.scala | 28 +++- modules/core/src/smithy4s/http/Method.scala | 6 + .../core/src/smithy4s/http/PathSegment.scala | 1 + .../smithy4s/http/internals/PathEncode.scala | 64 +++++++++ .../http/internals/SchematicPathEncoder.scala | 136 ++++++++++++++++++ .../http/internals/URIEncoderDecoder.scala | 133 ++++++----------- .../src/smithy4s/http/internals/package.scala | 19 +++ modules/core/src/smithy4s/package.scala | 5 - .../src-js/smithy4s/JsConvertersSpec.scala | 16 +++ .../src/smithy4s/TypeInferenceSmokeSpec.scala | 37 ++--- .../smithy4s/http/internals/PathSpec.scala | 67 +++++++++ .../src/smithy4s/example/FooService.scala | 7 +- .../src/smithy4s/example/ObjectService.scala | 13 +- .../DiscriminatedUnionValidatorSpec.scala | 16 +++ sampleSpecs/metadata.smithy | 11 +- sampleSpecs/pizza.smithy | 1 + 19 files changed, 418 insertions(+), 192 deletions(-) create mode 100644 modules/core/src/smithy4s/http/internals/PathEncode.scala create mode 100644 modules/core/src/smithy4s/http/internals/SchematicPathEncoder.scala create mode 100644 modules/core/test/src/smithy4s/http/internals/PathSpec.scala diff --git a/build.sbt b/build.sbt index ad658124b..9b08accef 100644 --- a/build.sbt +++ b/build.sbt @@ -284,7 +284,6 @@ lazy val `aws-http4s` = projectMatrix (ThisBuild / baseDirectory).value / "sampleSpecs" / "dynamodb.2012-08-10.json" ) ) - .settings(Smithy4sPlugin.doNotPublishArtifact) .jvmPlatform(latest2ScalaVersions, jvmDimSettings) .jsPlatform(latest2ScalaVersions, jsDimSettings) diff --git a/modules/aws-kernel/src/smithy4s/aws/AwsSignature.scala b/modules/aws-kernel/src/smithy4s/aws/AwsSignature.scala index 246bcf916..b9fb2dd4c 100644 --- a/modules/aws-kernel/src/smithy4s/aws/AwsSignature.scala +++ b/modules/aws-kernel/src/smithy4s/aws/AwsSignature.scala @@ -16,7 +16,7 @@ package smithy4s.aws.kernel -import smithy4s.http.internals.URIEncoderDecoder.{encodeOthers => uriEncode} +import smithy4s.http.internals.URIEncoderDecoder.{encode => uriEncode} import smithy4s.http.CaseInsensitive import smithy4s.http.HttpMethod diff --git a/modules/codegen/src/smithy4s/codegen/Renderer.scala b/modules/codegen/src/smithy4s/codegen/Renderer.scala index 49bb75ab1..d6d168d34 100644 --- a/modules/codegen/src/smithy4s/codegen/Renderer.scala +++ b/modules/codegen/src/smithy4s/codegen/Renderer.scala @@ -245,7 +245,6 @@ private[codegen] class Renderer(compilationUnit: CompilationUnit) { self => val opName = op.name val traitName = s"${serviceName}Operation" - val inputName = op.input.render val input = if (op.input == Type.unit) "" else "input" val errorName = if (op.errors.isEmpty) "Nothing" else s"${op.name}Error" @@ -254,13 +253,6 @@ private[codegen] class Renderer(compilationUnit: CompilationUnit) { self => s" with $Errorable_[$errorName]" } else "" - val httpEndpoint = - op.hints - .collectFirst { case Hint.Http(_, _, _) => - s" with http.HttpEndpoint[$inputName]" - } - .getOrElse("") - val errorUnion: Option[Union] = for { errorNel <- NonEmptyList.fromList(op.errors) alts <- errorNel.traverse { t => @@ -277,8 +269,7 @@ private[codegen] class Renderer(compilationUnit: CompilationUnit) { self => s"case class $opName($params) extends $traitName[${op.renderAlgParams}]", obj( opName, - ext = - s"$Endpoint_[${traitName}, ${op.renderAlgParams}]$httpEndpoint$errorable" + ext = s"$Endpoint_[${traitName}, ${op.renderAlgParams}]$errorable" )( renderId(op.name, op.originalNamespace), s"val input: $Schema_[${op.input.render}] = ${op.input.schemaRef}.withHints(smithy4s.internals.InputOutput.Input)", @@ -287,8 +278,7 @@ private[codegen] class Renderer(compilationUnit: CompilationUnit) { self => renderStreamingSchemaVal("streamedOutput", op.streamedOutput), renderHintsValWithId(op.hints), s"def wrap(input: ${op.input.render}) = ${opName}($input)", - renderErrorable(op), - renderHttpSpecific(op) + renderErrorable(op) ), renderedErrorUnion ).addImports(op.imports).addImports(syntaxImport) @@ -374,39 +364,6 @@ private[codegen] class Renderer(compilationUnit: CompilationUnit) { self => ).addImports(imports) } - private def renderHttpSpecific(op: Operation): RenderResult = lines { - op.hints - .collectFirst { case Hint.Http(method, uriPattern, code) => - val segments = uriPattern - .map { - case Segment.Label(value) => - s"""http.PathSegment.label("$value")""" - case Segment.GreedyLabel(value) => - s"""http.PathSegment.greedy("$value")""" - case Segment.Static(value) => - s"""http.PathSegment.static("$value")""" - } - .mkString("List(", ", ", ")") - - val uriValue = uriPattern - .map { - case Segment.Label(value) => - s"$${smithy4s.segment(input.$value)}" - case Segment.GreedyLabel(value) => - s"$${smithy4s.greedySegment(input.$value)}" - case Segment.Static(value) => value - } - .mkString("s\"", "/", "\"") - lines( - s"def path(input: ${op.input.render}) = $uriValue", - s"val path = $segments", - s"val code: Int = $code", - s"val method: http.HttpMethod = http.HttpMethod.$method" - ).addImports(Set("smithy4s.http")) - } - .getOrElse(empty) - } - private def renderErrorable(op: Operation): RenderResult = { val errorName = op.name + "Error" diff --git a/modules/core/src/smithy4s/http/HttpEndpoint.scala b/modules/core/src/smithy4s/http/HttpEndpoint.scala index d51f3b316..f3c082004 100644 --- a/modules/core/src/smithy4s/http/HttpEndpoint.scala +++ b/modules/core/src/smithy4s/http/HttpEndpoint.scala @@ -17,6 +17,9 @@ package smithy4s package http +import smithy4s.syntax._ +import smithy.api.Http + trait HttpEndpoint[I] { def path(input: I): String def path: List[PathSegment] @@ -36,8 +39,27 @@ object HttpEndpoint { def cast[Op[_, _, _, _, _], I, E, O, SI, SO]( endpoint: Endpoint[Op, I, E, O, SI, SO] - ): Option[HttpEndpoint[I]] = endpoint match { - case he: HttpEndpoint[_] => Some(he.asInstanceOf[HttpEndpoint[I]]) - case _ => None + ): Option[HttpEndpoint[I]] = { + for { + http <- endpoint.hints.get(Http) + httpMethod <- HttpMethod.fromString(http.method.value) + httpPath <- internals.pathSegments(http.uri.value) + encoder <- endpoint.input + .withHints(http) + .compile(internals.SchematicPathEncoder) + .get + } yield { + new HttpEndpoint[I] { + def path(input: I): String = { + val sb = new StringBuilder() + encoder.encode(sb, input) + sb.result() + } + val path: List[PathSegment] = httpPath.toList + val method: HttpMethod = httpMethod + val code: Int = http.code.getOrElse(200) + } + } } + } diff --git a/modules/core/src/smithy4s/http/Method.scala b/modules/core/src/smithy4s/http/Method.scala index a996d7fd8..08ae346fc 100644 --- a/modules/core/src/smithy4s/http/Method.scala +++ b/modules/core/src/smithy4s/http/Method.scala @@ -40,4 +40,10 @@ object HttpMethod { case object DELETE extends HttpMethod case object GET extends HttpMethod case object PATCH extends HttpMethod + + val values = List(PUT, POST, DELETE, GET, PATCH) + + def fromString(s: String): Option[HttpMethod] = values.find { m => + CaseInsensitive(s) == CaseInsensitive(m.showCapitalised) + } } diff --git a/modules/core/src/smithy4s/http/PathSegment.scala b/modules/core/src/smithy4s/http/PathSegment.scala index c5da86edf..ee5cb7a0f 100644 --- a/modules/core/src/smithy4s/http/PathSegment.scala +++ b/modules/core/src/smithy4s/http/PathSegment.scala @@ -26,4 +26,5 @@ object PathSegment { case class StaticSegment(value: String) extends PathSegment case class LabelSegment(value: String) extends PathSegment case class GreedySegment(value: String) extends PathSegment + } diff --git a/modules/core/src/smithy4s/http/internals/PathEncode.scala b/modules/core/src/smithy4s/http/internals/PathEncode.scala new file mode 100644 index 000000000..8fba64585 --- /dev/null +++ b/modules/core/src/smithy4s/http/internals/PathEncode.scala @@ -0,0 +1,64 @@ +/* + * Copyright 2021 Disney Streaming + * + * Licensed under the Tomorrow Open Source Technology License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://disneystreaming.github.io/TOST-1.0.txt + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package smithy4s.http.internals + +import smithy4s.internals.Hinted + +trait PathEncode[A] { self => + def encode(sb: StringBuilder, a: A): Unit + def encodeGreedy(sb: StringBuilder, a: A): Unit + + def contramap[B](from: B => A): PathEncode[B] = new PathEncode[B] { + def encode(sb: StringBuilder, b: B): Unit = self.encode(sb, from(b)) + + def encodeGreedy(sb: StringBuilder, b: B): Unit = + self.encodeGreedy(sb, from(b)) + } +} + +object PathEncode { + + type MaybePathEncode[A] = Option[PathEncode[A]] + type Make[A] = Hinted[MaybePathEncode, A] + + object Make { + def from[A](f: A => String): Make[A] = Hinted.static[MaybePathEncode, A] { + Some { + raw(f) + } + } + + def raw[A](f: A => String): PathEncode[A] = { + new PathEncode[A] { + def encode(sb: StringBuilder, a: A): Unit = { + val _ = sb.append(URIEncoderDecoder.encode(f(a))) + } + def encodeGreedy(sb: StringBuilder, a: A): Unit = { + f(a).split('/').foreach { + case s if s.isEmpty() => () + case s => sb.append('/').append(URIEncoderDecoder.encode(s)) + } + } + } + } + + def fromToString[A]: Make[A] = from(_.toString) + + def noop[A]: Make[A] = Hinted.static[MaybePathEncode, A](None) + } + +} diff --git a/modules/core/src/smithy4s/http/internals/SchematicPathEncoder.scala b/modules/core/src/smithy4s/http/internals/SchematicPathEncoder.scala new file mode 100644 index 000000000..20ba0e342 --- /dev/null +++ b/modules/core/src/smithy4s/http/internals/SchematicPathEncoder.scala @@ -0,0 +1,136 @@ +/* + * Copyright 2021 Disney Streaming + * + * Licensed under the Tomorrow Open Source Technology License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://disneystreaming.github.io/TOST-1.0.txt + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package smithy4s +package http.internals + +import smithy.api.TimestampFormat +import schematic.Field +import smithy4s.internals.Hinted +import smithy.api.Http +import smithy4s.http.PathSegment +import smithy4s.http.PathSegment.StaticSegment +import smithy4s.http.PathSegment.LabelSegment +import smithy4s.http.PathSegment.GreedySegment + +object SchematicPathEncoder + extends Schematic[PathEncode.Make] + with StubSchematic[PathEncode.Make] { + + def default[A]: PathEncode.Make[A] = PathEncode.Make.noop + override def withHints[A]( + fa: PathEncode.Make[A], + hints: smithy4s.Hints + ): PathEncode.Make[A] = + fa.addHints(hints) + + override def bijection[A, B]( + f: PathEncode.Make[A], + to: A => B, + from: B => A + ): PathEncode.Make[B] = + Hinted[PathEncode.MaybePathEncode, B]( + f.hints, + make = hints => f.make(hints).map(_.contramap(from)) + ) + + override val bigdecimal: PathEncode.Make[BigDecimal] = + PathEncode.Make.fromToString + override val bigint: PathEncode.Make[BigInt] = PathEncode.Make.fromToString + override val double: PathEncode.Make[Double] = PathEncode.Make.fromToString + override val int: PathEncode.Make[Int] = PathEncode.Make.fromToString + override val float: PathEncode.Make[Float] = PathEncode.Make.fromToString + override val short: PathEncode.Make[Short] = PathEncode.Make.fromToString + override val long: PathEncode.Make[Long] = PathEncode.Make.fromToString + override val string: PathEncode.Make[String] = PathEncode.Make.fromToString + override val uuid: PathEncode.Make[java.util.UUID] = + PathEncode.Make.fromToString + override val boolean: PathEncode.Make[Boolean] = PathEncode.Make.fromToString + + override val timestamp: PathEncode.Make[Timestamp] = + Hinted[PathEncode.MaybePathEncode].from { hints => + val fmt = hints.get(TimestampFormat).getOrElse(TimestampFormat.DATE_TIME) + + Some(PathEncode.Make.raw(_.format(fmt))) + } + + override val unit: PathEncode.Make[Unit] = + genericStruct(Vector.empty)(_ => ()) + + override def genericStruct[S](fields: Vector[Field[PathEncode.Make, S, _]])( + const: Vector[Any] => S + ): PathEncode.Make[S] = { + type Writer = (StringBuilder, S) => Unit + + def toPathEncoder[A]( + field: Field[PathEncode.Make, S, A], + greedy: Boolean + ): Option[Writer] = { + field.fold(new Field.Folder[PathEncode.Make, S, Option[Writer]] { + def onRequired[AA]( + label: String, + instance: PathEncode.Make[AA], + get: S => AA + ): Option[Writer] = + if (greedy) + instance.get.map(encoder => + (sb, s) => encoder.encodeGreedy(sb, get(s)) + ) + else + instance.get.map(encoder => (sb, s) => encoder.encode(sb, get(s))) + def onOptional[AA]( + label: String, + instance: PathEncode.Make[AA], + get: S => Option[AA] + ): Option[Writer] = None + }) + } + + def compile1(path: PathSegment): Option[Writer] = path match { + case StaticSegment(value) => + Some((sb, _) => { val _ = sb.append(value) }) + case LabelSegment(value) => + fields + .find(_.label == value) + .flatMap(field => toPathEncoder(field, greedy = false)) + case GreedySegment(value) => + fields + .find(_.label == value) + .flatMap(field => toPathEncoder(field, greedy = true)) + } + + def compilePath(path: Vector[PathSegment]): Option[Vector[Writer]] = + path.traverse(compile1(_)) + + Hinted[PathEncode.MaybePathEncode].onHintOpt[Http, S] { maybeHttpHint => + for { + httpHint <- maybeHttpHint + path <- pathSegments(httpHint.uri.value) + writers <- compilePath(path) + } yield new PathEncode[S] { + def encode(sb: StringBuilder, s: S): Unit = { + var first = true + writers.foreach { w => + if (first) { w.apply(sb, s); first = false } + else w.apply(sb.append('/'), s) + } + } + + def encodeGreedy(sb: StringBuilder, s: S) = () + } + } + } +} diff --git a/modules/core/src/smithy4s/http/internals/URIEncoderDecoder.scala b/modules/core/src/smithy4s/http/internals/URIEncoderDecoder.scala index 180cbae19..a52fc8fd0 100644 --- a/modules/core/src/smithy4s/http/internals/URIEncoderDecoder.scala +++ b/modules/core/src/smithy4s/http/internals/URIEncoderDecoder.scala @@ -19,113 +19,66 @@ package http package internals import java.io.ByteArrayOutputStream -import java.net._ +import scala.annotation.tailrec private[smithy4s] object URIEncoderDecoder { val digits: String = "0123456789ABCDEF" - val encoding: String = "UTF8" - def validate(s: String, legal: String): Unit = { - var i: Int = 0 - while (i < s.length) { - var continue = false - val ch: Char = s.charAt(i) - if (ch == '%') { - continue = true - while ({ - if (i + 2 >= s.length) { - throw new URISyntaxException(s, "Incomplete % sequence", i) + def encode(s: String): String = { + if (s == null) { + throw new NullPointerException + } + val buf = new java.lang.StringBuilder(s.length + 16) + var start = -1 + @tailrec + def loop(i: Int): Unit = { + if (i < s.length) { + val ch: Char = s.charAt(i) + if ( + (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || " .-*_" + .indexOf(ch.toInt) > -1 + ) { + if (start >= 0) { + encodeOthers(s.substring(start, i), buf, encoding) + start = -1 } - val d1: Int = java.lang.Character.digit(s.charAt(i + 1), 16) - val d2: Int = java.lang.Character.digit(s.charAt(i + 2), 16) - if (d1 == -1 || d2 == -1) { - throw new URISyntaxException( - s, - "Invalid % sequence (" + s.substring(i, i + 3) + ")", - i - ) + if (ch != ' ') { + buf.append(ch) + } else { + buf.append('+') } - i += 3 - // loop condition - // Scala 3 dropped do-while loops - // this is the recommended rewrite: - // https://docs.scala-lang.org/scala3/reference/dropped-features/do-while.html - (i < s.length && s.charAt(i) == '%') - }) {} - } else if ( - !((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || - (ch >= '0' && ch <= '9') || - legal.indexOf(ch.toInt) > -1 || - (ch > 127 && !java.lang.Character.isSpaceChar( - ch - ) && !java.lang.Character - .isISOControl(ch))) - ) { - throw new URISyntaxException(s, "Illegal character", i) - } - if (!continue) i += 1 - } - } - - def validateSimple(s: String, legal: String): Unit = { - var i: Int = 0 - while (i < s.length) { - val ch: Char = s.charAt(i) - if ( - !((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || - (ch >= '0' && ch <= '9') || - legal.indexOf(ch.toInt) > -1) - ) { - throw new URISyntaxException(s, "Illegal character", i) + } else if (start < 0) { + start = i + } + loop(i + 1) } - i += 1 } - } + loop(0) - def quoteIllegal(s: String, legal: String): String = { - val buf: StringBuilder = new StringBuilder() - for (i <- 0 until s.length) { - val ch: Char = s.charAt(i) - if ( - (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || - (ch >= '0' && ch <= '9') || - legal.indexOf(ch.toInt) > -1 || - (ch > 127 && !java.lang.Character.isSpaceChar( - ch - ) && !java.lang.Character - .isISOControl(ch)) - ) { - buf.append(ch) - } else { - val bytes: Array[Byte] = new String(Array(ch)).getBytes(encoding) - for (j <- bytes.indices) { - buf.append('%') - buf.append(digits.charAt((bytes(j) & 0xf0) >> 4)) - buf.append(digits.charAt(bytes(j) & 0xf)) - } - } + if (start >= 0) { + encodeOthers(s.substring(start, s.length), buf, encoding) } buf.toString } - def encodeOthers(s: String): String = { - val buf: StringBuilder = new StringBuilder() - for (i <- 0 until s.length) { - val ch: Char = s.charAt(i) - if (ch <= 127) { - buf.append(ch) - } else { - val bytes: Array[Byte] = new String(Array(ch)).getBytes(encoding) - for (j <- bytes.indices) { - buf.append('%') - buf.append(digits.charAt((bytes(j) & 0xf0) >> 4)) - buf.append(digits.charAt(bytes(j) & 0xf)) - } + private def encodeOthers( + s: String, + buf: java.lang.StringBuilder, + enc: String + ): Unit = { + val bytes = s.getBytes(enc) + @tailrec + def loop(j: Int): Unit = { + if (j < bytes.length) { + buf.append('%') + buf.append(digits((bytes(j) & 0xf0) >> 4)) + buf.append(digits(bytes(j) & 0xf)) + loop(j + 1) } } - buf.toString + loop(0) } def decode(s: String): String = { diff --git a/modules/core/src/smithy4s/http/internals/package.scala b/modules/core/src/smithy4s/http/internals/package.scala index 3d39f77fb..a70aa7e13 100644 --- a/modules/core/src/smithy4s/http/internals/package.scala +++ b/modules/core/src/smithy4s/http/internals/package.scala @@ -52,4 +52,23 @@ package object internals { } } + private[smithy4s] def pathSegments( + str: String + ): Option[Vector[PathSegment]] = { + str + .split('/') + .toVector + .filterNot(_.isEmpty()) + .traverse(fromToString(_)) + } + + private def fromToString(str: String): Option[PathSegment] = { + if (str.isEmpty()) None + else if (str.startsWith("{") && str.endsWith("+}")) + Some(PathSegment.greedy(str.substring(1, str.length() - 2))) + else if (str.startsWith("{") && str.endsWith("}")) + Some(PathSegment.label(str.substring(1, str.length() - 1))) + else Some(PathSegment.static(str)) + } + } diff --git a/modules/core/src/smithy4s/package.scala b/modules/core/src/smithy4s/package.scala index 77c200b72..5a63c2d1d 100644 --- a/modules/core/src/smithy4s/package.scala +++ b/modules/core/src/smithy4s/package.scala @@ -14,8 +14,6 @@ * limitations under the License. */ -import smithy4s.http.internals.URIEncoderDecoder - package object smithy4s extends TypeAliases { type Hint = Hints.Binding[_] @@ -27,9 +25,6 @@ package object smithy4s extends TypeAliases { val errorTypeHeader = "X-Error-Type" - def segment(s: Any): String = URIEncoderDecoder.encodeOthers(s.toString()) - def greedySegment(s: String) = s.split("/").map(segment).mkString("/") - // Allows to "inject" F[_] types in places that require F[_,_,_,_,_] type GenLift[F[_]] = { type λ[I, E, O, SI, SO] = F[O] diff --git a/modules/core/test/src-js/smithy4s/JsConvertersSpec.scala b/modules/core/test/src-js/smithy4s/JsConvertersSpec.scala index f3b18fc78..2bf6b48b4 100644 --- a/modules/core/test/src-js/smithy4s/JsConvertersSpec.scala +++ b/modules/core/test/src-js/smithy4s/JsConvertersSpec.scala @@ -1,3 +1,19 @@ +/* + * Copyright 2021 Disney Streaming + * + * Licensed under the Tomorrow Open Source Technology License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://disneystreaming.github.io/TOST-1.0.txt + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package smithy4s import weaver._ diff --git a/modules/core/test/src/smithy4s/TypeInferenceSmokeSpec.scala b/modules/core/test/src/smithy4s/TypeInferenceSmokeSpec.scala index e71cdb6f8..d0f2ee0c4 100644 --- a/modules/core/test/src/smithy4s/TypeInferenceSmokeSpec.scala +++ b/modules/core/test/src/smithy4s/TypeInferenceSmokeSpec.scala @@ -16,38 +16,17 @@ package smithy4s -import weaver._ import smithy4s.example._ import cats.Functor import cats.syntax.all._ -import cats.effect.IO -object TypeInferenceSmokeSpec extends SimpleIOSuite { - - test("Type inference works with service calls") { - /* - * Checks that `map` can be called without upcasting the result of - * the service call to F[something]. - */ - def foo[F[_]: Functor](dummyService: DummyService[F]): F[Int] = - dummyService.dummy().map(_ => 1) - - val dummyInstance = new DummyService[IO] { - - override def dummy( - str: Option[String], - int: Option[Int], - ts1: Option[Timestamp], - ts2: Option[Timestamp], - ts3: Option[Timestamp], - ts4: Option[Timestamp], - b: Option[Boolean], - sl: Option[List[String]], - slm: Option[Map[String, String]] - ): IO[Unit] = IO.unit - - } - foo(dummyInstance).map(x => expect(x == 1)) - } +// compile-time tests +object TypeInferenceSmokeSpec { + /* + * Checks that `map` can be called without upcasting the result of + * the service call to F[something]. + */ + def foo[F[_]: Functor](dummyService: DummyService[F]): F[Int] = + dummyService.dummy().map(_ => 1) } diff --git a/modules/core/test/src/smithy4s/http/internals/PathSpec.scala b/modules/core/test/src/smithy4s/http/internals/PathSpec.scala new file mode 100644 index 000000000..2a1e8559a --- /dev/null +++ b/modules/core/test/src/smithy4s/http/internals/PathSpec.scala @@ -0,0 +1,67 @@ +/* + * Copyright 2021 Disney Streaming + * + * Licensed under the Tomorrow Open Source Technology License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://disneystreaming.github.io/TOST-1.0.txt + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package smithy4s.http.internals + +import smithy4s.Timestamp +import smithy4s.example.DummyServiceGen.DummyPath +import smithy4s.example.PathParams +import smithy4s.http.HttpEndpoint +import smithy4s.http.PathSegment + +object PathSpec extends weaver.FunSuite { + + test("Parse path pattern into path segments") { + val result = pathSegments("/{head}/foo/{tail+}") + expect( + result == Option( + Vector( + PathSegment.label("head"), + PathSegment.static("foo"), + PathSegment.greedy("tail") + ) + ) + ) + } + + test("Write PathParams") { + val result = HttpEndpoint + .cast( + DummyPath + ) + .get + .path( + PathParams( + "example with spaces, %, / and \\", + 10, + Timestamp.fromEpochSecond(0L), + Timestamp.fromEpochSecond(0L), + Timestamp.fromEpochSecond(0L), + Timestamp.fromEpochSecond(0L), + true + ) + ) + + val expected = if (weaver.Platform.isJS) { + "dummy-path/example+with+spaces%2C+%25%2C+%2F+and+%5C/10/1970-01-01T00%3A00%3A00.000Z/1970-01-01T00%3A00%3A00.000Z/0/Thu%2C+01+Jan+1970+00%3A00%3A00+GMT/true" + } else { + "dummy-path/example+with+spaces%2C+%25%2C+%2F+and+%5C/10/1970-01-01T00%3A00%3A00Z/1970-01-01T00%3A00%3A00Z/0/Thu%2C+01+Jan+1970+00%3A00%3A00+GMT/true" + } + + assert.eql(result, expected) + } + +} diff --git a/modules/example/src/smithy4s/example/FooService.scala b/modules/example/src/smithy4s/example/FooService.scala index 1b8774bf5..af88f796d 100644 --- a/modules/example/src/smithy4s/example/FooService.scala +++ b/modules/example/src/smithy4s/example/FooService.scala @@ -1,6 +1,5 @@ package smithy4s.example -import smithy4s.http import smithy4s.syntax._ trait FooServiceGen[F[_, _, _, _, _]] { @@ -48,7 +47,7 @@ object FooServiceGen extends smithy4s.Service[FooServiceGen, FooServiceOperation } } case class GetFoo() extends FooServiceOperation[Unit, Nothing, GetFooOutput, Nothing, Nothing] - object GetFoo extends smithy4s.Endpoint[FooServiceOperation, Unit, Nothing, GetFooOutput, Nothing, Nothing] with http.HttpEndpoint[Unit] { + object GetFoo extends smithy4s.Endpoint[FooServiceOperation, Unit, Nothing, GetFooOutput, Nothing, Nothing] { val id: smithy4s.ShapeId = smithy4s.ShapeId("smithy4s.example", "GetFoo") val input: smithy4s.Schema[Unit] = unit.withHints(smithy4s.internals.InputOutput.Input) val output: smithy4s.Schema[GetFooOutput] = GetFooOutput.schema.withHints(smithy4s.internals.InputOutput.Output) @@ -60,10 +59,6 @@ object FooServiceGen extends smithy4s.Service[FooServiceGen, FooServiceOperation smithy.api.Readonly(), ) def wrap(input: Unit) = GetFoo() - def path(input: Unit) = s"foo" - val path = List(http.PathSegment.static("foo")) - val code: Int = 200 - val method: http.HttpMethod = http.HttpMethod.GET } } diff --git a/modules/example/src/smithy4s/example/ObjectService.scala b/modules/example/src/smithy4s/example/ObjectService.scala index dc651beff..de620d6f0 100644 --- a/modules/example/src/smithy4s/example/ObjectService.scala +++ b/modules/example/src/smithy4s/example/ObjectService.scala @@ -2,7 +2,6 @@ package smithy4s.example import ObjectServiceGen.GetObjectError import ObjectServiceGen.PutObjectError -import smithy4s.http import smithy4s.syntax._ trait ObjectServiceGen[F[_, _, _, _, _]] { @@ -57,7 +56,7 @@ object ObjectServiceGen extends smithy4s.Service[ObjectServiceGen, ObjectService } } case class PutObject(input: PutObjectInput) extends ObjectServiceOperation[PutObjectInput, PutObjectError, Unit, Nothing, Nothing] - object PutObject extends smithy4s.Endpoint[ObjectServiceOperation, PutObjectInput, PutObjectError, Unit, Nothing, Nothing] with http.HttpEndpoint[PutObjectInput] with smithy4s.Errorable[PutObjectError] { + object PutObject extends smithy4s.Endpoint[ObjectServiceOperation, PutObjectInput, PutObjectError, Unit, Nothing, Nothing] with smithy4s.Errorable[PutObjectError] { val id: smithy4s.ShapeId = smithy4s.ShapeId("smithy4s.example", "PutObject") val input: smithy4s.Schema[PutObjectInput] = PutObjectInput.schema.withHints(smithy4s.internals.InputOutput.Input) val output: smithy4s.Schema[Unit] = unit.withHints(smithy4s.internals.InputOutput.Output) @@ -80,10 +79,6 @@ object ObjectServiceGen extends smithy4s.Service[ObjectServiceGen, ObjectService case PutObjectError.ServerErrorCase(e) => e case PutObjectError.NoMoreSpaceCase(e) => e } - def path(input: PutObjectInput) = s"${smithy4s.segment(input.bucketName)}/${smithy4s.segment(input.key)}" - val path = List(http.PathSegment.label("bucketName"), http.PathSegment.label("key")) - val code: Int = 200 - val method: http.HttpMethod = http.HttpMethod.PUT } sealed trait PutObjectError object PutObjectError { @@ -117,7 +112,7 @@ object ObjectServiceGen extends smithy4s.Service[ObjectServiceGen, ObjectService implicit val staticSchema : schematic.Static[smithy4s.Schema[PutObjectError]] = schematic.Static(schema) } case class GetObject(input: GetObjectInput) extends ObjectServiceOperation[GetObjectInput, GetObjectError, GetObjectOutput, Nothing, Nothing] - object GetObject extends smithy4s.Endpoint[ObjectServiceOperation, GetObjectInput, GetObjectError, GetObjectOutput, Nothing, Nothing] with http.HttpEndpoint[GetObjectInput] with smithy4s.Errorable[GetObjectError] { + object GetObject extends smithy4s.Endpoint[ObjectServiceOperation, GetObjectInput, GetObjectError, GetObjectOutput, Nothing, Nothing] with smithy4s.Errorable[GetObjectError] { val id: smithy4s.ShapeId = smithy4s.ShapeId("smithy4s.example", "GetObject") val input: smithy4s.Schema[GetObjectInput] = GetObjectInput.schema.withHints(smithy4s.internals.InputOutput.Input) val output: smithy4s.Schema[GetObjectOutput] = GetObjectOutput.schema.withHints(smithy4s.internals.InputOutput.Output) @@ -138,10 +133,6 @@ object ObjectServiceGen extends smithy4s.Service[ObjectServiceGen, ObjectService def unliftError(e: GetObjectError) : Throwable = e match { case GetObjectError.ServerErrorCase(e) => e } - def path(input: GetObjectInput) = s"${smithy4s.segment(input.bucketName)}/${smithy4s.segment(input.key)}" - val path = List(http.PathSegment.label("bucketName"), http.PathSegment.label("key")) - val code: Int = 200 - val method: http.HttpMethod = http.HttpMethod.GET } sealed trait GetObjectError object GetObjectError { diff --git a/modules/protocol/test/src/smithy4s/api/validation/DiscriminatedUnionValidatorSpec.scala b/modules/protocol/test/src/smithy4s/api/validation/DiscriminatedUnionValidatorSpec.scala index c819e9ea4..e4f615363 100644 --- a/modules/protocol/test/src/smithy4s/api/validation/DiscriminatedUnionValidatorSpec.scala +++ b/modules/protocol/test/src/smithy4s/api/validation/DiscriminatedUnionValidatorSpec.scala @@ -1,3 +1,19 @@ +/* + * Copyright 2021 Disney Streaming + * + * Licensed under the Tomorrow Open Source Technology License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://disneystreaming.github.io/TOST-1.0.txt + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package smithy4s.api.validation import software.amazon.smithy.model.shapes.UnionShape diff --git a/sampleSpecs/metadata.smithy b/sampleSpecs/metadata.smithy index 0361bc446..089af2074 100644 --- a/sampleSpecs/metadata.smithy +++ b/sampleSpecs/metadata.smithy @@ -4,13 +4,21 @@ namespace smithy4s.example /// when testing core service DummyService { version: "0.0", - operations: [Dummy] + operations: [Dummy, DummyPath] } +@http(method: "GET", uri: "/dummy") +@readonly operation Dummy { input: Queries } +@http(method: "GET", uri: "/dummy-path/{str}/{int}/{ts1}/{ts2}/{ts3}/{ts4}/{b}") +@readonly +operation DummyPath { + input: PathParams +} + structure Queries { @httpQuery("str") str: String, @@ -59,6 +67,7 @@ structure Headers { slm: StringMap } + structure PathParams { @httpLabel @required diff --git a/sampleSpecs/pizza.smithy b/sampleSpecs/pizza.smithy index f3339588c..ecc95e3cb 100644 --- a/sampleSpecs/pizza.smithy +++ b/sampleSpecs/pizza.smithy @@ -60,6 +60,7 @@ structure AddMenuItemResult { added: Timestamp } +@readonly @http(method: "GET", uri: "/version", code: 200) operation Version { output: VersionOutput