diff --git a/smithy-build/src/main/java/software/amazon/smithy/build/SmithyBuild.java b/smithy-build/src/main/java/software/amazon/smithy/build/SmithyBuild.java index 6fa014a4136..cfb52f259f9 100644 --- a/smithy-build/src/main/java/software/amazon/smithy/build/SmithyBuild.java +++ b/smithy-build/src/main/java/software/amazon/smithy/build/SmithyBuild.java @@ -22,10 +22,13 @@ import java.util.function.Predicate; import java.util.function.Supplier; import java.util.logging.Logger; +import software.amazon.smithy.build.model.ProjectionConfig; import software.amazon.smithy.build.model.SmithyBuildConfig; import software.amazon.smithy.model.Model; import software.amazon.smithy.model.loader.ModelAssembler; import software.amazon.smithy.model.transform.ModelTransformer; +import software.amazon.smithy.model.validation.ValidatedResult; +import software.amazon.smithy.utils.FunctionalUtils; /** * Runs the projections and plugins found in a {@link SmithyBuildConfig} @@ -167,6 +170,102 @@ public void build(Consumer resultCallback, BiConsumerIf the base-model assemble is broken, the base result is returned + * unchanged so the caller can inspect its events. + * + *

The class loader used for service discovery is the + * {@linkplain Thread#getContextClassLoader() current thread's context + * class loader}, falling back to {@link SmithyBuild}'s class loader. Use + * {@link #toProjectedModel(SmithyBuildConfig, String, ClassLoader)} when + * the loader needs to be controlled explicitly (for example, in IDE or + * application-server environments). + * + * @param config Configuration to load. + * @param projectionName The projection to apply. + * @return The validated projected model. + * @throws UnknownProjectionException if the projection is not declared. + * @throws SmithyBuildException if the projection is abstract or the build + * fails. + */ + public static ValidatedResult toProjectedModel(SmithyBuildConfig config, String projectionName) { + ClassLoader contextLoader = Thread.currentThread().getContextClassLoader(); + return toProjectedModel(config, + projectionName, + contextLoader != null ? contextLoader : SmithyBuild.class.getClassLoader()); + } + + /** + * Assembles the model from the given configuration and applies the transforms + * of the named projection, using the given class loader for service + * discovery of traits, validators, etc., returning the projected model and + * its validation events without running any plugins. Both + * {@link SmithyBuildConfig#getSources()} and {@link SmithyBuildConfig#getImports()} + * are loaded into the model. + * + *

If the base-model assemble is broken, the base result is returned + * unchanged so the caller can inspect its events. + * + * @param config Configuration to load. + * @param projectionName The projection to apply. + * @param classLoader Class loader used for service discovery. + * @return The validated projected model. + * @throws UnknownProjectionException if the projection is not declared. + * @throws SmithyBuildException if the projection is abstract or the build + * fails. + */ + public static ValidatedResult toProjectedModel( + SmithyBuildConfig config, + String projectionName, + ClassLoader classLoader + ) { + ProjectionConfig projection = config.getProjections().get(projectionName); + if (projection == null) { + throw new UnknownProjectionException("Unknown projection: " + projectionName); + } + if (projection.isAbstract()) { + throw new SmithyBuildException("Cannot apply abstract projection: " + projectionName); + } + + ValidatedResult baseResult = config.toModelAssembler(classLoader).assemble(); + if (baseResult.isBroken()) { + // The inner pipeline unwraps the base model and would throw on ERROR/DANGER events, + // so short-circuit and hand the broken result to the caller intact. + return baseResult; + } + + // Restrict the run config to the targeted projection so SmithyBuildImpl does not eagerly + // resolve transformers for unrelated projections (which would throw on configs that mention + // transforms not on this classloader). + SmithyBuildConfig runConfig = config.toBuilder() + .projections(Collections.singletonMap(projectionName, projection)) + // Strip sources and imports because the pre-assembled base model is passed via + // .model() below, leaving them would re-parse the same files + .sources(Collections.emptyList()) + .imports(Collections.emptyList()) + // Set ignoreMissingPlugins because plugin resolution still walks declared plugins + // even though the pluginFilter rejects them all. + .ignoreMissingPlugins(true) + .build(); + + ProjectionResult result = SmithyBuild.create(classLoader) + .config(runConfig) + .model(baseResult.getResult().get()) + .pluginFilter(FunctionalUtils.alwaysFalse()) + .build() + .getProjectionResult(projectionName) + .orElseThrow(() -> new IllegalStateException( + "Projection result not found: " + projectionName)); + + return new ValidatedResult<>(result.getModel(), result.getEvents()); + } + /** * Sets the required configuration object used to * build the model. diff --git a/smithy-build/src/main/java/software/amazon/smithy/build/model/SmithyBuildConfig.java b/smithy-build/src/main/java/software/amazon/smithy/build/model/SmithyBuildConfig.java index 98edcc8e6dd..1ff0261b075 100644 --- a/smithy-build/src/main/java/software/amazon/smithy/build/model/SmithyBuildConfig.java +++ b/smithy-build/src/main/java/software/amazon/smithy/build/model/SmithyBuildConfig.java @@ -12,6 +12,8 @@ import java.util.Optional; import java.util.Set; import software.amazon.smithy.build.SmithyBuildException; +import software.amazon.smithy.model.Model; +import software.amazon.smithy.model.loader.ModelAssembler; import software.amazon.smithy.model.loader.ModelSyntaxException; import software.amazon.smithy.model.node.Node; import software.amazon.smithy.model.node.ObjectNode; @@ -226,6 +228,44 @@ public long getLastModifiedInMillis() { return lastModifiedInMillis; } + /** + * Creates a {@link ModelAssembler} populated with the sources and imports + * defined in this configuration. Both {@link #getSources()} and + * {@link #getImports()} are added to the assembler via + * {@link ModelAssembler#addImport(String)}. + * + *

Note: Maven dependencies declared in the configuration are not + * resolved by this method. Dependency resolution is the responsibility + * of the caller (for example, the Smithy CLI). + * + * @return Returns a pre-configured {@link ModelAssembler}. + * @see software.amazon.smithy.build.SmithyBuild#toProjectedModel(SmithyBuildConfig, String) + */ + public ModelAssembler toModelAssembler() { + return toModelAssembler(getClass().getClassLoader()); + } + + /** + * Creates a {@link ModelAssembler} populated with the sources and imports + * defined in this configuration, using the given {@code ClassLoader} for + * service discovery of traits, validators, and other providers. Both + * {@link #getSources()} and {@link #getImports()} are added to the assembler + * via {@link ModelAssembler#addImport(String)}. + * + *

Note: Maven dependencies declared in the configuration are not + * resolved by this method. Dependency resolution is the responsibility + * of the caller (for example, the Smithy CLI). + * + * @param classLoader Class loader used to discover traits and validators. + * @return Returns a pre-configured {@link ModelAssembler}. + */ + public ModelAssembler toModelAssembler(ClassLoader classLoader) { + ModelAssembler assembler = Model.assembler(classLoader); + sources.forEach(assembler::addImport); + imports.forEach(assembler::addImport); + return assembler; + } + /** * Builder used to create a {@link SmithyBuildConfig}. */ diff --git a/smithy-build/src/test/java/software/amazon/smithy/build/SmithyBuildTest.java b/smithy-build/src/test/java/software/amazon/smithy/build/SmithyBuildTest.java index ae33ab6722e..63bde8f606f 100644 --- a/smithy-build/src/test/java/software/amazon/smithy/build/SmithyBuildTest.java +++ b/smithy-build/src/test/java/software/amazon/smithy/build/SmithyBuildTest.java @@ -10,6 +10,7 @@ import static org.hamcrest.Matchers.empty; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.hasItem; +import static org.hamcrest.Matchers.hasProperty; import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.not; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -30,10 +31,12 @@ import java.util.ArrayList; import java.util.Comparator; import java.util.HashMap; +import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Set; import java.util.function.Function; import java.util.stream.Collectors; import org.junit.jupiter.api.AfterEach; @@ -60,6 +63,7 @@ import software.amazon.smithy.model.traits.Trait; import software.amazon.smithy.model.traits.TraitFactory; import software.amazon.smithy.model.validation.Severity; +import software.amazon.smithy.model.validation.ValidatedResult; import software.amazon.smithy.model.validation.ValidationEvent; import software.amazon.smithy.utils.IoUtils; import software.amazon.smithy.utils.ListUtils; @@ -1125,4 +1129,213 @@ public static List unrecognizedModelPaths() throws URISyntaxException // Test that passing explicit files works too. Arguments.of(ListUtils.of(rootPath.resolve("a.smithy"), rootPath.resolve("foo.md")))); } + + @Test + public void toProjectedModelAppliesTransforms() throws Exception { + String modelPath = Paths.get(getClass().getResource("simple-model.json").toURI()).toString(); + // excludeShapesByTag removes shapes tagged "foo" -- ns.foo#String1 has tag "foo". + TransformConfig transform = TransformConfig.builder() + .name("excludeShapesByTag") + .args(Node.objectNode().withMember("tags", Node.fromStrings("foo"))) + .build(); + ProjectionConfig projection = ProjectionConfig.builder() + .transforms(ListUtils.of(transform)) + .build(); + SmithyBuildConfig config = SmithyBuildConfig.builder() + .version(SmithyBuild.VERSION) + .sources(ListUtils.of(modelPath)) + .projections(MapUtils.of("filtered", projection)) + .build(); + + Model model = SmithyBuild.toProjectedModel(config, "filtered").unwrap(); + + // String1 has tag "foo" and should be excluded. + assertFalse(model.getShape(ShapeId.from("ns.foo#String1")).isPresent()); + // String2 has no tags and should remain. + assertTrue(model.getShape(ShapeId.from("ns.foo#String2")).isPresent()); + } + + @Test + public void toProjectedModelThrowsForUnknownProjection() { + SmithyBuildConfig config = SmithyBuildConfig.builder().version(SmithyBuild.VERSION).build(); + + UnknownProjectionException thrown = assertThrows(UnknownProjectionException.class, + () -> SmithyBuild.toProjectedModel(config, "nonexistent")); + assertThat(thrown.getMessage(), containsString("Unknown projection")); + assertThat(thrown.getMessage(), containsString("nonexistent")); + } + + @Test + public void toProjectedModelWithNoTransformsReturnsBaseModel() throws Exception { + String modelPath = Paths.get(getClass().getResource("simple-model.json").toURI()).toString(); + ProjectionConfig projection = ProjectionConfig.builder().build(); + SmithyBuildConfig config = SmithyBuildConfig.builder() + .version(SmithyBuild.VERSION) + .sources(ListUtils.of(modelPath)) + .projections(MapUtils.of("passthrough", projection)) + .build(); + + Model model = SmithyBuild.toProjectedModel(config, "passthrough").unwrap(); + + // All shapes should be present since no transforms were applied. + assertTrue(model.getShape(ShapeId.from("ns.foo#String1")).isPresent()); + assertTrue(model.getShape(ShapeId.from("ns.foo#String2")).isPresent()); + assertTrue(model.getShape(ShapeId.from("ns.foo#String3")).isPresent()); + } + + @Test + public void toProjectedModelReturnsBrokenBaseModelWithoutThrowing() throws Exception { + // invalid-model.smithy defines a shape that references a non-existent target, producing an + // ERROR event during assembly. toProjectedModel must surface that as a broken ValidatedResult + // rather than throwing out of the base-model unwrap. + String modelPath = Paths.get(getClass().getResource("invalid-model.smithy").toURI()).toString(); + ProjectionConfig projection = ProjectionConfig.builder().build(); + SmithyBuildConfig config = SmithyBuildConfig.builder() + .version(SmithyBuild.VERSION) + .sources(ListUtils.of(modelPath)) + .projections(MapUtils.of("passthrough", projection)) + .build(); + + ValidatedResult result = SmithyBuild.toProjectedModel(config, "passthrough"); + + assertTrue(result.isBroken()); + assertThat(result.getValidationEvents(), + hasItem(hasProperty("severity", is(Severity.ERROR)))); + } + + @Test + public void toProjectedModelAppliesOnlyTargetedProjectionWhenConfigHasMany() throws Exception { + String modelPath = Paths.get(getClass().getResource("simple-model.json").toURI()).toString(); + TransformConfig dropTagged = TransformConfig.builder() + .name("excludeShapesByTag") + .args(Node.objectNode().withMember("tags", Node.fromStrings("foo"))) + .build(); + TransformConfig dropString2 = TransformConfig.builder() + .name("excludeShapesBySelector") + .args(Node.objectNode().withMember("selector", "[id=ns.foo#String2]")) + .build(); + SmithyBuildConfig config = SmithyBuildConfig.builder() + .version(SmithyBuild.VERSION) + .sources(ListUtils.of(modelPath)) + .projections(MapUtils.of( + "drops-tagged", + ProjectionConfig.builder().transforms(ListUtils.of(dropTagged)).build(), + "drops-string2", + ProjectionConfig.builder().transforms(ListUtils.of(dropString2)).build())) + .build(); + + Model model = SmithyBuild.toProjectedModel(config, "drops-tagged").unwrap(); + + // Only the drops-tagged projection should have run: String1 (tagged "foo") is gone, String2 remains. + assertFalse(model.getShape(ShapeId.from("ns.foo#String1")).isPresent()); + assertTrue(model.getShape(ShapeId.from("ns.foo#String2")).isPresent()); + } + + @Test + public void toProjectedModelToleratesUnknownPlugins() throws Exception { + // External tools (LSP, smithy4s, playground) commonly load configs that declare plugins not on + // their classpath. Plugin resolution still walks the declarations even though the plugin filter + // rejects everything, so toProjectedModel must force ignoreMissingPlugins on internally. + String modelPath = Paths.get(getClass().getResource("simple-model.json").toURI()).toString(); + ProjectionConfig projection = ProjectionConfig.builder() + .plugins(MapUtils.of("some-unknown-plugin", Node.objectNode())) + .build(); + SmithyBuildConfig config = SmithyBuildConfig.builder() + .version(SmithyBuild.VERSION) + .sources(ListUtils.of(modelPath)) + .projections(MapUtils.of("uses-missing-plugin", projection)) + .build(); + + // Must not throw even though ignoreMissingPlugins is not set on the input config. + Model model = SmithyBuild.toProjectedModel(config, "uses-missing-plugin").unwrap(); + + assertTrue(model.getShape(ShapeId.from("ns.foo#String1")).isPresent()); + } + + @Test + public void toProjectedModelToleratesTopLevelUnknownPlugins() throws Exception { + // Top-level plugins are merged into every projection's plugin set, so the unknown-plugin + // tolerance must extend to declarations at the top level too. + String modelPath = Paths.get(getClass().getResource("simple-model.json").toURI()).toString(); + SmithyBuildConfig config = SmithyBuildConfig.builder() + .version(SmithyBuild.VERSION) + .sources(ListUtils.of(modelPath)) + .plugins(MapUtils.of("some-unknown-plugin", Node.objectNode())) + .projections(MapUtils.of("p", ProjectionConfig.builder().build())) + .build(); + + Model model = SmithyBuild.toProjectedModel(config, "p").unwrap(); + + assertTrue(model.getShape(ShapeId.from("ns.foo#String1")).isPresent()); + } + + @Test + public void toProjectedModelToleratesUnknownTransformsInUnrelatedProjections() throws Exception { + // Configs in IDE/LSP environments commonly reference transforms that are not on the tool's + // classpath. toProjectedModel restricts the run to the targeted projection so unrelated + // projections never have their transforms resolved. + String modelPath = Paths.get(getClass().getResource("simple-model.json").toURI()).toString(); + TransformConfig knownTransform = TransformConfig.builder() + .name("excludeShapesByTag") + .args(Node.objectNode().withMember("tags", Node.fromStrings("foo"))) + .build(); + TransformConfig unknownTransform = TransformConfig.builder() + .name("does-not-exist-transform") + .build(); + SmithyBuildConfig config = SmithyBuildConfig.builder() + .version(SmithyBuild.VERSION) + .sources(ListUtils.of(modelPath)) + .projections(MapUtils.of( + "target", + ProjectionConfig.builder().transforms(ListUtils.of(knownTransform)).build(), + "sibling", + ProjectionConfig.builder().transforms(ListUtils.of(unknownTransform)).build())) + .build(); + + Model model = SmithyBuild.toProjectedModel(config, "target").unwrap(); + + // Targeted projection ran (String1 is tagged "foo" and got excluded), and the sibling's + // bogus transform never tripped resolution. + assertFalse(model.getShape(ShapeId.from("ns.foo#String1")).isPresent()); + assertTrue(model.getShape(ShapeId.from("ns.foo#String2")).isPresent()); + } + + @Test + public void toProjectedModelDoesNotMutateInputConfig() throws Exception { + String modelPath = Paths.get(getClass().getResource("simple-model.json").toURI()).toString(); + SmithyBuildConfig config = SmithyBuildConfig.builder() + .version(SmithyBuild.VERSION) + .sources(ListUtils.of(modelPath)) + .plugins(MapUtils.of("some-unknown-plugin", Node.objectNode())) + .projections(MapUtils.of( + "target", + ProjectionConfig.builder().build(), + "sibling", + ProjectionConfig.builder().build())) + .build(); + + boolean preFlag = config.isIgnoreMissingPlugins(); + Set preProjections = new HashSet<>(config.getProjections().keySet()); + Set prePlugins = new HashSet<>(config.getPlugins().keySet()); + + SmithyBuild.toProjectedModel(config, "target"); + + assertEquals(preFlag, config.isIgnoreMissingPlugins()); + assertEquals(preProjections, config.getProjections().keySet()); + assertEquals(prePlugins, config.getPlugins().keySet()); + } + + @Test + public void toProjectedModelForAbstractProjectionFailsClearly() { + ProjectionConfig abstractProjection = ProjectionConfig.builder().setAbstract(true).build(); + SmithyBuildConfig config = SmithyBuildConfig.builder() + .version(SmithyBuild.VERSION) + .projections(MapUtils.of("base", abstractProjection)) + .build(); + + SmithyBuildException thrown = assertThrows(SmithyBuildException.class, + () -> SmithyBuild.toProjectedModel(config, "base")); + assertThat(thrown.getMessage(), containsString("Cannot apply abstract projection")); + assertThat(thrown.getMessage(), containsString("base")); + } } diff --git a/smithy-build/src/test/java/software/amazon/smithy/build/model/SmithyBuildConfigTest.java b/smithy-build/src/test/java/software/amazon/smithy/build/model/SmithyBuildConfigTest.java index b0b2ee523fb..25bd8a96305 100644 --- a/smithy-build/src/test/java/software/amazon/smithy/build/model/SmithyBuildConfigTest.java +++ b/smithy-build/src/test/java/software/amazon/smithy/build/model/SmithyBuildConfigTest.java @@ -14,6 +14,7 @@ import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.not; import static org.hamcrest.Matchers.notNullValue; +import static org.junit.jupiter.api.Assertions.assertTrue; import java.io.IOException; import java.net.URISyntaxException; @@ -25,10 +26,12 @@ import software.amazon.smithy.build.SmithyBuild; import software.amazon.smithy.build.SmithyBuildException; import software.amazon.smithy.build.SmithyBuildTest; +import software.amazon.smithy.model.Model; import software.amazon.smithy.model.SourceException; import software.amazon.smithy.model.SourceLocation; import software.amazon.smithy.model.node.Node; import software.amazon.smithy.model.node.ObjectNode; +import software.amazon.smithy.model.shapes.ShapeId; import software.amazon.smithy.utils.ListUtils; public class SmithyBuildConfigTest { @@ -250,4 +253,30 @@ public void mergingCombinesMavenConfigsWhenBothPresent() { assertThat(a.toBuilder().merge(b).build().getMaven().get().getDependencies(), contains("c:d:1.0.0", "a:b:1.0.0")); } + + @Test + public void toModelAssemblerLoadsSourcesAndImports() { + SmithyBuildConfig config = SmithyBuildConfig.load( + Paths.get(getModelResourcePath("config-with-sources-and-imports.json"))); + Model model = config.toModelAssembler().assemble().unwrap(); + + // simple-model.json defines ns.foo#String1, ns.foo#String2, ns.foo#String3 + assertThat(config.getSources(), is(not(empty()))); + assertTrue(model.getShape(ShapeId.from("ns.foo#String1")).isPresent()); + assertTrue(model.getShape(ShapeId.from("ns.foo#String2")).isPresent()); + assertTrue(model.getShape(ShapeId.from("ns.foo#String3")).isPresent()); + + // resource-model.json defines ns.foo#MyResource, ns.foo#GetMyResource, etc. + assertThat(config.getImports(), is(not(empty()))); + assertTrue(model.getShape(ShapeId.from("ns.foo#MyResource")).isPresent()); + assertTrue(model.getShape(ShapeId.from("ns.foo#GetMyResource")).isPresent()); + } + + private String getModelResourcePath(String name) { + try { + return Paths.get(getClass().getResource(name).toURI()).toString(); + } catch (URISyntaxException e) { + throw new RuntimeException(e); + } + } } diff --git a/smithy-build/src/test/resources/software/amazon/smithy/build/invalid-model.smithy b/smithy-build/src/test/resources/software/amazon/smithy/build/invalid-model.smithy new file mode 100644 index 00000000000..0d12100b7cf --- /dev/null +++ b/smithy-build/src/test/resources/software/amazon/smithy/build/invalid-model.smithy @@ -0,0 +1,9 @@ +$version: "2.0" + +namespace ns.foo + +// Structure references a target that does not exist, producing an ERROR +// event during assembly. Used by toProjectedModelReturnsBrokenBaseModelWithoutThrowing. +structure BadRef { + ref: DoesNotExist +} diff --git a/smithy-build/src/test/resources/software/amazon/smithy/build/model/config-with-sources-and-imports.json b/smithy-build/src/test/resources/software/amazon/smithy/build/model/config-with-sources-and-imports.json new file mode 100644 index 00000000000..6fde0d46199 --- /dev/null +++ b/smithy-build/src/test/resources/software/amazon/smithy/build/model/config-with-sources-and-imports.json @@ -0,0 +1,5 @@ +{ + "version": "1.0", + "sources": ["../simple-model.json"], + "imports": ["../resource-model.json"] +} diff --git a/smithy-utils/src/main/java/software/amazon/smithy/utils/FunctionalUtils.java b/smithy-utils/src/main/java/software/amazon/smithy/utils/FunctionalUtils.java index ee78e40892c..7fed8c1d60f 100644 --- a/smithy-utils/src/main/java/software/amazon/smithy/utils/FunctionalUtils.java +++ b/smithy-utils/src/main/java/software/amazon/smithy/utils/FunctionalUtils.java @@ -12,6 +12,9 @@ */ public final class FunctionalUtils { + @SuppressWarnings("rawtypes") + private static final Predicate ALWAYS_FALSE = x -> false; + @SuppressWarnings("rawtypes") private static final Predicate ALWAYS_TRUE = x -> true; @@ -41,6 +44,17 @@ public static Predicate alwaysTrue() { return (Predicate) ALWAYS_TRUE; } + /** + * Returns a {@link Predicate} that always returns false. + * + * @param Value that the predicate accepts. + * @return Returns the predicate. + */ + @SuppressWarnings("unchecked") + public static Predicate alwaysFalse() { + return (Predicate) ALWAYS_FALSE; + } + /** * Returns an identity function that always returns the given value. *