From 72116542c805419d1f91031ca31bef2499f3e5bc Mon Sep 17 00:00:00 2001 From: david-perez Date: Wed, 14 Jun 2023 15:31:51 +0200 Subject: [PATCH] Build services with a model-derived configuration object --- .../server/smithy/ServerCodegenVisitor.kt | 9 + .../generators/ServiceConfigGenerator.kt | 465 ++++++++++++++++++ .../generators/ServiceConfigGeneratorTest.kt | 291 +++++++++++ .../src/plugin/identity.rs | 1 + .../src/plugin/pipeline.rs | 1 + .../src/plugin/stack.rs | 1 + 6 files changed, 768 insertions(+) create mode 100644 codegen-server/src/main/kotlin/software/amazon/smithy/rust/codegen/server/smithy/generators/ServiceConfigGenerator.kt create mode 100644 codegen-server/src/test/kotlin/software/amazon/smithy/rust/codegen/server/smithy/generators/ServiceConfigGeneratorTest.kt diff --git a/codegen-server/src/main/kotlin/software/amazon/smithy/rust/codegen/server/smithy/ServerCodegenVisitor.kt b/codegen-server/src/main/kotlin/software/amazon/smithy/rust/codegen/server/smithy/ServerCodegenVisitor.kt index fdf686e38e..420fe87733 100644 --- a/codegen-server/src/main/kotlin/software/amazon/smithy/rust/codegen/server/smithy/ServerCodegenVisitor.kt +++ b/codegen-server/src/main/kotlin/software/amazon/smithy/rust/codegen/server/smithy/ServerCodegenVisitor.kt @@ -73,6 +73,7 @@ import software.amazon.smithy.rust.codegen.server.smithy.generators.ServerRootGe import software.amazon.smithy.rust.codegen.server.smithy.generators.ServerRuntimeTypesReExportsGenerator import software.amazon.smithy.rust.codegen.server.smithy.generators.ServerServiceGenerator import software.amazon.smithy.rust.codegen.server.smithy.generators.ServerStructureConstrainedTraitImpl +import software.amazon.smithy.rust.codegen.server.smithy.generators.ServiceConfigGenerator import software.amazon.smithy.rust.codegen.server.smithy.generators.UnconstrainedCollectionGenerator import software.amazon.smithy.rust.codegen.server.smithy.generators.UnconstrainedMapGenerator import software.amazon.smithy.rust.codegen.server.smithy.generators.UnconstrainedUnionGenerator @@ -613,6 +614,14 @@ open class ServerCodegenVisitor( ).render(this) ScopeMacroGenerator(codegenContext).render(this) + + // TODO Expose docs for `Config` and `config::Builder`. + // Rename hierarchy: https://github.com/david-perez/smithy-rs-service-config/pull/1#discussion_r1202320199 + ServiceConfigGenerator( + codegenContext, + // TODO Load from decorator + emptyList(), + ).render(this) } } diff --git a/codegen-server/src/main/kotlin/software/amazon/smithy/rust/codegen/server/smithy/generators/ServiceConfigGenerator.kt b/codegen-server/src/main/kotlin/software/amazon/smithy/rust/codegen/server/smithy/generators/ServiceConfigGenerator.kt new file mode 100644 index 0000000000..6f4484e5d7 --- /dev/null +++ b/codegen-server/src/main/kotlin/software/amazon/smithy/rust/codegen/server/smithy/generators/ServiceConfigGenerator.kt @@ -0,0 +1,465 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.rust.codegen.server.smithy.generators + +import software.amazon.smithy.codegen.core.CodegenException +import software.amazon.smithy.model.Model +import software.amazon.smithy.rust.codegen.core.rustlang.RustWriter +import software.amazon.smithy.rust.codegen.core.rustlang.Writable +import software.amazon.smithy.rust.codegen.core.rustlang.join +import software.amazon.smithy.rust.codegen.core.rustlang.plus +import software.amazon.smithy.rust.codegen.core.rustlang.rust +import software.amazon.smithy.rust.codegen.core.rustlang.rustBlockTemplate +import software.amazon.smithy.rust.codegen.core.rustlang.rustTemplate +import software.amazon.smithy.rust.codegen.core.rustlang.writable +import software.amazon.smithy.rust.codegen.core.smithy.RuntimeConfig +import software.amazon.smithy.rust.codegen.core.smithy.RuntimeType +import software.amazon.smithy.rust.codegen.core.util.dq +import software.amazon.smithy.rust.codegen.core.util.toPascalCase +import software.amazon.smithy.rust.codegen.server.smithy.ServerCargoDependency +import software.amazon.smithy.rust.codegen.server.smithy.ServerCodegenContext + +/** + * TODO Docs + * + * https://github.com/david-perez/smithy-rs-service-config/pull/1 + */ +class ServiceConfigGenerator( + private val codegenContext: ServerCodegenContext, + private val pluginResolvers: List, +) { + private val runtimeConfig = codegenContext.runtimeConfig + private val smithyHttpServer = ServerCargoDependency.smithyHttpServer(runtimeConfig).toType() + private val codegenScope = + arrayOf( + *RuntimeType.preludeScope, + "Debug" to RuntimeType.Debug, + "Display" to RuntimeType.Display, + "HashMap" to RuntimeType.HashMap, + "IdentityPlugin" to smithyHttpServer.resolve("plugin::IdentityPlugin"), + "PluginPipeline" to smithyHttpServer.resolve("plugin::PluginPipeline"), + "PluginStack" to smithyHttpServer.resolve("plugin::PluginStack"), + "SmithyHttpServer" to smithyHttpServer, + "StdError" to RuntimeType.StdError, + ) + + private val pluginResolverToPluginRegistrations = pluginResolvers.associateWith { it.resolve(codegenContext.model) } + private val pluginToPluginResolvers = calculatePluginToPluginResolversMap() + private val pluginRegistrations = pluginResolverToPluginRegistrations.flatMap { it.value } + + private fun calculatePluginToPluginResolversMap(): Map> { + val pluginToPluginResolvers = mutableMapOf>() + for (pluginResolver in pluginResolvers) { + for (pluginRegistration in pluginResolver.resolve(codegenContext.model)) { + val pluginResolvers = pluginToPluginResolvers.getOrDefault(pluginRegistration.plugin, mutableListOf()) + pluginResolvers.add(pluginResolver) + pluginToPluginResolvers[pluginRegistration.plugin] = pluginResolvers + } + } + return pluginToPluginResolvers.toMap() + } + + // Some checks to validate input. + init { + // Check that each plugin is due to a single plugin resolver. + val pluginResolversDuplicatingPlugins = pluginToPluginResolvers.filter { it.value.size > 1 } + if (pluginResolversDuplicatingPlugins.isNotEmpty()) { + val msg = pluginResolversDuplicatingPlugins.map { (plugin, pluginResolvers) -> + "- `${plugin.runtimeType.fullyQualifiedName()}`: [${pluginResolvers.joinToString(", ") { it.javaClass.name }}]" + }.joinToString("\n") + throw CodegenException("The following plugins are resolved by multiple plugin resolvers:\n${msg}") + } + + // Check that each plugin is configured using a distinct setter name. + val duplicateConfigBuilderSetterNames = pluginRegistrations + .groupBy { it.configBuilderSetterName } + .mapValues { (_, v) -> v.map { it.plugin } } + .filterValues { it.size > 1 } + if (duplicateConfigBuilderSetterNames.isNotEmpty()) { + val msg = duplicateConfigBuilderSetterNames.map { (setter, plugins) -> + "- `$setter`: [${plugins.joinToString(", ") { "${it.runtimeType.fullyQualifiedName()} (resolved by ${pluginToPluginResolvers[it]!!.first().javaClass.name})" }}]" + }.joinToString("\n") + throw CodegenException("The following plugins are configured using the same setter name:\n${msg}") + } + } + + private val configBuilderSetterNames = pluginRegistrations.map { it.configBuilderSetterName }.toSet() + + private fun configInsertedAtFields(): Writable = + configBuilderSetterNames.map { + writable { rust("${it}_inserted_at: None,") } + }.join { "\n" } + + private fun configBuilderInsertedAtFields(): Writable = + configBuilderSetterNames.map { + writable { rustTemplate("pub(super) ${it}_inserted_at: #{Option},", *codegenScope) } + }.join { "\n" } + + private fun configBuilderCopyInsertedAtFields(insertThisOne: String?): Writable { + if (insertThisOne != null) { + check(configBuilderSetterNames.contains(insertThisOne)) + } + + var ret = configBuilderSetterNames.filter { it != insertThisOne }.map { + writable { rust("${it}_inserted_at: self.${it}_inserted_at,") } + }.join { "\n" } + + if (insertThisOne != null) { + ret += writable { + rust("${insertThisOne}_inserted_at: Some(self.plugin_count),") + } + } + + return ret + } + + private fun configBuilderSetters(pluginRegistrations: List): Writable = + pluginRegistrations.map { + val paramList = it.configBuilderSetterParams.map { (paramName, paramRuntimeType) -> + writable { + rustTemplate("$paramName: #{ParamRuntimeType},", "ParamRuntimeType" to paramRuntimeType) + } + }.join { " " } + + // TODO Building the plugin can be fallible. + writable { + rustTemplate( + """ + /// ${it.configBuilderSetterDocs} + pub fn ${it.configBuilderSetterName}( + self, + #{ParamList:W} + ) -> #{Result}>, Error> { + let built_plugin = { + #{PluginInstantiation:W} + }; + if self.${it.configBuilderSetterName}_inserted_at.is_some() { + return Err(Error { + msg: "`${it.configBuilderSetterName}` can only be configured once".to_owned(), + }); + } + Ok(Builder { + plugin_pipeline: self.plugin_pipeline.push(built_plugin), + #{ConfigBuilderCopyInsertedAtFields:W} + plugin_count: self.plugin_count + 1, + }) + } + """, + *codegenScope, + "ParamList" to paramList, + "PluginRuntimeType" to it.plugin.runtimeType, + "ConfigBuilderCopyInsertedAtFields" to configBuilderCopyInsertedAtFields(insertThisOne = it.configBuilderSetterName), + "PluginInstantiation" to it.pluginInstantiation(), + ) + } + }.join { "\n" } + + private fun configBuilderBuildFn(topoSortedPluginRegistrations: List): Writable = { + if (topoSortedPluginRegistrations.isEmpty()) { + rustTemplate( + """ + pub fn build(self) -> super::Config<#{PluginPipeline}> { + super::Config { + plugin: self.plugin_pipeline, + } + } + """, + *codegenScope, + ) + } else { + rustBlockTemplate( + "pub fn build(self) -> #{Result}>, Error>", + *codegenScope, + ) { + // First check that required plugins have been registered and return early if not. + rust("let mut msg = String::new();") + // TODO Unit test optional plugins are really optional. + for (pluginRegistration in topoSortedPluginRegistrations.filter { !it.optional }) { + rust( + """ + if self.${pluginRegistration.configBuilderSetterName}_inserted_at.is_none() { + msg += &format!("\n- `${pluginRegistration.configBuilderSetterName}`"); + } + """, + ) + } + rust( + """ + if !msg.is_empty() { + return Err( + Error { + msg: format!("You must configure the following for `${codegenContext.serviceShape.id.name.toPascalCase()}`:{}", msg) + } + ); + } + """, + ) + + // Now check plugins have been registered in the correct order. + // We again key by `Plugin` and not by `PluginRegistration`. + val pluginToId = topoSortedPluginRegistrations.mapIndexed { idx, p -> p.plugin to idx }.toMap() + val adjList = topoSortedPluginRegistrations.mapIndexed { idx, p -> + val predecessorIds = p.predecessors.map { pluginToId[it] }.joinToString(", ") + writable { + rust( + """ + Node { + active: true, + id: $idx, + predecessors: vec![$predecessorIds], + }, + """ + ) + } + }.join("") + val settersList = topoSortedPluginRegistrations.joinToString(", ") { it.configBuilderSetterName.dq() } + val registrations = topoSortedPluginRegistrations.map { + writable { + rust( + """ + self.${it.configBuilderSetterName}_inserted_at, + """ + ) + } + }.join("") + rustTemplate( + """ + let id_to_name: [&'static str; ${topoSortedPluginRegistrations.size}] = [$settersList]; + + struct Node { + active: bool, + id: usize, + predecessors: Vec + } + + let mut adj_list: [Node; ${topoSortedPluginRegistrations.size}] = [ + #{AdjList:W} + ]; + + let props: [Option; ${topoSortedPluginRegistrations.size}] = [ + #{Registrations:W} + ]; + let mut registrations: Vec<(usize, usize)> = props + .iter() + .zip(0..) + .filter_map(|(inserted_at, id)| inserted_at.map(|idx| (idx, id))) + .collect(); + registrations.sort(); + + for (_inserted_at, id) in registrations { + debug_assert!(adj_list[id].active); + + let unregistered_predecessors = adj_list[id] + .predecessors + .iter() + .filter(|id| adj_list[**id].active) + .map(|id| id_to_name[*id]) + .fold(String::new(), |mut acc, x| { + acc.reserve(5 + x.len()); + acc.push_str("\n -"); + acc.push_str("`"); + acc.push_str(x); + acc.push_str("`"); + acc + }); + if !unregistered_predecessors.is_empty() { + return Err(Error { + msg: format!( + "The following must be configured before `{}`:{}", + id_to_name[id], unregistered_predecessors + ), + }); + } + + adj_list[id].active = false; + } + """, + *codegenScope, + "AdjList" to adjList, + "Registrations" to registrations, + ) + + // Everything is ok. + rust( + """ + Ok(super::Config { + plugin: self.plugin_pipeline, + }) + """, + ) + } + } + } + + fun render(writer: RustWriter) { + // We want to calculate a topological sorting of the graph where an edge `(u, v)` indicates that plugin `u` + // must be registered _before_ plugin `v`. However, we have the inverse graph, since a `PluginRegistration` + // node contains its _predecessors_, not its successors. + // The reversed topological sorting of a graph is a valid topological sorting of the inverse graph. + val topoSortedPluginRegistrations = topoSort(pluginRegistrations).reversed() + + writer.rustTemplate( + """ + ##[derive(#{Debug})] + pub struct Config { + pub(crate) plugin: Plugin, + } + + impl Config<()> { + pub fn builder() -> config::Builder<#{IdentityPlugin}> { + config::Builder { + plugin_pipeline: #{PluginPipeline}::default(), + #{InsertedAtFields:W} + plugin_count: 0, + } + } + } + + /// Module hosting the builder for [`Config`]. + pub mod config { + /// Error that can occur when [`build`][Builder::build]ing the [`Builder`].` + ##[derive(#{Debug})] + pub struct Error { + msg: #{String}, + } + + impl #{Display} for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + writeln!(f, "{}", self.msg)?; + Ok(()) + } + } + + impl #{StdError} for Error {} + + ##[derive(#{Debug})] + pub struct Builder { + pub(super) plugin_pipeline: #{PluginPipeline}, + + #{ConfigBuilderFields:W} + + pub(super) plugin_count: usize, + } + + impl Builder { + /// Apply a new [plugin](#{SmithyHttpServer}::plugin) after the ones that have already been registered. + pub fn plugin( + self, + plugin: NewPlugin, + ) -> Builder<#{PluginStack}> { + Builder { + plugin_pipeline: self.plugin_pipeline.push(plugin), + #{CopyInsertedAtFields:W} + plugin_count: self.plugin_count + 1, + } + } + + #{ConfigBuilderSetters:W} + + #{ConfigBuilderBuildFn:W} + } + } + """, + *codegenScope, + "InsertedAtFields" to configInsertedAtFields(), + "ConfigBuilderFields" to configBuilderInsertedAtFields(), + "CopyInsertedAtFields" to configBuilderCopyInsertedAtFields(insertThisOne = null), + "ConfigBuilderSetters" to configBuilderSetters(topoSortedPluginRegistrations), + "ConfigBuilderBuildFn" to configBuilderBuildFn(topoSortedPluginRegistrations), + ) + } + + /** + * Precondition: each `Plugin` is due to a single `PluginResolver`. + * https://en.wikipedia.org/wiki/Topological_sorting#Kahn's_algorithm + */ + private fun topoSort(pluginRegistrations: List): List { + // It's a bit awkward to work with `Plugin`s instead of directly with `PluginRegistration`s, but the former + // implements equals correctly whereas we can't put the latter directly in a map's keys because the + // references in `predecessors` might not point directly to elements of `pluginRegistrations`! + val pluginToPluginRegistration = pluginRegistrations.associateBy { it.plugin } + val inDegree = pluginRegistrations.map { it.plugin }.associateWith { 0 }.toMutableMap() + + for (u in pluginRegistrations) { + for (v in u.predecessors) { + inDegree[v] = inDegree[v]!! + 1 + } + } + + val q = ArrayDeque(inDegree.filterValues { it == 0 }.keys) + var cnt = 0 + + val topoSorted: MutableList = mutableListOf() + while (q.isNotEmpty()) { + val u = q.removeFirst() + val uPluginRegistration = pluginToPluginRegistration[u]!! + topoSorted.add(uPluginRegistration) + + for (v in uPluginRegistration.predecessors) { + inDegree[v] = inDegree[v]!! - 1 + + if (inDegree[v] == 0) { + q.add(v) + } + } + + cnt += 1 + } + + if (cnt != pluginRegistrations.size) { + // TODO Better exception message: print cycle + throw CodegenException("Cycle detected") + } + + return topoSorted + } + + @JvmInline + value class Plugin(val runtimeType: RuntimeType) + + interface PluginRegistration { + /** + * The actual Rust type implementing the `aws_smithy_http_server::plugin::Plugin` trait. + */ + val plugin: Plugin + + /** + * The list of plugins that MUST be configured before this one. + */ + val predecessors: List + val optional: Boolean + + /** + * The setter name to register this plugin in the service config builder. It is recommended this + * be a verb in imperative form without the `set_` prefix, e.g. `authenticate`. + */ + val configBuilderSetterName: String + + /** + * The Rust docs for the setter method. This should be a string not prefixed with `///`. + */ + val configBuilderSetterDocs: String + + /** + * The list of parameters of the setter method. Keys are variable binding names and values are Rust types. + */ + val configBuilderSetterParams: Map + + /** + * An expression to instantiate the plugin. This expression can make use of the variable binding names for + * the parameters defined in `configBuilderSetterParams`. + */ + fun pluginInstantiation(): Writable + } + + interface ModelDerivedPluginsResolver { + /** + * Given a model, returns the set of plugins that must be registered in the service config object. + * The service cannot be built without these plugins properly configured and applied. + */ + fun resolve(model: Model): Set + } +} diff --git a/codegen-server/src/test/kotlin/software/amazon/smithy/rust/codegen/server/smithy/generators/ServiceConfigGeneratorTest.kt b/codegen-server/src/test/kotlin/software/amazon/smithy/rust/codegen/server/smithy/generators/ServiceConfigGeneratorTest.kt new file mode 100644 index 0000000000..063d93d6e4 --- /dev/null +++ b/codegen-server/src/test/kotlin/software/amazon/smithy/rust/codegen/server/smithy/generators/ServiceConfigGeneratorTest.kt @@ -0,0 +1,291 @@ +package software.amazon.smithy.rust.codegen.server.smithy.generators + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.shouldBe +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.MethodSource +import software.amazon.smithy.codegen.core.CodegenException +import software.amazon.smithy.model.Model +import software.amazon.smithy.rust.codegen.core.rustlang.RustModule +import software.amazon.smithy.rust.codegen.core.rustlang.Writable +import software.amazon.smithy.rust.codegen.core.rustlang.join +import software.amazon.smithy.rust.codegen.core.rustlang.rust +import software.amazon.smithy.rust.codegen.core.rustlang.rustTemplate +import software.amazon.smithy.rust.codegen.core.rustlang.writable +import software.amazon.smithy.rust.codegen.core.smithy.RuntimeType +import software.amazon.smithy.rust.codegen.core.testutil.TestWorkspace +import software.amazon.smithy.rust.codegen.core.testutil.asSmithyModel +import software.amazon.smithy.rust.codegen.core.testutil.compileAndTest +import software.amazon.smithy.rust.codegen.core.testutil.testModule +import software.amazon.smithy.rust.codegen.core.testutil.unitTest +import software.amazon.smithy.rust.codegen.server.smithy.ServerCargoDependency +import software.amazon.smithy.rust.codegen.server.smithy.testutil.serverTestCodegenContext +import java.util.stream.Stream + +/** + * This is just a `PluginRegistration` that allows for predecessor plugins + * to be added after the `PluginRegistration` object is created. + */ +class MutablePluginRegistration(private val runtimeType: RuntimeType, setterName: String? = null) : + ServiceConfigGenerator.PluginRegistration { + override val plugin: ServiceConfigGenerator.Plugin = ServiceConfigGenerator.Plugin(runtimeType) + override val predecessors: MutableList = mutableListOf() + override val optional: Boolean = false + override val configBuilderSetterName: String = setterName ?: runtimeType.name.lowercase() + override val configBuilderSetterDocs: String = "Docs for ${runtimeType.name.lowercase()}." + override val configBuilderSetterParams: Map = emptyMap() + + override fun pluginInstantiation(): Writable = + writable { rustTemplate("#{Plugin}", "Plugin" to runtimeType) } + + fun addPredecessor(plugin: ServiceConfigGenerator.Plugin) { + predecessors.add(plugin) + } +} + +private fun invertGraph(graph: List>): List> { + val n = graph.size + val ret: List> = List(n) { mutableListOf() } + + for (u in 0 until n) { + for (v in graph[u]) { + ret[v].add(u) + } + } + + return ret +} + +private fun pluginResolverGenerator(graph: List>): ServiceConfigGenerator.ModelDerivedPluginsResolver { + // We invert the graph so that the edge `(u, v)` indicates that `v` is a predecessor of `u` in the original graph. + val invertedGraph = invertGraph(graph) + + val pluginRegistrations = List(invertedGraph.size) { idx -> + MutablePluginRegistration(pluginName(idx)) + } + + for ((u, predecessors) in invertedGraph.withIndex()) { + for (p in predecessors) { + pluginRegistrations[u].addPredecessor(pluginRegistrations[p].plugin) + } + } + + return object: ServiceConfigGenerator.ModelDerivedPluginsResolver { + override fun resolve(model: Model): Set { + return pluginRegistrations.toSet() + } + } +} + +/** + * Parses a directed graph from a string describing an adjacency list. The string comprises lines. + * The first line contains a single integer `n`, the number of nodes in the graph, numbered from 0 to `n-1`. + * `n` lines follow. Each line is a list of integers, separated by spaces, with the outgoing edges for the node. + * + * https://en.wikipedia.org/wiki/Adjacency_list + */ +private fun parseGraph(s: String): List> { + val lines = s.lines() + val n = lines.first().toInt() + val ret: List> = List(n) { mutableListOf() } + for (u in 1..n) { + val l = lines[u] + if (l.isNotEmpty()) { + for (v in l.split(" ").map { it.toInt() }) { + ret[u - 1].add(v) + } + } + } + + return ret +} + +private fun pluginName(n: Int): RuntimeType = RuntimeType("crate::Plugin$n") + +private fun writePlugins(pluginNames: List, smithyHttpServer: RuntimeType): Writable = pluginNames.map { + writable { + rustTemplate( + """ + ##[derive(#{Debug})] + pub struct $it; + + impl #{Plugin} for $it { + type Output = T; + + fn apply(&self, svc: T) -> T { + #{Tracing}::debug!("applying plugin $it"); + svc + } + } + """, + "Debug" to RuntimeType.Debug, + "Plugin" to smithyHttpServer.resolve("plugin::Plugin"), + "Tracing" to RuntimeType.Tracing, + ) + } +}.join("") + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class ServiceConfigGeneratorTest { + private val model = + """ + namespace test + + service MyService { } + """.asSmithyModel() + + private fun testParameters(): Stream { + val noPlugins = "0" to + writable { + unitTest("no_plugins_makes_config_build_infallible") { + rust( + """ + crate::service::Config::builder().build(); + """ + ) + } + } + + val graphUVW = + """3 +1 2 +2 +""" to + writable { + unitTest("plugin_0_missing") { + rust( + """ + let err = crate::service::Config::builder().plugin1().unwrap().build().unwrap_err(); + let msg = format!("{}", err); + assert_eq!( + &msg, + "You must configure the following for `MyService`:\n- `plugin0`\n- `plugin2`\n" + ); + """ + ) + } + + unitTest("plugin_2_misordered") { + rust( + """ + let err = crate::service::Config::builder() + .plugin0().unwrap() + .plugin2().unwrap() + .plugin1().unwrap() + .build().unwrap_err(); + let msg = format!("{}", err); + assert_eq!( + &msg, + "The following must be configured before `plugin2`:\n -`plugin1`\n" + ); + """ + ) + } + + unitTest("all_ok") { + rust( + """ + crate::service::Config::builder() + .plugin0().unwrap() + .plugin1().unwrap() + .plugin2().unwrap() + .build().unwrap(); + """ + ) + } + } + + return Stream.of( + Arguments.of(graphUVW.first, graphUVW.second), + Arguments.of(noPlugins.first, noPlugins.second) + ) + } + + @ParameterizedTest(name = "(#{index}) Plugin graphs. inputGraph: {0}") + @MethodSource("testParameters") + fun `plugin graphs`(inputGraph: String, writable: Writable) { + val codegenContext = serverTestCodegenContext(model) + val project = TestWorkspace.testProject(codegenContext.symbolProvider) + + val n = inputGraph.lines().first().toInt() + val smithyHttpServer = ServerCargoDependency.smithyHttpServer(codegenContext.runtimeConfig).toType() + val pluginNames = (0 until n).map { pluginName(it).name } + project.lib { + rustTemplate( + """ + #{Plugins} + """, + "Plugins" to writePlugins(pluginNames, smithyHttpServer), + ) + } + + project.withModule(RustModule.public("service")) { + ServiceConfigGenerator(codegenContext, listOf(pluginResolverGenerator(parseGraph(inputGraph)))).render(this) + project.testModule(writable) + } + + project.compileAndTest() + } + + @Test + fun `cycles are detected`() { + val codegenContext = serverTestCodegenContext(model) + val project = TestWorkspace.testProject(codegenContext.symbolProvider) + project.withModule(RustModule.public("service")) { + val exception = shouldThrow { + ServiceConfigGenerator(codegenContext, listOf(pluginResolverGenerator(parseGraph( + """3 +1 +2 +0 +""" + )))).render(this) + } + exception.message shouldBe "Cycle detected" + } + } + + @Test + fun `check that each plugin is due to a single plugin resolver`() { + val codegenContext = serverTestCodegenContext(model) + val project = TestWorkspace.testProject(codegenContext.symbolProvider) + project.withModule(RustModule.public("service")) { + val exception = shouldThrow { + ServiceConfigGenerator(codegenContext, listOf( + pluginResolverGenerator(parseGraph("1\n")), + pluginResolverGenerator(parseGraph("1\n")) + )).render(this) + } + exception.message shouldBe + """The following plugins are resolved by multiple plugin resolvers: +- `crate::Plugin0`: [software.amazon.smithy.rust.codegen.server.smithy.generators.ServiceConfigGeneratorTestKt${'$'}pluginResolverGenerator${'$'}1, software.amazon.smithy.rust.codegen.server.smithy.generators.ServiceConfigGeneratorTestKt${'$'}pluginResolverGenerator${'$'}1]""" + } + } + + @Test + fun `check that each plugin is configured using a distinct setter name`() { + val codegenContext = serverTestCodegenContext(model) + val project = TestWorkspace.testProject(codegenContext.symbolProvider) + project.withModule(RustModule.public("service")) { + // We use an actual class instead of an object expression because the class name appears in the exception message. + class Foo: ServiceConfigGenerator.ModelDerivedPluginsResolver { + override fun resolve(model: Model): Set = + // Two different plugins, but with the same setter name. + setOf( + MutablePluginRegistration(pluginName(1), "duplicate_setter_name"), + MutablePluginRegistration(pluginName(2), "duplicate_setter_name"), + ) + } + + val exception = shouldThrow { + ServiceConfigGenerator(codegenContext, listOf(Foo())).render(this) + } + exception.message shouldBe + """The following plugins are configured using the same setter name: +- `duplicate_setter_name`: [crate::Plugin1 (resolved by software.amazon.smithy.rust.codegen.server.smithy.generators.ServiceConfigGeneratorTest${'$'}check that each plugin is configured using a distinct setter name${'$'}1${'$'}Foo), crate::Plugin2 (resolved by software.amazon.smithy.rust.codegen.server.smithy.generators.ServiceConfigGeneratorTest${'$'}check that each plugin is configured using a distinct setter name${'$'}1${'$'}Foo)]""" + } + } +} diff --git a/rust-runtime/aws-smithy-http-server/src/plugin/identity.rs b/rust-runtime/aws-smithy-http-server/src/plugin/identity.rs index affbd9f6b9..1f47c33382 100644 --- a/rust-runtime/aws-smithy-http-server/src/plugin/identity.rs +++ b/rust-runtime/aws-smithy-http-server/src/plugin/identity.rs @@ -6,6 +6,7 @@ use super::Plugin; /// A [`Plugin`] that maps a service to itself. +#[derive(Debug)] pub struct IdentityPlugin; impl Plugin for IdentityPlugin { diff --git a/rust-runtime/aws-smithy-http-server/src/plugin/pipeline.rs b/rust-runtime/aws-smithy-http-server/src/plugin/pipeline.rs index acac792c3b..2e514ce67d 100644 --- a/rust-runtime/aws-smithy-http-server/src/plugin/pipeline.rs +++ b/rust-runtime/aws-smithy-http-server/src/plugin/pipeline.rs @@ -108,6 +108,7 @@ use super::LayerPlugin; /// // Our custom method! /// .with_auth(); /// ``` +#[derive(Debug)] pub struct PluginPipeline

(pub(crate) P); impl Default for PluginPipeline { diff --git a/rust-runtime/aws-smithy-http-server/src/plugin/stack.rs b/rust-runtime/aws-smithy-http-server/src/plugin/stack.rs index 63f8e44865..0002576676 100644 --- a/rust-runtime/aws-smithy-http-server/src/plugin/stack.rs +++ b/rust-runtime/aws-smithy-http-server/src/plugin/stack.rs @@ -10,6 +10,7 @@ use super::Plugin; /// The `Inner::map` is run _then_ the `Outer::map`. /// /// Note that the primary tool for composing plugins is [`PluginPipeline`](crate::plugin::PluginPipeline). +#[derive(Debug)] pub struct PluginStack { inner: Inner, outer: Outer,