diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 75ff527..9b305d5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -83,13 +83,15 @@ jobs: if: matrix.java == 'temurin@8' && matrix.os == 'ubuntu-latest' run: sbt '++ ${{ matrix.scala }}' doc + - run: sbt '++ ${{ matrix.scala }}' docs/mdoc + - name: Make target directories if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/main') - run: mkdir -p core/target/native-3 target core/target/jvm-3 core/target/js-2.13 core/target/jvm-2.13 core/target/js-3 project/target + run: mkdir -p target modules/core/target/jvm-2.13 modules/core/target/jvm-3 modules/docs/target/jvm-2.13 project/target - name: Compress target directories if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/main') - run: tar cf targets.tar core/target/native-3 target core/target/jvm-3 core/target/js-2.13 core/target/jvm-2.13 core/target/js-3 project/target + run: tar cf targets.tar target modules/core/target/jvm-2.13 modules/core/target/jvm-3 modules/docs/target/jvm-2.13 project/target - name: Upload target directories if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/main') diff --git a/README.md b/README.md index f1b24a0..d49d2c8 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,101 @@ ## Installation +In sbt: + ```scala -TODO +libraryDependencies ++= Seq("org.polyvariant" %% "smithy4s-caliban" % version) +``` + +In scala-cli: + +``` +//> using lib "org.polyvariant::smithy4s-caliban:version" ``` ## Usage -TODO +### Set up smithy4s + +Follow the [quickstart steps](https://disneystreaming.github.io/smithy4s/docs/overview/quickstart). + +### Write a Smithy spec + +For example: + +```smithy +$version: "2" + +namespace hello + +service HelloService { + operations: [GetHello] +} + +operation GetHello { + input := { + @required + name: String + } + output := { + @required + greeting: String + } +} + +``` + +Make sure you can generate Smithy4s code for that spec. + +### Implement service + +Implement the trait generated by Smithy4s: + +```scala mdoc +import hello._ +import cats.effect._ + +val impl: HelloService[IO] = new HelloService[IO] { + override def getHello(name: String): IO[GetHelloOutput] = + IO.println("hello, " + name).as(GetHelloOutput("hello, " + name)) +} +``` + +### Interpret to GraphQL + +This is what the library will allow you to do: convert that service implementation to a GraphQL root. + +```scala mdoc +import org.polyvariant.smithy4scaliban._ +import caliban.GraphQL + +val api: Resource[IO, GraphQL[Any]] = CalibanGraphQLInterpreter.server(impl) +``` + +This returns a Resource because it requires (and creates) a `Dispatcher`. + +Now, the last part - you have to connect this GraphQL instance to a server. How you do it is up to you, but [http4s](https://ghostdogpr.github.io/caliban/docs/adapters.html#json-handling) is recommended. Here's a full example (requires `caliban-http4s`, `tapir-json-circe` and `http4s-ember-server`): + +```scala mdoc +import caliban.Http4sAdapter +import caliban.CalibanError +import caliban.interop.tapir.HttpInterpreter +import sttp.tapir.json.circe._ +import caliban.interop.cats.implicits._ +import org.http4s.ember.server.EmberServerBuilder + +val server: IO[Nothing] = api.evalMap { serverApi => + implicit val rt: zio.Runtime[Any] = zio.Runtime.default + + serverApi + .interpreterAsync[IO] + .map { interp => + Http4sAdapter.makeHttpServiceF[IO, Any, CalibanError](HttpInterpreter(interp)) + } +} +.flatMap { routes => + EmberServerBuilder.default[IO].withHttpApp(routes.orNotFound).build +}.useForever +``` + +This will launch a server on `localhost:8080` running your Smithy spec as a GraphQL API. diff --git a/build.sbt b/build.sbt index 5b470aa..e5e7780 100644 --- a/build.sbt +++ b/build.sbt @@ -25,19 +25,52 @@ val commonSettings = Seq( ) lazy val core = projectMatrix + .in(file("modules/core")) .settings( name := "smithy4s-caliban", commonSettings, libraryDependencies ++= Seq( + "com.github.ghostdogpr" %%% "caliban-cats" % "2.2.1", "com.disneystreaming.smithy4s" %%% "smithy4s-core" % smithy4s.codegen.BuildInfo.version, "com.disneystreaming" %%% "weaver-cats" % "0.8.3" % Test, + "io.circe" %% "circe-core" % "0.14.5" % Test, ), testFrameworks += new TestFramework("weaver.framework.CatsEffect"), + Smithy4sCodegenPlugin.defaultSettings(Test), + scalacOptions --= { + if (scalaVersion.value.startsWith("3")) + Seq("-Ykind-projector:underscores") + else + Seq() + }, + scalacOptions ++= { + if (scalaVersion.value.startsWith("3")) + Seq("-Ykind-projector") + else + Seq() + }, ) .jvmPlatform(Seq(Scala213, Scala3)) - .jsPlatform(Seq(Scala213, Scala3)) - .nativePlatform(Seq(Scala3)) + +lazy val docs = projectMatrix + .in(file("modules/docs")) + .settings( + mdocIn := new File("README.md"), + libraryDependencies ++= Seq( + "org.http4s" %%% "http4s-ember-server" % "0.23.19", + "com.github.ghostdogpr" %%% "caliban-http4s" % "2.2.1", + "com.softwaremill.sttp.tapir" %%% "tapir-json-circe" % "1.5.0", + ), + ThisBuild / githubWorkflowBuild += + WorkflowStep.Sbt( + List("docs/mdoc") + ), + ) + .dependsOn(core) + .jvmPlatform(Seq(Scala213)) + .enablePlugins(MdocPlugin, Smithy4sCodegenPlugin) lazy val root = project .in(file(".")) .aggregate(core.componentProjects.map(p => p: ProjectReference): _*) + .enablePlugins(NoPublishPlugin) diff --git a/core/src/test/scala/MainTest.scala b/core/src/test/scala/MainTest.scala deleted file mode 100644 index b58bf51..0000000 --- a/core/src/test/scala/MainTest.scala +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Copyright 2023 Polyvariant - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * 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. - */ - -import weaver._ - -object MainTest extends FunSuite { - - def showPlatform = - (if (Platform.isJS) - "\u001b[36m" + - "JS" - else if (Platform.isNative) - "\u001b[32m" + - "Native" - else - "\u001b[31m" + - "JVM") + "\u001b[0m" - - def showScala = - (if (Platform.isScala3) - "\u001b[32m" + - "Scala 3" - else - "\u001b[31m" + - "Scala 2") + "\u001b[0m" - - test( - s"main is tested ($showPlatform, $showScala)" - ) { - assert(Main.v == 1) - } -} diff --git a/modules/core/src/main/scala/org/polyvariant/smithy4scaliban/ArgBuilderVisitor.scala b/modules/core/src/main/scala/org/polyvariant/smithy4scaliban/ArgBuilderVisitor.scala new file mode 100644 index 0000000..0acc9bc --- /dev/null +++ b/modules/core/src/main/scala/org/polyvariant/smithy4scaliban/ArgBuilderVisitor.scala @@ -0,0 +1,269 @@ +/* + * Copyright 2023 Polyvariant + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 org.polyvariant.smithy4scaliban + +import caliban.Value.NullValue +import cats.implicits._ +import smithy4s.Bijection +import smithy4s.ByteArray +import smithy4s.Document +import smithy4s.Hints +import smithy4s.Refinement +import smithy4s.ShapeId +import smithy4s.Timestamp +import smithy4s.Schema +import smithy4s.schema.Field +import smithy4s.schema.Field.Wrapped +import smithy4s.schema.Primitive +import smithy4s.schema.SchemaVisitor +import smithy4s.schema.Alt +import smithy4s.schema.SchemaAlt +import caliban.schema.ArgBuilder +import caliban.InputValue +import caliban.CalibanError +import smithy4s.schema.EnumValue +import smithy4s.schema.CollectionTag +import caliban.Value +import smithy4s.Lazy +import java.util.Base64 +import caliban.Value.StringValue +import caliban.Value.IntValue.IntNumber +import caliban.Value.FloatValue.BigDecimalNumber +import caliban.Value.IntValue.BigIntNumber +import caliban.Value.BooleanValue +import caliban.Value.FloatValue.FloatNumber +import caliban.Value.FloatValue.DoubleNumber +import caliban.Value.IntValue.LongNumber +import caliban.InputValue.ObjectValue +import caliban.InputValue.ListValue +import smithy.api.TimestampFormat +import smithy4s.IntEnum +import smithy4s.schema.CompilationCache + +private[smithy4scaliban] class ArgBuilderVisitor(val cache: CompilationCache[ArgBuilder]) + extends SchemaVisitor.Cached[ArgBuilder] { + + override def biject[A, B]( + schema: smithy4s.Schema[A], + bijection: Bijection[A, B], + ): ArgBuilder[B] = schema.compile(this).map(bijection.to) + + override def refine[A, B]( + schema: smithy4s.schema.Schema[A], + refinement: Refinement[A, B], + ): ArgBuilder[B] = schema + .compile(this) + .flatMap(refinement(_).leftMap(e => CalibanError.ExecutionError(e))) + + override def struct[S]( + shapeId: ShapeId, + hints: Hints, + fields: Vector[Field[smithy4s.Schema, S, ?]], + make: IndexedSeq[Any] => S, + ): ArgBuilder[S] = { + val fieldsCompiled = fields.map { f => + f.label -> + f.mapK(this) + .instanceA( + new Field.ToOptional[ArgBuilder] { + override def apply[A0]( + fa: ArgBuilder[A0] + ): Wrapped[ArgBuilder, Option, A0] = ArgBuilder.option(fa) + + } + ) + } + + { + case InputValue.ObjectValue(objectFields) => + fieldsCompiled + .traverse { case (label, f) => f.build(objectFields.getOrElse(label, NullValue)) } + .map(make) + + case iv => Left(CalibanError.ExecutionError(s"Expected object, got $iv")) + } + } + + override def union[U]( + shapeId: ShapeId, + hints: Hints, + alternatives: Vector[SchemaAlt[U, _]], + dispatch: Alt.Dispatcher[smithy4s.schema.Schema, U], + ): ArgBuilder[U] = { + val instancesByKey = alternatives.map(alt => alt.label -> handleAlt(alt)).toMap + + { + case InputValue.ObjectValue(in) if in.sizeIs == 1 => + val (k, v) = in.head + instancesByKey + .get(k) + .toRight( + CalibanError.ExecutionError(msg = "Invalid union case: " + k) + ) + .flatMap(_.build(v)) + case iv => Left(CalibanError.ExecutionError(s"Expected object with single key, got $iv")) + } + } + + private def handleAlt[U, A]( + alt: Alt[Schema, U, A] + ): ArgBuilder[U] = alt.instance.compile(this).map(alt.inject) + + override def enumeration[E]( + shapeId: ShapeId, + hints: Hints, + values: List[EnumValue[E]], + total: E => EnumValue[E], + ): ArgBuilder[E] = + hints.has(IntEnum) match { + case false => + val valuesByString = values.map(v => v.stringValue -> v.value).toMap + + ArgBuilder + .string + .flatMap { v => + valuesByString + .get(v) + .toRight(CalibanError.ExecutionError("Unknown enum case: " + v)) + } + + case true => + val valuesByInt = values.map(v => v.intValue -> v.value).toMap + + ArgBuilder + .int + .flatMap { v => + valuesByInt + .get(v) + .toRight(CalibanError.ExecutionError("Unknown int enum case: " + v)) + } + + } + + private val inputValueToDoc: PartialFunction[InputValue, Document] = { + case StringValue(value) => Document.fromString(value) + case IntNumber(value) => Document.fromInt(value) + case BigDecimalNumber(value) => Document.fromBigDecimal(value) + case BigIntNumber(value) => Document.fromBigDecimal(BigDecimal(value)) + case BooleanValue(value) => Document.fromBoolean(value) + case FloatNumber(value) => Document.fromDouble(value.toDouble) + case DoubleNumber(value) => Document.fromDouble(value) + case LongNumber(value) => Document.fromLong(value) + case ObjectValue(fields) => Document.DObject(fields.fmap(inputValueToDoc)) + case ListValue(values) => Document.array(values.map(inputValueToDoc)) + } + + override def primitive[P]( + shapeId: ShapeId, + hints: Hints, + tag: Primitive[P], + ): ArgBuilder[P] = { + // todo: these would probably benefit from a direct implementation... later. + implicit val shortArgBuilder: ArgBuilder[Short] = ArgBuilder.int.flatMap { + case i if i.isValidShort => Right(i.toShort) + case i => Left(CalibanError.ExecutionError("Integer too large for short: " + i)) + } + + implicit val byteArgBuilder: ArgBuilder[Byte] = ArgBuilder.int.flatMap { + case i if i.isValidByte => Right(i.toByte) + case i => Left(CalibanError.ExecutionError("Integer too large for byte: " + i)) + } + + implicit val byteArrayArgBuilder: ArgBuilder[ByteArray] = ArgBuilder.string.map { str => + ByteArray(Base64.getDecoder().decode(str)) + } + + implicit val documentArgBuilder: ArgBuilder[Document] = + v => + inputValueToDoc + .lift(v) + .toRight( + CalibanError.ExecutionError( + s"Unsupported input value for Document: $v" + ) + ) + + implicit val timestampArgBuilder: ArgBuilder[Timestamp] = + hints.get(TimestampFormat) match { + case Some(TimestampFormat.EPOCH_SECONDS) | None => + ArgBuilder.long.map(Timestamp.fromEpochSecond(_)) + + case Some(format) => + ArgBuilder + .string + .flatMap(s => + Timestamp + .parse(s, format) + .toRight( + CalibanError.ExecutionError( + s"Invalid timestamp for format $format: $s" + ) + ) + ) + } + + Primitive.deriving[ArgBuilder].apply(tag) + } + + override def collection[C[_], A]( + shapeId: ShapeId, + hints: Hints, + tag: CollectionTag[C], + member: Schema[A], + ): ArgBuilder[C[A]] = + tag match { + case CollectionTag.ListTag => ArgBuilder.list(member.compile(this)) + + case CollectionTag.IndexedSeqTag => ArgBuilder.seq(member.compile(this)).map(_.toIndexedSeq) + + case CollectionTag.VectorTag => ArgBuilder.seq(member.compile(this)).map(_.toVector) + + case CollectionTag.SetTag => ArgBuilder.set(member.compile(this)) + } + + override def map[K, V]( + shapeId: ShapeId, + hints: Hints, + key: Schema[K], + value: Schema[V], + ): ArgBuilder[Map[K, V]] = { + val keyBuilder = key.compile(this) + val valueBuilder = value.compile(this) + + { + case InputValue.ObjectValue(keys) => + keys + .toList + .traverse { case (k, v) => + ( + keyBuilder.build(Value.StringValue(k)), + valueBuilder.build(v), + ).tupled + } + .map(_.toMap) + + case iv => Left(CalibanError.ExecutionError(s"Invalid input value for map: $iv")) + } + } + + override def lazily[A](suspend: Lazy[Schema[A]]): ArgBuilder[A] = { + val underlying = suspend.map(_.compile(this)) + + underlying.value.build(_) + } + +} diff --git a/modules/core/src/main/scala/org/polyvariant/smithy4scaliban/CalibanGraphQLInterpreter.scala b/modules/core/src/main/scala/org/polyvariant/smithy4scaliban/CalibanGraphQLInterpreter.scala new file mode 100644 index 0000000..7a2b3a1 --- /dev/null +++ b/modules/core/src/main/scala/org/polyvariant/smithy4scaliban/CalibanGraphQLInterpreter.scala @@ -0,0 +1,138 @@ +/* + * Copyright 2023 Polyvariant + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 org.polyvariant.smithy4scaliban + +import caliban.GraphQL +import caliban.RootResolver +import caliban.interop.cats.FromEffect +import caliban.interop.cats.implicits._ +import caliban.schema._ +import cats.effect.std.Dispatcher +import smithy4s.kinds.FunctorInterpreter +import smithy4s.kinds.FunctorAlgebra +import smithy4s.Service +import smithy.api.Readonly +import caliban.introspection.adt.__Field +import smithy4s.Endpoint +import smithy4s.schema.CompilationCache +import caliban.InputValue +import cats.effect.kernel.Async +import cats.effect.kernel.Resource + +object CalibanGraphQLInterpreter { + + def server[Alg[_[_, _, _, _, _]], F[_]: Async]( + impl: FunctorAlgebra[Alg, F] + )( + implicit + service: Service[Alg] + ): Resource[F, GraphQL[Any]] = Dispatcher.parallel[F].map { implicit dispatcher => + val abv = new ArgBuilderVisitor(CompilationCache.make[ArgBuilder]) + val csv = new CalibanSchemaVisitor(CompilationCache.make[Schema[Any, *]]) + + val (queries, mutations) = service.endpoints.partition(_.hints.has[Readonly]) + + val interp = service.toPolyFunction(impl) + + val querySchema: Schema[Any, service.FunctorInterpreter[F]] = + Schema.obj(name = "Queries", description = None)(implicit fa => + queries.map(endpointToSchema[F].apply(_, abv, csv)) + ) + + val mutationSchema: Schema[Any, service.FunctorInterpreter[F]] = + Schema.obj(name = "Mutations", description = None)(implicit fa => + mutations.map(endpointToSchema[F].apply(_, abv, csv)) + ) + + caliban.graphQL( + RootResolver( + queryResolver = Option.when(queries.nonEmpty)(interp), + mutationResolver = Option.when(mutations.nonEmpty)(interp), + subscriptionResolver = None, + ) + )( + SubscriptionSchema.unitSubscriptionSchema, + querySchema, + mutationSchema, + Schema.unitSchema, + ) + } + + private def endpointToSchema[F[_]: Dispatcher] = new EndpointToSchemaPartiallyApplied[F] + + // "partially-applied type" pattern used here to give the compiler a hint about what F is + // but let it infer the remaining type parameters + final class EndpointToSchemaPartiallyApplied[F[_]: Dispatcher] private[smithy4scaliban] { + + def apply[Op[_, _, _, _, _], I, E, O, SI, SO]( + e: Endpoint[Op, I, E, O, SI, SO], + abv: ArgBuilderVisitor, + csv: CalibanSchemaVisitor, + )( + implicit fa: FieldAttributes + ): (__Field, FunctorInterpreter[Op, F] => Step[Any]) = { + val hasArgs = e.input.shapeId != smithy4s.schema.Schema.unit.shapeId + + if (hasArgs) + // function type + Schema.fieldWithArgs(e.name.uncapitalize) { (interp: FunctorInterpreter[Op, F]) => (i: I) => + interp(e.wrap(i)) + }( + Schema.functionSchema[Any, Any, I, F[O]]( + e.input.compile(abv), + e.input.compile(csv), + catsEffectSchema( + FromEffect.forDispatcher, + e.output.compile(csv), + ), + ), + fa, + ) + else { + val inputDecodedFromEmptyObj: I = + e + .input + .compile(abv) + .build(InputValue.ObjectValue(Map.empty)) + .toTry + .get + + Schema.field(e.name.uncapitalize) { (interp: FunctorInterpreter[Op, F]) => + interp(e.wrap(inputDecodedFromEmptyObj)) + }( + catsEffectSchema( + FromEffect.forDispatcher, + e.output.compile(csv), + ), + fa, + ) + } + } + + } + + private implicit final class StringOps(val s: String) extends AnyVal { + + def uncapitalize: String = + s match { + case "" => "" + case _ => s"${s.head.toLower}${s.tail}" + } + + } + +} diff --git a/modules/core/src/main/scala/org/polyvariant/smithy4scaliban/CalibanSchemaVisitor.scala b/modules/core/src/main/scala/org/polyvariant/smithy4scaliban/CalibanSchemaVisitor.scala new file mode 100644 index 0000000..fbf123a --- /dev/null +++ b/modules/core/src/main/scala/org/polyvariant/smithy4scaliban/CalibanSchemaVisitor.scala @@ -0,0 +1,271 @@ +/* + * Copyright 2023 Polyvariant + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 org.polyvariant.smithy4scaliban + +import caliban.ResponseValue +import caliban.Value +import caliban.introspection.adt.__Type +import caliban.schema._ +import cats.implicits._ +import smithy.api.TimestampFormat +import smithy4s.Bijection +import smithy4s.ByteArray +import smithy4s.Document +import smithy4s.Hints +import smithy4s.IntEnum +import smithy4s.Lazy +import smithy4s.Refinement +import smithy4s.ShapeId +import smithy4s.Timestamp +import smithy4s.schema +import smithy4s.schema.Alt +import smithy4s.schema.CollectionTag +import smithy4s.schema.Field +import smithy4s.schema.Field.Wrapped +import smithy4s.schema.Primitive +import smithy4s.schema.SchemaAlt +import smithy4s.schema.SchemaVisitor + +private class CalibanSchemaVisitor(val cache: schema.CompilationCache[Schema[Any, *]]) + extends SchemaVisitor.Cached[Schema[Any, *]] { + + private def fromScalar[A]( + shapeId: ShapeId + )( + f: A => ResponseValue + ): Schema[Any, A] = Schema + .scalarSchema( + name = shapeId.name, + description = None, + specifiedBy = None, + directives = None, + makeResponse = f, + ) + + override def primitive[P]( + shapeId: ShapeId, + hints: Hints, + tag: Primitive[P], + ): Schema[Any, P] = { + implicit val byteSchema: Schema[Any, Byte] = fromScalar(shapeId)(v => Value.IntValue(v.toInt)) + + // base-64 encoded string + implicit val blobSchema: Schema[Any, ByteArray] = + fromScalar(shapeId)(v => Value.StringValue(v.toString())) + + // json "any" type + implicit val documentSchema: Schema[Any, Document] = fromScalar(shapeId)(documentToValue) + + implicit val timestampSchema: Schema[Any, Timestamp] = + hints.get(TimestampFormat) match { + case Some(TimestampFormat.EPOCH_SECONDS) | None => + Schema.longSchema.contramap(_.epochSecond) + + case Some(format) => Schema.stringSchema.contramap(_.format(format)) + } + + Primitive + .deriving[Schema[Any, *]] + .apply(tag) + .withName(shapeId) + } + + private val documentToValue: Document => ResponseValue = { + case Document.DNull => Value.NullValue + case Document.DString(s) => Value.StringValue(s) + case Document.DObject(keys) => ResponseValue.ObjectValue(keys.fmap(documentToValue).toList) + case Document.DArray(values) => ResponseValue.ListValue(values.map(documentToValue).toList) + case Document.DNumber(n) => Value.FloatValue(n) + case Document.DBoolean(b) => Value.BooleanValue(b) + } + + private def field[S, A]( + f: Field[Schema[Any, *], S, A] + )( + implicit fa: FieldAttributes + ) = { + val schema = f + .instanceA(new Field.ToOptional[Schema[Any, *]] { + + override def apply[A0]( + fa: Schema[Any, A0] + ): Wrapped[Schema[Any, *], Option, A0] = Schema.optionSchema(fa) + + }) + + Schema.field(f.label)(f.get)( + schema, + fa, + ) + } + + override def biject[A, B]( + schema: smithy4s.Schema[A], + bijection: Bijection[A, B], + ): Schema[Any, B] = schema.compile(this).contramap(bijection.from) + + override def refine[A, B]( + schema: smithy4s.Schema[A], + refinement: Refinement[A, B], + ): Schema[Any, B] = schema.compile(this).contramap(refinement.from) + + override def struct[S]( + shapeId: ShapeId, + hints: Hints, + fields: Vector[Field[smithy4s.Schema, S, ?]], + make: IndexedSeq[Any] => S, + ): Schema[Any, S] = Schema + .obj(shapeId.name, None) { implicit fa => + fields + .map(_.mapK(this)) + .map(field(_)) + .toList + } + .withName(shapeId) + + override def collection[C[_], A]( + shapeId: ShapeId, + hints: Hints, + tag: CollectionTag[C], + member: schema.Schema[A], + ): Schema[Any, C[A]] = + tag match { + case CollectionTag.ListTag => + Schema + .listSchema(member.compile(this)) + .withName(shapeId) + + case CollectionTag.IndexedSeqTag => + Schema + .seqSchema(member.compile(this)) + .contramap[C[A]](identity(_)) + .withName(shapeId) + + case CollectionTag.VectorTag => + Schema + .vectorSchema(member.compile(this)) + .withName(shapeId) + + case CollectionTag.SetTag => + Schema + .setSchema(member.compile(this)) + .withName(shapeId) + } + + override def union[U]( + shapeId: ShapeId, + hints: Hints, + alternatives: Vector[SchemaAlt[U, _]], + dispatch: Alt.Dispatcher[smithy4s.Schema, U], + ): Schema[Any, U] = { + val self = this + + type Resolve[A] = A => Step[Any] + + val resolve0 = dispatch.compile(new Alt.Precompiler[smithy4s.Schema, Resolve] { + override def apply[A]( + label: String, + instance: smithy4s.Schema[A], + ): Resolve[A] = { + val underlying = instance.compile(self) + a => + Step.ObjectStep( + shapeId.name + label + "Case", + Map(label -> underlying.resolve(a)), + ) + } + }) + + new Schema[Any, U] { + override def resolve(value: U): Step[Any] = resolve0(value) + + override def toType(isInput: Boolean, isSubscription: Boolean): __Type = Types.makeUnion( + name = Some(shapeId.name), + description = None, + subTypes = + alternatives + .map(handleAlt(shapeId, _)) + .map(_.toType_(isInput, isSubscription)) + .toList, + ) + } + }.withName(shapeId) + + private def handleAlt[U, A](parent: ShapeId, alt: SchemaAlt[U, A]) = + Schema.obj( + parent.name + alt.label + "Case" + )(fa => + List( + Schema + .field[A](alt.label)(a => a)(alt.instance.compile(this), fa) + ) + ) + + override def enumeration[E]( + shapeId: ShapeId, + hints: Hints, + values: List[schema.EnumValue[E]], + total: E => schema.EnumValue[E], + ): Schema[Any, E] = { + hints.has(IntEnum) match { + case false => Schema.stringSchema.contramap(total(_: E).stringValue) + case true => Schema.intSchema.contramap(total(_: E).intValue) + } + }.withName(shapeId) + + override def map[K, V]( + shapeId: ShapeId, + hints: Hints, + key: schema.Schema[K], + value: schema.Schema[V], + ): Schema[Any, Map[K, V]] = Schema + .mapSchema(key.compile(this), value.compile(this)) + .withName(shapeId) + + override def lazily[A](suspend: Lazy[schema.Schema[A]]): Schema[Any, A] = { + val underlying = suspend.map(_.compile(this)) + new Schema[Any, A] { + def toType( + isInput: Boolean, + isSubscription: Boolean, + ): __Type = underlying.value.toType_(isInput, isSubscription) + + def resolve(value: A): Step[Any] = underlying.value.resolve(value) + + } + } + + private case class SchemaWithOrigin[A]( + underlying: Schema[Any, A], + shapeId: ShapeId, + ) extends Schema[Any, A] { + + override def toType(isInput: Boolean, isSubscription: Boolean): __Type = underlying + .toType_(isInput, isSubscription) + .copy( + name = Some(shapeId.name), + origin = Some(shapeId.namespace), + ) + + override def resolve(value: A): Step[Any] = underlying.resolve(value) + } + + final implicit class AnySchemaOps[A](private val schema: Schema[Any, A]) { + def withName(shapeId: ShapeId): Schema[Any, A] = SchemaWithOrigin(schema, shapeId) + } + +} diff --git a/modules/core/src/test/scala/org/polyvariant/smithy4scaliban/ArgBuilderTests.scala b/modules/core/src/test/scala/org/polyvariant/smithy4scaliban/ArgBuilderTests.scala new file mode 100644 index 0000000..a2cfeae --- /dev/null +++ b/modules/core/src/test/scala/org/polyvariant/smithy4scaliban/ArgBuilderTests.scala @@ -0,0 +1,333 @@ +/* + * Copyright 2023 Polyvariant + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 org.polyvariant.smithy4scaliban + +import weaver._ +import smithy4s.Schema +import caliban.Value +import smithy4s.Bijection +import smithy.api.Length +import caliban.InputValue +import smithy4s.example.CityCoordinates +import smithy4s.example.MovieTheater +import smithy4s.example.Foo +import smithy4s.example.Ingredient +import smithy4s.example.EnumResult +import smithy4s.example.Rec +import smithy4s.Document +import java.time.Instant +import smithy4s.Timestamp +import smithy.api.TimestampFormat +import smithy4s.ByteArray +import Smithy4sTestUtils._ +import smithy4s.schema.CompilationCache + +object ArgBuilderTests extends FunSuite { + private val abv = new ArgBuilderVisitor(CompilationCache.nop) + + // todo: tests for negative paths + private def decodeArgSuccess[A]( + value: InputValue, + expected: A, + )( + implicit schema: Schema[A] + ) = assert.same( + schema + .compile(abv) + .build(value), + Right(expected), + ) + + test("string") { + decodeArgSuccess( + Value.StringValue("test"), + "test", + )( + Schema.string + ) + } + + test("bijection") { + decodeArgSuccess( + Value.StringValue("test"), + "TEST", + )( + Schema + .string + .biject( + Bijection[String, String](_.toUpperCase, identity(_)) + ) + ) + } + + test("refinement") { + decodeArgSuccess( + Value.StringValue("test"), + "test", + )( + Schema.string.refined(Length(min = Some(1))) + ) + } + + test("structure schema - all fields required") { + decodeArgSuccess( + InputValue.ObjectValue( + Map( + "latitude" -> Value.FloatValue(42.0f), + "longitude" -> Value.FloatValue(21.37f), + ) + ), + CityCoordinates(latitude = 42.0f, longitude = 21.37f), + ) + } + + test("structure schema - missing optional field") { + decodeArgSuccess( + InputValue.ObjectValue( + Map.empty + ), + MovieTheater(name = None), + ) + } + + test("structure schema - null optional field") { + decodeArgSuccess( + InputValue.ObjectValue( + Map( + "name" -> Value.NullValue + ) + ), + MovieTheater(name = None), + ) + } + + test("structure schema - present optional field") { + decodeArgSuccess( + InputValue.ObjectValue( + Map( + "name" -> Value.StringValue("cinema") + ) + ), + MovieTheater(name = Some("cinema")), + ) + } + + test("union schema") { + decodeArgSuccess( + InputValue.ObjectValue( + Map( + "str" -> Value.StringValue("test") + ) + ), + Foo.StrCase("test").widen, + ) + } + + test("enum schema") { + decodeArgSuccess( + Value.StringValue("Tomato"), + Ingredient.TOMATO.widen, + ) + } + + test("int enum schema") { + decodeArgSuccess( + Value.IntValue(2), + EnumResult.SECOND.widen, + ) + } + + test("list") { + decodeArgSuccess( + InputValue.ListValue( + List( + Value.StringValue("test"), + Value.StringValue("test2"), + ) + ), + List("test", "test2"), + )(Schema.list(Schema.string)) + } + + test("vector") { + decodeArgSuccess( + InputValue.ListValue( + List( + Value.StringValue("test"), + Value.StringValue("test2"), + ) + ), + Vector("test", "test2"), + )(Schema.vector(Schema.string)) + } + + test("indexedSeq") { + decodeArgSuccess( + InputValue.ListValue( + List( + Value.StringValue("test"), + Value.StringValue("test2"), + ) + ), + IndexedSeq("test", "test2"), + )(Schema.indexedSeq(Schema.string)) + } + + test("set") { + decodeArgSuccess( + InputValue.ListValue( + List( + Value.StringValue("test"), + Value.StringValue("test"), + ) + ), + Set("test"), + )(Schema.set(Schema.string)) + } + + test("map") { + decodeArgSuccess( + InputValue.ObjectValue( + Map( + "test" -> Value.StringValue("test"), + "test2" -> Value.StringValue("test2"), + ) + ), + Map("test" -> "test", "test2" -> "test2"), + )(Schema.map(Schema.string, Schema.string)) + } + + test("recursive struct") { + decodeArgSuccess( + InputValue.ObjectValue( + Map( + "name" -> Value.StringValue("level1"), + "next" -> InputValue.ObjectValue( + Map( + "name" -> Value.StringValue("level2"), + "next" -> InputValue.ObjectValue( + Map( + "name" -> Value.StringValue("level3") + ) + ), + ) + ), + ) + ), + Rec( + name = "level1", + next = Some( + Rec(name = "level2", next = Some(Rec(name = "level3", next = None))) + ), + ), + ) + } + + test("timestamp (default: epoch second)") { + decodeArgSuccess( + Value.IntValue(100L), + Timestamp.fromEpochSecond(100L), + )(Schema.timestamp) + } + + test("timestamp (DATE_TIME)") { + decodeArgSuccess( + Value.StringValue(Instant.EPOCH.toString()), + Timestamp.fromInstant(Instant.EPOCH), + )(Schema.timestamp.addHints(TimestampFormat.DATE_TIME.widen)) + } + + test("timestamp (HTTP_DATE)") { + decodeArgSuccess( + Value.StringValue("Tue, 29 Apr 2014 18:30:38 GMT"), + Timestamp( + year = 2014, + month = 4, + day = 29, + hour = 18, + minute = 30, + second = 38, + ), + )(Schema.timestamp.addHints(TimestampFormat.HTTP_DATE.widen)) + } + + test("short") { + decodeArgSuccess( + Value.IntValue(Short.MaxValue), + Short.MaxValue, + )(Schema.short) + } + + test("byte") { + decodeArgSuccess( + Value.IntValue(Byte.MaxValue), + Byte.MaxValue, + )(Schema.byte) + } + + test("bytes") { + decodeArgSuccess( + Value.StringValue(ByteArray(Array(1, 2, 3)).toString), + ByteArray(Array(1, 2, 3)), + )(Schema.bytes) + } + + test("document") { + decodeArgSuccess( + InputValue.ObjectValue( + Map( + "str" -> Value.StringValue("test"), + "int" -> Value.IntValue(42), + "long" -> Value.IntValue.LongNumber(42L), + "float" -> Value.FloatValue(42.0f), + "double" -> Value.FloatValue.DoubleNumber(42.0d), + "bigint" -> Value.IntValue.BigIntNumber(BigInt(10)), + "bigdecimal" -> Value.FloatValue.BigDecimalNumber(BigDecimal(10)), + "arr" -> InputValue.ListValue( + List( + Value.StringValue("string in list"), + InputValue.ObjectValue( + Map("k1" -> Value.StringValue("in array object")) + ), + ) + ), + "obj" -> InputValue.ObjectValue( + Map( + "k2" -> Value.StringValue("in object") + ) + ), + ) + ), + Document.obj( + "str" -> Document.fromString("test"), + "int" -> Document.fromInt(42), + "long" -> Document.fromLong(42L), + "float" -> Document.fromDouble(42.0d), + "double" -> Document.fromDouble(42.0d), + "bigint" -> Document.fromBigDecimal(BigDecimal(10)), + "bigdecimal" -> Document.fromBigDecimal(BigDecimal(10)), + "arr" -> Document.array( + Document.fromString("string in list"), + Document.obj( + "k1" -> Document.fromString("in array object") + ), + ), + "obj" -> Document.obj("k2" -> Document.fromString("in object")), + ), + )(Schema.document) + } +} diff --git a/modules/core/src/test/scala/org/polyvariant/smithy4scaliban/CalibanGraphQLInterpreterTests.scala b/modules/core/src/test/scala/org/polyvariant/smithy4scaliban/CalibanGraphQLInterpreterTests.scala new file mode 100644 index 0000000..c92b481 --- /dev/null +++ b/modules/core/src/test/scala/org/polyvariant/smithy4scaliban/CalibanGraphQLInterpreterTests.scala @@ -0,0 +1,106 @@ +/* + * Copyright 2023 Polyvariant + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 org.polyvariant.smithy4scaliban + +import cats.effect.IO +import io.circe.Json +import io.circe.syntax._ +import smithy4s.example.ListCitiesOutput +import smithy4s.example.WeatherService + +import CalibanTestUtils._ +import smithy4s.example.City +import smithy4s.example.RefreshCitiesOutput + +object CalibanGraphQLInterpreterTests extends weaver.SimpleIOSuite { + + private val weatherImpl: WeatherService[IO] = + new WeatherService.Default[IO](IO.stub) { + + override def listCities( + ): IO[ListCitiesOutput] = IO( + ListCitiesOutput( + List( + City("London"), + City("NYC"), + ) + ) + ) + + override def refreshCities(): IO[RefreshCitiesOutput] = IO(RefreshCitiesOutput("ok")) + + } + + test("WeatherService service query interpreter: query") { + CalibanGraphQLInterpreter + .server(weatherImpl) + .use { api => + testApiResult( + api, + """query { + | listCities { + | cities { + | name + | } + | } + |}""".stripMargin, + ) + } + .map( + assert.eql( + _, + Json.obj( + "listCities" := Json.obj( + "cities" := Json.arr( + Json.obj( + "name" := "London" + ), + Json.obj( + "name" := "NYC" + ), + ) + ) + ), + ) + ) + } + test("WeatherService service query interpreter: mutation") { + CalibanGraphQLInterpreter + .server(weatherImpl) + .use { api => + testApiResult( + api, + """mutation { + | refreshCities { + | result + | } + |}""".stripMargin, + ) + } + .map( + assert.eql( + _, + Json.obj( + "refreshCities" := Json.obj( + "result" := "ok" + ) + ), + ) + ) + } + +} diff --git a/modules/core/src/test/scala/org/polyvariant/smithy4scaliban/CalibanSchemaTests.scala b/modules/core/src/test/scala/org/polyvariant/smithy4scaliban/CalibanSchemaTests.scala new file mode 100644 index 0000000..7a95403 --- /dev/null +++ b/modules/core/src/test/scala/org/polyvariant/smithy4scaliban/CalibanSchemaTests.scala @@ -0,0 +1,327 @@ +/* + * Copyright 2023 Polyvariant + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 org.polyvariant.smithy4scaliban + +import weaver._ +import CalibanTestUtils._ +import smithy4s.example.CityCoordinates +import io.circe.syntax._ +import io.circe.Json +import smithy4s.example.MovieTheater +import smithy4s.example.Foo +import smithy4s.Schema +import smithy.api.Length +import smithy4s.Bijection +import smithy4s.example.Ingredient +import smithy4s.example.EnumResult +import smithy4s.example.Rec +import Smithy4sTestUtils._ +import smithy4s.Timestamp +import smithy.api.TimestampFormat +import smithy4s.ByteArray +import smithy4s.Document + +object CalibanSchemaTests extends SimpleIOSuite { + // Workaround for https://github.com/disneystreaming/smithy4s/issues/537 + // works by eagerly instantiating the Timestamp type and everything in its schema + Schema.timestamp.shapeId.show: Unit + + test("structure schema - all fields required") { + testQueryResultWithSchema( + CityCoordinates(latitude = 42.0f, longitude = 21.37f), + """query { + | latitude + | longitude + |}""".stripMargin, + ).map( + assert.eql( + _, + Json.obj( + "latitude" := 42.0f, + "longitude" := 21.37f, + ), + ) + ) + } + + test("structure schema - missing optional field") { + testQueryResultWithSchema( + MovieTheater(name = None), + """query { + | name + |}""".stripMargin, + ) + .map(assert.eql(_, Json.obj("name" := Json.Null))) + } + + test("structure schema - present optional field") { + testQueryResultWithSchema( + MovieTheater(name = Some("cinema")), + """query { + | name + |}""".stripMargin, + ) + .map(assert.eql(_, Json.obj("name" := "cinema"))) + } + + test("union schema") { + testQueryResultWithSchema( + Foo.StrCase("myString"): Foo, + """query { + | ... on FoostrCase { + | str + | } + |}""".stripMargin, + ) + .map(assert.eql(_, Json.obj("str" := "myString"))) + } + + test("list schema") { + + testQueryResultWithSchema( + List("a", "b", "c"), + "query { items }", + )(Schema.list(Schema.string).nested("items")) + .map(assert.eql(_, Json.obj("items" := List("a", "b", "c")))) + } + + test("indexedSeq schema") { + testQueryResultWithSchema( + IndexedSeq("a", "b", "c"), + "query { items }", + )(Schema.indexedSeq(Schema.string).nested("items")) + .map(assert.eql(_, Json.obj("items" := List("a", "b", "c")))) + } + + test("vector schema") { + testQueryResultWithSchema( + Vector("a", "b", "c"), + "query { items }", + )(Schema.vector(Schema.string).nested("items")) + .map(assert.eql(_, Json.obj("items" := List("a", "b", "c")))) + } + + test("set schema") { + testQueryResultWithSchema( + Set("a", "b", "c"), + "query { items }", + )(Schema.set(Schema.string).nested("items")) + .map(assert.eql(_, Json.obj("items" := List("a", "b", "c")))) + } + + test("refinement schema") { + + testQueryResultWithSchema( + "test", + "query { item }", + )( + Schema + .string + .refined(Length(min = Some(1))) + .nested("item") + ).map(assert.eql(_, Json.obj("item" := "test"))) + } + + test("bijection schema") { + testQueryResultWithSchema( + "test", + "query { item }", + )( + Schema + .string + .biject(Bijection[String, String](identity, _.toUpperCase())) + .nested("item") + ) + .map(assert.eql(_, Json.obj("item" := "TEST"))) + } + + test("enum schema") { + testQueryResultWithSchema( + Ingredient.TOMATO.widen, + """query { item }""".stripMargin, + )(Ingredient.schema.nested("item")) + .map(assert.eql(_, Json.obj("item" := "Tomato"))) + } + + test("int enum schema") { + testQueryResultWithSchema( + EnumResult.SECOND.widen, + """query { item }""".stripMargin, + )(EnumResult.schema.nested("item")) + .map(assert.eql(_, Json.obj("item" := 2))) + } + + test("byte schema") { + testQueryResultWithSchema( + 42.toByte, + """query { item }""".stripMargin, + )(Schema.byte.nested("item")) + .map(assert.eql(_, Json.obj("item" := 42))) + } + + test("blob schema") { + testQueryResultWithSchema( + ByteArray("foo".getBytes()), + """query { item }""".stripMargin, + )(Schema.bytes.nested("item")) + .map(assert.eql(_, Json.obj("item" := "Zm9v"))) + } + + test("document schema") { + testQueryResultWithSchema( + Document.obj( + "str" -> Document.fromString("test"), + "int" -> Document.fromInt(42), + "long" -> Document.fromLong(42L), + "float" -> Document.fromDouble(42.0d), + "double" -> Document.fromDouble(42.0d), + "bigint" -> Document.fromBigDecimal(BigDecimal(10)), + "bigdecimal" -> Document.fromBigDecimal(BigDecimal(10)), + "arr" -> Document.array( + Document.fromString("string in list"), + Document.obj( + "k1" -> Document.fromString("in array object") + ), + ), + "obj" -> Document.obj("k2" -> Document.fromString("in object")), + ), + """query { item }""".stripMargin, + )(Schema.document.nested("item")) + .map( + assert.eql( + _, + Json.obj( + "item" := Json.obj( + "str" := "test", + "int" := 42, + "long" := 42L, + "float" := 42.0d, + "double" := 42.0d, + "bigint" := BigDecimal(10), + "bigdecimal" := BigDecimal(10), + "arr" := List( + "string in list".asJson, + Map("k1" := "in array object").asJson, + ), + "obj" := Map("k2" := "in object"), + ) + ), + ) + ) + } + + test("timestamp schema (default: epoch second)") { + testQueryResultWithSchema( + Timestamp.epoch, + """query { item }""".stripMargin, + )(Schema.timestamp.nested("item")) + .map(assert.eql(_, Json.obj("item" := 0L))) + } + + test("timestamp schema (DATE_TIME)") { + testQueryResultWithSchema( + Timestamp.epoch, + """query { item }""".stripMargin, + )(Schema.timestamp.addHints(TimestampFormat.DATE_TIME.widen).nested("item")) + .map(assert.eql(_, Json.obj("item" := "1970-01-01T00:00:00Z"))) + } + + test("timestamp schema (HTTP_DATE)") { + testQueryResultWithSchema( + Timestamp.epoch, + """query { item }""".stripMargin, + )(Schema.timestamp.addHints(TimestampFormat.HTTP_DATE.widen).nested("item")) + .map(assert.eql(_, Json.obj("item" := "Thu, 01 Jan 1970 00:00:00 GMT"))) + } + + test("map schema") { + testQueryResultWithSchema( + Map("a" -> "b", "c" -> "d"), + """query { + | items { + | key + | value + | } + |}""".stripMargin, + )(Schema.map(Schema.string, Schema.string).nested("items")) + .map( + assert.eql( + _, + Json.obj( + "items" := Json.arr( + Json.obj( + "key" := "a", + "value" := "b", + ), + Json.obj( + "key" := "c", + "value" := "d", + ), + ) + ), + ) + ) + } + + test("recursive struct") { + testQueryResultWithSchema( + Rec( + name = "level1", + next = Some( + Rec( + name = "level2", + next = Some( + Rec( + name = "level3", + next = None, + ) + ), + ) + ), + ), + """query { + | item { + | name + | next { + | name + | next { + | name + | next { name } + | } + | } + | } + |}""".stripMargin, + )(Rec.schema.nested("item")) + .map( + assert.eql( + _, + Json.obj( + "item" := Json.obj( + "name" := "level1", + "next" := Json.obj( + "name" := "level2", + "next" := Json.obj( + "name" := "level3", + "next" := Json.Null, + ), + ), + ) + ), + ) + ) + } +} diff --git a/modules/core/src/test/scala/org/polyvariant/smithy4scaliban/CalibanTestUtils.scala b/modules/core/src/test/scala/org/polyvariant/smithy4scaliban/CalibanTestUtils.scala new file mode 100644 index 0000000..94a9f1c --- /dev/null +++ b/modules/core/src/test/scala/org/polyvariant/smithy4scaliban/CalibanTestUtils.scala @@ -0,0 +1,61 @@ +/* + * Copyright 2023 Polyvariant + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 org.polyvariant.smithy4scaliban + +import caliban.RootResolver +import caliban.interop.cats.implicits._ +import caliban.schema.Schema +import cats.effect.IO +import io.circe.Json +import io.circe.syntax._ +import caliban.GraphQL +import caliban.wrappers.Wrappers +import smithy4s.schema.CompilationCache + +object CalibanTestUtils { + private implicit val rt: zio.Runtime[Any] = zio.Runtime.default + private val csv = new CalibanSchemaVisitor(CompilationCache.nop) + + def testApiResult[A]( + api: GraphQL[Any], + q: String, + ): IO[Json] = (api @@ Wrappers.printErrors) + .interpreterAsync[IO] + .flatMap(_.executeAsync[IO](q)) + .map(_.data.asJson) + + def testQueryResult[A]( + api: A, + q: String, + )( + implicit + aSchema: Schema[Any, A] + ): IO[Json] = caliban + .graphQL(RootResolver(api)) + .interpreterAsync[IO] + .flatMap(_.executeAsync[IO](q)) + .map(_.data.asJson) + + def testQueryResultWithSchema[A: smithy4s.Schema]( + api: A, + q: String, + ): IO[Json] = + testQueryResult(api, q)( + implicitly[smithy4s.Schema[A]].compile(csv) + ) + +} diff --git a/core/src/main/scala/Main.scala b/modules/core/src/test/scala/org/polyvariant/smithy4scaliban/Smithy4sTestUtils.scala similarity index 60% rename from core/src/main/scala/Main.scala rename to modules/core/src/test/scala/org/polyvariant/smithy4scaliban/Smithy4sTestUtils.scala index f876216..f459e01 100644 --- a/core/src/main/scala/Main.scala +++ b/modules/core/src/test/scala/org/polyvariant/smithy4scaliban/Smithy4sTestUtils.scala @@ -14,6 +14,18 @@ * limitations under the License. */ -object Main { - def v = 1 +package org.polyvariant.smithy4scaliban + +import smithy4s.schema.Schema +import smithy4s.Bijection + +object Smithy4sTestUtils { + + implicit class SchemaOps[A](schema: Schema[A]) { + def nested(label: String): Schema[A] = + Schema.struct(schema.required[A](label, identity(_)))(identity(_)) + + def biject[B](bijection: Bijection[A, B]): Schema[B] = Schema.bijection(schema, bijection) + } + } diff --git a/modules/core/src/test/smithy/example.smithy b/modules/core/src/test/smithy/example.smithy new file mode 100644 index 0000000..d4564f7 --- /dev/null +++ b/modules/core/src/test/smithy/example.smithy @@ -0,0 +1,67 @@ +$version: "2" + +namespace smithy4s.example + +structure Rec { + next: Rec + @required + name: String +} + +structure MovieTheater { + name: String +} + +union Foo { + int: Integer + str: String + bInt: BigInteger + bDec: BigDecimal +} + +intEnum EnumResult { + FIRST = 1 + SECOND = 2 +} + +enum Ingredient { + MUSHROOM + CHEESE + SALAD + TOMATO = "Tomato" +} + +structure CityCoordinates { + @required + latitude: Float + @required + longitude: Float +} + +service WeatherService { + operations: [ListCities, RefreshCities] +} + +@readonly +operation ListCities { + output := { + @required + cities: Cities + } +} + +list Cities { + member: City +} + +structure City { + @required + name: String +} + +operation RefreshCities { + output := { + @required + result: String + } +} diff --git a/modules/docs/src/main/smithy/hello.smithy b/modules/docs/src/main/smithy/hello.smithy new file mode 100644 index 0000000..bcd1952 --- /dev/null +++ b/modules/docs/src/main/smithy/hello.smithy @@ -0,0 +1,18 @@ +$version: "2" + +namespace hello + +service HelloService { + operations: [GetHello] +} + +operation GetHello { + input := { + @required + name: String + } + output := { + @required + greeting: String + } +} diff --git a/project/plugins.sbt b/project/plugins.sbt index fb2ef9e..e18fdeb 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -3,5 +3,6 @@ addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.13.1") addSbtPlugin("org.scala-native" % "sbt-scala-native" % "0.4.12") addSbtPlugin("com.disneystreaming.smithy4s" % "smithy4s-sbt-codegen" % "0.17.7") addSbtPlugin("com.eed3si9n" % "sbt-projectmatrix" % "0.9.0") +addSbtPlugin("org.scalameta" % "sbt-mdoc" % "2.3.7") addDependencyTreePlugin