From 001bb47bd88f112fc40ce7f0fdce9934855d68fb Mon Sep 17 00:00:00 2001 From: Astrid Jahn <30652062+astridej@users.noreply.github.com> Date: Mon, 6 May 2024 12:42:01 +0200 Subject: [PATCH] Add smithy build openapi support (#1492) This adds support for supplying a `smithy-build.json` during codegen, either by CLI or by either the SBT or Mill plugins. Initially, the only thing in this file that is supported is the OpenAPI plugin, but in future more features can be parsed and handled in codegen if so desired. Docs have been added that explain this. --- CHANGELOG.md | 1 + .../smithy4s/codegen/cli/CodegenCommand.scala | 16 ++- .../codegen/cli/CommandParsingSpec.scala | 8 +- .../codegen-plugin/smithy-build/build.sbt | 20 +++ .../smithy-build/project/build.properties | 1 + .../smithy-build/project/plugins.sbt | 9 ++ .../smithy-build/smithy-build.json | 12 ++ .../src/main/smithy/example.smithy | 83 ++++++++++++ .../sbt-test/codegen-plugin/smithy-build/test | 5 + .../src/smithy4s/codegen/JsonConverters.scala | 9 +- .../codegen/Smithy4sCodegenPlugin.scala | 9 +- .../src/smithy4s/codegen/CodegenArgs.scala | 3 +- .../smithy4s/codegen/SmithyBuildJson.scala | 2 +- .../codegen/internals/CodegenImpl.scala | 17 ++- .../codegen/internals/SmithyBuild.scala | 125 +++++++++++++++++- .../codegen/internals/SmithyBuildSpec.scala | 66 ++++++++- .../markdown/06-guides/smithy-build-config.md | 47 +++++++ .../codegen/mill/Smithy4sModule.scala | 7 +- .../resources/smithy-build/smithy-build.json | 12 ++ .../smithy-build/smithy/service.smithy | 83 ++++++++++++ .../codegen/mill/Smithy4sModuleSpec.scala | 22 +++ 21 files changed, 534 insertions(+), 23 deletions(-) create mode 100644 modules/codegen-plugin/src/sbt-test/codegen-plugin/smithy-build/build.sbt create mode 100644 modules/codegen-plugin/src/sbt-test/codegen-plugin/smithy-build/project/build.properties create mode 100644 modules/codegen-plugin/src/sbt-test/codegen-plugin/smithy-build/project/plugins.sbt create mode 100644 modules/codegen-plugin/src/sbt-test/codegen-plugin/smithy-build/smithy-build.json create mode 100644 modules/codegen-plugin/src/sbt-test/codegen-plugin/smithy-build/src/main/smithy/example.smithy create mode 100644 modules/codegen-plugin/src/sbt-test/codegen-plugin/smithy-build/test create mode 100644 modules/docs/markdown/06-guides/smithy-build-config.md create mode 100644 modules/mill-codegen-plugin/test/resources/smithy-build/smithy-build.json create mode 100644 modules/mill-codegen-plugin/test/resources/smithy-build/smithy/service.smithy diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a5bc2fa3..9a3b25778 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ * Constraints applied to list or map members are now correctly rendered in the generated code. * Fix an issue with duplicated entries in generated smithy-build.json file (#1491) +* Add support for passing custom OpenAPI config via a `smithy-build.json` file # 0.18.16 diff --git a/modules/codegen-cli/src/smithy4s/codegen/cli/CodegenCommand.scala b/modules/codegen-cli/src/smithy4s/codegen/cli/CodegenCommand.scala index 39a89420f..4e776418a 100644 --- a/modules/codegen-cli/src/smithy4s/codegen/cli/CodegenCommand.scala +++ b/modules/codegen-cli/src/smithy4s/codegen/cli/CodegenCommand.scala @@ -93,6 +93,14 @@ object CodegenCommand { .map(_.toSet) .orNone + val smithyBuildOpt: Opts[Option[os.Path]] = + Opts + .option[os.Path]( + "smithy-build", + "Path of smithy-build.json file containing smithy build arguments" + ) + .orNone + val options = ( outputOpt, @@ -105,11 +113,12 @@ object CodegenCommand { dependenciesOpt, transformersOpt, localJarsOpt, - specsArgs + specsArgs, + smithyBuildOpt ) .mapN { // format: off - case (output, resourseOutput, skip, discoverModels, allowedNS, excludedNS, repositories, dependencies, transformers, localJars, specsArgs) => + case (output, resourseOutput, skip, discoverModels, allowedNS, excludedNS, repositories, dependencies, transformers, localJars, specsArgs, smithyBuild) => // format: on val dependenciesWithDefaults = { import Defaults._ @@ -126,7 +135,8 @@ object CodegenCommand { repositories.getOrElse(List.empty), dependenciesWithDefaults, transformers.getOrElse(List.empty), - localJars.getOrElse(List.empty) + localJars.getOrElse(List.empty), + smithyBuild ) } diff --git a/modules/codegen-cli/test/src/smithy4s/codegen/cli/CommandParsingSpec.scala b/modules/codegen-cli/test/src/smithy4s/codegen/cli/CommandParsingSpec.scala index 9190935f8..d37f20917 100644 --- a/modules/codegen-cli/test/src/smithy4s/codegen/cli/CommandParsingSpec.scala +++ b/modules/codegen-cli/test/src/smithy4s/codegen/cli/CommandParsingSpec.scala @@ -40,7 +40,8 @@ object CommandParsingSpec extends FunSuite { repositories = Nil, dependencies = defaultDependencies, transformers = Nil, - localJars = Nil + localJars = Nil, + smithyBuild = None ) ) ) @@ -60,6 +61,8 @@ object CommandParsingSpec extends FunSuite { "scala", "--skip", "openapi", + "--smithy-build", + "smithy-build.json", "--allowed-ns", "name1,name2", "--repositories", @@ -94,7 +97,8 @@ object CommandParsingSpec extends FunSuite { localJars = List( os.pwd / "lib1.jar", os.pwd / "lib2.jar" - ) + ), + smithyBuild = Some(os.pwd / "smithy-build.json") ) ) ) diff --git a/modules/codegen-plugin/src/sbt-test/codegen-plugin/smithy-build/build.sbt b/modules/codegen-plugin/src/sbt-test/codegen-plugin/smithy-build/build.sbt new file mode 100644 index 000000000..9abb225ad --- /dev/null +++ b/modules/codegen-plugin/src/sbt-test/codegen-plugin/smithy-build/build.sbt @@ -0,0 +1,20 @@ +lazy val root = (project in file(".")) + .enablePlugins(Smithy4sCodegenPlugin) + .settings( + scalaVersion := "2.13.13", + libraryDependencies ++= Seq( + "com.disneystreaming.smithy4s" %% "smithy4s-core" % smithy4sVersion.value + ), + Compile / smithyBuild := Some(baseDirectory.value / "smithy-build.json"), + TaskKey[Unit]("checkOpenApi") := { + val resourceDir = (Compile / smithy4sResourceDir).value + val content = + IO.readLines( + resourceDir / "smithy4s.example.ObjectService.json" + ).filter(_.trim().nonEmpty) + .mkString("") + .trim() + if (!content.contains("X-Bar") || !content.contains("3.1.0")) + sys.error("OpenAPI transformation was not applied") + } + ) diff --git a/modules/codegen-plugin/src/sbt-test/codegen-plugin/smithy-build/project/build.properties b/modules/codegen-plugin/src/sbt-test/codegen-plugin/smithy-build/project/build.properties new file mode 100644 index 000000000..72413de15 --- /dev/null +++ b/modules/codegen-plugin/src/sbt-test/codegen-plugin/smithy-build/project/build.properties @@ -0,0 +1 @@ +sbt.version=1.8.3 diff --git a/modules/codegen-plugin/src/sbt-test/codegen-plugin/smithy-build/project/plugins.sbt b/modules/codegen-plugin/src/sbt-test/codegen-plugin/smithy-build/project/plugins.sbt new file mode 100644 index 000000000..b8589b92c --- /dev/null +++ b/modules/codegen-plugin/src/sbt-test/codegen-plugin/smithy-build/project/plugins.sbt @@ -0,0 +1,9 @@ +sys.props.get("plugin.version") match { + case Some(x) => + addSbtPlugin("com.disneystreaming.smithy4s" % "smithy4s-sbt-codegen" % x) + case _ => + sys.error( + """|The system property 'plugin.version' is not defined. + |Specify this property using the scriptedLaunchOpts -D.""".stripMargin + ) +} diff --git a/modules/codegen-plugin/src/sbt-test/codegen-plugin/smithy-build/smithy-build.json b/modules/codegen-plugin/src/sbt-test/codegen-plugin/smithy-build/smithy-build.json new file mode 100644 index 000000000..bac01c42c --- /dev/null +++ b/modules/codegen-plugin/src/sbt-test/codegen-plugin/smithy-build/smithy-build.json @@ -0,0 +1,12 @@ +{ + "version": "1.0", + "plugins": { + "openapi": { + "service": "smithy4s.example#ObjectService", + "version": "3.1.0", + "substitutions": { + "X-Foo": "X-Bar" + } + } + } +} \ No newline at end of file diff --git a/modules/codegen-plugin/src/sbt-test/codegen-plugin/smithy-build/src/main/smithy/example.smithy b/modules/codegen-plugin/src/sbt-test/codegen-plugin/smithy-build/src/main/smithy/example.smithy new file mode 100644 index 000000000..dd1ecfa07 --- /dev/null +++ b/modules/codegen-plugin/src/sbt-test/codegen-plugin/smithy-build/src/main/smithy/example.smithy @@ -0,0 +1,83 @@ +namespace smithy4s.example + +use alloy#simpleRestJson + +@simpleRestJson +service ObjectService { + version: "1.0.0", + operations: [PutObject, GetObject] +} + + +@idempotent +@http(method: "PUT", uri: "/{bucketName}/{key}", code: 200) +operation PutObject { + input: PutObjectInput, + errors: [NoMoreSpace] +} + +@readonly +@http(method: "GET", uri: "/{bucketName}/{key}", code: 200) +operation GetObject { + input: GetObjectInput, + output: GetObjectOutput +} + +structure PutObjectInput { + // Sent in the URI label named "key". + @required + @httpLabel + key: String, + + // Sent in the URI label named "bucketName". + @required + @httpLabel + bucketName: String, + + // Sent in the X-Foo header + @httpHeader("X-Foo") + foo: String, + + // Sent in the query string as paramName + @httpQuery("paramName") + someValue: String, + + // Sent in the body + @httpPayload + @required + data: String +} + +structure GetObjectInput { + // Sent in the URI label named "key". + @required + @httpLabel + key: String, + + // Sent in the URI label named "bucketName". + @required + @httpLabel + bucketName: String, +} + +structure GetObjectOutput { + @httpHeader("X-Size") + @required + size: Integer, + @httpPayload + data: String +} + +union Foo { + int: Integer, + str: String +} + +@error("server") +@httpError(507) +structure NoMoreSpace { + @required + message: String, + foo: Foo +} + diff --git a/modules/codegen-plugin/src/sbt-test/codegen-plugin/smithy-build/test b/modules/codegen-plugin/src/sbt-test/codegen-plugin/smithy-build/test new file mode 100644 index 000000000..0f186a0a1 --- /dev/null +++ b/modules/codegen-plugin/src/sbt-test/codegen-plugin/smithy-build/test @@ -0,0 +1,5 @@ +# check if smithy4sCodegen works +> compile +$ exists target/scala-2.13/src_managed/main/scala/smithy4s/example/ObjectService.scala +$ exists target/scala-2.13/resource_managed/main/smithy4s.example.ObjectService.json +> checkOpenApi \ No newline at end of file diff --git a/modules/codegen-plugin/src/smithy4s/codegen/JsonConverters.scala b/modules/codegen-plugin/src/smithy4s/codegen/JsonConverters.scala index c571ae3de..7d60c16f3 100644 --- a/modules/codegen-plugin/src/smithy4s/codegen/JsonConverters.scala +++ b/modules/codegen-plugin/src/smithy4s/codegen/JsonConverters.scala @@ -61,7 +61,7 @@ private[smithy4s] object JsonConverters { ) // format: off - type GenTarget = List[os.Path] :*: os.Path :*: os.Path :*: Set[FileType] :*: Boolean:*: Option[Set[String]] :*: Option[Set[String]] :*: List[String] :*: List[String] :*: List[String] :*: List[os.Path] :*: LNil + type GenTarget = List[os.Path] :*: os.Path :*: os.Path :*: Set[FileType] :*: Boolean:*: Option[Set[String]] :*: Option[Set[String]] :*: List[String] :*: List[String] :*: List[String] :*: List[os.Path] :*: Option[os.Path] :*: LNil // format: on implicit val codegenArgsIso = LList.iso[CodegenArgs, GenTarget]( { ca: CodegenArgs => @@ -76,6 +76,7 @@ private[smithy4s] object JsonConverters { ("dependencies", ca.dependencies) :*: ("transformers", ca.transformers) :*: ("localJars", ca.localJars) :*: + ("smithyBuild", ca.smithyBuild) :*: LNil }, { @@ -89,7 +90,8 @@ private[smithy4s] object JsonConverters { (_, repositories) :*: (_, dependencies) :*: (_, transformers) :*: - (_, localJars) :*: LNil => + (_, localJars) :*: + (_, smithyBuild) :*: LNil => CodegenArgs( specs, output, @@ -101,7 +103,8 @@ private[smithy4s] object JsonConverters { repositories, dependencies, transformers, - localJars + localJars, + smithyBuild ) } ) diff --git a/modules/codegen-plugin/src/smithy4s/codegen/Smithy4sCodegenPlugin.scala b/modules/codegen-plugin/src/smithy4s/codegen/Smithy4sCodegenPlugin.scala index 4f2c625ad..5bbb81d22 100644 --- a/modules/codegen-plugin/src/smithy4s/codegen/Smithy4sCodegenPlugin.scala +++ b/modules/codegen-plugin/src/smithy4s/codegen/Smithy4sCodegenPlugin.scala @@ -128,6 +128,10 @@ object Smithy4sCodegenPlugin extends AutoPlugin { ).mkString(" ") ) + val smithyBuild = taskKey[Option[File]]( + "smithy-build.json to use for reading build configuration" + ) + val smithy4sWildcardArgument = taskKey[String]( "String value to use as wildcard argument in types in generated code" @@ -230,6 +234,7 @@ object Smithy4sCodegenPlugin extends AutoPlugin { (config / smithy4sInternalDependenciesAsJars).value ++ fetch(config / smithy4sAllExternalDependencies).value }, + config / smithyBuild := None, config / smithy4sWildcardArgument := { // This logic configures the default wildcard argument based on the scala version and scalac options // In the following scenarios we use "?" instead of "_" @@ -423,6 +428,7 @@ object Smithy4sCodegenPlugin extends AutoPlugin { val skipSet = skipResources val filePaths = inputFiles.map(_.getAbsolutePath()) + val smithyBuildValue = (conf / smithyBuild).value.map(os.Path(_)) val codegenArgs = CodegenArgs( filePaths.map(os.Path(_)).toList, output = os.Path(outputPath), @@ -434,7 +440,8 @@ object Smithy4sCodegenPlugin extends AutoPlugin { repositories = res, dependencies = List.empty, transformers = transforms, - localJars = localJars + localJars = localJars, + smithyBuild = smithyBuildValue ) val cached = diff --git a/modules/codegen/src/smithy4s/codegen/CodegenArgs.scala b/modules/codegen/src/smithy4s/codegen/CodegenArgs.scala index c3e347360..b59d2f20f 100644 --- a/modules/codegen/src/smithy4s/codegen/CodegenArgs.scala +++ b/modules/codegen/src/smithy4s/codegen/CodegenArgs.scala @@ -30,7 +30,8 @@ final case class CodegenArgs( repositories: List[String], dependencies: List[String], transformers: List[String], - localJars: List[os.Path] + localJars: List[os.Path], + smithyBuild: Option[os.Path] ) { def skipScala: Boolean = skip(FileType.Scala) def skipOpenapi: Boolean = skip(FileType.Openapi) diff --git a/modules/codegen/src/smithy4s/codegen/SmithyBuildJson.scala b/modules/codegen/src/smithy4s/codegen/SmithyBuildJson.scala index 5bb4da9c8..1b3cb3809 100644 --- a/modules/codegen/src/smithy4s/codegen/SmithyBuildJson.scala +++ b/modules/codegen/src/smithy4s/codegen/SmithyBuildJson.scala @@ -30,7 +30,7 @@ private[codegen] object SmithyBuildJson { repositories: ListSet[String] ): String = { SmithyBuild.writeJson( - SmithyBuild( + SmithyBuild.Serializable( version = "1.0", imports, SmithyBuildMaven( diff --git a/modules/codegen/src/smithy4s/codegen/internals/CodegenImpl.scala b/modules/codegen/src/smithy4s/codegen/internals/CodegenImpl.scala index e5dc66988..5ab4e063e 100644 --- a/modules/codegen/src/smithy4s/codegen/internals/CodegenImpl.scala +++ b/modules/codegen/src/smithy4s/codegen/internals/CodegenImpl.scala @@ -24,6 +24,7 @@ import smithy4s.codegen.transformers._ import software.amazon.smithy.model.Model import software.amazon.smithy.model.node.Node import software.amazon.smithy.model.shapes.ModelSerializer +import software.amazon.smithy.openapi.OpenApiConfig import scala.jdk.CollectionConverters._ import software.amazon.smithy.model.transform.ModelTransformer @@ -31,6 +32,9 @@ import software.amazon.smithy.model.transform.ModelTransformer private[codegen] object CodegenImpl { self => def generate(args: CodegenArgs): CodegenResult = { + val smithyBuild = args.smithyBuild + .map(os.read) + .map(SmithyBuild.readJson(_)) val (classloader, model): (ClassLoader, Model) = internals.ModelLoader.load( args.specs.map(_.toIO).toSet, args.dependencies, @@ -64,12 +68,19 @@ private[codegen] object CodegenImpl { self => } else (List.empty, List.empty) val openApiFiles = if (!args.skipOpenapi) { - alloy.openapi.convert(model, args.allowedNS, classloader).map { - case OpenApiConversionResult(_, serviceId, outputString) => + val openApiConfig: Unit => OpenApiConfig = _ => + smithyBuild + .flatMap(_.getPlugin[SmithyBuildPlugin.OpenApi]) + .map(_.config) + .getOrElse(new OpenApiConfig()) + + alloy.openapi + .convertWithConfig(model, args.allowedNS, openApiConfig, classloader) + .map { case OpenApiConversionResult(_, serviceId, outputString) => val name = serviceId.getNamespace() + "." + serviceId.getName() val openapiFile = (args.resourceOutput / (name + ".json")) CodegenEntry.FromMemory(openapiFile, outputString) - } + } } else List.empty val protoFiles = if (!args.skipProto) { diff --git a/modules/codegen/src/smithy4s/codegen/internals/SmithyBuild.scala b/modules/codegen/src/smithy4s/codegen/internals/SmithyBuild.scala index 565b62799..540f4f575 100644 --- a/modules/codegen/src/smithy4s/codegen/internals/SmithyBuild.scala +++ b/modules/codegen/src/smithy4s/codegen/internals/SmithyBuild.scala @@ -17,18 +17,80 @@ package smithy4s.codegen package internals -import io.circe.Codec -import io.circe.generic.semiauto._ +import cats.syntax.all._ +import io.circe._ import io.circe.syntax._ +import io.circe.generic.semiauto._ +import software.amazon.smithy.model.node.Node +import software.amazon.smithy.openapi.OpenApiConfig + +import scala.collection.Set +import scala.reflect.ClassTag +import scala.util.Try private[internals] final case class SmithyBuild( version: String, - imports: Set[String], - maven: SmithyBuildMaven -) + imports: Set[os.FilePath], + plugins: Set[SmithyBuildPlugin], + maven: Option[SmithyBuildMaven] +) { + def getPlugin[T <: SmithyBuildPlugin](implicit + classTag: ClassTag[T] + ): Option[T] = + plugins.collectFirst { case t: T => t } +} + private[codegen] object SmithyBuild { - implicit val codecs: Codec[SmithyBuild] = deriveCodec - def writeJson(sb: SmithyBuild): String = sb.asJson.spaces4 + // automatically map absence of value to empty Seq for ease of use + implicit def optionalSetDecoder[T](implicit + base: Decoder[T] + ): Decoder[Set[T]] = + Decoder.decodeOption(Decoder.decodeSet[T]).map(_.getOrElse(Set.empty)) + + implicit val pathDecoder: Decoder[os.FilePath] = + Decoder.decodeString.emapTry { raw => + Try(os.FilePath(raw)) + } + + implicit val pluginDecoder: Decoder[Set[SmithyBuildPlugin]] = Decoder + .decodeOption { (c: HCursor) => + c.keys match { + case None => DecodingFailure("Expected JSON object", c.history).asLeft + case Some(keys) => + keys.toList + .traverse(key => c.get(key)(SmithyBuildPlugin.decode(key))) + .map(_.toSet) + } + } + .map(_.getOrElse(Set.empty)) + + /* Class containing only the subset of the smithy-build.json properties that need + * to be serialized when creating a smithy-build.json file. Allows us to skip + * things that are both unnecessary and very complicated to serialize, + * such as OpenApiConfig. + */ + case class Serializable( + version: String, + imports: Set[String], + maven: SmithyBuildMaven + ) + + implicit val decoder: Decoder[SmithyBuild] = deriveDecoder + + implicit val serializableEncoder: Encoder[Serializable] = deriveEncoder + + def writeJson(sb: SmithyBuild.Serializable): String = sb.asJson.spaces4 + + def readJson(in: String): SmithyBuild = parser + .decode[SmithyBuild](in) + .left + .map(err => + throw new IllegalArgumentException( + s"Input is not a valid smithy-build.json file: ${err.getMessage}", + err + ) + ) + .merge } private[internals] final case class SmithyBuildMaven( @@ -36,6 +98,8 @@ private[internals] final case class SmithyBuildMaven( repositories: Set[SmithyBuildMavenRepository] ) private[codegen] object SmithyBuildMaven { + import SmithyBuild.optionalSetDecoder + implicit val codecs: Codec[SmithyBuildMaven] = deriveCodec } @@ -45,3 +109,50 @@ private[internals] final case class SmithyBuildMavenRepository( private[codegen] object SmithyBuildMavenRepository { implicit val codecs: Codec[SmithyBuildMavenRepository] = deriveCodec } + +private[codegen] sealed trait SmithyBuildPlugin + +private[codegen] object SmithyBuildPlugin { + def decode(key: String): Decoder[SmithyBuildPlugin] = key match { + case "openapi" => Decoder[OpenApi].widen + case other => + Decoder.failedWithMessage( + s"Plugin $other is not supported by smithy4s. Currently supported plugins: openapi" + ) + } + + case class OpenApi(config: OpenApiConfig) extends SmithyBuildPlugin + + object OpenApi { + private val nodeFolder: Json.Folder[Node] = new Json.Folder[Node] { + import scala.jdk.CollectionConverters._ + override def onNull: Node = Node.nullNode() + + override def onBoolean(value: Boolean): Node = Node.from(value) + + override def onNumber(value: JsonNumber): Node = + // try to avoid rounding errors from double conversion if we possibly can + value.toInt + .map(Node.from(_)) + .orElse(value.toLong.map(Node.from(_))) + .getOrElse(Node.from(value.toDouble)) + + override def onString(value: String): Node = Node.from(value) + + override def onArray(value: Vector[Json]): Node = + Node.arrayNode(value.map(_.foldWith(this)): _*) + + override def onObject(value: JsonObject): Node = + Node.objectNode(value.toMap.map { case (name, json) => + Node.from(name) -> json.foldWith(this) + }.asJava) + } + + implicit val decoder: Decoder[OpenApi] = Decoder[Json].emapTry { obj => + Try { + val config = OpenApiConfig.fromNode(obj.foldWith(nodeFolder)) + OpenApi(config) + } + } + } +} diff --git a/modules/codegen/test/src/smithy4s/codegen/internals/SmithyBuildSpec.scala b/modules/codegen/test/src/smithy4s/codegen/internals/SmithyBuildSpec.scala index a32f5c007..b3622c29f 100644 --- a/modules/codegen/test/src/smithy4s/codegen/internals/SmithyBuildSpec.scala +++ b/modules/codegen/test/src/smithy4s/codegen/internals/SmithyBuildSpec.scala @@ -17,12 +17,14 @@ package smithy4s.codegen.internals import smithy4s.codegen.SmithyBuildJson +import software.amazon.smithy.model.shapes.ShapeId +import software.amazon.smithy.openapi.OpenApiVersion import scala.collection.immutable.ListSet final class SmithyBuildSpec extends munit.FunSuite { test("generate json") { val actual = SmithyBuild.writeJson( - SmithyBuild( + SmithyBuild.Serializable( "1.0", ListSet("src/"), SmithyBuildMaven( @@ -136,4 +138,66 @@ final class SmithyBuildSpec extends munit.FunSuite { ) } + test("Can parse smithy-build file with OpenAPI plugin") { + val input = + """ + |{ + | "version": "1.0", + | "imports": [ "foo.smithy", "some/directory" ], + | "plugins": { + | "openapi": { + | "service": "example.weather#Weather", + | "version": "3.1.0" + | } + | } + |}""".stripMargin + + val actual = io.circe.parser + .decode[SmithyBuild](input) + .left + .map(x => throw x) + .merge + + // OpenApiConfig doesn't override equals, so we need to check expected == actual in pieces: + assertEquals(actual.maven, None) + assertEquals("1.0", actual.version) + assertEquals( + Set[os.FilePath]( + os.FilePath("foo.smithy"), + os.FilePath("some/directory") + ), + actual.imports.toSet + ) + val actualOpenApiConfig = actual + .getPlugin[SmithyBuildPlugin.OpenApi] + .getOrElse(fail("No OpenAPI plugin on parsed smithy-build")) + .config + assertEquals( + ShapeId.from("example.weather#Weather"), + actualOpenApiConfig.getService.toShapeId + ) + assertEquals( + OpenApiVersion.VERSION_3_1_0, + actualOpenApiConfig.getVersion + ) + } + + test("Can parse smithy-build file with no optional fields") { + val input = + """ + |{ + | "version": "1.0" + |}""".stripMargin + + val actual = io.circe.parser + .decode[SmithyBuild](input) + .left + .map(x => throw x) + .merge + + assertEquals(actual.maven, None) + assertEquals("1.0", actual.version) + assertEquals(Set.empty[os.FilePath], actual.imports.toSet) + assertEquals(Set.empty[SmithyBuildPlugin], actual.plugins.toSet) + } } diff --git a/modules/docs/markdown/06-guides/smithy-build-config.md b/modules/docs/markdown/06-guides/smithy-build-config.md new file mode 100644 index 000000000..9f1a12bc7 --- /dev/null +++ b/modules/docs/markdown/06-guides/smithy-build-config.md @@ -0,0 +1,47 @@ +--- +sidebar_label: Smithy build config +title: Smithy Build Configuration +--- + +## Introduction + +Smithy provides the ability to configure the Smithy build and output by a [smithy-build configuration file](https://smithy.io/2.0/guides/smithy-build-json.html#smithy-build-json). As smithy4s uses its own build logic, it generally loads its configuration from elsewhere. However, limited support for build customization using a Smithy build configuration file is available. In particular, the [OpenAPI plugin](https://smithy.io/2.0/guides/model-translations/converting-to-openapi.html) can be used to customize the OpenAPI generation. + +### Customizing OpenAPI generation via smithy build + +In order to apply a custom OpenAPI config, you need a `smithy-build.json` file with the OpenAPI configuration, such as the following: + +```json +{ + "version": "1.0", + "plugins": { + "openapi": { + "service": "smithy.example#Weather", + "version": "3.1.0", + "jsonAdd": { + "/info/title": "Replaced title value", + "/info/nested/foo": { + "hi": "Adding this object created intermediate objects too!" + }, + "/info/nested/foo/baz": true + } + } + } +} +``` + +This file can then used to configure codegen via the appropriate SBT setting: + +```scala +Compile / smithyBuild := Some(baseDirectory.value / "smithy-build.json") +``` + +It can also be configured in Mill: + +```scala +override def smithyBuild = Some(PathRef(millSourcePath / "smithy-build.json")) +``` + +Or, if you are using codegen directly via the command line tool, it can be passed via the argument `--smithy-build ./smithy-build.json`. + +The generated OpenAPI should then have the configured transformations applied. \ No newline at end of file diff --git a/modules/mill-codegen-plugin/src/smithy4s/codegen/mill/Smithy4sModule.scala b/modules/mill-codegen-plugin/src/smithy4s/codegen/mill/Smithy4sModule.scala index d57a9f653..dde86ebce 100644 --- a/modules/mill-codegen-plugin/src/smithy4s/codegen/mill/Smithy4sModule.scala +++ b/modules/mill-codegen-plugin/src/smithy4s/codegen/mill/Smithy4sModule.scala @@ -58,6 +58,8 @@ trait Smithy4sModule extends ScalaModule { def generateOpenApiSpecs: T[Boolean] = true + def smithyBuild: T[Option[PathRef]] = None + def smithy4sAllowedNamespaces: T[Option[Set[String]]] = None def smithy4sExcludedNamespaces: T[Option[Set[String]]] = None @@ -204,6 +206,8 @@ trait Smithy4sModule extends ScalaModule { val skipSet = skipResources ++ skipOpenApi + val smithyBuildFile = smithyBuild().map(_.path) + val allLocalJars = smithy4sAllDependenciesAsJars().map(_.path).iterator.to(List) @@ -218,7 +222,8 @@ trait Smithy4sModule extends ScalaModule { repositories = smithy4sRepositories(), dependencies = List.empty, transformers = smithy4sModelTransformers(), - localJars = allLocalJars + localJars = allLocalJars, + smithyBuild = smithyBuildFile ) Smithy4s.generateToDisk(args) diff --git a/modules/mill-codegen-plugin/test/resources/smithy-build/smithy-build.json b/modules/mill-codegen-plugin/test/resources/smithy-build/smithy-build.json new file mode 100644 index 000000000..bac01c42c --- /dev/null +++ b/modules/mill-codegen-plugin/test/resources/smithy-build/smithy-build.json @@ -0,0 +1,12 @@ +{ + "version": "1.0", + "plugins": { + "openapi": { + "service": "smithy4s.example#ObjectService", + "version": "3.1.0", + "substitutions": { + "X-Foo": "X-Bar" + } + } + } +} \ No newline at end of file diff --git a/modules/mill-codegen-plugin/test/resources/smithy-build/smithy/service.smithy b/modules/mill-codegen-plugin/test/resources/smithy-build/smithy/service.smithy new file mode 100644 index 000000000..f522df194 --- /dev/null +++ b/modules/mill-codegen-plugin/test/resources/smithy-build/smithy/service.smithy @@ -0,0 +1,83 @@ +namespace smithy4s.example + +use alloy#simpleRestJson + +@simpleRestJson +service ObjectService { + version: "1.0.0", + operations: [PutObject, GetObject] +} + + +@idempotent +@http(method: "PUT", uri: "/{bucketName}/{key}", code: 200) +operation PutObject { + input: PutObjectInput, + errors: [NoMoreSpace] +} + +@readonly +@http(method: "GET", uri: "/{bucketName}/{key}", code: 200) +operation GetObject { + input: GetObjectInput, + output: GetObjectOutput +} + +structure PutObjectInput { + // Sent in the URI label named "key". + @required + @httpLabel + key: String, + + // Sent in the URI label named "bucketName". + @required + @httpLabel + bucketName: String, + + // Sent in the X-Foo header + @httpHeader("X-Foo") + foo: String, + + // Sent in the query string as paramName + @httpQuery("paramName") + someValue: String, + + // Sent in the body + @httpPayload + @required + data: String +} + +structure GetObjectInput { + // Sent in the URI label named "key". + @required + @httpLabel + key: String, + + // Sent in the URI label named "bucketName". + @required + @httpLabel + bucketName: String, +} + +structure GetObjectOutput { + @httpHeader("X-Size") + @required + size: Integer, + @httpPayload + data: String +} + +union Foo { + int: Integer, + str: String +} + +@error("server") +@httpError(507) +structure NoMoreSpace { + @required + message: String, + foo: Foo +} + diff --git a/modules/mill-codegen-plugin/test/src/smithy4s/codegen/mill/Smithy4sModuleSpec.scala b/modules/mill-codegen-plugin/test/src/smithy4s/codegen/mill/Smithy4sModuleSpec.scala index 6f37c156b..2bf389bfa 100644 --- a/modules/mill-codegen-plugin/test/src/smithy4s/codegen/mill/Smithy4sModuleSpec.scala +++ b/modules/mill-codegen-plugin/test/src/smithy4s/codegen/mill/Smithy4sModuleSpec.scala @@ -146,6 +146,28 @@ class Smithy4sModuleSpec extends munit.FunSuite { ) } + test("codegen with custom smithy-build.json works") { + object foo extends testKit.BaseModule with Smithy4sModule { + override def scalaVersion = "2.13.10" + override def ivyDeps = Agg(coreDep) + override def millSourcePath = resourcePath / "smithy-build" + override def smithyBuild = + Some(PathRef(millSourcePath / "smithy-build.json")) + } + val ev = + testKit.staticTestEvaluator(foo)(FullName("smithy-build")) + + compileWorks(foo, ev) + val openApiFile = + ev.outPath / "smithy4sResourceOutputDir.dest" / "resources" / "smithy4s.example.ObjectService.json" + checkFileExist(openApiFile, shouldExist = true) + val openApiJson = os.read(openApiFile) + assert( + openApiJson.contains("X-Bar"), + "Smithy Build openApi configuration was not applied" + ) + } + test("multi-module codegen works") { object foo extends testKit.BaseModule with Smithy4sModule {