From e146b9ba4fe361e31a4a6a2685e0ab2e35d9ea89 Mon Sep 17 00:00:00 2001 From: LMLiam <46268350+TheRealEmissions@users.noreply.github.com> Date: Tue, 21 Apr 2026 00:24:53 +0100 Subject: [PATCH 01/26] feat(gen): add generated origins manifests and root discovery --- .../schemas/protobuf/ProtoFileArtifact.kt | 1 + .../protobuf/ProtoFileArtifactAssembler.kt | 2 + .../schemas/protobuf/ProtoFileContribution.kt | 1 + .../protobuf/ProtobufArtifactContributor.kt | 7 ++ .../dotnet/msbuild/MsBuildProjectArtifact.kt | 1 + .../MsBuildProjectArtifactAssembler.kt | 2 + .../msbuild/MsBuildProjectContribution.kt | 1 + .../artifact/files/TextFileArtifact.kt | 6 +- .../files/TextFileArtifactAssembler.kt | 6 +- .../files/TextFileArtifactContribution.kt | 7 +- .../cli/execution/RunCompletionReporter.kt | 3 +- .../rpc/ProtobufRpcServiceArtifactCompiler.kt | 14 ++++ .../protobuf/ProtoFileArtifactCompiler.kt | 1 + ...DotnetPackageReferencesArtifactCompiler.kt | 3 + .../DotnetPackageVersionsArtifactCompiler.kt | 3 + .../DotnetPackageArtifactCompilerTests.kt | 12 +++ .../msbuild/MsBuildProjectArtifactCompiler.kt | 8 ++ .../MsBuildProjectArtifactCompilerTests.kt | 3 + .../microsmith/gen/files/GeneratedFile.kt | 7 +- .../render/GeneratedByMicrosmithBanner.kt | 34 ++++++--- .../files/render/TextFileArtifactRenderer.kt | 7 +- .../GeneratedOriginsManifestBuilder.kt | 74 +++++++++++++++++++ .../gen/helpers/MicrosmithGenerationRunner.kt | 9 ++- .../render/TextFileArtifactRendererTests.kt | 39 ++++++++++ .../GeneratedOriginsManifestBuilderTests.kt | 45 +++++++++++ .../gradle/MicrosmithGenerateTask.kt | 42 ++++++++++- .../maven/MicrosmithMavenResultHandler.kt | 3 +- .../model/GeneratedOutputRootsLocator.kt | 52 +++++++++++++ .../model/GeneratedOutputRootsLocatorTests.kt | 44 +++++++++++ .../microsmith/sbt/MicrosmithSbtPlugin.scala | 38 +++++----- 30 files changed, 435 insertions(+), 40 deletions(-) create mode 100644 modules/gen/src/main/kotlin/io/github/lmliam/microsmith/gen/helpers/GeneratedOriginsManifestBuilder.kt create mode 100644 modules/gen/src/test/kotlin/io/github/lmliam/microsmith/gen/helpers/GeneratedOriginsManifestBuilderTests.kt create mode 100644 modules/runtime-scripting/src/main/kotlin/io/github/lmliam/microsmith/runtime/scripting/model/GeneratedOutputRootsLocator.kt create mode 100644 modules/runtime-scripting/src/test/kotlin/io/github/lmliam/microsmith/runtime/scripting/model/GeneratedOutputRootsLocatorTests.kt diff --git a/modules/artifact-schemas-protobuf/src/main/kotlin/io/github/lmliam/microsmith/artifact/schemas/protobuf/ProtoFileArtifact.kt b/modules/artifact-schemas-protobuf/src/main/kotlin/io/github/lmliam/microsmith/artifact/schemas/protobuf/ProtoFileArtifact.kt index 09eaa9d8..8184da33 100644 --- a/modules/artifact-schemas-protobuf/src/main/kotlin/io/github/lmliam/microsmith/artifact/schemas/protobuf/ProtoFileArtifact.kt +++ b/modules/artifact-schemas-protobuf/src/main/kotlin/io/github/lmliam/microsmith/artifact/schemas/protobuf/ProtoFileArtifact.kt @@ -7,4 +7,5 @@ data class ProtoFileArtifact( val packageName: String?, val imports: List, val declarations: List, + val origins: Set = emptySet(), ) : ProtobufArtifact diff --git a/modules/artifact-schemas-protobuf/src/main/kotlin/io/github/lmliam/microsmith/artifact/schemas/protobuf/ProtoFileArtifactAssembler.kt b/modules/artifact-schemas-protobuf/src/main/kotlin/io/github/lmliam/microsmith/artifact/schemas/protobuf/ProtoFileArtifactAssembler.kt index 4e7852ad..7966052c 100644 --- a/modules/artifact-schemas-protobuf/src/main/kotlin/io/github/lmliam/microsmith/artifact/schemas/protobuf/ProtoFileArtifactAssembler.kt +++ b/modules/artifact-schemas-protobuf/src/main/kotlin/io/github/lmliam/microsmith/artifact/schemas/protobuf/ProtoFileArtifactAssembler.kt @@ -15,6 +15,7 @@ class ProtoFileArtifactAssembler : ArtifactAssembler { packageName = contribution.packageName, imports = contribution.imports.distinct().sorted(), declarations = contribution.declarations, + origins = contribution.origins, ) } @@ -40,6 +41,7 @@ class ProtoFileArtifactAssembler : ArtifactAssembler { return current.copy( imports = (current.imports + next.imports).distinct().sorted(), declarations = mergedDeclarations.values.toList(), + origins = current.origins + next.origins, ) } diff --git a/modules/artifact-schemas-protobuf/src/main/kotlin/io/github/lmliam/microsmith/artifact/schemas/protobuf/ProtoFileContribution.kt b/modules/artifact-schemas-protobuf/src/main/kotlin/io/github/lmliam/microsmith/artifact/schemas/protobuf/ProtoFileContribution.kt index 5c1f8610..2e57341f 100644 --- a/modules/artifact-schemas-protobuf/src/main/kotlin/io/github/lmliam/microsmith/artifact/schemas/protobuf/ProtoFileContribution.kt +++ b/modules/artifact-schemas-protobuf/src/main/kotlin/io/github/lmliam/microsmith/artifact/schemas/protobuf/ProtoFileContribution.kt @@ -7,6 +7,7 @@ data class ProtoFileContribution( val packageName: String?, val imports: List = emptyList(), val declarations: List, + val origins: Set = emptySet(), ) : ArtifactContribution { init { require(declarations.isNotEmpty()) { diff --git a/modules/artifact-schemas-protobuf/src/main/kotlin/io/github/lmliam/microsmith/artifact/schemas/protobuf/ProtobufArtifactContributor.kt b/modules/artifact-schemas-protobuf/src/main/kotlin/io/github/lmliam/microsmith/artifact/schemas/protobuf/ProtobufArtifactContributor.kt index b9c68b10..d889c3e2 100644 --- a/modules/artifact-schemas-protobuf/src/main/kotlin/io/github/lmliam/microsmith/artifact/schemas/protobuf/ProtobufArtifactContributor.kt +++ b/modules/artifact-schemas-protobuf/src/main/kotlin/io/github/lmliam/microsmith/artifact/schemas/protobuf/ProtobufArtifactContributor.kt @@ -30,6 +30,13 @@ class ProtobufArtifactContributor : ArtifactContributor = emptyMap(), val properties: Map, val items: List, + val origins: Set = emptySet(), ) : DotnetArtifact { init { projectAttributes.keys.forEach(MsBuildNames::requireAttributeName) diff --git a/modules/artifact-services-dotnet/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/msbuild/MsBuildProjectArtifactAssembler.kt b/modules/artifact-services-dotnet/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/msbuild/MsBuildProjectArtifactAssembler.kt index c4050c48..4a2fc36e 100644 --- a/modules/artifact-services-dotnet/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/msbuild/MsBuildProjectArtifactAssembler.kt +++ b/modules/artifact-services-dotnet/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/msbuild/MsBuildProjectArtifactAssembler.kt @@ -15,6 +15,7 @@ class MsBuildProjectArtifactAssembler : ArtifactAssembler().apply { putAll(contribution.projectAttributes) }, properties = linkedMapOf().apply { putAll(contribution.properties) }, items = contribution.items.toList(), + origins = contribution.origins, ) } @@ -63,6 +64,7 @@ class MsBuildProjectArtifactAssembler : ArtifactAssembler = emptyMap(), val properties: Map = emptyMap(), val items: List = emptyList(), + val origins: Set = emptySet(), ) : ArtifactContribution { init { projectAttributes.keys.forEach(MsBuildNames::requireAttributeName) diff --git a/modules/artifact/src/main/kotlin/io/github/lmliam/microsmith/artifact/files/TextFileArtifact.kt b/modules/artifact/src/main/kotlin/io/github/lmliam/microsmith/artifact/files/TextFileArtifact.kt index 0b258bbf..d86ca9d0 100644 --- a/modules/artifact/src/main/kotlin/io/github/lmliam/microsmith/artifact/files/TextFileArtifact.kt +++ b/modules/artifact/src/main/kotlin/io/github/lmliam/microsmith/artifact/files/TextFileArtifact.kt @@ -2,4 +2,8 @@ package io.github.lmliam.microsmith.artifact.files import io.github.lmliam.microsmith.artifact.core.Artifact -data class TextFileArtifact(override val id: TextFileArtifactId, val contents: String) : Artifact +data class TextFileArtifact( + override val id: TextFileArtifactId, + val contents: String, + val origins: Set = emptySet(), +) : Artifact diff --git a/modules/artifact/src/main/kotlin/io/github/lmliam/microsmith/artifact/files/TextFileArtifactAssembler.kt b/modules/artifact/src/main/kotlin/io/github/lmliam/microsmith/artifact/files/TextFileArtifactAssembler.kt index a3e22147..187e95a2 100644 --- a/modules/artifact/src/main/kotlin/io/github/lmliam/microsmith/artifact/files/TextFileArtifactAssembler.kt +++ b/modules/artifact/src/main/kotlin/io/github/lmliam/microsmith/artifact/files/TextFileArtifactAssembler.kt @@ -13,6 +13,7 @@ class TextFileArtifactAssembler : ArtifactAssembler { return TextFileArtifact( id = contribution.artifactId, contents = contribution.contents, + origins = contribution.origins, ) } @@ -25,7 +26,10 @@ class TextFileArtifactAssembler : ArtifactAssembler { "Conflicting text artifact contributions for '${current.id.relativePath}' " + "under '${current.id.outputRoot}'." } - return current + if (current.origins == next.origins) { + return current + } + return current.copy(origins = current.origins + next.origins) } private fun requireContribution( diff --git a/modules/artifact/src/main/kotlin/io/github/lmliam/microsmith/artifact/files/TextFileArtifactContribution.kt b/modules/artifact/src/main/kotlin/io/github/lmliam/microsmith/artifact/files/TextFileArtifactContribution.kt index cb9e5143..220f1d69 100644 --- a/modules/artifact/src/main/kotlin/io/github/lmliam/microsmith/artifact/files/TextFileArtifactContribution.kt +++ b/modules/artifact/src/main/kotlin/io/github/lmliam/microsmith/artifact/files/TextFileArtifactContribution.kt @@ -2,5 +2,8 @@ package io.github.lmliam.microsmith.artifact.files import io.github.lmliam.microsmith.artifact.core.ArtifactContribution -data class TextFileArtifactContribution(override val artifactId: TextFileArtifactId, val contents: String) : - ArtifactContribution +data class TextFileArtifactContribution( + override val artifactId: TextFileArtifactId, + val contents: String, + val origins: Set = emptySet(), +) : ArtifactContribution diff --git a/modules/cli/src/main/kotlin/io/github/lmliam/microsmith/cli/execution/RunCompletionReporter.kt b/modules/cli/src/main/kotlin/io/github/lmliam/microsmith/cli/execution/RunCompletionReporter.kt index 8cd2a008..1d90d2cf 100644 --- a/modules/cli/src/main/kotlin/io/github/lmliam/microsmith/cli/execution/RunCompletionReporter.kt +++ b/modules/cli/src/main/kotlin/io/github/lmliam/microsmith/cli/execution/RunCompletionReporter.kt @@ -4,6 +4,7 @@ import io.github.lmliam.microsmith.cli.command.RunCommand import io.github.lmliam.microsmith.cli.diagnostics.CliDiagnosticEmitter import io.github.lmliam.microsmith.cli.diagnostics.CliFailureCode import io.github.lmliam.microsmith.cli.eventlog.RunEventLogEntry +import io.github.lmliam.microsmith.runtime.scripting.model.GeneratedOutputRootsLocator import io.github.lmliam.microsmith.runtime.scripting.model.ScriptFailureType import io.github.lmliam.microsmith.runtime.scripting.model.ScriptRunFailure import io.github.lmliam.microsmith.runtime.scripting.model.ScriptRunResult @@ -45,7 +46,7 @@ internal class RunCompletionReporter(private val eventLogWriter: (Path, RunEvent emitter.warn(warning) } val cacheState = if (runResult.cacheHit) "hit" else "miss" - val generatedOutputRoot = command.outputDir.toAbsolutePath().normalize().resolve("proto") + val generatedOutputRoot = GeneratedOutputRootsLocator.describe(command.outputDir) emitter.info( "Generated script '${command.script}' into '$generatedOutputRoot' " + "(compile-cache=$cacheState, elapsed=${runResult.elapsedMillis}ms).", diff --git a/modules/compile-schemas-protobuf-rpc/src/main/kotlin/io/github/lmliam/microsmith/compile/schemas/protobuf/rpc/ProtobufRpcServiceArtifactCompiler.kt b/modules/compile-schemas-protobuf-rpc/src/main/kotlin/io/github/lmliam/microsmith/compile/schemas/protobuf/rpc/ProtobufRpcServiceArtifactCompiler.kt index d1ad9200..d689d948 100644 --- a/modules/compile-schemas-protobuf-rpc/src/main/kotlin/io/github/lmliam/microsmith/compile/schemas/protobuf/rpc/ProtobufRpcServiceArtifactCompiler.kt +++ b/modules/compile-schemas-protobuf-rpc/src/main/kotlin/io/github/lmliam/microsmith/compile/schemas/protobuf/rpc/ProtobufRpcServiceArtifactCompiler.kt @@ -7,6 +7,7 @@ import io.github.lmliam.microsmith.artifact.schemas.protobuf.ProtoDeclaration import io.github.lmliam.microsmith.artifact.schemas.protobuf.ProtoFileArtifactId import io.github.lmliam.microsmith.artifact.schemas.protobuf.ProtoFileContribution import io.github.lmliam.microsmith.artifact.schemas.protobuf.rpc.ProtobufRpcServiceArtifact +import io.github.lmliam.microsmith.artifact.schemas.protobuf.rpc.ProtobufRpcServiceArtifactId import io.github.lmliam.microsmith.compile.core.ArtifactCompiler import io.github.lmliam.microsmith.compile.schemas.core.SchemasArtifactCompiler @@ -28,6 +29,19 @@ class ProtobufRpcServiceArtifactCompiler : SchemasArtifactCompiler + add(artifact.id.qualifiedName(operation.name)) + } + }, ), ) + + private fun ProtobufRpcServiceArtifactId.qualifiedName(suffix: String): String = buildString { + append("schemas.protobuf") + packageName?.let { append('.').append(it) } + append('.').append(serviceName) + append('.').append(suffix) + } } diff --git a/modules/compile-schemas-protobuf/src/main/kotlin/io/github/lmliam/microsmith/compile/schemas/protobuf/ProtoFileArtifactCompiler.kt b/modules/compile-schemas-protobuf/src/main/kotlin/io/github/lmliam/microsmith/compile/schemas/protobuf/ProtoFileArtifactCompiler.kt index 18f3e1da..49a091d3 100644 --- a/modules/compile-schemas-protobuf/src/main/kotlin/io/github/lmliam/microsmith/compile/schemas/protobuf/ProtoFileArtifactCompiler.kt +++ b/modules/compile-schemas-protobuf/src/main/kotlin/io/github/lmliam/microsmith/compile/schemas/protobuf/ProtoFileArtifactCompiler.kt @@ -19,6 +19,7 @@ class ProtoFileArtifactCompiler : SchemasArtifactCompiler { TextFileArtifactContribution( artifactId = TextFileArtifactId(relativePath = artifact.relativePath()), contents = ProtobufFileRenderer.render(artifact), + origins = artifact.origins, ), ) diff --git a/modules/compile-services-dotnet-packages/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/packages/DotnetPackageReferencesArtifactCompiler.kt b/modules/compile-services-dotnet-packages/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/packages/DotnetPackageReferencesArtifactCompiler.kt index 029279bf..14299d80 100644 --- a/modules/compile-services-dotnet-packages/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/packages/DotnetPackageReferencesArtifactCompiler.kt +++ b/modules/compile-services-dotnet-packages/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/packages/DotnetPackageReferencesArtifactCompiler.kt @@ -33,6 +33,9 @@ class DotnetPackageReferencesArtifactCompiler : ServicesArtifactCompiler + "services.${artifact.id.serviceName}.packages.${packageReference.name}" + }, ), ) } diff --git a/modules/compile-services-dotnet-packages/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/packages/DotnetPackageVersionsArtifactCompiler.kt b/modules/compile-services-dotnet-packages/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/packages/DotnetPackageVersionsArtifactCompiler.kt index 9ca27af9..e61f9cc5 100644 --- a/modules/compile-services-dotnet-packages/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/packages/DotnetPackageVersionsArtifactCompiler.kt +++ b/modules/compile-services-dotnet-packages/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/packages/DotnetPackageVersionsArtifactCompiler.kt @@ -31,6 +31,9 @@ class DotnetPackageVersionsArtifactCompiler : ServicesArtifactCompiler + "services.solutions.${artifact.id.solutionName}.packages.${packageVersion.name}" + }, ), ) } diff --git a/modules/compile-services-dotnet-packages/src/test/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/packages/DotnetPackageArtifactCompilerTests.kt b/modules/compile-services-dotnet-packages/src/test/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/packages/DotnetPackageArtifactCompilerTests.kt index 5e32db86..52dd66cc 100644 --- a/modules/compile-services-dotnet-packages/src/test/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/packages/DotnetPackageArtifactCompilerTests.kt +++ b/modules/compile-services-dotnet-packages/src/test/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/packages/DotnetPackageArtifactCompilerTests.kt @@ -34,6 +34,10 @@ class DotnetPackageArtifactCompilerTests : contribution.artifactId.kind shouldBe MsBuildProjectKind.DirectoryPackagesProps contribution.properties shouldContainExactly mapOf(MsBuildNames.MANAGE_PACKAGE_VERSIONS_CENTRALLY_PROPERTY to "true") + contribution.origins shouldContainExactly setOf( + "services.solutions.Platform.packages.Serilog.AspNetCore", + "services.solutions.Platform.packages.Serilog.Settings.Configuration", + ) contribution.items.map { it.include to it.attributes } shouldContainExactly listOf( "Serilog.AspNetCore" to mapOf(MsBuildNames.VERSION_ATTRIBUTE to "9.0.0"), "Serilog.Settings.Configuration" to mapOf(MsBuildNames.VERSION_ATTRIBUTE to "9.0.1"), @@ -59,6 +63,10 @@ class DotnetPackageArtifactCompilerTests : contribution.artifactId.projectName shouldBe "UserService.Api" contribution.artifactId.kind shouldBe MsBuildProjectKind.DirectoryBuildProps contribution.properties shouldBe emptyMap() + contribution.origins shouldContainExactly setOf( + "services.UserService.packages.Serilog.AspNetCore", + "services.UserService.packages.Serilog.Settings.Configuration", + ) contribution.items.map { it.include to it.attributes } shouldContainExactly listOf( "Serilog.AspNetCore" to emptyMap(), "Serilog.Settings.Configuration" to emptyMap(), @@ -84,6 +92,10 @@ class DotnetPackageArtifactCompilerTests : contribution.artifactId.projectName shouldBe "UserService.Api" contribution.artifactId.kind shouldBe MsBuildProjectKind.DirectoryBuildProps contribution.properties shouldBe emptyMap() + contribution.origins shouldContainExactly setOf( + "services.UserService.packages.Serilog.AspNetCore", + "services.UserService.packages.Serilog.Settings.Configuration", + ) contribution.items.map { it.include to it.attributes } shouldContainExactly listOf( "Serilog.AspNetCore" to mapOf(MsBuildNames.VERSION_ATTRIBUTE to "9.0.0"), "Serilog.Settings.Configuration" to mapOf(MsBuildNames.VERSION_ATTRIBUTE to "9.0.1"), diff --git a/modules/compile-services-dotnet/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/msbuild/MsBuildProjectArtifactCompiler.kt b/modules/compile-services-dotnet/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/msbuild/MsBuildProjectArtifactCompiler.kt index 507392ce..984efc6f 100644 --- a/modules/compile-services-dotnet/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/msbuild/MsBuildProjectArtifactCompiler.kt +++ b/modules/compile-services-dotnet/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/msbuild/MsBuildProjectArtifactCompiler.kt @@ -6,6 +6,7 @@ import io.github.lmliam.microsmith.artifact.core.ArtifactContribution import io.github.lmliam.microsmith.artifact.files.TextFileArtifactContribution import io.github.lmliam.microsmith.artifact.files.TextFileArtifactId import io.github.lmliam.microsmith.artifact.services.dotnet.msbuild.MsBuildProjectArtifact +import io.github.lmliam.microsmith.artifact.services.dotnet.msbuild.MsBuildProjectArtifactId import io.github.lmliam.microsmith.artifact.services.dotnet.msbuild.MsBuildProjectKind import io.github.lmliam.microsmith.compile.core.ArtifactCompiler import io.github.lmliam.microsmith.compile.services.core.ServicesArtifactCompiler @@ -22,6 +23,7 @@ class MsBuildProjectArtifactCompiler : ServicesArtifactCompiler Path.of("${requireNotNull(artifact.id.projectName)}.csproj") } + private fun MsBuildProjectArtifactId.originName(): String = buildString { + append("dotnet.solutions.").append(solutionName) + projectName?.let { append(".projects.").append(it) } + append(".").append(kind.name) + } + private companion object { val dotnetOutputRoot: Path = Path.of("dotnet") } diff --git a/modules/compile-services-dotnet/src/test/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/msbuild/MsBuildProjectArtifactCompilerTests.kt b/modules/compile-services-dotnet/src/test/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/msbuild/MsBuildProjectArtifactCompilerTests.kt index 75ed4011..a3071239 100644 --- a/modules/compile-services-dotnet/src/test/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/msbuild/MsBuildProjectArtifactCompilerTests.kt +++ b/modules/compile-services-dotnet/src/test/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/msbuild/MsBuildProjectArtifactCompilerTests.kt @@ -39,6 +39,7 @@ class MsBuildProjectArtifactCompilerTests : textContribution.artifactId.relativePath shouldBe java.nio.file.Path.of("Directory.Packages.props") textContribution.artifactId.outputRoot shouldBe java.nio.file.Path.of("dotnet", "Platform") + textContribution.origins shouldBe setOf("dotnet.solutions.Platform.DirectoryPackagesProps") contents.shouldContain("true") contents.shouldContain("") } @@ -68,6 +69,7 @@ class MsBuildProjectArtifactCompilerTests : textContribution.artifactId.relativePath shouldBe java.nio.file.Path.of("Directory.Build.props") textContribution.artifactId.outputRoot shouldBe java.nio.file.Path.of("dotnet", "Platform", "UserService.Api") + textContribution.origins shouldBe setOf("dotnet.solutions.Platform.projects.UserService.Api.DirectoryBuildProps") textContribution.contents.shouldContain("") } @@ -90,6 +92,7 @@ class MsBuildProjectArtifactCompilerTests : textContribution.artifactId.relativePath shouldBe java.nio.file.Path.of("UserService.Api.csproj") textContribution.artifactId.outputRoot shouldBe java.nio.file.Path.of("dotnet", "Platform", "UserService.Api") + textContribution.origins shouldBe setOf("dotnet.solutions.Platform.projects.UserService.Api.Project") textContribution.contents.shouldContain("""""") textContribution.contents.shouldContain("net8.0") } diff --git a/modules/gen/src/main/kotlin/io/github/lmliam/microsmith/gen/files/GeneratedFile.kt b/modules/gen/src/main/kotlin/io/github/lmliam/microsmith/gen/files/GeneratedFile.kt index da3cea67..a8d5ae2b 100644 --- a/modules/gen/src/main/kotlin/io/github/lmliam/microsmith/gen/files/GeneratedFile.kt +++ b/modules/gen/src/main/kotlin/io/github/lmliam/microsmith/gen/files/GeneratedFile.kt @@ -2,7 +2,12 @@ package io.github.lmliam.microsmith.gen.files import java.nio.file.Path -class GeneratedFile(val relativePath: Path, contents: ByteArray, val outputRoot: Path = Path.of(".")) { +class GeneratedFile( + val relativePath: Path, + contents: ByteArray, + val outputRoot: Path = Path.of("."), + val origins: Set = emptySet(), +) { private val bytes = contents.copyOf() val contents: ByteArray diff --git a/modules/gen/src/main/kotlin/io/github/lmliam/microsmith/gen/files/render/GeneratedByMicrosmithBanner.kt b/modules/gen/src/main/kotlin/io/github/lmliam/microsmith/gen/files/render/GeneratedByMicrosmithBanner.kt index 40b8e197..df7ef1a6 100644 --- a/modules/gen/src/main/kotlin/io/github/lmliam/microsmith/gen/files/render/GeneratedByMicrosmithBanner.kt +++ b/modules/gen/src/main/kotlin/io/github/lmliam/microsmith/gen/files/render/GeneratedByMicrosmithBanner.kt @@ -4,6 +4,7 @@ import java.nio.file.Path internal object GeneratedByMicrosmithBanner { private const val HEADER_TEXT = "Generated by Microsmith" + private const val ORIGINS_HEADER = "Origins:" private val xmlExtensions = setOf("csproj", "fsproj", "props", "targets", "vbproj", "xml") private val slashCommentExtensions = setOf( @@ -43,24 +44,34 @@ internal object GeneratedByMicrosmithBanner { "zsh", ) - fun prepend(path: Path, contents: String): String { + fun prepend(path: Path, contents: String, origins: Set = emptySet()): String { val comment = resolveComment(path) ?: return contents - if (contents.startsWith(comment)) { + if (contents.startsWith(commentText(comment, HEADER_TEXT))) { return contents } + val bannerLines = buildList { + add(commentText(comment, HEADER_TEXT)) + if (origins.isNotEmpty()) { + add(commentText(comment, ORIGINS_HEADER)) + origins.toList().sorted().forEach { origin -> + add(commentText(comment, "- $origin")) + } + } + } + val banner = bannerLines.joinToString(separator = "\n") if (comment.startsWith("#") && contents.startsWith("#!")) { val shebangEnd = contents.indexOf('\n') if (shebangEnd == -1) { - return "$contents\n$comment\n" + return "$contents\n$banner\n" } val shebang = contents.substring(0, shebangEnd) val remainder = contents.substring(shebangEnd + 1) - return listOf(shebang, comment, remainder).joinToString(separator = "\n") + return listOf(shebang, banner, remainder).joinToString(separator = "\n") } return if (contents.isEmpty()) { - "$comment\n" + "$banner\n" } else { - "$comment\n$contents" + "$banner\n$contents" } } @@ -68,10 +79,15 @@ internal object GeneratedByMicrosmithBanner { val fileName = path.fileName.toString() val extension = fileName.substringAfterLast('.', missingDelimiterValue = "") return when (extension) { - in xmlExtensions -> "" - in slashCommentExtensions -> "// $HEADER_TEXT" - in hashCommentExtensions -> "# $HEADER_TEXT" + in xmlExtensions -> "xml" + in slashCommentExtensions -> "//" + in hashCommentExtensions -> "#" else -> null } } + + private fun commentText(comment: String, text: String): String = when (comment) { + "xml" -> "" + else -> "$comment $text" + } } diff --git a/modules/gen/src/main/kotlin/io/github/lmliam/microsmith/gen/files/render/TextFileArtifactRenderer.kt b/modules/gen/src/main/kotlin/io/github/lmliam/microsmith/gen/files/render/TextFileArtifactRenderer.kt index 5aba6ddb..a559f988 100644 --- a/modules/gen/src/main/kotlin/io/github/lmliam/microsmith/gen/files/render/TextFileArtifactRenderer.kt +++ b/modules/gen/src/main/kotlin/io/github/lmliam/microsmith/gen/files/render/TextFileArtifactRenderer.kt @@ -11,11 +11,16 @@ class TextFileArtifactRenderer : ArtifactRenderer { override val artifactType = TextFileArtifact::class override fun render(artifact: TextFileArtifact): GeneratedFile { - val renderedContents = GeneratedByMicrosmithBanner.prepend(artifact.id.relativePath, artifact.contents) + val renderedContents = GeneratedByMicrosmithBanner.prepend( + artifact.id.relativePath, + artifact.contents, + artifact.origins, + ) return GeneratedFile( relativePath = artifact.id.relativePath, contents = renderedContents.toByteArray(StandardCharsets.UTF_8), outputRoot = artifact.id.outputRoot, + origins = artifact.origins, ) } } diff --git a/modules/gen/src/main/kotlin/io/github/lmliam/microsmith/gen/helpers/GeneratedOriginsManifestBuilder.kt b/modules/gen/src/main/kotlin/io/github/lmliam/microsmith/gen/helpers/GeneratedOriginsManifestBuilder.kt new file mode 100644 index 00000000..74038d51 --- /dev/null +++ b/modules/gen/src/main/kotlin/io/github/lmliam/microsmith/gen/helpers/GeneratedOriginsManifestBuilder.kt @@ -0,0 +1,74 @@ +package io.github.lmliam.microsmith.gen.helpers + +import io.github.lmliam.microsmith.gen.files.GeneratedFile +import java.nio.charset.StandardCharsets +import java.nio.file.Path + +internal object GeneratedOriginsManifestBuilder { + private val manifestRelativePath = Path.of(".microsmith", "origins.json") + + fun appendTo(outputs: List): List { + val manifests = outputs + .groupBy(GeneratedFile::outputRoot) + .mapNotNull { (outputRoot, files) -> + val tracedFiles = files + .filter { it.relativePath != manifestRelativePath } + .map { file -> + TracedFile(relativePath = file.relativePath.toString().replace('\\', '/'), origins = file.origins.toList().sorted()) + }.sortedBy(TracedFile::relativePath) + if (tracedFiles.isEmpty()) { + return@mapNotNull null + } + GeneratedFile( + relativePath = manifestRelativePath, + contents = renderManifest(tracedFiles).toByteArray(StandardCharsets.UTF_8), + outputRoot = outputRoot, + origins = tracedFiles.flatMapTo(sortedSetOf()) { it.origins }, + ) + } + return outputs + manifests + } + + private fun renderManifest(files: List): String = buildString { + appendLine("{") + appendLine(""" "generatedBy": "Microsmith",""") + appendLine(""" "files": [""") + files.forEachIndexed { index, file -> + appendLine(" {") + appendLine(""" "path": "${escapeJson(file.relativePath)}",""") + appendLine(""" "origins": [""") + file.origins.forEachIndexed { originIndex, origin -> + val suffix = if (originIndex == file.origins.lastIndex) "" else "," + appendLine(""" "${escapeJson(origin)}"$suffix""") + } + val fileSuffix = if (index == files.lastIndex) "" else "," + appendLine(" ]") + appendLine(" }$fileSuffix") + } + appendLine(" ]") + append('}') + } + + private fun escapeJson(value: String): String = buildString(value.length) { + value.forEach { char -> + when (char) { + '\\' -> append("\\\\") + '"' -> append("\\\"") + '\b' -> append("\\b") + '\u000C' -> append("\\f") + '\n' -> append("\\n") + '\r' -> append("\\r") + '\t' -> append("\\t") + else -> { + if (char.code < 0x20) { + append("\\u%04x".format(char.code)) + } else { + append(char) + } + } + } + } + } + + private data class TracedFile(val relativePath: String, val origins: List) +} diff --git a/modules/gen/src/main/kotlin/io/github/lmliam/microsmith/gen/helpers/MicrosmithGenerationRunner.kt b/modules/gen/src/main/kotlin/io/github/lmliam/microsmith/gen/helpers/MicrosmithGenerationRunner.kt index c1d6a88f..5728571d 100644 --- a/modules/gen/src/main/kotlin/io/github/lmliam/microsmith/gen/helpers/MicrosmithGenerationRunner.kt +++ b/modules/gen/src/main/kotlin/io/github/lmliam/microsmith/gen/helpers/MicrosmithGenerationRunner.kt @@ -25,10 +25,11 @@ internal class MicrosmithGenerationRunner( val contributions = artifactContributionService.contribute(resolvedModels) val assembly = artifactAssemblyService.assemble(contributions) val compiledAssembly = artifactCompilationService.compile(assembly) - val generated = artifactRenderingService.render(compiledAssembly) - outputUniquenessValidator.requireUniqueOutputPaths(generated) - outputWriter.write(generated, tempSpace) - generated + val generatedWithOriginsManifest = + GeneratedOriginsManifestBuilder.appendTo(artifactRenderingService.render(compiledAssembly)) + outputUniquenessValidator.requireUniqueOutputPaths(generatedWithOriginsManifest) + outputWriter.write(generatedWithOriginsManifest, tempSpace) + generatedWithOriginsManifest } outputWriter.write(outputs, finalDir) diff --git a/modules/gen/src/test/kotlin/io/github/lmliam/microsmith/gen/files/render/TextFileArtifactRendererTests.kt b/modules/gen/src/test/kotlin/io/github/lmliam/microsmith/gen/files/render/TextFileArtifactRendererTests.kt index c715cf2f..b97cb155 100644 --- a/modules/gen/src/test/kotlin/io/github/lmliam/microsmith/gen/files/render/TextFileArtifactRendererTests.kt +++ b/modules/gen/src/test/kotlin/io/github/lmliam/microsmith/gen/files/render/TextFileArtifactRendererTests.kt @@ -66,6 +66,45 @@ class TextFileArtifactRendererTests : """.trimIndent() } + "render includes origin comments for supported text formats" { + val rendered = + renderer.render( + TextFileArtifact( + id = TextFileArtifactId(relativePath = Path.of("proto", "User.proto")), + contents = "syntax = \"proto3\";", + origins = setOf("schemas.protobuf.pkg.User", "schemas.protobuf.pkg.UserCreated"), + ), + ) + + String(rendered.contents, StandardCharsets.UTF_8) shouldBe + """ + // Generated by Microsmith + // Origins: + // - schemas.protobuf.pkg.User + // - schemas.protobuf.pkg.UserCreated + syntax = "proto3"; + """.trimIndent() + } + + "render formats origin comments as valid xml comments" { + val rendered = + renderer.render( + TextFileArtifact( + id = TextFileArtifactId(relativePath = Path.of("Directory.Packages.props")), + contents = "", + origins = setOf("services.solutions.Platform.packages.Serilog.AspNetCore"), + ), + ) + + String(rendered.contents, StandardCharsets.UTF_8) shouldBe + """ + + + + + """.trimIndent() + } + "render leaves unsupported text formats unchanged" { val rendered = renderer.render( diff --git a/modules/gen/src/test/kotlin/io/github/lmliam/microsmith/gen/helpers/GeneratedOriginsManifestBuilderTests.kt b/modules/gen/src/test/kotlin/io/github/lmliam/microsmith/gen/helpers/GeneratedOriginsManifestBuilderTests.kt new file mode 100644 index 00000000..8fb89619 --- /dev/null +++ b/modules/gen/src/test/kotlin/io/github/lmliam/microsmith/gen/helpers/GeneratedOriginsManifestBuilderTests.kt @@ -0,0 +1,45 @@ +package io.github.lmliam.microsmith.gen.helpers + +import io.github.lmliam.microsmith.gen.files.GeneratedFile +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain +import java.nio.charset.StandardCharsets +import kotlin.io.path.Path + +class GeneratedOriginsManifestBuilderTests : + StringSpec({ + "appendTo adds one origins manifest per output root" { + val outputs = listOf( + GeneratedFile( + relativePath = Path("proto/User.proto"), + contents = byteArrayOf(1), + outputRoot = Path("repo-a"), + origins = setOf("schemas.protobuf.pkg.User"), + ), + GeneratedFile( + relativePath = Path("Controllers/UserServiceController.cs"), + contents = byteArrayOf(2), + outputRoot = Path("repo-a"), + origins = setOf("services.UserService.rest.GetUser"), + ), + GeneratedFile( + relativePath = Path("Program.cs"), + contents = byteArrayOf(3), + outputRoot = Path("repo-b"), + origins = setOf("services.AdminService"), + ), + ) + + val withManifest = GeneratedOriginsManifestBuilder.appendTo(outputs) + val manifests = withManifest.filter { it.relativePath == Path(".microsmith/origins.json") } + + manifests.size shouldBe 2 + String(manifests.single { it.outputRoot == Path("repo-a") }.contents, StandardCharsets.UTF_8) + .shouldContain("Controllers/UserServiceController.cs") + String(manifests.single { it.outputRoot == Path("repo-a") }.contents, StandardCharsets.UTF_8) + .shouldContain("schemas.protobuf.pkg.User") + String(manifests.single { it.outputRoot == Path("repo-b") }.contents, StandardCharsets.UTF_8) + .shouldContain("services.AdminService") + } + }) diff --git a/modules/gradle-plugin/src/main/kotlin/io/github/lmliam/microsmith/gradle/MicrosmithGenerateTask.kt b/modules/gradle-plugin/src/main/kotlin/io/github/lmliam/microsmith/gradle/MicrosmithGenerateTask.kt index 60874e05..971cc797 100644 --- a/modules/gradle-plugin/src/main/kotlin/io/github/lmliam/microsmith/gradle/MicrosmithGenerateTask.kt +++ b/modules/gradle-plugin/src/main/kotlin/io/github/lmliam/microsmith/gradle/MicrosmithGenerateTask.kt @@ -16,6 +16,11 @@ import org.gradle.api.tasks.PathSensitive import org.gradle.api.tasks.PathSensitivity import org.gradle.api.tasks.TaskAction import org.gradle.work.DisableCachingByDefault +import java.nio.file.Files +import java.nio.file.Path +import kotlin.io.path.exists +import kotlin.io.path.isRegularFile +import kotlin.io.path.name @DisableCachingByDefault( because = "Microsmith maintains its own compilation cache and task-level caching needs deeper normalization.", @@ -76,7 +81,7 @@ abstract class MicrosmithGenerateTask : DefaultTask() { private fun reportSuccess(result: MicrosmithGradleWorkerSuccess) { result.warnings.forEach(logger::warn) - val generatedOutputRoot = outputDirectory.get().asFile.toPath().toAbsolutePath().normalize().resolve("proto") + val generatedOutputRoot = describeGeneratedOutputRoot(outputDirectory.get().asFile.toPath()) logger.lifecycle( "Generated Microsmith outputs into '$generatedOutputRoot'. " + "(compile-cache=${if (result.cacheHit) "hit" else "miss"}, elapsed=${result.elapsedMillis}ms)", @@ -87,4 +92,39 @@ abstract class MicrosmithGenerateTask : DefaultTask() { appendLine("Microsmith generation failed (${result.type.lowercase()}).") result.diagnostics.forEach(::appendLine) }.trimEnd() + + private fun describeGeneratedOutputRoot(outputDirectory: Path): String { + val normalizedOutputDirectory = outputDirectory.toAbsolutePath().normalize() + val generatedRoots = locateGeneratedRoots(normalizedOutputDirectory) + return when (generatedRoots.size) { + 0 -> normalizedOutputDirectory.toString() + 1 -> generatedRoots.single().toString() + else -> buildString { + append(normalizedOutputDirectory) + append(" (roots: ") + append(generatedRoots.joinToString()) + append(')') + } + } + } + + private fun locateGeneratedRoots(outputDirectory: Path): List { + if (!outputDirectory.exists()) { + return emptyList() + } + + Files.walk(outputDirectory).use { paths -> + return paths + .filter { path -> + path.isRegularFile() && + path.name == "origins.json" && + path.parent?.name == ".microsmith" + }.map { manifestPath -> + manifestPath.parent?.parent ?: outputDirectory + }.map(Path::normalize) + .distinct() + .sorted() + .toList() + } + } } diff --git a/modules/maven-plugin/src/main/kotlin/io/github/lmliam/microsmith/maven/MicrosmithMavenResultHandler.kt b/modules/maven-plugin/src/main/kotlin/io/github/lmliam/microsmith/maven/MicrosmithMavenResultHandler.kt index 88ffce65..a8cf8d9d 100644 --- a/modules/maven-plugin/src/main/kotlin/io/github/lmliam/microsmith/maven/MicrosmithMavenResultHandler.kt +++ b/modules/maven-plugin/src/main/kotlin/io/github/lmliam/microsmith/maven/MicrosmithMavenResultHandler.kt @@ -1,5 +1,6 @@ package io.github.lmliam.microsmith.maven +import io.github.lmliam.microsmith.runtime.scripting.model.GeneratedOutputRootsLocator import io.github.lmliam.microsmith.runtime.scripting.model.ScriptFailureType import io.github.lmliam.microsmith.runtime.scripting.model.ScriptRunFailure import io.github.lmliam.microsmith.runtime.scripting.model.ScriptRunResult @@ -20,7 +21,7 @@ internal class MicrosmithMavenResultHandler { private fun handleSuccess(log: Log, outputDirectory: Path, result: ScriptRunSuccess) { result.warnings.forEach(log::warn) - val generatedOutputRoot = outputDirectory.toAbsolutePath().normalize().resolve("proto") + val generatedOutputRoot = GeneratedOutputRootsLocator.describe(outputDirectory) log.info( "Generated Microsmith outputs into '$generatedOutputRoot'. " + "(compile-cache=${if (result.cacheHit) "hit" else "miss"}, elapsed=${result.elapsedMillis}ms)", diff --git a/modules/runtime-scripting/src/main/kotlin/io/github/lmliam/microsmith/runtime/scripting/model/GeneratedOutputRootsLocator.kt b/modules/runtime-scripting/src/main/kotlin/io/github/lmliam/microsmith/runtime/scripting/model/GeneratedOutputRootsLocator.kt new file mode 100644 index 00000000..3ea78add --- /dev/null +++ b/modules/runtime-scripting/src/main/kotlin/io/github/lmliam/microsmith/runtime/scripting/model/GeneratedOutputRootsLocator.kt @@ -0,0 +1,52 @@ +package io.github.lmliam.microsmith.runtime.scripting.model + +import java.nio.file.Files +import java.nio.file.Path +import kotlin.io.path.exists +import kotlin.io.path.isRegularFile +import kotlin.io.path.name + +object GeneratedOutputRootsLocator { + private val originsDirectoryName = ".microsmith" + private val originsManifestName = "origins.json" + + @JvmStatic + fun locate(outputDirectory: Path): List { + val normalizedOutputDirectory = outputDirectory.toAbsolutePath().normalize() + if (!normalizedOutputDirectory.exists()) { + return emptyList() + } + + Files.walk(normalizedOutputDirectory).use { paths -> + return paths + .filter(::isOriginsManifest) + .map { manifestPath -> + manifestPath.parent?.parent ?: normalizedOutputDirectory + }.map(Path::normalize) + .distinct() + .sorted() + .toList() + } + } + + @JvmStatic + fun describe(outputDirectory: Path): String { + val normalizedOutputDirectory = outputDirectory.toAbsolutePath().normalize() + val roots = locate(normalizedOutputDirectory) + return when (roots.size) { + 0 -> normalizedOutputDirectory.toString() + 1 -> roots.single().toString() + else -> buildString { + append(normalizedOutputDirectory) + append(" (roots: ") + append(roots.joinToString()) + append(')') + } + } + } + + private fun isOriginsManifest(path: Path): Boolean = + path.isRegularFile() && + path.name == originsManifestName && + path.parent?.name == originsDirectoryName +} diff --git a/modules/runtime-scripting/src/test/kotlin/io/github/lmliam/microsmith/runtime/scripting/model/GeneratedOutputRootsLocatorTests.kt b/modules/runtime-scripting/src/test/kotlin/io/github/lmliam/microsmith/runtime/scripting/model/GeneratedOutputRootsLocatorTests.kt new file mode 100644 index 00000000..623efa12 --- /dev/null +++ b/modules/runtime-scripting/src/test/kotlin/io/github/lmliam/microsmith/runtime/scripting/model/GeneratedOutputRootsLocatorTests.kt @@ -0,0 +1,44 @@ +package io.github.lmliam.microsmith.runtime.scripting.model + +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.collections.shouldContainExactly +import io.kotest.matchers.string.shouldContain +import java.nio.file.Files +import kotlin.io.path.createDirectories +import kotlin.io.path.writeText + +class GeneratedOutputRootsLocatorTests : + StringSpec({ + "locate discovers generated output roots from origins manifests" { + val outputDir = Files.createTempDirectory("generated-output-roots-") + val protoManifest = outputDir.resolve("proto/.microsmith/origins.json") + val aspManifest = outputDir.resolve("dotnet/Platform/UserService.Api/.microsmith/origins.json") + protoManifest.parent.createDirectories() + aspManifest.parent.createDirectories() + protoManifest.writeText("""{"files":[]}""") + aspManifest.writeText("""{"files":[]}""") + + GeneratedOutputRootsLocator.locate(outputDir) shouldContainExactly listOf( + outputDir.resolve("dotnet/Platform/UserService.Api").toAbsolutePath().normalize(), + outputDir.resolve("proto").toAbsolutePath().normalize(), + ) + } + + "describe summarizes multiple generated roots" { + val outputDir = Files.createTempDirectory("generated-output-root-description-") + val protoManifest = outputDir.resolve("proto/.microsmith/origins.json") + val aspManifest = outputDir.resolve("dotnet/Platform/UserService.Api/.microsmith/origins.json") + protoManifest.parent.createDirectories() + aspManifest.parent.createDirectories() + protoManifest.writeText("""{"files":[]}""") + aspManifest.writeText("""{"files":[]}""") + + val description = GeneratedOutputRootsLocator.describe(outputDir) + + description.shouldContain(outputDir.toAbsolutePath().normalize().toString()) + description.shouldContain(outputDir.resolve("proto").toAbsolutePath().normalize().toString()) + description.shouldContain( + outputDir.resolve("dotnet/Platform/UserService.Api").toAbsolutePath().normalize().toString(), + ) + } + }) diff --git a/modules/sbt-plugin/src/main/scala/io/github/lmliam/microsmith/sbt/MicrosmithSbtPlugin.scala b/modules/sbt-plugin/src/main/scala/io/github/lmliam/microsmith/sbt/MicrosmithSbtPlugin.scala index 0370ce7c..17447187 100644 --- a/modules/sbt-plugin/src/main/scala/io/github/lmliam/microsmith/sbt/MicrosmithSbtPlugin.scala +++ b/modules/sbt-plugin/src/main/scala/io/github/lmliam/microsmith/sbt/MicrosmithSbtPlugin.scala @@ -1,5 +1,6 @@ package io.github.lmliam.microsmith.sbt +import io.github.lmliam.microsmith.runtime.scripting.model.GeneratedOutputRootsLocator import _root_.sbt._ import _root_.sbt.Keys._ import _root_.sbt.internal.util.MessageOnlyException @@ -82,12 +83,13 @@ object MicrosmithSbtPlugin extends AutoPlugin { try { val outcome = executionService.execute(configuration) outcome.getWarnings.asScala.foreach(message => logger.warn(message)) - val generatedOutputRoot = outcome.getOutputDirectory.toAbsolutePath.normalize.resolve("proto") + val generatedRoots = GeneratedOutputRootsLocator.locate(outcome.getOutputDirectory).asScala.toSeq + val generatedOutputRoot = GeneratedOutputRootsLocator.describe(outcome.getOutputDirectory) logger.info( s"Generated Microsmith outputs into '$generatedOutputRoot'. " + s"(compile-cache=${if (outcome.getCacheHit) "hit" else "miss"}, elapsed=${outcome.getElapsedMillis}ms)" ) - generatedFiles(generatedOutputRoot.toFile) + generatedFiles(generatedRoots.map(_.toFile)) } catch { case error: MicrosmithSbtScriptFailureException => throw new MessageOnlyException(error.getMessage) @@ -96,20 +98,20 @@ object MicrosmithSbtPlugin extends AutoPlugin { } } - private def generatedFiles(outputDirectory: File): Seq[File] = { - if (!outputDirectory.isDirectory) { - return Seq.empty - } - - val stream = Files.walk(outputDirectory.toPath) - try { - stream.iterator.asScala - .filter(Files.isRegularFile(_)) - .map(_.toFile) - .toVector - .sortBy(_.getAbsolutePath) - } finally { - stream.close() - } - } + private def generatedFiles(outputRoots: Seq[File]): Seq[File] = + outputRoots + .filter(_.isDirectory) + .flatMap { outputDirectory => + val stream = Files.walk(outputDirectory.toPath) + try { + stream.iterator.asScala + .filter(Files.isRegularFile(_)) + .map(_.toFile) + .toVector + } finally { + stream.close() + } + } + .distinct + .sortBy(_.getAbsolutePath) } From 9e12121156ef652bb285912267a47b7a6917db55 Mon Sep 17 00:00:00 2001 From: LMLiam <46268350+TheRealEmissions@users.noreply.github.com> Date: Tue, 21 Apr 2026 00:25:32 +0100 Subject: [PATCH 02/26] feat(services-dotnet-asp): generate concrete ASP.NET project files --- .../artifact-services-dotnet-asp/build.gradle | 3 +- .../asp/DotnetAspArtifactContributor.kt | 218 +++++- .../dotnet/asp/DotnetAspServiceArtifact.kt | 6 +- .../asp/DotnetAspServiceArtifactAssembler.kt | 4 +- .../asp/DotnetAspServiceArtifactDetails.kt | 67 ++ .../asp/DotnetAspServiceContribution.kt | 6 +- .../dotnet/asp/DotnetAspProjectRenderer.kt | 647 ++++++++++++++++ .../asp/DotnetAspServiceArtifactCompiler.kt | 210 +++-- .../DotnetAspServiceArtifactCompilerTests.kt | 727 +++++++----------- .../DotnetAspGenerationIntegrationTests.kt | 260 +++---- 10 files changed, 1420 insertions(+), 728 deletions(-) create mode 100644 modules/artifact-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspServiceArtifactDetails.kt create mode 100644 modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspProjectRenderer.kt diff --git a/modules/artifact-services-dotnet-asp/build.gradle b/modules/artifact-services-dotnet-asp/build.gradle index fb24664d..8477d97f 100644 --- a/modules/artifact-services-dotnet-asp/build.gradle +++ b/modules/artifact-services-dotnet-asp/build.gradle @@ -5,8 +5,9 @@ dependencies { api project(":artifact-services") api project(":artifact-services-dotnet") api project(":dsl-services-dotnet") - api project(":resolve-services-dotnet-asp") + implementation project(":dsl-services-dotnet-asp") implementation project(":resolve") + implementation project(":resolve-services-dotnet-asp") api libs.spi.tooling.annotations kapt libs.spi.tooling.processor } diff --git a/modules/artifact-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspArtifactContributor.kt b/modules/artifact-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspArtifactContributor.kt index 5049b0b1..783947ab 100644 --- a/modules/artifact-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspArtifactContributor.kt +++ b/modules/artifact-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspArtifactContributor.kt @@ -3,39 +3,207 @@ package io.github.lmliam.microsmith.artifact.services.dotnet.asp import com.github.eventhorizonlab.spi.ServiceProvider import io.github.lmliam.microsmith.artifact.core.ArtifactContribution import io.github.lmliam.microsmith.artifact.core.ArtifactContributor +import io.github.lmliam.microsmith.dsl.services.dotnet.core.model.DotnetModel import io.github.lmliam.microsmith.resolve.services.dotnet.asp.DotnetAspWorkspace +import io.github.lmliam.microsmith.resolve.services.dotnet.asp.ResolvedDotnetAspEndpoint +import io.github.lmliam.microsmith.resolve.services.dotnet.asp.ResolvedDotnetAspHeadersBinding +import io.github.lmliam.microsmith.resolve.services.dotnet.asp.ResolvedDotnetAspModel +import io.github.lmliam.microsmith.resolve.services.dotnet.asp.ResolvedDotnetAspModelLocality +import io.github.lmliam.microsmith.resolve.services.dotnet.asp.ResolvedDotnetAspRequestBinding +import io.github.lmliam.microsmith.resolve.services.dotnet.asp.ResolvedDotnetAspResponse +import io.github.lmliam.microsmith.resolve.services.dotnet.asp.ResolvedDotnetAspService @ServiceProvider(ArtifactContributor::class) class DotnetAspArtifactContributor : ArtifactContributor { override val resolvedType = DotnetAspWorkspace::class - override fun contribute(model: DotnetAspWorkspace): List> = model.servicesByName.values - .map { service -> - service to DotnetAspServiceArtifactId(service.solutionName, service.projectName) - }.sortedWith( - compareBy( - { (_, artifactId) -> artifactId.solutionName }, - { (_, artifactId) -> artifactId.projectName }, - ), - ) - .let { serviceArtifacts -> - val allocatedPorts = - serviceArtifacts.associate { (service, artifactId) -> - artifactId to allocateDotnetAspPorts(artifactId, service.ports) + override fun contribute(model: DotnetAspWorkspace): List> = + model.servicesByName.values.sortedBy { + it.name + }.mapIndexed(::toContribution) + + private fun toContribution(index: Int, service: ResolvedDotnetAspService): DotnetAspServiceContribution { + val httpPort = BASE_HTTP_PORT + (index * PORT_STRIDE) + val usedTypeNames = linkedSetOf() + val sharedModelsByName = service.models.values + .sortedBy(DotnetModel::name) + .associate { model -> + usedTypeNames += model.name + model.name to DotnetAspModelArtifact( + typeName = model.name, + locality = DotnetAspModelLocality.SHARED, + model = model, + origins = setOf("services.${service.name}.models.${model.name}"), + ) } - validateUniqueDotnetAspPorts(allocatedPorts.toList()) - serviceArtifacts.map { (service, artifactId) -> - val ports = requireNotNull(allocatedPorts[artifactId]) - DotnetAspServiceContribution( - artifactId = artifactId, - serviceName = service.name, - targetFrameworkMoniker = service.targetFrameworkMoniker, - outputRoot = service.outputRoot, - httpPort = ports.http, - httpsPort = ports.https, - models = service.models, - rest = service.rest, + val contractModels = mutableListOf() + contractModels += sharedModelsByName.values + + val endpoints = service.rest.endpoints.map { endpoint -> + val endpointOrigin = "services.${service.name}.rest.${endpoint.operationName}" + DotnetAspEndpointArtifact( + method = endpoint.method.name, + route = endpoint.route, + operationName = endpoint.operationName, + bindings = DotnetAspEndpointBindingsArtifact( + path = endpoint.bindings.path?.toRequestBindingArtifact( + serviceName = service.name, + endpoint = endpoint, + bindingLabel = "path", + usedTypeNames = usedTypeNames, + ), + query = endpoint.bindings.query?.toRequestBindingArtifact( + serviceName = service.name, + endpoint = endpoint, + bindingLabel = "query", + usedTypeNames = usedTypeNames, + ), + headers = endpoint.bindings.headers?.toHeadersBindingArtifact( + serviceName = service.name, + endpoint = endpoint, + usedTypeNames = usedTypeNames, + ), + body = endpoint.bindings.body?.toModelArtifact( + serviceName = service.name, + endpoint = endpoint, + sharedModelsByName = sharedModelsByName, + usedTypeNames = usedTypeNames, + contractModels = contractModels, + originKind = "body", + ), + ), + responses = endpoint.responses.map { response -> + response.toResponseArtifact( + serviceName = service.name, + endpoint = endpoint, + sharedModelsByName = sharedModelsByName, + usedTypeNames = usedTypeNames, + contractModels = contractModels, + ) + }, + origins = setOf(endpointOrigin), ) } + + return DotnetAspServiceContribution( + artifactId = DotnetAspServiceArtifactId(service.solutionName, service.projectName), + serviceName = service.name, + targetFrameworkMoniker = service.targetFrameworkMoniker, + outputRoot = service.outputRoot, + httpPort = httpPort, + httpsPort = httpPort + 1, + contractModels = contractModels.toList(), + endpoints = endpoints, + ) + } + + private companion object { + const val BASE_HTTP_PORT = 5000 + const val PORT_STRIDE = 10 + } + + private fun ResolvedDotnetAspRequestBinding.toRequestBindingArtifact( + serviceName: String, + endpoint: ResolvedDotnetAspEndpoint, + bindingLabel: String, + usedTypeNames: MutableSet, + ): DotnetAspRequestBindingArtifact { + val origin = "services.$serviceName.rest.${endpoint.operationName}.$bindingLabel.$name" + return DotnetAspRequestBindingArtifact( + typeName = allocateTypeName(usedTypeNames, name, "${endpoint.operationName}$name"), + name = name, + fields = fields.map { field -> + DotnetAspRequestFieldArtifact( + name = field.name, + type = field.type, + optional = field.optional, + defaultValue = field.defaultValue, + ) + }, + origins = setOf(origin), + ) + } + + private fun ResolvedDotnetAspHeadersBinding.toHeadersBindingArtifact( + serviceName: String, + endpoint: ResolvedDotnetAspEndpoint, + usedTypeNames: MutableSet, + ): DotnetAspHeadersBindingArtifact { + val origin = "services.$serviceName.rest.${endpoint.operationName}.headers.$name" + return DotnetAspHeadersBindingArtifact( + typeName = allocateTypeName(usedTypeNames, name, "${endpoint.operationName}$name"), + name = name, + headers = headers.map { header -> + DotnetAspHeaderFieldArtifact(name = header.name, headerName = header.headerName) + }, + origins = setOf(origin), + ) + } + + private fun ResolvedDotnetAspModel.toModelArtifact( + serviceName: String, + endpoint: ResolvedDotnetAspEndpoint, + sharedModelsByName: Map, + usedTypeNames: MutableSet, + contractModels: MutableList, + originKind: String, + ): DotnetAspModelArtifact = when (locality) { + ResolvedDotnetAspModelLocality.SHARED -> requireNotNull(sharedModelsByName[model.name]) { + "Missing shared ASP.NET model artifact for '${model.name}'." + } + + ResolvedDotnetAspModelLocality.INLINE -> DotnetAspModelArtifact( + typeName = allocateTypeName( + usedTypeNames, + model.name, + "${endpoint.operationName}${model.name}", + "${endpoint.operationName}${originKind.replaceFirstChar(Char::uppercase)}", + ), + locality = DotnetAspModelLocality.INLINE, + model = model, + origins = setOf("services.$serviceName.rest.${endpoint.operationName}.$originKind.${model.name}"), + ).also(contractModels::add) + } + + private fun ResolvedDotnetAspResponse.toResponseArtifact( + serviceName: String, + endpoint: ResolvedDotnetAspEndpoint, + sharedModelsByName: Map, + usedTypeNames: MutableSet, + contractModels: MutableList, + ): DotnetAspResponseArtifact { + val responseOrigin = "services.$serviceName.rest.${endpoint.operationName}.responses.$statusCode" + val modelArtifact = model.toModelArtifact( + serviceName = serviceName, + endpoint = endpoint, + sharedModelsByName = sharedModelsByName, + usedTypeNames = usedTypeNames, + contractModels = contractModels, + originKind = "responses.$statusCode", + ) + return DotnetAspResponseArtifact( + statusCode = statusCode, + model = modelArtifact, + headers = headers.map { header -> DotnetAspResponseHeaderArtifact(header.name) }, + origins = setOf(responseOrigin) + modelArtifact.origins, + ) + } + + private fun allocateTypeName(usedTypeNames: MutableSet, vararg candidates: String): String { + candidates + .map(String::trim) + .filter(String::isNotBlank) + .firstOrNull { candidate -> usedTypeNames.add(candidate) } + ?.let { return it } + + val fallbackBase = candidates.firstOrNull(String::isNotBlank)?.trim().orEmpty() + var suffix = 2 + while (true) { + val candidate = "$fallbackBase$suffix" + if (usedTypeNames.add(candidate)) { + return candidate + } + suffix += 1 } + } } diff --git a/modules/artifact-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspServiceArtifact.kt b/modules/artifact-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspServiceArtifact.kt index e072a599..81ddb876 100644 --- a/modules/artifact-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspServiceArtifact.kt +++ b/modules/artifact-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspServiceArtifact.kt @@ -1,8 +1,6 @@ package io.github.lmliam.microsmith.artifact.services.dotnet.asp import io.github.lmliam.microsmith.artifact.services.core.ServicesArtifact -import io.github.lmliam.microsmith.dsl.services.dotnet.core.model.DotnetModel -import io.github.lmliam.microsmith.resolve.services.dotnet.asp.ResolvedDotnetAspRest import java.nio.file.Path data class DotnetAspServiceArtifact( @@ -12,6 +10,6 @@ data class DotnetAspServiceArtifact( val outputRoot: Path, val httpPort: Int, val httpsPort: Int, - val models: Map, - val rest: ResolvedDotnetAspRest, + val contractModels: List, + val endpoints: List, ) : ServicesArtifact diff --git a/modules/artifact-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspServiceArtifactAssembler.kt b/modules/artifact-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspServiceArtifactAssembler.kt index 95161efe..eec888ec 100644 --- a/modules/artifact-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspServiceArtifactAssembler.kt +++ b/modules/artifact-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspServiceArtifactAssembler.kt @@ -17,8 +17,8 @@ class DotnetAspServiceArtifactAssembler : ArtifactAssembler, +) + +enum class DotnetAspModelLocality { + SHARED, + INLINE, +} + +data class DotnetAspEndpointArtifact( + val method: String, + val route: String, + val operationName: String, + val bindings: DotnetAspEndpointBindingsArtifact, + val responses: List, + val origins: Set, +) + +data class DotnetAspEndpointBindingsArtifact( + val path: DotnetAspRequestBindingArtifact? = null, + val query: DotnetAspRequestBindingArtifact? = null, + val headers: DotnetAspHeadersBindingArtifact? = null, + val body: DotnetAspModelArtifact? = null, +) + +data class DotnetAspRequestBindingArtifact( + val typeName: String, + val name: String, + val fields: List, + val origins: Set, +) + +data class DotnetAspRequestFieldArtifact( + val name: String, + val type: DotnetFieldType, + val optional: Boolean, + val defaultValue: Any?, +) + +data class DotnetAspHeadersBindingArtifact( + val typeName: String, + val name: String, + val headers: List, + val origins: Set, +) + +data class DotnetAspHeaderFieldArtifact( + val name: String, + val headerName: String, +) + +data class DotnetAspResponseArtifact( + val statusCode: Int, + val model: DotnetAspModelArtifact, + val headers: List, + val origins: Set, +) + +data class DotnetAspResponseHeaderArtifact(val name: String) diff --git a/modules/artifact-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspServiceContribution.kt b/modules/artifact-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspServiceContribution.kt index 04743d0f..4127a52b 100644 --- a/modules/artifact-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspServiceContribution.kt +++ b/modules/artifact-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspServiceContribution.kt @@ -1,8 +1,6 @@ package io.github.lmliam.microsmith.artifact.services.dotnet.asp import io.github.lmliam.microsmith.artifact.core.ArtifactContribution -import io.github.lmliam.microsmith.dsl.services.dotnet.core.model.DotnetModel -import io.github.lmliam.microsmith.resolve.services.dotnet.asp.ResolvedDotnetAspRest import java.nio.file.Path data class DotnetAspServiceContribution( @@ -12,6 +10,6 @@ data class DotnetAspServiceContribution( val outputRoot: Path, val httpPort: Int, val httpsPort: Int, - val models: Map, - val rest: ResolvedDotnetAspRest, + val contractModels: List, + val endpoints: List, ) : ArtifactContribution diff --git a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspProjectRenderer.kt b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspProjectRenderer.kt new file mode 100644 index 00000000..a49fa9c9 --- /dev/null +++ b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspProjectRenderer.kt @@ -0,0 +1,647 @@ +package io.github.lmliam.microsmith.compile.services.dotnet.asp + +import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspEndpointArtifact +import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspHeadersBindingArtifact +import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspModelArtifact +import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspRequestBindingArtifact +import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspRequestFieldArtifact +import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspResponseArtifact +import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspServiceArtifact +import io.github.lmliam.microsmith.dsl.services.dotnet.core.model.DotnetFieldType +import java.util.Locale + +internal object DotnetAspProjectRenderer { + fun renderProgramFile(): String = """ + var builder = WebApplication.CreateBuilder(args); + + builder.Services.AddControllers(); + + var app = builder.Build(); + + app.Use(async (context, next) => + { + try + { + await next(); + } + catch (BadHttpRequestException exception) + { + await Results.Json( + new + { + detail = exception.Message, + }, + statusCode: StatusCodes.Status400BadRequest).ExecuteAsync(context); + } + }); + + app.MapControllers(); + + app.Run(); + + public partial class Program { } + """.trimIndent() + + fun renderControllerFile(artifact: DotnetAspServiceArtifact): String = buildString { + val projectNamespace = artifact.id.projectName + appendLine("namespace $projectNamespace.Controllers;") + appendLine() + appendLine("using $projectNamespace.Bindings;") + appendLine("using $projectNamespace.Generated;") + appendLine("using $projectNamespace.Models;") + appendLine("using System.Globalization;") + appendLine("using Microsoft.AspNetCore.Mvc;") + appendLine() + appendLine("[ApiController]") + appendLine("public sealed class ${artifact.serviceName}Controller : ControllerBase") + appendLine("{") + artifact.endpoints.forEach { endpoint -> + append(renderEndpoint(endpoint)) + appendLine() + } + append(renderRequestedStatusHelper()) + appendLine("}") + } + + fun renderModelFile(projectNamespace: String, model: DotnetAspModelArtifact): String = buildString { + appendLine("namespace $projectNamespace.Models;") + appendLine() + appendLine("public sealed class ${model.typeName}") + appendLine("{") + model.model.fields.forEach { field -> + appendLine(" public ${renderModelPropertyType(field.type)} ${field.name} { get; set; }${renderInitializer(field.type)}") + } + appendLine("}") + } + + fun renderRequestBindingFile(projectNamespace: String, binding: DotnetAspRequestBindingArtifact): String = buildString { + val referenceTargets = binding.fields.any { it.type is DotnetFieldType.Reference } + appendLine("namespace $projectNamespace.Bindings;") + appendLine() + if (referenceTargets) { + appendLine("using $projectNamespace.Models;") + appendLine() + } + appendLine("public sealed class ${binding.typeName}") + appendLine("{") + binding.fields.forEach { field -> + appendLine( + " public ${renderBindingPropertyType(field)} ${field.name} { get; set; }${renderBindingInitializer(field)}", + ) + } + appendLine("}") + } + + fun renderHeadersBindingFile(projectNamespace: String, binding: DotnetAspHeadersBindingArtifact): String = buildString { + appendLine("namespace $projectNamespace.Bindings;") + appendLine() + appendLine("public sealed class ${binding.typeName}") + appendLine("{") + binding.headers.forEach { header -> + appendLine(" public string? ${header.name} { get; set; } = null;") + } + appendLine("}") + } + + fun renderRequestParserFile(projectNamespace: String): String = """ + namespace $projectNamespace.Generated; + + using System; + using System.Globalization; + using System.Text.Json; + using Microsoft.AspNetCore.Http; + using Microsoft.AspNetCore.Routing; + using Microsoft.Extensions.Primitives; + + internal static class MicrosmithRequestParser + { + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + + internal static string? ReadRouteValue(RouteValueDictionary routeValues, string name) => + routeValues.TryGetValue(name, out var value) ? value?.ToString() : null; + + internal static string? ReadQueryValue(IQueryCollection query, string name) => + ReadStringValues(query[name]); + + internal static string? ReadHeaderValue(IHeaderDictionary headers, string name) => + ReadStringValues(headers[name]); + + internal static string RequireString(string? raw, string bindingName) => + !string.IsNullOrWhiteSpace(raw) ? raw : throw Missing(bindingName); + + internal static string? OptionalString(string? raw) => + string.IsNullOrWhiteSpace(raw) ? null : raw; + + internal static char RequireChar(string? raw, string bindingName) + { + var value = RequireString(raw, bindingName); + if (value.Length != 1) + { + throw Invalid(bindingName, value); + } + + return value[0]; + } + + internal static char? OptionalChar(string? raw, string bindingName) => + string.IsNullOrWhiteSpace(raw) ? null : RequireChar(raw, bindingName); + + internal static byte RequireByte(string? raw, string bindingName) => + ParseRequired(raw, bindingName, value => byte.Parse(value, NumberStyles.Integer, CultureInfo.InvariantCulture)); + + internal static byte? OptionalByte(string? raw, string bindingName) => + ParseOptionalStruct(raw, bindingName, value => byte.Parse(value, NumberStyles.Integer, CultureInfo.InvariantCulture)); + + internal static sbyte RequireSignedByte(string? raw, string bindingName) => + ParseRequired(raw, bindingName, value => sbyte.Parse(value, NumberStyles.Integer, CultureInfo.InvariantCulture)); + + internal static sbyte? OptionalSignedByte(string? raw, string bindingName) => + ParseOptionalStruct(raw, bindingName, value => sbyte.Parse(value, NumberStyles.Integer, CultureInfo.InvariantCulture)); + + internal static short RequireShort(string? raw, string bindingName) => + ParseRequired(raw, bindingName, value => short.Parse(value, NumberStyles.Integer, CultureInfo.InvariantCulture)); + + internal static short? OptionalShort(string? raw, string bindingName) => + ParseOptionalStruct(raw, bindingName, value => short.Parse(value, NumberStyles.Integer, CultureInfo.InvariantCulture)); + + internal static ushort RequireUnsignedShort(string? raw, string bindingName) => + ParseRequired(raw, bindingName, value => ushort.Parse(value, NumberStyles.Integer, CultureInfo.InvariantCulture)); + + internal static ushort? OptionalUnsignedShort(string? raw, string bindingName) => + ParseOptionalStruct(raw, bindingName, value => ushort.Parse(value, NumberStyles.Integer, CultureInfo.InvariantCulture)); + + internal static int RequireInt(string? raw, string bindingName) => + ParseRequired(raw, bindingName, value => int.Parse(value, NumberStyles.Integer, CultureInfo.InvariantCulture)); + + internal static int? OptionalInt(string? raw, string bindingName) => + ParseOptionalStruct(raw, bindingName, value => int.Parse(value, NumberStyles.Integer, CultureInfo.InvariantCulture)); + + internal static uint RequireUnsignedInt(string? raw, string bindingName) => + ParseRequired(raw, bindingName, value => uint.Parse(value, NumberStyles.Integer, CultureInfo.InvariantCulture)); + + internal static uint? OptionalUnsignedInt(string? raw, string bindingName) => + ParseOptionalStruct(raw, bindingName, value => uint.Parse(value, NumberStyles.Integer, CultureInfo.InvariantCulture)); + + internal static long RequireLong(string? raw, string bindingName) => + ParseRequired(raw, bindingName, value => long.Parse(value, NumberStyles.Integer, CultureInfo.InvariantCulture)); + + internal static long? OptionalLong(string? raw, string bindingName) => + ParseOptionalStruct(raw, bindingName, value => long.Parse(value, NumberStyles.Integer, CultureInfo.InvariantCulture)); + + internal static ulong RequireUnsignedLong(string? raw, string bindingName) => + ParseRequired(raw, bindingName, value => ulong.Parse(value, NumberStyles.Integer, CultureInfo.InvariantCulture)); + + internal static ulong? OptionalUnsignedLong(string? raw, string bindingName) => + ParseOptionalStruct(raw, bindingName, value => ulong.Parse(value, NumberStyles.Integer, CultureInfo.InvariantCulture)); + + internal static nint RequireNativeInt(string? raw, string bindingName) => + ParseRequired(raw, bindingName, value => nint.Parse(value, CultureInfo.InvariantCulture)); + + internal static nint? OptionalNativeInt(string? raw, string bindingName) => + ParseOptionalStruct(raw, bindingName, value => nint.Parse(value, CultureInfo.InvariantCulture)); + + internal static nuint RequireUnsignedNativeInt(string? raw, string bindingName) => + ParseRequired(raw, bindingName, value => nuint.Parse(value, CultureInfo.InvariantCulture)); + + internal static nuint? OptionalUnsignedNativeInt(string? raw, string bindingName) => + ParseOptionalStruct(raw, bindingName, value => nuint.Parse(value, CultureInfo.InvariantCulture)); + + internal static float RequireFloat(string? raw, string bindingName) => + ParseRequired(raw, bindingName, value => float.Parse(value, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture)); + + internal static float? OptionalFloat(string? raw, string bindingName) => + ParseOptionalStruct(raw, bindingName, value => float.Parse(value, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture)); + + internal static double RequireDouble(string? raw, string bindingName) => + ParseRequired(raw, bindingName, value => double.Parse(value, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture)); + + internal static double? OptionalDouble(string? raw, string bindingName) => + ParseOptionalStruct(raw, bindingName, value => double.Parse(value, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture)); + + internal static decimal RequireDecimal(string? raw, string bindingName) => + ParseRequired(raw, bindingName, value => decimal.Parse(value, NumberStyles.Number, CultureInfo.InvariantCulture)); + + internal static decimal? OptionalDecimal(string? raw, string bindingName) => + ParseOptionalStruct(raw, bindingName, value => decimal.Parse(value, NumberStyles.Number, CultureInfo.InvariantCulture)); + + internal static bool RequireBool(string? raw, string bindingName) => + ParseRequired(raw, bindingName, value => bool.Parse(value)); + + internal static bool? OptionalBool(string? raw, string bindingName) => + ParseOptionalStruct(raw, bindingName, value => bool.Parse(value)); + + internal static Guid RequireGuid(string? raw, string bindingName) => + ParseRequired(raw, bindingName, value => Guid.Parse(value)); + + internal static Guid? OptionalGuid(string? raw, string bindingName) => + ParseOptionalStruct(raw, bindingName, value => Guid.Parse(value)); + + internal static DateOnly RequireDateOnly(string? raw, string bindingName) => + ParseRequired(raw, bindingName, value => DateOnly.Parse(value, CultureInfo.InvariantCulture)); + + internal static DateOnly? OptionalDateOnly(string? raw, string bindingName) => + ParseOptionalStruct(raw, bindingName, value => DateOnly.Parse(value, CultureInfo.InvariantCulture)); + + internal static TimeOnly RequireTimeOnly(string? raw, string bindingName) => + ParseRequired(raw, bindingName, value => TimeOnly.Parse(value, CultureInfo.InvariantCulture)); + + internal static TimeOnly? OptionalTimeOnly(string? raw, string bindingName) => + ParseOptionalStruct(raw, bindingName, value => TimeOnly.Parse(value, CultureInfo.InvariantCulture)); + + internal static DateTime RequireDateTime(string? raw, string bindingName) => + ParseRequired(raw, bindingName, value => DateTime.Parse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind)); + + internal static DateTime? OptionalDateTime(string? raw, string bindingName) => + ParseOptionalStruct(raw, bindingName, value => DateTime.Parse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind)); + + internal static DateTimeOffset RequireDateTimeOffset(string? raw, string bindingName) => + ParseRequired(raw, bindingName, value => DateTimeOffset.Parse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind)); + + internal static DateTimeOffset? OptionalDateTimeOffset(string? raw, string bindingName) => + ParseOptionalStruct(raw, bindingName, value => DateTimeOffset.Parse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind)); + + internal static TimeSpan RequireTimeSpan(string? raw, string bindingName) => + ParseRequired(raw, bindingName, value => TimeSpan.Parse(value, CultureInfo.InvariantCulture)); + + internal static TimeSpan? OptionalTimeSpan(string? raw, string bindingName) => + ParseOptionalStruct(raw, bindingName, value => TimeSpan.Parse(value, CultureInfo.InvariantCulture)); + + internal static T RequireJson(string? raw, string bindingName) where T : class + { + var value = JsonSerializer.Deserialize(RequireString(raw, bindingName), JsonOptions); + return value ?? throw Invalid(bindingName, raw ?? string.Empty); + } + + internal static T? OptionalJson(string? raw, string bindingName) where T : class => + string.IsNullOrWhiteSpace(raw) ? null : RequireJson(raw, bindingName); + + internal static Guid ParseGuidLiteral(string raw) => Guid.Parse(raw); + + internal static DateOnly ParseDateOnlyLiteral(string raw) => + DateOnly.Parse(raw, CultureInfo.InvariantCulture); + + internal static TimeOnly ParseTimeOnlyLiteral(string raw) => + TimeOnly.Parse(raw, CultureInfo.InvariantCulture); + + internal static DateTime ParseDateTimeLiteral(string raw) => + DateTime.Parse(raw, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind); + + internal static DateTimeOffset ParseDateTimeOffsetLiteral(string raw) => + DateTimeOffset.Parse(raw, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind); + + internal static TimeSpan ParseTimeSpanLiteral(string raw) => + TimeSpan.Parse(raw, CultureInfo.InvariantCulture); + + internal static T ParseJsonLiteral(string raw) where T : class => + JsonSerializer.Deserialize(raw, JsonOptions) ?? throw new InvalidOperationException($"Invalid JSON literal for {typeof(T).Name}."); + + private static T ParseRequired(string? raw, string bindingName, Func parser) + { + try + { + return parser(RequireString(raw, bindingName)); + } + catch (Exception) when (!string.IsNullOrWhiteSpace(raw)) + { + throw Invalid(bindingName, raw ?? string.Empty); + } + } + + private static T? ParseOptionalStruct(string? raw, string bindingName, Func parser) + where T : struct + { + if (string.IsNullOrWhiteSpace(raw)) + { + return null; + } + + return ParseRequired(raw, bindingName, parser); + } + + private static string? ReadStringValues(StringValues values) => + StringValues.IsNullOrEmpty(values) ? null : values[0]; + + private static BadHttpRequestException Missing(string bindingName) => + new($"Missing required value for '{bindingName}'."); + + private static BadHttpRequestException Invalid(string bindingName, string raw) => + new($"Invalid value '{raw}' for '{bindingName}'."); + } + """.trimIndent() + + private fun renderEndpoint(endpoint: DotnetAspEndpointArtifact): String = buildString { + appendLine(" [Http${endpoint.method.lowercase().replaceFirstChar(Char::uppercase)}(${escapeCsharpStringLiteral(endpoint.route)}, Name = ${escapeCsharpStringLiteral(endpoint.operationName)})]") + endpoint.responses.forEach { response -> + appendLine(" [ProducesResponseType(typeof(${response.model.typeName}), ${response.statusCode})]") + } + append(" public IActionResult ${endpoint.operationName}(") + val parameters = buildList { + endpoint.bindings.body?.let { bodyModel -> + add("[FromBody] ${bodyModel.typeName} body") + } + } + append(parameters.joinToString(", ")) + appendLine(")") + appendLine(" {") + endpoint.bindings.path?.let { binding -> + append(renderRequestBindingAssignment(binding, RequestSource.PATH)) + } + endpoint.bindings.query?.let { binding -> + append(renderRequestBindingAssignment(binding, RequestSource.QUERY)) + } + endpoint.bindings.headers?.let { binding -> + append(renderHeadersBindingAssignment(binding)) + } + endpoint.bindings.body?.let { + appendLine(" _ = body;") + } + if (endpoint.responses.size > 1) { + appendLine(" var requestedStatusCode = RequestedStatusCode();") + endpoint.responses.forEachIndexed { index, response -> + val condition = + if (index == 0) { + "requestedStatusCode == ${response.statusCode} || requestedStatusCode is null" + } else { + "requestedStatusCode == ${response.statusCode}" + } + appendLine(" if ($condition)") + appendLine(" {") + append(renderResponseBlock(response)) + appendLine(" }") + } + append(renderResponseBlock(primaryResponse(endpoint.responses))) + } else { + append(renderResponseBlock(endpoint.responses.single())) + } + appendLine(" }") + } + + private fun renderRequestBindingAssignment( + binding: DotnetAspRequestBindingArtifact, + requestSource: RequestSource, + ): String = buildString { + appendLine(" var ${bindingVariableName(binding.name)} = new ${binding.typeName}") + appendLine(" {") + binding.fields.forEachIndexed { index, field -> + val suffix = if (index == binding.fields.lastIndex) "" else "," + appendLine( + " ${field.name} = ${renderParsedFieldExpression(field, requestSource)}$suffix", + ) + } + appendLine(" };") + appendLine(" _ = ${bindingVariableName(binding.name)};") + } + + private fun renderHeadersBindingAssignment(binding: DotnetAspHeadersBindingArtifact): String = buildString { + appendLine(" var ${bindingVariableName(binding.name)} = new ${binding.typeName}") + appendLine(" {") + binding.headers.forEachIndexed { index, header -> + val suffix = if (index == binding.headers.lastIndex) "" else "," + appendLine( + " ${header.name} = MicrosmithRequestParser.OptionalString(" + + "MicrosmithRequestParser.ReadHeaderValue(Request.Headers, ${escapeCsharpStringLiteral(header.headerName)}))$suffix", + ) + } + appendLine(" };") + appendLine(" _ = ${bindingVariableName(binding.name)};") + } + + private fun renderResponseBlock(response: DotnetAspResponseArtifact): String = buildString { + response.headers.forEach { header -> + appendLine( + " Response.Headers[${escapeCsharpStringLiteral(header.name)}] = " + + "${escapeCsharpStringLiteral(sampleHeaderValue(header.name))};", + ) + } + if (response.statusCode == 204) { + appendLine(" return StatusCode(204);") + } else { + appendLine(" return StatusCode(${response.statusCode}, new ${response.model.typeName}());") + } + } + + private fun renderRequestedStatusHelper(): String = """ + private int? RequestedStatusCode() + { + if (!Request.Headers.TryGetValue("X-Microsmith-Response-Status", out var values)) + { + return null; + } + + var candidate = values.Count > 0 ? values[0] : null; + if (string.IsNullOrWhiteSpace(candidate)) + { + return null; + } + + return int.TryParse(candidate, NumberStyles.Integer, CultureInfo.InvariantCulture, out var statusCode) + ? statusCode + : null; + } + """.trimIndent().prependIndent(" ") + "\n" + + private fun renderParsedFieldExpression( + field: DotnetAspRequestFieldArtifact, + requestSource: RequestSource, + ): String { + val rawExpression = when (requestSource) { + RequestSource.PATH -> + "MicrosmithRequestParser.ReadRouteValue(RouteData.Values, ${escapeCsharpStringLiteral(field.name)})" + + RequestSource.QUERY -> + "MicrosmithRequestParser.ReadQueryValue(Request.Query, ${escapeCsharpStringLiteral(field.name)})" + } + val bindingName = "${requestSource.label}.${field.name}" + val parsedExpression = when (val type = field.type) { + DotnetFieldType.String -> + if (field.optional || field.defaultValue != null) { + "MicrosmithRequestParser.OptionalString($rawExpression)" + } else { + "MicrosmithRequestParser.RequireString($rawExpression, ${escapeCsharpStringLiteral(bindingName)})" + } + + DotnetFieldType.Char -> renderScalarParse("Char", field, rawExpression, bindingName) + DotnetFieldType.Byte -> renderScalarParse("Byte", field, rawExpression, bindingName) + DotnetFieldType.SignedByte -> renderScalarParse("SignedByte", field, rawExpression, bindingName) + DotnetFieldType.Short -> renderScalarParse("Short", field, rawExpression, bindingName) + DotnetFieldType.UnsignedShort -> renderScalarParse("UnsignedShort", field, rawExpression, bindingName) + DotnetFieldType.Int -> renderScalarParse("Int", field, rawExpression, bindingName) + DotnetFieldType.UnsignedInt -> renderScalarParse("UnsignedInt", field, rawExpression, bindingName) + DotnetFieldType.Long -> renderScalarParse("Long", field, rawExpression, bindingName) + DotnetFieldType.UnsignedLong -> renderScalarParse("UnsignedLong", field, rawExpression, bindingName) + DotnetFieldType.NativeInt -> renderScalarParse("NativeInt", field, rawExpression, bindingName) + DotnetFieldType.UnsignedNativeInt -> renderScalarParse("UnsignedNativeInt", field, rawExpression, bindingName) + DotnetFieldType.Float -> renderScalarParse("Float", field, rawExpression, bindingName) + DotnetFieldType.Double -> renderScalarParse("Double", field, rawExpression, bindingName) + DotnetFieldType.Decimal -> renderScalarParse("Decimal", field, rawExpression, bindingName) + DotnetFieldType.Bool -> renderScalarParse("Bool", field, rawExpression, bindingName) + DotnetFieldType.Guid -> renderScalarParse("Guid", field, rawExpression, bindingName) + DotnetFieldType.DateOnly -> renderScalarParse("DateOnly", field, rawExpression, bindingName) + DotnetFieldType.TimeOnly -> renderScalarParse("TimeOnly", field, rawExpression, bindingName) + DotnetFieldType.DateTime -> renderScalarParse("DateTime", field, rawExpression, bindingName) + DotnetFieldType.DateTimeOffset -> renderScalarParse("DateTimeOffset", field, rawExpression, bindingName) + DotnetFieldType.TimeSpan -> renderScalarParse("TimeSpan", field, rawExpression, bindingName) + is DotnetFieldType.Reference -> { + val typeName = type.target + if (field.optional || field.defaultValue != null) { + "MicrosmithRequestParser.OptionalJson<$typeName>($rawExpression, ${escapeCsharpStringLiteral(bindingName)})" + } else { + "MicrosmithRequestParser.RequireJson<$typeName>($rawExpression, ${escapeCsharpStringLiteral(bindingName)})" + } + } + } + val defaultValue = field.defaultValue + if (defaultValue == null) { + return parsedExpression + } + return "($parsedExpression ?? ${renderDefaultExpression(field.type, defaultValue)})" + } + + private fun renderScalarParse( + parserSuffix: String, + field: DotnetAspRequestFieldArtifact, + rawExpression: String, + bindingName: String, + ): String { + val mode = if (field.optional || field.defaultValue != null) "Optional" else "Require" + return "MicrosmithRequestParser.$mode$parserSuffix($rawExpression, ${escapeCsharpStringLiteral(bindingName)})" + } + + private fun renderDefaultExpression(type: DotnetFieldType, defaultValue: Any): String = when (type) { + DotnetFieldType.String -> escapeCsharpStringLiteral(defaultValue.toString()) + DotnetFieldType.Char -> escapeCsharpCharLiteral(defaultValue.toString().first()) + DotnetFieldType.Byte, + DotnetFieldType.SignedByte, + DotnetFieldType.Short, + DotnetFieldType.UnsignedShort, + DotnetFieldType.Int, + DotnetFieldType.NativeInt, + -> defaultValue.toString() + + DotnetFieldType.UnsignedInt -> "${defaultValue}U" + DotnetFieldType.Long -> "${defaultValue}L" + DotnetFieldType.UnsignedLong -> "${defaultValue}UL" + DotnetFieldType.UnsignedNativeInt -> "${defaultValue}U" + DotnetFieldType.Float -> "${defaultValue.toString().ensureDecimal()}F" + DotnetFieldType.Double -> "${defaultValue.toString().ensureDecimal()}D" + DotnetFieldType.Decimal -> "${defaultValue.toString().ensureDecimal()}M" + DotnetFieldType.Bool -> defaultValue.toString().lowercase(Locale.ROOT) + DotnetFieldType.Guid -> + "MicrosmithRequestParser.ParseGuidLiteral(${escapeCsharpStringLiteral(defaultValue.toString())})" + + DotnetFieldType.DateOnly -> + "MicrosmithRequestParser.ParseDateOnlyLiteral(${escapeCsharpStringLiteral(defaultValue.toString())})" + + DotnetFieldType.TimeOnly -> + "MicrosmithRequestParser.ParseTimeOnlyLiteral(${escapeCsharpStringLiteral(defaultValue.toString())})" + + DotnetFieldType.DateTime -> + "MicrosmithRequestParser.ParseDateTimeLiteral(${escapeCsharpStringLiteral(defaultValue.toString())})" + + DotnetFieldType.DateTimeOffset -> + "MicrosmithRequestParser.ParseDateTimeOffsetLiteral(${escapeCsharpStringLiteral(defaultValue.toString())})" + + DotnetFieldType.TimeSpan -> + "MicrosmithRequestParser.ParseTimeSpanLiteral(${escapeCsharpStringLiteral(defaultValue.toString())})" + + is DotnetFieldType.Reference -> + "MicrosmithRequestParser.ParseJsonLiteral<${type.target}>(${escapeCsharpStringLiteral(defaultValue.toString())})" + } + + private fun renderModelPropertyType(type: DotnetFieldType): String = when (type) { + is DotnetFieldType.Reference -> type.target + else -> type.csharpType + } + + private fun renderBindingPropertyType(field: DotnetAspRequestFieldArtifact): String { + val baseType = renderModelPropertyType(field.type) + return if (field.optional) "$baseType?" else baseType + } + + private fun renderInitializer(type: DotnetFieldType): String = when (type) { + DotnetFieldType.String -> " = string.Empty;" + DotnetFieldType.Char -> " = 'A';" + DotnetFieldType.Byte, + DotnetFieldType.SignedByte, + DotnetFieldType.Short, + DotnetFieldType.UnsignedShort, + DotnetFieldType.Int, + DotnetFieldType.UnsignedInt, + DotnetFieldType.Long, + DotnetFieldType.UnsignedLong, + DotnetFieldType.NativeInt, + DotnetFieldType.UnsignedNativeInt, + -> " = 0;" + + DotnetFieldType.Float -> " = 0F;" + DotnetFieldType.Double -> " = 0D;" + DotnetFieldType.Decimal -> " = 0M;" + DotnetFieldType.Bool -> " = false;" + DotnetFieldType.Guid -> " = Guid.Empty;" + DotnetFieldType.DateOnly -> " = DateOnly.MinValue;" + DotnetFieldType.TimeOnly -> " = TimeOnly.MinValue;" + DotnetFieldType.DateTime -> " = DateTime.UnixEpoch;" + DotnetFieldType.DateTimeOffset -> " = DateTimeOffset.UnixEpoch;" + DotnetFieldType.TimeSpan -> " = TimeSpan.Zero;" + is DotnetFieldType.Reference -> " = null!;" + } + + private fun renderBindingInitializer(field: DotnetAspRequestFieldArtifact): String { + val defaultValue = field.defaultValue + return when { + field.optional -> " = null;" + defaultValue != null -> " = ${renderDefaultExpression(field.type, defaultValue)};" + else -> renderInitializer(field.type) + } + } + + private fun primaryResponse(responses: List): DotnetAspResponseArtifact = + responses.firstOrNull { it.statusCode in 200..299 } ?: responses.first() + + private fun bindingVariableName(bindingName: String): String = + bindingName.replaceFirstChar { char -> char.lowercase(Locale.ROOT) } + + private fun escapeCsharpStringLiteral(value: String): String = buildString { + append('"') + value.forEach { char -> + when (char) { + '\\' -> append("\\\\") + '"' -> append("\\\"") + '\b' -> append("\\b") + '\u000C' -> append("\\f") + '\n' -> append("\\n") + '\r' -> append("\\r") + '\t' -> append("\\t") + else -> { + if (char.code < 0x20) { + append("\\u%04x".format(char.code)) + } else { + append(char) + } + } + } + } + append('"') + } + + private fun escapeCsharpCharLiteral(value: Char): String = when (value) { + '\\' -> "'\\\\'" + '\'' -> "'\\''" + '\n' -> "'\\n'" + '\r' -> "'\\r'" + '\t' -> "'\\t'" + '\b' -> "'\\b'" + '\u000C' -> "'\\f'" + else -> if (value.code < 0x20) "'\\u%04x'".format(value.code) else "'$value'" + } + + private fun sampleHeaderValue(headerName: String): String = + "sample-" + headerName.lowercase(Locale.ROOT).replace(Regex("[^a-z0-9]+"), "-").trim('-') + + private fun String.ensureDecimal(): String = if (contains('.') || contains('E', ignoreCase = true)) this else "$this.0" + + private enum class RequestSource(val label: String) { + PATH("path"), + QUERY("query"), + } +} diff --git a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspServiceArtifactCompiler.kt b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspServiceArtifactCompiler.kt index cef070ee..15c1a1c9 100644 --- a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspServiceArtifactCompiler.kt +++ b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspServiceArtifactCompiler.kt @@ -5,6 +5,10 @@ import io.github.lmliam.microsmith.artifact.core.Artifact import io.github.lmliam.microsmith.artifact.core.ArtifactContribution import io.github.lmliam.microsmith.artifact.files.TextFileArtifactContribution import io.github.lmliam.microsmith.artifact.files.TextFileArtifactId +import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspEndpointArtifact +import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspHeadersBindingArtifact +import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspModelArtifact +import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspRequestBindingArtifact import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspServiceArtifact import io.github.lmliam.microsmith.artifact.services.dotnet.msbuild.MsBuildNames import io.github.lmliam.microsmith.artifact.services.dotnet.msbuild.MsBuildProjectArtifactId @@ -12,47 +16,103 @@ import io.github.lmliam.microsmith.artifact.services.dotnet.msbuild.MsBuildProje import io.github.lmliam.microsmith.artifact.services.dotnet.msbuild.MsBuildProjectKind import io.github.lmliam.microsmith.compile.core.ArtifactCompiler import io.github.lmliam.microsmith.compile.services.core.ServicesArtifactCompiler -import java.nio.charset.StandardCharsets import java.nio.file.Path @ServiceProvider(ArtifactCompiler::class) class DotnetAspServiceArtifactCompiler : ServicesArtifactCompiler { - private val endpointRenderer = DotnetAspEndpointTextFileRenderer() + private companion object { + const val FIRST_NON_PRINTABLE_ASCII_CODE_POINT = 0x20 + } override val artifactType = DotnetAspServiceArtifact::class - override fun compile(artifact: DotnetAspServiceArtifact): List> = buildList { - add( - MsBuildProjectContribution( - artifactId = - MsBuildProjectArtifactId( - solutionName = artifact.id.solutionName, - projectName = artifact.id.projectName, - kind = MsBuildProjectKind.Project, + override fun compile(artifact: DotnetAspServiceArtifact): List> { + val serviceOrigin = setOf("services.${artifact.serviceName}") + val requestBindings = artifact.requestBindings() + val headerBindings = artifact.headerBindings() + val controllerOrigins = serviceOrigin + + artifact.endpoints.flatMapTo(linkedSetOf()) { endpoint -> + endpoint.origins + + endpoint.responses.flatMapTo(linkedSetOf()) { it.origins } + + listOfNotNull( + endpoint.bindings.path?.origins, + endpoint.bindings.query?.origins, + endpoint.bindings.headers?.origins, + endpoint.bindings.body?.origins, + ).flatten() + } + + return buildList { + add( + MsBuildProjectContribution( + artifactId = MsBuildProjectArtifactId( + solutionName = artifact.id.solutionName, + projectName = artifact.id.projectName, + kind = MsBuildProjectKind.Project, + ), + projectAttributes = mapOf(MsBuildNames.SDK_ATTRIBUTE to "Microsoft.NET.Sdk.Web"), + properties = mapOf( + MsBuildNames.IMPLICIT_USINGS_PROPERTY to "enable", + MsBuildNames.NULLABLE_PROPERTY to "enable", + MsBuildNames.TARGET_FRAMEWORK_PROPERTY to artifact.targetFrameworkMoniker, + ), + origins = serviceOrigin, ), - projectAttributes = mapOf(MsBuildNames.SDK_ATTRIBUTE to "Microsoft.NET.Sdk.Web"), - properties = - mapOf( - MsBuildNames.IMPLICIT_USINGS_PROPERTY to "enable", - MsBuildNames.NULLABLE_PROPERTY to "enable", - MsBuildNames.TARGET_FRAMEWORK_PROPERTY to artifact.targetFrameworkMoniker, + ) + add(textContribution(artifact, "Program.cs", DotnetAspProjectRenderer.renderProgramFile(), serviceOrigin)) + add(textContribution(artifact, "appsettings.json", renderAppSettingsFile(artifact), serviceOrigin)) + add( + textContribution( + artifact, + "Properties/launchSettings.json", + renderLaunchSettingsFile(artifact), + serviceOrigin, ), - ), - ) - add(textContribution(artifact, "appsettings.json", renderAppSettingsFile(artifact))) - add( - textContribution( - artifact, - "Properties/launchSettings.json", - renderLaunchSettingsFile(artifact), - ), - ) - endpointRenderer.render(artifact).forEach { generatedFile -> + ) add( textContribution( - artifact = artifact, - relativePath = generatedFile.relativePath, - contents = generatedFile.contents, + artifact, + "Controllers/${artifact.serviceName}Controller.cs", + DotnetAspProjectRenderer.renderControllerFile(artifact), + controllerOrigins, + ), + ) + artifact.contractModels.distinctBy(DotnetAspModelArtifact::typeName).forEach { model -> + add( + textContribution( + artifact, + "Models/${model.typeName}.cs", + DotnetAspProjectRenderer.renderModelFile(artifact.id.projectName, model), + model.origins, + ), + ) + } + requestBindings.forEach { binding -> + add( + textContribution( + artifact, + "Bindings/${binding.typeName}.cs", + DotnetAspProjectRenderer.renderRequestBindingFile(artifact.id.projectName, binding), + binding.origins, + ), + ) + } + headerBindings.forEach { binding -> + add( + textContribution( + artifact, + "Bindings/${binding.typeName}.cs", + DotnetAspProjectRenderer.renderHeadersBindingFile(artifact.id.projectName, binding), + binding.origins, + ), + ) + } + add( + textContribution( + artifact, + "Generated/MicrosmithRequestParser.cs", + DotnetAspProjectRenderer.renderRequestParserFile(artifact.id.projectName), + controllerOrigins, ), ) } @@ -62,44 +122,78 @@ class DotnetAspServiceArtifactCompiler : ServicesArtifactCompiler, ): TextFileArtifactContribution = TextFileArtifactContribution( - artifactId = - TextFileArtifactId( + artifactId = TextFileArtifactId( relativePath = Path.of(relativePath), outputRoot = artifact.outputRoot, ), contents = contents, + origins = origins, ) - private fun renderAppSettingsFile(artifact: DotnetAspServiceArtifact): String = renderTemplate( - name = "appsettings.json.template", - substitutions = mapOf( - "{{SERVICE_NAME}}" to dotnetAspEscapeStringContents(artifact.serviceName), - ), - ) + private fun renderAppSettingsFile(artifact: DotnetAspServiceArtifact): String = """ + { + "Microsmith": { + "ServiceName": "${escapeJsonString(artifact.serviceName)}" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" + } + """.trimIndent() - private fun renderLaunchSettingsFile(artifact: DotnetAspServiceArtifact): String = renderTemplate( - name = "launchSettings.json.template", - substitutions = mapOf( - "{{PROJECT_NAME}}" to artifact.id.projectName, - "{{HTTP_PORT}}" to artifact.httpPort.toString(), - "{{HTTPS_PORT}}" to artifact.httpsPort.toString(), - ), - ) + private fun renderLaunchSettingsFile(artifact: DotnetAspServiceArtifact): String = """ + { + "${'$'}schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "${artifact.id.projectName}": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:${artifact.httpPort};https://localhost:${artifact.httpsPort}", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } + } + """.trimIndent() - private fun renderTemplate(name: String, substitutions: Map): String = - substitutions.entries.fold(loadTemplate(name)) { rendered, (placeholder, replacement) -> - rendered.replace(placeholder, replacement) + private fun escapeJsonString(value: String): String { + val escaped = StringBuilder(value.length) + value.forEach { char -> + when (char) { + '\\' -> escaped.append("\\\\") + '"' -> escaped.append("\\\"") + '\b' -> escaped.append("\\b") + '\u000C' -> escaped.append("\\f") + '\n' -> escaped.append("\\n") + '\r' -> escaped.append("\\r") + '\t' -> escaped.append("\\t") + else -> { + if (char.code < FIRST_NON_PRINTABLE_ASCII_CODE_POINT) { + escaped.append("\\u%04x".format(char.code)) + } else { + escaped.append(char) + } + } + } } + return escaped.toString() + } - private fun loadTemplate(name: String): String = javaClass - .getResourceAsStream("$TEMPLATE_RESOURCE_ROOT/$name") - ?.readBytes() - ?.toString(StandardCharsets.UTF_8) - ?: error("Missing ASP.NET template resource '$name'.") + private fun DotnetAspServiceArtifact.requestBindings(): List = endpoints + .flatMap { endpoint -> + listOfNotNull(endpoint.bindings.path, endpoint.bindings.query) + }.distinctBy(DotnetAspRequestBindingArtifact::typeName) - private companion object { - const val TEMPLATE_RESOURCE_ROOT = - "/io/github/lmliam/microsmith/compile/services/dotnet/asp/templates" - } + private fun DotnetAspServiceArtifact.headerBindings(): List = endpoints + .map(DotnetAspEndpointArtifact::bindings) + .mapNotNull { it.headers } + .distinctBy(DotnetAspHeadersBindingArtifact::typeName) } diff --git a/modules/compile-services-dotnet-asp/src/test/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspServiceArtifactCompilerTests.kt b/modules/compile-services-dotnet-asp/src/test/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspServiceArtifactCompilerTests.kt index d52fa5dc..c24106b6 100644 --- a/modules/compile-services-dotnet-asp/src/test/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspServiceArtifactCompilerTests.kt +++ b/modules/compile-services-dotnet-asp/src/test/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspServiceArtifactCompilerTests.kt @@ -1,30 +1,27 @@ package io.github.lmliam.microsmith.compile.services.dotnet.asp import io.github.lmliam.microsmith.artifact.files.TextFileArtifactContribution +import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspEndpointArtifact +import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspEndpointBindingsArtifact +import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspHeaderFieldArtifact +import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspHeadersBindingArtifact +import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspModelArtifact +import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspModelLocality +import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspRequestBindingArtifact +import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspRequestFieldArtifact +import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspResponseArtifact +import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspResponseHeaderArtifact import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspServiceArtifact import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspServiceArtifactId import io.github.lmliam.microsmith.artifact.services.dotnet.msbuild.MsBuildNames import io.github.lmliam.microsmith.artifact.services.dotnet.msbuild.MsBuildProjectContribution import io.github.lmliam.microsmith.artifact.services.dotnet.msbuild.MsBuildProjectKind -import io.github.lmliam.microsmith.dsl.services.dotnet.asp.core.rest.endpoint.DotnetAspHttpMethod -import io.github.lmliam.microsmith.dsl.services.dotnet.asp.core.rest.request.DotnetAspDefaultValue import io.github.lmliam.microsmith.dsl.services.dotnet.core.model.DotnetField import io.github.lmliam.microsmith.dsl.services.dotnet.core.model.DotnetFieldType import io.github.lmliam.microsmith.dsl.services.dotnet.core.model.DotnetModel -import io.github.lmliam.microsmith.resolve.services.dotnet.asp.ResolvedDotnetAspEndpoint -import io.github.lmliam.microsmith.resolve.services.dotnet.asp.ResolvedDotnetAspEndpointBindings -import io.github.lmliam.microsmith.resolve.services.dotnet.asp.ResolvedDotnetAspHeaderField -import io.github.lmliam.microsmith.resolve.services.dotnet.asp.ResolvedDotnetAspHeadersBinding -import io.github.lmliam.microsmith.resolve.services.dotnet.asp.ResolvedDotnetAspModel -import io.github.lmliam.microsmith.resolve.services.dotnet.asp.ResolvedDotnetAspModelLocality -import io.github.lmliam.microsmith.resolve.services.dotnet.asp.ResolvedDotnetAspRequestBinding -import io.github.lmliam.microsmith.resolve.services.dotnet.asp.ResolvedDotnetAspRequestField -import io.github.lmliam.microsmith.resolve.services.dotnet.asp.ResolvedDotnetAspResponse -import io.github.lmliam.microsmith.resolve.services.dotnet.asp.ResolvedDotnetAspResponseHeader -import io.github.lmliam.microsmith.resolve.services.dotnet.asp.ResolvedDotnetAspRest -import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.StringSpec import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder +import io.kotest.matchers.collections.shouldContain import io.kotest.matchers.maps.shouldContainExactly import io.kotest.matchers.shouldBe import io.kotest.matchers.string.shouldContain @@ -33,8 +30,8 @@ import java.nio.file.Path class DotnetAspServiceArtifactCompilerTests : StringSpec({ - "compile emits the base ASP.NET scaffold artefacts" { - val artifact = emptyArtifact() + "compile emits the generated ASP.NET project layout" { + val artifact = sampleArtifact() val contributions = DotnetAspServiceArtifactCompiler().compile(artifact) val msbuild = contributions.filterIsInstance().single() @@ -48,491 +45,301 @@ class DotnetAspServiceArtifactCompilerTests : MsBuildNames.NULLABLE_PROPERTY to "enable", MsBuildNames.TARGET_FRAMEWORK_PROPERTY to "net8.0", ) + msbuild.origins shouldBe setOf("services.UserService") - textFiles.map { it.artifactId.relativePath.toString() } - .shouldContainExactlyInAnyOrder( - listOf( - "appsettings.json", - "Properties/launchSettings.json", - "Generated/Hosting/MicrosmithHostingExtensions.cs", - "Generated/Contracts/ServiceModels.cs", - "Generated/Contracts/RequestModels.cs", - "Generated/Contracts/ResponseModels.cs", - "Generated/Controllers/MicrosmithControllerBase.cs", - "Generated/Controllers/UserServiceApiControllerBase.cs", - ), - ) + textFiles.map { it.artifactId.relativePath.toString() } shouldContainExactlyInAnyOrder listOf( + "Program.cs", + "appsettings.json", + "Properties/launchSettings.json", + "Controllers/UserServiceController.cs", + "Models/User.cs", + "Models/Problem.cs", + "Models/CreateUserBody.cs", + "Bindings/GetUserPath.cs", + "Bindings/GetUserQuery.cs", + "Bindings/GetUserHeaders.cs", + "Generated/MicrosmithRequestParser.cs", + ) textFiles.forEach { it.artifactId.outputRoot shouldBe Path.of("dotnet", "Platform", "UserService.Api") } - textFiles - .single { - it.artifactId.relativePath.toString() == "Generated/Hosting/MicrosmithHostingExtensions.cs" - }.contents - .shouldContain("public static WebApplicationBuilder AddMicrosmith") - textFiles - .single { - it.artifactId.relativePath.toString() == "Generated/Hosting/MicrosmithHostingExtensions.cs" - }.contents - .shouldContain("public static WebApplication MapMicrosmith") - textFiles - .single { - it.artifactId.relativePath.toString() == "Generated/Contracts/RequestModels.cs" - }.contents - .shouldContain("namespace UserService.Api.Generated.Contracts;") - textFiles - .single { - it.artifactId.relativePath.toString() == "Generated/Controllers/UserServiceApiControllerBase.cs" - }.contents - .shouldContain("public abstract class UserServiceApiControllerBase : MicrosmithControllerBase\n{}") - textFiles - .single { it.artifactId.relativePath.toString() == "appsettings.json" } - .contents - .shouldContain( - "\"ServiceName\": \"UserService\"", - ) - textFiles - .single { - it.artifactId.relativePath.toString() == "Properties/launchSettings.json" - } - .contents + + textFiles.single { it.artifactId.relativePath.toString() == "Program.cs" }.contents + .shouldContain("AddControllers") + textFiles.single { it.artifactId.relativePath.toString() == "Program.cs" }.contents + .shouldContain("public partial class Program { }") + textFiles.single { it.artifactId.relativePath.toString() == "Program.cs" }.contents + .shouldContain("StatusCodes.Status400BadRequest") + + val controller = textFiles.single { it.artifactId.relativePath.toString() == "Controllers/UserServiceController.cs" } + controller.contents.shouldContain("""[HttpGet("/users/{id}", Name = "GetUser")]""") + controller.contents.shouldContain("""[HttpPost("/users", Name = "CreateUser")]""") + controller.contents.shouldContain("MicrosmithRequestParser.ReadRouteValue") + controller.contents.shouldContain("""Response.Headers["Location"] = "sample-location";""") + controller.origins shouldContain "services.UserService.rest.GetUser" + controller.origins shouldContain "services.UserService.rest.CreateUser.body.CreateUserBody" + controller.origins shouldContain "services.UserService.rest.GetUser.responses.200" + + textFiles.single { it.artifactId.relativePath.toString() == "Models/CreateUserBody.cs" }.contents + .shouldContain("public sealed class CreateUserBody") + textFiles.single { it.artifactId.relativePath.toString() == "Controllers/UserServiceController.cs" }.contents + .shouldContain("?? false") + textFiles.single { it.artifactId.relativePath.toString() == "Generated/MicrosmithRequestParser.cs" }.contents + .shouldContain("internal static bool? OptionalBool") + textFiles.single { it.artifactId.relativePath.toString() == "appsettings.json" }.contents + .shouldContain("\"ServiceName\": \"UserService\"") + textFiles.single { it.artifactId.relativePath.toString() == "Properties/launchSettings.json" }.contents .shouldContain("http://localhost:5000;https://localhost:5001") } "compile escapes service names before embedding them in appsettings json" { - val artifact = - emptyArtifact( - serviceName = "User\"Service\\Api", - ) - val appSettings = DotnetAspServiceArtifactCompiler() - .compile(artifact) + .compile(sampleArtifact(serviceName = "User\"Service\\Api")) .filterIsInstance() - .single { - it.artifactId.relativePath.toString() == "appsettings.json" - } + .single { it.artifactId.relativePath.toString() == "appsettings.json" } .contents appSettings.shouldContain("\"ServiceName\": \"User\\\"Service\\\\Api\"") appSettings.shouldNotContain("\"ServiceName\": \"User\"Service\\Api\"") } - "compile emits generated endpoint contracts and abstract controller glue" { - val userModel = - DotnetModel( - name = "User", - fields = listOf( - DotnetField("id", DotnetFieldType.String), - DotnetField("email", DotnetFieldType.String), - ), - ) - val problemModel = - DotnetModel( - name = "Problem", - fields = listOf(DotnetField("message", DotnetFieldType.String)), - ) - val artifact = - emptyArtifact( - models = mapOf( - "Problem" to problemModel, - "User" to userModel, - ), - rest = ResolvedDotnetAspRest( - listOf( - ResolvedDotnetAspEndpoint( - method = DotnetAspHttpMethod.GET, - route = "/users/{id}", - routePlaceholders = listOf("id"), - operationName = "GetUser", - bindings = ResolvedDotnetAspEndpointBindings( - path = ResolvedDotnetAspRequestBinding( - name = "GetUserPath", - fields = listOf( - ResolvedDotnetAspRequestField( - name = "id", - type = DotnetFieldType.String, - optional = false, - defaultValue = null, - ), - ), - ), - headers = ResolvedDotnetAspHeadersBinding( - name = "GetUserHeaders", - headers = listOf( - ResolvedDotnetAspHeaderField( - name = "ifNoneMatch", - headerName = "If-None-Match", - ), - ), - ), - ), - responses = listOf( - ResolvedDotnetAspResponse( - statusCode = 200, - model = ResolvedDotnetAspModel( - ResolvedDotnetAspModelLocality.SHARED, - userModel, - ), - headers = emptyList(), - ), - ResolvedDotnetAspResponse( - statusCode = 404, - model = ResolvedDotnetAspModel( - ResolvedDotnetAspModelLocality.SHARED, - problemModel, - ), - headers = listOf( - ResolvedDotnetAspResponseHeader("X-Trace-Id"), - ), - ), - ), - ), - ResolvedDotnetAspEndpoint( - method = DotnetAspHttpMethod.POST, - route = "/users", - routePlaceholders = emptyList(), - operationName = "CreateUser", - bindings = ResolvedDotnetAspEndpointBindings( - query = ResolvedDotnetAspRequestBinding( - name = "CreateUserQuery", - fields = listOf( - ResolvedDotnetAspRequestField( - name = "dryRun", - type = DotnetFieldType.Bool, - optional = true, - defaultValue = DotnetAspDefaultValue.BooleanValue(false), - ), - ), - ), - body = ResolvedDotnetAspModel( - locality = ResolvedDotnetAspModelLocality.INLINE, - model = DotnetModel( - name = "Body", - fields = listOf( - DotnetField("email", DotnetFieldType.String), - ), - ), - ), - ), - responses = listOf( - ResolvedDotnetAspResponse( - statusCode = 201, - model = ResolvedDotnetAspModel( - ResolvedDotnetAspModelLocality.SHARED, - userModel, - ), - headers = listOf( - ResolvedDotnetAspResponseHeader("Location"), - ), - ), - ResolvedDotnetAspResponse( - statusCode = 400, - model = ResolvedDotnetAspModel( - locality = ResolvedDotnetAspModelLocality.INLINE, - model = DotnetModel( - name = "Problem", - fields = listOf( - DotnetField("message", DotnetFieldType.String), - ), - ), - ), - headers = emptyList(), - ), - ), - ), - ), - ), - ) + "compile emits typed request bindings for supported scalar inputs" { + val artifact = typedBindingArtifact() - val textFiles = - DotnetAspServiceArtifactCompiler() - .compile(artifact) - .filterIsInstance() - .associateBy { it.artifactId.relativePath.toString() } + val textFiles = DotnetAspServiceArtifactCompiler() + .compile(artifact) + .filterIsInstance() - textFiles.keys shouldContainExactlyInAnyOrder listOf( - "appsettings.json", - "Properties/launchSettings.json", - "Generated/Hosting/MicrosmithHostingExtensions.cs", - "Generated/Contracts/ServiceModels.cs", - "Generated/Contracts/RequestModels.cs", - "Generated/Contracts/ResponseModels.cs", - "Generated/Controllers/MicrosmithControllerBase.cs", - "Generated/Controllers/UserServiceApiControllerBase.cs", - ) + val controller = textFiles.single { it.artifactId.relativePath.toString() == "Controllers/ReportServiceController.cs" }.contents + val parser = textFiles.single { it.artifactId.relativePath.toString() == "Generated/MicrosmithRequestParser.cs" }.contents + + controller.shouldContain("""MicrosmithRequestParser.RequireGuid(MicrosmithRequestParser.ReadRouteValue(RouteData.Values, "reportId"), "path.reportId")""") + controller.shouldContain("""MicrosmithRequestParser.RequireInt(MicrosmithRequestParser.ReadQueryValue(Request.Query, "days"), "query.days")""") + controller.shouldContain("""MicrosmithRequestParser.RequireDateOnly(MicrosmithRequestParser.ReadQueryValue(Request.Query, "since"), "query.since")""") + controller.shouldContain("""MicrosmithRequestParser.RequireDateTimeOffset(MicrosmithRequestParser.ReadQueryValue(Request.Query, "requestedAt"), "query.requestedAt")""") + controller.shouldContain("""(MicrosmithRequestParser.OptionalDecimal(MicrosmithRequestParser.ReadQueryValue(Request.Query, "threshold"), "query.threshold") ?? 1.5M)""") + controller.shouldContain("""MicrosmithRequestParser.OptionalTimeSpan(MicrosmithRequestParser.ReadQueryValue(Request.Query, "window"), "query.window")""") - textFiles - .getValue("Generated/Contracts/ServiceModels.cs") - .contents - .shouldContain("public sealed record User") - textFiles.getValue("Generated/Contracts/RequestModels.cs").contents - .shouldContain("public sealed record GetUserPath") - textFiles.getValue("Generated/Contracts/RequestModels.cs").contents - .shouldContain("public bool DryRun { get; set; } = false;") - textFiles.getValue("Generated/Contracts/RequestModels.cs").contents - .shouldContain("public sealed record CreateUserBody") - textFiles.getValue("Generated/Contracts/ResponseModels.cs").contents - .shouldContain("public abstract record GetUserResult;") - textFiles.getValue("Generated/Contracts/ResponseModels.cs").contents - .shouldContain( - "public sealed record CreateUserCreated(" + - "User Body, string? Location = null" + - ") : CreateUserResult;", - ) - textFiles.getValue("Generated/Contracts/ResponseModels.cs").contents - .shouldContain("public sealed record CreateUserBadRequestProblem") - textFiles.getValue("Generated/Controllers/MicrosmithControllerBase.cs").contents - .shouldContain("public abstract class MicrosmithControllerBase : ControllerBase") - textFiles.getValue("Generated/Controllers/MicrosmithControllerBase.cs").contents - .shouldContain("protected ObjectResult Respond(") - textFiles.getValue("Generated/Controllers/UserServiceApiControllerBase.cs").contents - .shouldContain("[HttpGet(\"/users/{id}\", Name = \"GetUser\")]") - textFiles.getValue("Generated/Controllers/UserServiceApiControllerBase.cs").contents - .shouldContain("var headers = new GetUserHeaders") - textFiles.getValue("Generated/Controllers/UserServiceApiControllerBase.cs").contents - .shouldContain("public abstract class UserServiceApiControllerBase : MicrosmithControllerBase") - textFiles.getValue("Generated/Controllers/UserServiceApiControllerBase.cs").contents - .shouldContain("protected abstract Task OnGetUserAsync(") - textFiles.getValue("Generated/Controllers/UserServiceApiControllerBase.cs").contents - .shouldContain("CreateUserBadRequest response => Respond(response.Body, 400)") - textFiles.getValue("Generated/Controllers/UserServiceApiControllerBase.cs").contents - .shouldContain("throw new InvalidOperationException(\$\"") - textFiles.getValue("Generated/Controllers/UserServiceApiControllerBase.cs").contents - .shouldContain("""Unsupported GetUser result type '{result.GetType().FullName}'.""") - textFiles.getValue("Generated/Hosting/MicrosmithHostingExtensions.cs").contents - .shouldContain("builder.Services.AddControllers();") - textFiles.getValue("Generated/Hosting/MicrosmithHostingExtensions.cs").contents - .shouldContain("app.MapControllers();") + parser.shouldContain("internal static Guid RequireGuid") + parser.shouldContain("internal static DateOnly RequireDateOnly") + parser.shouldContain("internal static DateTimeOffset RequireDateTimeOffset") + parser.shouldContain("internal static TimeSpan? OptionalTimeSpan") } + }) + +private fun sampleArtifact(serviceName: String = "UserService"): DotnetAspServiceArtifact { + val userModel = sharedModel("User", "services.UserService.models.User") { + stringField("id") + stringField("email") + } + val problemModel = sharedModel("Problem", "services.UserService.models.Problem") { + stringField("detail") + } + val createUserBody = inlineModel("CreateUserBody", "services.UserService.rest.CreateUser.body.CreateUserBody") { + stringField("email") + } - "compile emits assignable literals for ushort request defaults" { - val artifact = - emptyArtifact( - rest = ResolvedDotnetAspRest( - listOf( - ResolvedDotnetAspEndpoint( - method = DotnetAspHttpMethod.GET, - route = "/users", - routePlaceholders = emptyList(), - operationName = "ListUsers", - bindings = ResolvedDotnetAspEndpointBindings( - query = ResolvedDotnetAspRequestBinding( - name = "ListUsersQuery", - fields = listOf( - ResolvedDotnetAspRequestField( - name = "rank", - type = DotnetFieldType.UnsignedShort, - optional = true, - defaultValue = DotnetAspDefaultValue.NumericValue(1), - ), - ), - ), - ), - responses = listOf( - sharedResponse( - statusCode = 200, - modelName = "User", - ), - ), + return DotnetAspServiceArtifact( + id = DotnetAspServiceArtifactId(solutionName = "Platform", projectName = "UserService.Api"), + serviceName = serviceName, + targetFrameworkMoniker = "net8.0", + outputRoot = Path.of("dotnet", "Platform", "UserService.Api"), + httpPort = 5000, + httpsPort = 5001, + contractModels = listOf(userModel, problemModel, createUserBody), + endpoints = listOf( + DotnetAspEndpointArtifact( + method = "GET", + route = "/users/{id}", + operationName = "GetUser", + bindings = DotnetAspEndpointBindingsArtifact( + path = DotnetAspRequestBindingArtifact( + typeName = "GetUserPath", + name = "GetUserPath", + fields = listOf( + DotnetAspRequestFieldArtifact( + name = "id", + type = DotnetFieldType.String, + optional = false, + defaultValue = null, ), ), + origins = setOf("services.UserService.rest.GetUser.path.GetUserPath"), ), - models = singleStringModel("User"), - ) - - val requestModels = - DotnetAspServiceArtifactCompiler() - .compile(artifact) - .filterIsInstance() - .single { - it.artifactId.relativePath.toString() == "Generated/Contracts/RequestModels.cs" - } - .contents - - requestModels.shouldContain("public ushort Rank { get; set; } = 1;") - requestModels.shouldNotContain("public ushort Rank { get; set; } = 1u;") - } - - "compile rejects out-of-range integer request defaults instead of truncating them" { - val artifact = - emptyArtifact( - rest = ResolvedDotnetAspRest( - listOf( - ResolvedDotnetAspEndpoint( - method = DotnetAspHttpMethod.GET, - route = "/users", - routePlaceholders = emptyList(), - operationName = "ListUsers", - bindings = ResolvedDotnetAspEndpointBindings( - query = ResolvedDotnetAspRequestBinding( - name = "ListUsersQuery", - fields = listOf( - ResolvedDotnetAspRequestField( - name = "rank", - type = DotnetFieldType.Byte, - optional = true, - defaultValue = DotnetAspDefaultValue.NumericValue(256), - ), - ), - ), - ), - responses = listOf(sharedResponse(statusCode = 200, modelName = "User")), + query = DotnetAspRequestBindingArtifact( + typeName = "GetUserQuery", + name = "GetUserQuery", + fields = listOf( + DotnetAspRequestFieldArtifact( + name = "includeDetails", + type = DotnetFieldType.Bool, + optional = true, + defaultValue = false, ), ), + origins = setOf("services.UserService.rest.GetUser.query.GetUserQuery"), ), - models = singleStringModel("User"), - ) - - shouldThrow { - DotnetAspServiceArtifactCompiler().compile(artifact) - }.message.shouldContain("out of range for integer type 'byte'") - } - - "compile rejects fractional integer request defaults instead of coercing them" { - val artifact = - emptyArtifact( - rest = ResolvedDotnetAspRest( - listOf( - ResolvedDotnetAspEndpoint( - method = DotnetAspHttpMethod.GET, - route = "/users", - routePlaceholders = emptyList(), - operationName = "ListUsers", - bindings = ResolvedDotnetAspEndpointBindings( - query = ResolvedDotnetAspRequestBinding( - name = "ListUsersQuery", - fields = listOf( - ResolvedDotnetAspRequestField( - name = "page", - type = DotnetFieldType.Int, - optional = true, - defaultValue = DotnetAspDefaultValue.NumericValue(1.5), - ), - ), - ), - ), - responses = listOf(sharedResponse(statusCode = 200, modelName = "User")), + headers = DotnetAspHeadersBindingArtifact( + typeName = "GetUserHeaders", + name = "GetUserHeaders", + headers = listOf( + DotnetAspHeaderFieldArtifact( + name = "correlationId", + headerName = "X-Correlation-Id", ), ), + origins = setOf("services.UserService.rest.GetUser.headers.GetUserHeaders"), + ), + ), + responses = listOf( + DotnetAspResponseArtifact( + statusCode = 200, + model = userModel, + headers = listOf(DotnetAspResponseHeaderArtifact("ETag")), + origins = setOf("services.UserService.rest.GetUser.responses.200"), ), - models = singleStringModel("User"), - ) + DotnetAspResponseArtifact( + statusCode = 404, + model = problemModel, + headers = emptyList(), + origins = setOf("services.UserService.rest.GetUser.responses.404"), + ), + ), + origins = setOf("services.UserService.rest.GetUser"), + ), + DotnetAspEndpointArtifact( + method = "POST", + route = "/users", + operationName = "CreateUser", + bindings = DotnetAspEndpointBindingsArtifact(body = createUserBody), + responses = listOf( + DotnetAspResponseArtifact( + statusCode = 201, + model = userModel, + headers = listOf(DotnetAspResponseHeaderArtifact("Location")), + origins = setOf("services.UserService.rest.CreateUser.responses.201"), + ), + DotnetAspResponseArtifact( + statusCode = 400, + model = problemModel, + headers = emptyList(), + origins = setOf("services.UserService.rest.CreateUser.responses.400"), + ), + ), + origins = setOf("services.UserService.rest.CreateUser"), + ), + ), + ) +} - shouldThrow { - DotnetAspServiceArtifactCompiler().compile(artifact) - }.message.shouldContain("is not representable as integer type 'int'") - } +private fun typedBindingArtifact(): DotnetAspServiceArtifact { + val reportModel = sharedModel("Report", "services.ReportService.models.Report") { + stringField("id") + stringField("title") + } - "compile sanitizes response header names into valid csharp identifiers" { - val artifact = - emptyArtifact( - models = singleStringModel("User"), - rest = ResolvedDotnetAspRest( - listOf( - ResolvedDotnetAspEndpoint( - method = DotnetAspHttpMethod.GET, - route = "/users/{id}", - routePlaceholders = listOf("id"), - operationName = "GetUser", - bindings = ResolvedDotnetAspEndpointBindings( - path = ResolvedDotnetAspRequestBinding( - name = "GetUserPath", - fields = listOf( - ResolvedDotnetAspRequestField( - name = "id", - type = DotnetFieldType.String, - optional = false, - defaultValue = null, - ), - ), - ), - ), - responses = listOf( - sharedResponse( - statusCode = 200, - modelName = "User", - headers = listOf("X.Trace-Id"), - ), - ), + return DotnetAspServiceArtifact( + id = DotnetAspServiceArtifactId(solutionName = "Platform", projectName = "ReportService.Api"), + serviceName = "ReportService", + targetFrameworkMoniker = "net8.0", + outputRoot = Path.of("dotnet", "Platform", "ReportService.Api"), + httpPort = 5002, + httpsPort = 5003, + contractModels = listOf(reportModel), + endpoints = listOf( + DotnetAspEndpointArtifact( + method = "GET", + route = "/reports/{reportId}", + operationName = "GetReport", + bindings = DotnetAspEndpointBindingsArtifact( + path = DotnetAspRequestBindingArtifact( + typeName = "GetReportPath", + name = "GetReportPath", + fields = listOf( + DotnetAspRequestFieldArtifact( + name = "reportId", + type = DotnetFieldType.Guid, + optional = false, + defaultValue = null, ), ), + origins = setOf("services.ReportService.rest.GetReport.path.GetReportPath"), ), - ) - - val textFiles = - DotnetAspServiceArtifactCompiler() - .compile(artifact) - .filterIsInstance() - .associateBy { it.artifactId.relativePath.toString() } - - textFiles.getValue("Generated/Contracts/ResponseModels.cs").contents - .shouldContain("string? XTraceId = null") - textFiles.getValue("Generated/Controllers/UserServiceApiControllerBase.cs").contents - .shouldContain("response.XTraceId") - } - - "compile rejects response headers that collide after csharp identifier sanitization" { - val artifact = - emptyArtifact( - models = singleStringModel("User"), - rest = ResolvedDotnetAspRest( - listOf( - ResolvedDotnetAspEndpoint( - method = DotnetAspHttpMethod.GET, - route = "/users", - routePlaceholders = emptyList(), - operationName = "GetUser", - bindings = ResolvedDotnetAspEndpointBindings(), - responses = listOf( - sharedResponse( - statusCode = 200, - modelName = "User", - headers = listOf("X-Trace-Id", "X.Trace Id"), - ), - ), + query = DotnetAspRequestBindingArtifact( + typeName = "GetReportQuery", + name = "GetReportQuery", + fields = listOf( + DotnetAspRequestFieldArtifact( + name = "days", + type = DotnetFieldType.Int, + optional = false, + defaultValue = null, + ), + DotnetAspRequestFieldArtifact( + name = "since", + type = DotnetFieldType.DateOnly, + optional = false, + defaultValue = null, + ), + DotnetAspRequestFieldArtifact( + name = "requestedAt", + type = DotnetFieldType.DateTimeOffset, + optional = false, + defaultValue = null, + ), + DotnetAspRequestFieldArtifact( + name = "threshold", + type = DotnetFieldType.Decimal, + optional = true, + defaultValue = 1.5, + ), + DotnetAspRequestFieldArtifact( + name = "window", + type = DotnetFieldType.TimeSpan, + optional = true, + defaultValue = null, ), ), + origins = setOf("services.ReportService.rest.GetReport.query.GetReportQuery"), ), - ) - - shouldThrow { - DotnetAspServiceArtifactCompiler().compile(artifact) - }.message.shouldContain("colliding generated property names") - } - }) + ), + responses = listOf( + DotnetAspResponseArtifact( + statusCode = 200, + model = reportModel, + headers = emptyList(), + origins = setOf("services.ReportService.rest.GetReport.responses.200"), + ), + ), + origins = setOf("services.ReportService.rest.GetReport"), + ), + ), + ) +} -private fun emptyArtifact( - serviceName: String = "UserService", - models: Map = emptyMap(), - rest: ResolvedDotnetAspRest = ResolvedDotnetAspRest.empty(), -): DotnetAspServiceArtifact = DotnetAspServiceArtifact( - id = DotnetAspServiceArtifactId(solutionName = "Platform", projectName = "UserService.Api"), - serviceName = serviceName, - targetFrameworkMoniker = "net8.0", - outputRoot = Path.of("dotnet", "Platform", "UserService.Api"), - httpPort = 5000, - httpsPort = 5001, - models = models, - rest = rest, +private fun sharedModel( + name: String, + origin: String, + fields: MutableList.() -> Unit, +): DotnetAspModelArtifact = DotnetAspModelArtifact( + typeName = name, + locality = DotnetAspModelLocality.SHARED, + model = DotnetModel(name = name, fields = buildList(fields)), + origins = setOf(origin), ) -private fun singleStringModel(modelName: String): Map = mapOf( - modelName to DotnetModel( - name = modelName, - fields = listOf(DotnetField("id", DotnetFieldType.String)), - ), +private fun inlineModel( + name: String, + origin: String, + fields: MutableList.() -> Unit, +): DotnetAspModelArtifact = DotnetAspModelArtifact( + typeName = name, + locality = DotnetAspModelLocality.INLINE, + model = DotnetModel(name = name, fields = buildList(fields)), + origins = setOf(origin), ) -private fun sharedResponse( - statusCode: Int, - modelName: String, - headers: List = emptyList(), -): ResolvedDotnetAspResponse = ResolvedDotnetAspResponse( - statusCode = statusCode, - model = ResolvedDotnetAspModel( - locality = ResolvedDotnetAspModelLocality.SHARED, - model = requireNotNull(singleStringModel(modelName)[modelName]), - ), - headers = headers.map(::ResolvedDotnetAspResponseHeader), -) +private fun MutableList.stringField(name: String) { + add(DotnetField(name = name, type = DotnetFieldType.String)) +} diff --git a/modules/gen/src/test/kotlin/io/github/lmliam/microsmith/gen/helpers/DotnetAspGenerationIntegrationTests.kt b/modules/gen/src/test/kotlin/io/github/lmliam/microsmith/gen/helpers/DotnetAspGenerationIntegrationTests.kt index 149ff4d2..62c89901 100644 --- a/modules/gen/src/test/kotlin/io/github/lmliam/microsmith/gen/helpers/DotnetAspGenerationIntegrationTests.kt +++ b/modules/gen/src/test/kotlin/io/github/lmliam/microsmith/gen/helpers/DotnetAspGenerationIntegrationTests.kt @@ -7,209 +7,121 @@ import io.github.lmliam.microsmith.dsl.services.dotnet.core.service.asp import io.kotest.core.spec.style.StringSpec import io.kotest.matchers.shouldBe import io.kotest.matchers.string.shouldContain -import io.kotest.matchers.string.shouldNotContain import java.nio.file.Files import kotlin.io.path.readText import kotlin.io.path.writeText class DotnetAspGenerationIntegrationTests : StringSpec({ - "generateTo emits the ASP.NET scaffold and endpoint extension surface" { + "generateTo emits a runnable ASP.NET project layout with provenance" { val outputDir = Files.createTempDirectory("microsmith-dotnet-asp-output-") - val model = - microsmith { - services { - dotnet { - target(NET8) - solutions { - "Platform" {} - } - } - - "UserService" { - dotnet { - solution("Platform") - project("UserService.Api") - models { - "User" { - string("id") - string("email") - } + val model = sampleDotnetAspModel() - "Problem" { - string("message") - } - } - - asp { - rest { - "/users" { - get("/{id}", "GetUser") { - path("GetUserPath") { - string("id") - } + model.generateTo(outputDir) - responses { - ok("User") - notFound("Problem") { - headers { - header("X-Trace-Id") - } - } - } - } + val projectRoot = outputDir.resolve("dotnet/Platform/UserService.Api") + Files.exists(projectRoot.resolve("UserService.Api.csproj")) shouldBe true + Files.exists(projectRoot.resolve("Program.cs")) shouldBe true + Files.exists(projectRoot.resolve("Controllers/UserServiceController.cs")) shouldBe true + Files.exists(projectRoot.resolve("Models/User.cs")) shouldBe true + Files.exists(projectRoot.resolve("Models/CreateUserBody.cs")) shouldBe true + Files.exists(projectRoot.resolve("Bindings/GetUserPath.cs")) shouldBe true + Files.exists(projectRoot.resolve("Generated/MicrosmithRequestParser.cs")) shouldBe true + Files.exists(projectRoot.resolve(".microsmith/origins.json")) shouldBe true + + projectRoot.resolve("Program.cs").readText().shouldContain("Generated by Microsmith") + projectRoot.resolve("Program.cs").readText().shouldContain("MapControllers") + projectRoot.resolve("Controllers/UserServiceController.cs").readText() + .shouldContain("""[HttpGet("/users/{id}", Name = "GetUser")]""") + projectRoot.resolve("Controllers/UserServiceController.cs").readText() + .shouldContain("""[HttpPost("/users", Name = "CreateUser")]""") + projectRoot.resolve(".microsmith/origins.json").readText() + .shouldContain("services.UserService.rest.GetUser") + projectRoot.resolve(".microsmith/origins.json").readText() + .shouldContain("services.UserService.rest.CreateUser.body.CreateUserBody") + } - post("CreateUser") { - query("CreateUserQuery") { - bool("dryRun") { - optional() - default(false) - } - } + "generateTo overwrites generator-owned ASP.NET files on rerun" { + val outputDir = Files.createTempDirectory("microsmith-dotnet-asp-rerun-") + val model = sampleDotnetAspModel() - body("Body") { - string("email") - } + model.generateTo(outputDir) - responses { - created("User") { - headers { - header("Location") - } - } - badRequest("Problem") { - model { - string("message") - } - } - } - } - } - } - } - } - } - } - } + val controllerFile = outputDir.resolve("dotnet/Platform/UserService.Api/Controllers/UserServiceController.cs") + controllerFile.writeText("stale") model.generateTo(outputDir) - val projectRoot = outputDir.resolve("dotnet/Platform/UserService.Api") - Files.exists(projectRoot.resolve("UserService.Api.csproj")) shouldBe true - Files.exists(projectRoot.resolve("Program.cs")) shouldBe false - Files.exists(projectRoot.resolve("appsettings.json")) shouldBe true - Files.exists(projectRoot.resolve("Properties/launchSettings.json")) shouldBe true - Files.exists(projectRoot.resolve("Generated/Hosting/MicrosmithHostingExtensions.cs")) shouldBe true - Files.exists(projectRoot.resolve("Generated/Contracts/ServiceModels.cs")) shouldBe true - Files.exists(projectRoot.resolve("Generated/Contracts/RequestModels.cs")) shouldBe true - Files.exists(projectRoot.resolve("Generated/Contracts/ResponseModels.cs")) shouldBe true - Files.exists( - projectRoot.resolve("Generated/Controllers/UserServiceApiControllerBase.cs"), - ) shouldBe true - Files.exists( - projectRoot.resolve("Controllers/UserServiceApiController.cs"), - ) shouldBe false - projectRoot.resolve("Generated/Hosting/MicrosmithHostingExtensions.cs").readText() - .shouldContain("Generated by Microsmith") - projectRoot.resolve("Generated/Hosting/MicrosmithHostingExtensions.cs").readText() - .shouldContain("AddMicrosmith") - projectRoot.resolve("Generated/Hosting/MicrosmithHostingExtensions.cs").readText() - .shouldContain("MapMicrosmith") - projectRoot.resolve("Generated/Controllers/UserServiceApiControllerBase.cs").readText() - .shouldContain("protected abstract Task OnGetUserAsync(") - projectRoot.resolve("Generated/Contracts/RequestModels.cs").readText() - .shouldContain("public bool DryRun { get; set; } = false;") + controllerFile.readText().shouldContain("MicrosmithRequestParser") } + }) - "generateTo overwrites generated ASP.NET endpoint files on rerun" { - val outputDir = Files.createTempDirectory("microsmith-dotnet-asp-rerun-") - val initialModel = - microsmith { - services { - dotnet { - target(NET8) - solutions { - "Platform" {} - } +private fun sampleDotnetAspModel() = + microsmith { + services { + dotnet { + target(NET8) + solutions { + "Platform" {} + } + } + + "UserService" { + dotnet { + solution("Platform") + project("UserService.Api") + models { + "User" { + string("id") + string("email") } - - "UserService" { - dotnet { - solution("Platform") - project("UserService.Api") - models { - "User" { + "Problem" { + string("detail") + } + } + asp { + rest { + "/users" { + get("/{id}", "GetUser") { + path("GetUserPath") { string("id") } - } - - asp { - rest { - "/users" { - get("/{id}", "GetUser") { - path("GetUserPath") { - string("id") - } - - responses { - ok("User") - } + query("GetUserQuery") { + bool("includeDetails") { + optional() + default(false) + } + } + headers("GetUserHeaders") { + header("X-Correlation-Id") + } + responses { + ok("User") { + headers { + header("ETag") } } + notFound("Problem") } } - } - } - } - } - initialModel.generateTo(outputDir) - - val controllerBaseFile = - outputDir - .resolve("dotnet/Platform/UserService.Api") - .resolve("Generated/Controllers/UserServiceApiControllerBase.cs") - val requestModelsFile = - outputDir - .resolve("dotnet/Platform/UserService.Api") - .resolve("Generated/Contracts/RequestModels.cs") - controllerBaseFile.writeText("stale") - requestModelsFile.writeText("stale") - - val updatedModel = - microsmith { - services { - dotnet { - target(NET8) - solutions { - "Platform" {} - } - } - - "UserService" { - dotnet { - solution("Platform") - project("UserService.Api") - models { - "User" { - string("id") + post("CreateUser") { + body("CreateUserBody") { + string("email") + } + responses { + created("User") { + headers { + header("Location") + } + } + badRequest("Problem") } } - - asp {} } } } } - - updatedModel.generateTo(outputDir) - - controllerBaseFile.readText() - .shouldContain("public abstract class UserServiceApiControllerBase : MicrosmithControllerBase\n{}") - controllerBaseFile.readText().shouldNotContain("stale") - controllerBaseFile.readText().shouldNotContain("OnGetUserAsync") - requestModelsFile.readText().shouldContain("namespace UserService.Api.Generated.Contracts;") - requestModelsFile.readText().shouldNotContain("stale") - requestModelsFile.readText().shouldNotContain("GetUserPath") + } } - }) + } From c502bc9d120d2f53e5793550e5de3b9ee3fdbae2 Mon Sep 17 00:00:00 2001 From: LMLiam <46268350+TheRealEmissions@users.noreply.github.com> Date: Tue, 21 Apr 2026 00:26:19 +0100 Subject: [PATCH 03/26] feat(cli): bundle ASP.NET service workflows by default --- .../cli/init/BootstrapScriptTemplates.kt | 113 +++++++++++++++ .../cli/provider/CliProviderValidator.kt | 129 +++++++++++------- .../microsmith/cli/init/InitBootstrapTests.kt | 25 ++-- .../cli/provider/CliProviderValidatorTests.kt | 109 +++++++++++++-- ...icrosmithScriptCompilationConfiguration.kt | 46 ++----- .../host/MicrosmithScriptHostTests.kt | 76 +++++++++++ 6 files changed, 399 insertions(+), 99 deletions(-) diff --git a/modules/cli/src/main/kotlin/io/github/lmliam/microsmith/cli/init/BootstrapScriptTemplates.kt b/modules/cli/src/main/kotlin/io/github/lmliam/microsmith/cli/init/BootstrapScriptTemplates.kt index 20f617a0..ea892854 100644 --- a/modules/cli/src/main/kotlin/io/github/lmliam/microsmith/cli/init/BootstrapScriptTemplates.kt +++ b/modules/cli/src/main/kotlin/io/github/lmliam/microsmith/cli/init/BootstrapScriptTemplates.kt @@ -13,6 +13,10 @@ internal object BootstrapScriptTemplates { } private fun renderDefaultBuildScript(profile: OnboardingProfile): String = buildString { + if (profile == DotnetOnboardingProfile) { + append(renderDotnetBuildScript(profile)) + return@buildString + } appendLine("// Bootstrapped Microsmith schema for this ${profile.bootstrapTargetDescription}.") appendLine("// Canonical first run:") appendLine("// microsmith run build.microsmith.kts") @@ -96,4 +100,113 @@ internal object BootstrapScriptTemplates { } """.trimIndent() } + + private fun renderDotnetBuildScript(profile: OnboardingProfile): String = buildString { + appendLine("// Bootstrapped Microsmith ASP.NET service generation for this ${profile.bootstrapTargetDescription}.") + appendLine("// Canonical first run:") + appendLine("// microsmith run build.microsmith.kts") + profile.recommendedOutputDirectory?.let { outputDirectory -> + appendLine("// Common repository-native output path:") + appendLine("// microsmith run build.microsmith.kts --out $outputDirectory") + } + appendLine( + """ + microsmith { + services { + dotnet { + target(NET8) + solutions { + "Platform" { } + } + } + + "UserService" { + dotnet { + solution("Platform") + project("UserService.Api") + models { + "User" { + string("id") + string("email") + } + "Problem" { + string("detail") + } + "Report" { + string("id") + string("title") + } + } + asp { + rest { + "/users" { + get("/{id}", "GetUser") { + path("GetUserPath") { + string("id") + } + query("GetUserQuery") { + bool("includeDetails") { + optional() + default(false) + } + } + headers("GetUserHeaders") { + header("X-Correlation-Id") + } + responses { + ok("User") { + headers { + header("ETag") + } + } + notFound("Problem") + } + } + + post("CreateUser") { + body("CreateUserBody") { + string("email") + } + responses { + created("User") { + headers { + header("Location") + } + } + badRequest("Problem") + } + } + } + + "/reports" { + get("/{reportId}", "GetReport") { + path("GetReportPath") { + guid("reportId") + } + query("GetReportQuery") { + int("days") + dateOnly("since") + dateTimeOffset("requestedAt") + decimal("threshold") { + optional() + default(1.5) + } + timeSpan("window") { + optional() + } + } + responses { + ok("Report") + } + } + } + } + } + } + } + } + } + """.trimIndent(), + ) + } } diff --git a/modules/cli/src/main/kotlin/io/github/lmliam/microsmith/cli/provider/CliProviderValidator.kt b/modules/cli/src/main/kotlin/io/github/lmliam/microsmith/cli/provider/CliProviderValidator.kt index 099280f8..50c5f8aa 100644 --- a/modules/cli/src/main/kotlin/io/github/lmliam/microsmith/cli/provider/CliProviderValidator.kt +++ b/modules/cli/src/main/kotlin/io/github/lmliam/microsmith/cli/provider/CliProviderValidator.kt @@ -5,10 +5,17 @@ import io.github.lmliam.microsmith.artifact.core.ArtifactContributor import io.github.lmliam.microsmith.artifact.files.TextFileArtifact import io.github.lmliam.microsmith.artifact.schemas.protobuf.ProtoFileArtifact import io.github.lmliam.microsmith.artifact.schemas.protobuf.rpc.ProtobufRpcServiceArtifact +import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspServiceArtifact +import io.github.lmliam.microsmith.artifact.services.dotnet.msbuild.MsBuildProjectArtifact +import io.github.lmliam.microsmith.artifact.services.dotnet.packages.DotnetPackageReferencesArtifact +import io.github.lmliam.microsmith.artifact.services.dotnet.packages.DotnetPackageVersionsArtifact import io.github.lmliam.microsmith.compile.core.ArtifactCompiler +import io.github.lmliam.microsmith.dsl.services.core.ServicesExtension import io.github.lmliam.microsmith.dsl.schemas.core.SchemasExtension import io.github.lmliam.microsmith.gen.core.ArtifactRenderer import io.github.lmliam.microsmith.resolve.core.DomainResolver +import io.github.lmliam.microsmith.resolve.services.dotnet.asp.DotnetAspWorkspace +import io.github.lmliam.microsmith.resolve.services.dotnet.packages.DotnetPackageWorkspace import io.github.lmliam.microsmith.resolve.schemas.protobuf.ResolvedProtobufSchemaModel import io.github.lmliam.microsmith.resolve.schemas.protobuf.rpc.ResolvedProtobufRpcSchemaModel import java.util.ServiceLoader @@ -22,61 +29,87 @@ internal fun verifyBuiltinProviders( ): List { val errors = mutableListOf() - if ( - domainResolvers.none { - it.authoringType == SchemasExtension::class && - it.resolvedType == ResolvedProtobufSchemaModel::class - } - ) { - errors += "Missing built-in DomainResolver for SchemasExtension -> " + - "ResolvedProtobufSchemaModel. Check CLI runtime packaging." - } - - if ( - domainResolvers.none { - it.authoringType == SchemasExtension::class && - it.resolvedType == ResolvedProtobufRpcSchemaModel::class - } - ) { - errors += "Missing built-in DomainResolver for SchemasExtension -> " + - "ResolvedProtobufRpcSchemaModel. Check CLI runtime packaging." - } - - if (artifactContributors.none { it.resolvedType == ResolvedProtobufSchemaModel::class }) { - errors += "Missing built-in ArtifactContributor for ResolvedProtobufSchemaModel. " + - "Check CLI runtime packaging." - } + errors += requireDomainResolver(domainResolvers, SchemasExtension::class, ResolvedProtobufSchemaModel::class) + errors += requireDomainResolver(domainResolvers, SchemasExtension::class, ResolvedProtobufRpcSchemaModel::class) + errors += requireDomainResolver(domainResolvers, ServicesExtension::class, DotnetAspWorkspace::class) + errors += requireDomainResolver(domainResolvers, ServicesExtension::class, DotnetPackageWorkspace::class) + + errors += requireArtifactContributor(artifactContributors, ResolvedProtobufSchemaModel::class) + errors += requireArtifactContributor(artifactContributors, ResolvedProtobufRpcSchemaModel::class) + errors += requireArtifactContributor(artifactContributors, DotnetAspWorkspace::class) + errors += requireArtifactContributor(artifactContributors, DotnetPackageWorkspace::class) + + errors += requireArtifactAssembler(artifactAssemblers, ProtoFileArtifact::class) + errors += requireArtifactAssembler(artifactAssemblers, ProtobufRpcServiceArtifact::class) + errors += requireArtifactAssembler(artifactAssemblers, DotnetAspServiceArtifact::class) + errors += requireArtifactAssembler(artifactAssemblers, DotnetPackageVersionsArtifact::class) + errors += requireArtifactAssembler(artifactAssemblers, DotnetPackageReferencesArtifact::class) + errors += requireArtifactAssembler(artifactAssemblers, MsBuildProjectArtifact::class) + errors += requireArtifactAssembler(artifactAssemblers, TextFileArtifact::class) + + errors += requireArtifactCompiler(artifactCompilers, ProtoFileArtifact::class) + errors += requireArtifactCompiler(artifactCompilers, ProtobufRpcServiceArtifact::class) + errors += requireArtifactCompiler(artifactCompilers, DotnetAspServiceArtifact::class) + errors += requireArtifactCompiler(artifactCompilers, DotnetPackageVersionsArtifact::class) + errors += requireArtifactCompiler(artifactCompilers, DotnetPackageReferencesArtifact::class) + errors += requireArtifactCompiler(artifactCompilers, MsBuildProjectArtifact::class) + + errors += requireArtifactRenderer(artifactRenderers, TextFileArtifact::class) - if (artifactContributors.none { it.resolvedType == ResolvedProtobufRpcSchemaModel::class }) { - errors += "Missing built-in ArtifactContributor for ResolvedProtobufRpcSchemaModel. " + - "Check CLI runtime packaging." - } - - if (artifactAssemblers.none { it.artifactType == ProtoFileArtifact::class }) { - errors += "Missing built-in ArtifactAssembler for ProtoFileArtifact. Check CLI runtime packaging." - } - - if (artifactAssemblers.none { it.artifactType == ProtobufRpcServiceArtifact::class }) { - errors += "Missing built-in ArtifactAssembler for ProtobufRpcServiceArtifact. Check CLI runtime packaging." - } + return errors +} - if (artifactAssemblers.none { it.artifactType == TextFileArtifact::class }) { - errors += "Missing built-in ArtifactAssembler for TextFileArtifact. Check CLI runtime packaging." +private fun requireDomainResolver( + domainResolvers: List>, + authoringType: kotlin.reflect.KClass<*>, + resolvedType: kotlin.reflect.KClass<*>, +): List = if ( + domainResolvers.none { + it.authoringType == authoringType && it.resolvedType == resolvedType } +) { + listOf( + "Missing built-in DomainResolver for ${authoringType.simpleName} -> " + + "${resolvedType.simpleName}. Check CLI runtime packaging.", + ) +} else { + emptyList() +} - if (artifactCompilers.none { it.artifactType == ProtoFileArtifact::class }) { - errors += "Missing built-in ArtifactCompiler for ProtoFileArtifact. Check CLI runtime packaging." - } +private fun requireArtifactContributor( + artifactContributors: List>, + resolvedType: kotlin.reflect.KClass<*>, +): List = if (artifactContributors.none { it.resolvedType == resolvedType }) { + listOf("Missing built-in ArtifactContributor for ${resolvedType.simpleName}. Check CLI runtime packaging.") +} else { + emptyList() +} - if (artifactCompilers.none { it.artifactType == ProtobufRpcServiceArtifact::class }) { - errors += "Missing built-in ArtifactCompiler for ProtobufRpcServiceArtifact. Check CLI runtime packaging." - } +private fun requireArtifactAssembler( + artifactAssemblers: List>, + artifactType: kotlin.reflect.KClass<*>, +): List = if (artifactAssemblers.none { it.artifactType == artifactType }) { + listOf("Missing built-in ArtifactAssembler for ${artifactType.simpleName}. Check CLI runtime packaging.") +} else { + emptyList() +} - if (artifactRenderers.none { it.artifactType == TextFileArtifact::class }) { - errors += "Missing built-in ArtifactRenderer for TextFileArtifact. Check CLI runtime packaging." - } +private fun requireArtifactCompiler( + artifactCompilers: List>, + artifactType: kotlin.reflect.KClass<*>, +): List = if (artifactCompilers.none { it.artifactType == artifactType }) { + listOf("Missing built-in ArtifactCompiler for ${artifactType.simpleName}. Check CLI runtime packaging.") +} else { + emptyList() +} - return errors +private fun requireArtifactRenderer( + artifactRenderers: List>, + artifactType: kotlin.reflect.KClass<*>, +): List = if (artifactRenderers.none { it.artifactType == artifactType }) { + listOf("Missing built-in ArtifactRenderer for ${artifactType.simpleName}. Check CLI runtime packaging.") +} else { + emptyList() } private fun loadDomainResolvers() = ServiceLoader.load(DomainResolver::class.java) diff --git a/modules/cli/src/test/kotlin/io/github/lmliam/microsmith/cli/init/InitBootstrapTests.kt b/modules/cli/src/test/kotlin/io/github/lmliam/microsmith/cli/init/InitBootstrapTests.kt index 8c79a742..e9b73849 100644 --- a/modules/cli/src/test/kotlin/io/github/lmliam/microsmith/cli/init/InitBootstrapTests.kt +++ b/modules/cli/src/test/kotlin/io/github/lmliam/microsmith/cli/init/InitBootstrapTests.kt @@ -103,9 +103,17 @@ class InitBootstrapTests : } } - "creates repo-aware bootstrap files for .NET repositories with ASP.NET sample generation" { + "creates ASP.NET bootstrap files for .NET repositories" { val repoRoot = createTempDirectory("microsmith-init-bootstrap-dotnet") - repoRoot.resolve("MicrosmithFixture.csproj").writeText("\n") + repoRoot.resolve("MicrosmithFixture.csproj").writeText( + """ + + + net9.0 + + + """.trimIndent() + "\n", + ) try { val helperRoot = repoRoot.resolve(".microsmith/ide") val result = @@ -122,18 +130,19 @@ class InitBootstrapTests : ) result.repositoryDetection.profile shouldBe DotnetOnboardingProfile - result.repositoryDetection.matchedMarkers shouldBe listOf("MicrosmithFixture.csproj") + result.repositoryDetection.matchedMarkers shouldContainExactly listOf("MicrosmithFixture.csproj") val buildScript = repoRoot.resolve("build.microsmith.kts").readText() val settingsScript = repoRoot.resolve("settings.microsmith.kts").readText() - buildScript.shouldContain("DotnetUserCreated") + buildScript.shouldContain("// Bootstrapped Microsmith ASP.NET service generation for this .NET repository.") + buildScript.shouldContain("// microsmith run build.microsmith.kts --out ./Generated") + buildScript.shouldContain("services {") buildScript.shouldContain("target(NET8)") buildScript.shouldContain("project(\"UserService.Api\")") buildScript.shouldContain("asp {") - buildScript.shouldContain("get(\"/{id}\", \"GetUser\")") - buildScript.shouldContain("ok(\"User\")") - buildScript.shouldContain("Common repository-native output path:") - buildScript.shouldContain("./Generated") + buildScript.shouldContain("header(\"ETag\")") + buildScript.shouldContain("guid(\"reportId\")") + buildScript.shouldContain("dateTimeOffset(\"requestedAt\")") settingsScript.shouldContain("Detected repository profile: .NET") } finally { runCatching { repoRoot.deleteRecursively() } diff --git a/modules/cli/src/test/kotlin/io/github/lmliam/microsmith/cli/provider/CliProviderValidatorTests.kt b/modules/cli/src/test/kotlin/io/github/lmliam/microsmith/cli/provider/CliProviderValidatorTests.kt index c49f000f..f28741e9 100644 --- a/modules/cli/src/test/kotlin/io/github/lmliam/microsmith/cli/provider/CliProviderValidatorTests.kt +++ b/modules/cli/src/test/kotlin/io/github/lmliam/microsmith/cli/provider/CliProviderValidatorTests.kt @@ -9,17 +9,26 @@ import io.github.lmliam.microsmith.artifact.files.TextFileArtifactContribution import io.github.lmliam.microsmith.artifact.schemas.protobuf.ProtoFileArtifact import io.github.lmliam.microsmith.artifact.schemas.protobuf.ProtoFileContribution import io.github.lmliam.microsmith.artifact.schemas.protobuf.rpc.ProtobufRpcServiceArtifact +import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspServiceArtifact +import io.github.lmliam.microsmith.artifact.services.dotnet.msbuild.MsBuildProjectArtifact +import io.github.lmliam.microsmith.artifact.services.dotnet.packages.DotnetPackageReferencesArtifact +import io.github.lmliam.microsmith.artifact.services.dotnet.packages.DotnetPackageVersionsArtifact import io.github.lmliam.microsmith.compile.core.ArtifactCompiler +import io.github.lmliam.microsmith.dsl.services.core.ServicesExtension import io.github.lmliam.microsmith.dsl.schemas.core.SchemasExtension import io.github.lmliam.microsmith.gen.core.ArtifactRenderer import io.github.lmliam.microsmith.gen.files.GeneratedFile import io.github.lmliam.microsmith.resolve.core.DomainResolver +import io.github.lmliam.microsmith.resolve.core.ResolvedModel +import io.github.lmliam.microsmith.resolve.services.dotnet.asp.DotnetAspWorkspace +import io.github.lmliam.microsmith.resolve.services.dotnet.packages.DotnetPackageWorkspace import io.github.lmliam.microsmith.resolve.schemas.protobuf.ResolvedProtobufSchemaModel import io.github.lmliam.microsmith.resolve.schemas.protobuf.rpc.ResolvedProtobufRpcSchemaModel import io.kotest.core.spec.style.StringSpec import io.kotest.matchers.collections.shouldContain import io.kotest.matchers.collections.shouldHaveSize import io.kotest.matchers.shouldBe +import kotlin.reflect.KClass class CliProviderValidatorTests : StringSpec({ @@ -33,7 +42,7 @@ class CliProviderValidatorTests : artifactRenderers = emptyList(), ) - errors.shouldHaveSize(10) + errors.shouldHaveSize(22) errors.shouldContain( "Missing built-in DomainResolver for SchemasExtension -> " + "ResolvedProtobufSchemaModel. Check CLI runtime packaging.", @@ -42,6 +51,14 @@ class CliProviderValidatorTests : "Missing built-in DomainResolver for SchemasExtension -> " + "ResolvedProtobufRpcSchemaModel. Check CLI runtime packaging.", ) + errors.shouldContain( + "Missing built-in DomainResolver for ServicesExtension -> " + + "DotnetAspWorkspace. Check CLI runtime packaging.", + ) + errors.shouldContain( + "Missing built-in DomainResolver for ServicesExtension -> " + + "DotnetPackageWorkspace. Check CLI runtime packaging.", + ) errors.shouldContain( "Missing built-in ArtifactContributor for ResolvedProtobufSchemaModel. " + "Check CLI runtime packaging.", @@ -50,12 +67,30 @@ class CliProviderValidatorTests : "Missing built-in ArtifactContributor for ResolvedProtobufRpcSchemaModel. " + "Check CLI runtime packaging.", ) + errors.shouldContain( + "Missing built-in ArtifactContributor for DotnetAspWorkspace. Check CLI runtime packaging.", + ) + errors.shouldContain( + "Missing built-in ArtifactContributor for DotnetPackageWorkspace. Check CLI runtime packaging.", + ) errors.shouldContain( "Missing built-in ArtifactAssembler for ProtoFileArtifact. Check CLI runtime packaging.", ) errors.shouldContain( "Missing built-in ArtifactAssembler for ProtobufRpcServiceArtifact. Check CLI runtime packaging.", ) + errors.shouldContain( + "Missing built-in ArtifactAssembler for DotnetAspServiceArtifact. Check CLI runtime packaging.", + ) + errors.shouldContain( + "Missing built-in ArtifactAssembler for DotnetPackageVersionsArtifact. Check CLI runtime packaging.", + ) + errors.shouldContain( + "Missing built-in ArtifactAssembler for DotnetPackageReferencesArtifact. Check CLI runtime packaging.", + ) + errors.shouldContain( + "Missing built-in ArtifactAssembler for MsBuildProjectArtifact. Check CLI runtime packaging.", + ) errors.shouldContain( "Missing built-in ArtifactAssembler for TextFileArtifact. Check CLI runtime packaging.", ) @@ -65,6 +100,18 @@ class CliProviderValidatorTests : errors.shouldContain( "Missing built-in ArtifactCompiler for ProtobufRpcServiceArtifact. Check CLI runtime packaging.", ) + errors.shouldContain( + "Missing built-in ArtifactCompiler for DotnetAspServiceArtifact. Check CLI runtime packaging.", + ) + errors.shouldContain( + "Missing built-in ArtifactCompiler for DotnetPackageVersionsArtifact. Check CLI runtime packaging.", + ) + errors.shouldContain( + "Missing built-in ArtifactCompiler for DotnetPackageReferencesArtifact. Check CLI runtime packaging.", + ) + errors.shouldContain( + "Missing built-in ArtifactCompiler for MsBuildProjectArtifact. Check CLI runtime packaging.", + ) errors.shouldContain( "Missing built-in ArtifactRenderer for TextFileArtifact. Check CLI runtime packaging.", ) @@ -73,14 +120,35 @@ class CliProviderValidatorTests : "returns no errors when required providers are present" { val errors = verifyBuiltinProviders( - domainResolvers = listOf(ProtobufResolverStub(), ProtobufRpcResolverStub()), - artifactContributors = listOf(ProtobufContributorStub(), ProtobufRpcContributorStub()), + domainResolvers = listOf( + ProtobufResolverStub(), + ProtobufRpcResolverStub(), + DotnetAspResolverStub(), + DotnetPackageResolverStub(), + ), + artifactContributors = listOf( + ProtobufContributorStub(), + ProtobufRpcContributorStub(), + ContributorStub(DotnetAspWorkspace::class), + ContributorStub(DotnetPackageWorkspace::class), + ), artifactAssemblers = listOf( ProtoFileAssemblerStub(), ProtobufRpcAssemblerStub(), + AssemblerStub(DotnetAspServiceArtifact::class), + AssemblerStub(DotnetPackageVersionsArtifact::class), + AssemblerStub(DotnetPackageReferencesArtifact::class), + AssemblerStub(MsBuildProjectArtifact::class), TextFileAssemblerStub(), ), - artifactCompilers = listOf(ProtoFileCompilerStub(), ProtobufRpcCompilerStub()), + artifactCompilers = listOf( + CompilerStub(ProtoFileArtifact::class), + CompilerStub(ProtobufRpcServiceArtifact::class), + CompilerStub(DotnetAspServiceArtifact::class), + CompilerStub(DotnetPackageVersionsArtifact::class), + CompilerStub(DotnetPackageReferencesArtifact::class), + CompilerStub(MsBuildProjectArtifact::class), + ), artifactRenderers = listOf(TextFileRendererStub()), ) @@ -116,6 +184,25 @@ private class ProtobufRpcContributorStub : ArtifactContributor> = emptyList() } +private class DotnetAspResolverStub : DomainResolver { + override val authoringType = ServicesExtension::class + override val resolvedType = DotnetAspWorkspace::class + + override fun resolve(authoring: ServicesExtension): DotnetAspWorkspace = DotnetAspWorkspace(emptyMap()) +} + +private class DotnetPackageResolverStub : DomainResolver { + override val authoringType = ServicesExtension::class + override val resolvedType = DotnetPackageWorkspace::class + + override fun resolve(authoring: ServicesExtension): DotnetPackageWorkspace = + DotnetPackageWorkspace(emptyMap(), emptyMap()) +} + +private class ContributorStub(override val resolvedType: KClass) : ArtifactContributor { + override fun contribute(model: T): List> = emptyList() +} + private class ProtoFileAssemblerStub : ArtifactAssembler { override val artifactType = ProtoFileArtifact::class @@ -148,6 +235,14 @@ private class ProtobufRpcAssemblerStub : ArtifactAssembler(override val artifactType: KClass) : ArtifactAssembler { + override fun create(first: ArtifactContribution): T { + error("Not used in provider validator tests.") + } + + override fun merge(current: T, contribution: ArtifactContribution): T = current +} + private class TextFileAssemblerStub : ArtifactAssembler { override val artifactType = TextFileArtifact::class @@ -171,10 +266,8 @@ private class ProtoFileCompilerStub : ArtifactCompiler { override fun compile(artifact: ProtoFileArtifact): List> = emptyList() } -private class ProtobufRpcCompilerStub : ArtifactCompiler { - override val artifactType = ProtobufRpcServiceArtifact::class - - override fun compile(artifact: ProtobufRpcServiceArtifact): List> = emptyList() +private class CompilerStub(override val artifactType: KClass) : ArtifactCompiler { + override fun compile(artifact: T): List> = emptyList() } private class TextFileRendererStub : ArtifactRenderer { diff --git a/modules/runtime-scripting/src/main/kotlin/io/github/lmliam/microsmith/runtime/scripting/definition/MicrosmithScriptCompilationConfiguration.kt b/modules/runtime-scripting/src/main/kotlin/io/github/lmliam/microsmith/runtime/scripting/definition/MicrosmithScriptCompilationConfiguration.kt index b765c0a8..25e9a7e8 100644 --- a/modules/runtime-scripting/src/main/kotlin/io/github/lmliam/microsmith/runtime/scripting/definition/MicrosmithScriptCompilationConfiguration.kt +++ b/modules/runtime-scripting/src/main/kotlin/io/github/lmliam/microsmith/runtime/scripting/definition/MicrosmithScriptCompilationConfiguration.kt @@ -1,25 +1,6 @@ package io.github.lmliam.microsmith.runtime.scripting.definition -import io.github.lmliam.microsmith.dsl.core.MicrosmithScope -import io.github.lmliam.microsmith.dsl.core.microsmith -import io.github.lmliam.microsmith.dsl.schemas.core.SchemasScope -import io.github.lmliam.microsmith.dsl.schemas.core.schemas -import io.github.lmliam.microsmith.dsl.schemas.protobuf.ProtobufScope -import io.github.lmliam.microsmith.dsl.schemas.protobuf.protobuf -import io.github.lmliam.microsmith.dsl.schemas.protobuf.rpc.ServiceScope -import io.github.lmliam.microsmith.dsl.schemas.protobuf.rpc.service -import io.github.lmliam.microsmith.dsl.services.core.ServicesScope -import io.github.lmliam.microsmith.dsl.services.core.services -import io.github.lmliam.microsmith.dsl.services.dotnet.core.DotnetTarget -import io.github.lmliam.microsmith.dsl.services.dotnet.core.dotnet -import io.github.lmliam.microsmith.dsl.services.dotnet.core.service.DotnetServiceScope -import io.github.lmliam.microsmith.dsl.services.dotnet.core.service.asp -import io.github.lmliam.microsmith.dsl.services.dotnet.core.service.packages -import io.github.lmliam.microsmith.dsl.services.dotnet.core.solution.DotnetSolutionScope -import io.github.lmliam.microsmith.dsl.services.dotnet.core.solution.packages import io.github.lmliam.microsmith.runtime.scripting.context.MicrosmithScriptContext -import kotlin.reflect.KClass -import kotlin.reflect.KFunction import kotlin.script.experimental.api.ScriptCompilationConfiguration import kotlin.script.experimental.api.defaultImports import kotlin.script.experimental.api.implicitReceivers @@ -30,15 +11,16 @@ import kotlin.script.experimental.jvm.util.classpathFromClassloader object MicrosmithScriptCompilationConfiguration : ScriptCompilationConfiguration( { defaultImports( - importFromPackageOf(MicrosmithScope::class, ::microsmith), - importFromPackageOf(SchemasScope::class, MicrosmithScope::schemas), - importFromPackageOf(ProtobufScope::class, SchemasScope::protobuf), - importFromPackageOf(ServiceScope::class, ProtobufScope::service), - importFromPackageOf(ServicesScope::class, MicrosmithScope::services), - importFromPackageOf(DotnetTarget::class, ServicesScope::dotnet), - importFromPackageOf(DotnetServiceScope::class, DotnetServiceScope::asp), - importFromPackageOf(DotnetServiceScope::class, DotnetServiceScope::packages), - importFromPackageOf(DotnetSolutionScope::class, DotnetSolutionScope::packages), + "io.github.lmliam.microsmith.dsl.core.microsmith", + "io.github.lmliam.microsmith.dsl.services.core.services", + "io.github.lmliam.microsmith.dsl.services.dotnet.core.dotnet", + "io.github.lmliam.microsmith.dsl.services.dotnet.core.service.asp", + "io.github.lmliam.microsmith.dsl.services.dotnet.core.service.aspNet", + "io.github.lmliam.microsmith.dsl.services.dotnet.core.service.packages", + "io.github.lmliam.microsmith.dsl.services.dotnet.core.solution.packages", + "io.github.lmliam.microsmith.dsl.schemas.core.schemas", + "io.github.lmliam.microsmith.dsl.schemas.protobuf.protobuf", + "io.github.lmliam.microsmith.dsl.schemas.protobuf.rpc.service", ) implicitReceivers(MicrosmithScriptContext::class) @@ -52,10 +34,4 @@ object MicrosmithScriptCompilationConfiguration : ScriptCompilationConfiguration ) } }, -) { - @Suppress("unused") - private fun readResolve(): Any = MicrosmithScriptCompilationConfiguration -} - -private fun importFromPackageOf(owner: KClass<*>, symbol: KFunction<*>): String = - "${owner.java.packageName}.${symbol.name}" +) diff --git a/modules/runtime-scripting/src/test/kotlin/io/github/lmliam/microsmith/runtime/scripting/host/MicrosmithScriptHostTests.kt b/modules/runtime-scripting/src/test/kotlin/io/github/lmliam/microsmith/runtime/scripting/host/MicrosmithScriptHostTests.kt index cd99fea3..b5522a6a 100644 --- a/modules/runtime-scripting/src/test/kotlin/io/github/lmliam/microsmith/runtime/scripting/host/MicrosmithScriptHostTests.kt +++ b/modules/runtime-scripting/src/test/kotlin/io/github/lmliam/microsmith/runtime/scripting/host/MicrosmithScriptHostTests.kt @@ -196,6 +196,82 @@ class MicrosmithScriptHostTests : } } + "runs service scripts and generates ASP.NET outputs" { + val tempDir = createTempDirectory("microsmith-script-host-dotnet-asp") + try { + val script = tempDir.resolve("service.microsmith.kts") + val output = tempDir.resolve("generated") + val cache = tempDir.resolve("cache") + + script.writeText( + """ + microsmith { + services { + dotnet { + target(NET8) + solutions { + "Platform" {} + } + } + + "UserService" { + dotnet { + solution("Platform") + project("UserService.Api") + models { + "User" { + string("id") + string("email") + } + "Problem" { + string("detail") + } + } + asp { + rest { + "/users" { + get("/{id}", "GetUser") { + path("GetUserPath") { + string("id") + } + responses { + ok("User") + notFound("Problem") + } + } + } + } + } + } + } + } + } + """.trimIndent(), + ) + + val host = MicrosmithScriptHost(cacheDirectory = cache) + val result = + host.run( + ScriptRunRequest( + script = script, + outputDir = output, + variables = emptyMap(), + flags = emptySet(), + ), + ) + + result.shouldBeTypeOf() + output.resolve("dotnet/Platform/UserService.Api/Program.cs").exists() shouldBe true + output.resolve("dotnet/Platform/UserService.Api/Controllers/UserServiceController.cs").exists() shouldBe true + output.resolve("dotnet/Platform/UserService.Api/.microsmith/origins.json").exists() shouldBe true + output.resolve("dotnet/Platform/UserService.Api/Controllers/UserServiceController.cs") + .readText() + .shouldContain("""[HttpGet("/users/{id}", Name = "GetUser")]""") + } finally { + runCatching { tempDir.deleteRecursively() } + } + } + "returns deterministic diagnostics for script errors" { val tempDir = createTempDirectory("microsmith-script-host-error") try { From 41029ad88b995973338d8acef28f722d6ca00ddc Mon Sep 17 00:00:00 2001 From: LMLiam <46268350+TheRealEmissions@users.noreply.github.com> Date: Tue, 21 Apr 2026 00:26:51 +0100 Subject: [PATCH 04/26] test(services-dotnet-asp): add native fixture and CI coverage --- .github/workflows/build_test_qodana.yml | 160 +++++++-- README.md | 102 ++---- .../dotnet/.github/workflows/microsmith.yml | 28 ++ examples/gradle/dotnet/build.gradle.kts | 12 + examples/gradle/dotnet/build.microsmith.kts | 95 ++++++ examples/gradle/dotnet/settings.gradle.kts | 43 +++ .../dotnet/.github/workflows/microsmith.yml | 37 ++ examples/maven/dotnet/build.microsmith.kts | 95 ++++++ examples/maven/dotnet/pom.xml | 57 ++++ .../dotnet/.github/workflows/microsmith.yml | 29 +- .../non-gradle/dotnet/schema.microsmith.kts | 95 +++++- .../dotnet/.github/workflows/microsmith.yml | 39 +++ examples/sbt/dotnet/build.microsmith.kts | 95 ++++++ examples/sbt/dotnet/build.sbt | 24 ++ examples/sbt/dotnet/project/build.properties | 1 + examples/sbt/dotnet/project/plugins.sbt | 17 + .../gen/helpers/DotnetAspRuntimeE2eTests.kt | 323 ++++++++++++++++++ .../MicrosmithGradlePluginFunctionalTests.kt | 58 ++++ .../maven/MicrosmithGenerateMojoTests.kt | 50 +++ .../sbt/MicrosmithSbtExecutionServiceTests.kt | 51 +++ 20 files changed, 1286 insertions(+), 125 deletions(-) create mode 100644 examples/gradle/dotnet/.github/workflows/microsmith.yml create mode 100644 examples/gradle/dotnet/build.gradle.kts create mode 100644 examples/gradle/dotnet/build.microsmith.kts create mode 100644 examples/gradle/dotnet/settings.gradle.kts create mode 100644 examples/maven/dotnet/.github/workflows/microsmith.yml create mode 100644 examples/maven/dotnet/build.microsmith.kts create mode 100644 examples/maven/dotnet/pom.xml create mode 100644 examples/sbt/dotnet/.github/workflows/microsmith.yml create mode 100644 examples/sbt/dotnet/build.microsmith.kts create mode 100644 examples/sbt/dotnet/build.sbt create mode 100644 examples/sbt/dotnet/project/build.properties create mode 100644 examples/sbt/dotnet/project/plugins.sbt create mode 100644 modules/gen/src/test/kotlin/io/github/lmliam/microsmith/gen/helpers/DotnetAspRuntimeE2eTests.kt diff --git a/.github/workflows/build_test_qodana.yml b/.github/workflows/build_test_qodana.yml index 01c0e908..35363af9 100644 --- a/.github/workflows/build_test_qodana.yml +++ b/.github/workflows/build_test_qodana.yml @@ -29,6 +29,10 @@ jobs: with: distribution: temurin java-version: 24 + - name: Setup .NET SDK + uses: actions/setup-dotnet@v5 + with: + dotnet-version: 8.0.x - name: Setup Gradle uses: gradle/actions/setup-gradle@v5 - name: Build @@ -41,7 +45,7 @@ jobs: run: | set -euo pipefail VERSION="$(./gradlew -q properties | sed -n 's/^version: //p' | head -n 1)" - validate_gradle_fixture() { + validate_gradle_proto_fixture() { local repo_root="$1" local generated_root="$repo_root/proto" @@ -50,32 +54,58 @@ jobs: find "$generated_root" -maxdepth 1 -type f -name '*.proto' | grep -q . } - validate_gradle_fixture examples/gradle/java - validate_gradle_fixture examples/gradle/kotlin - validate_gradle_fixture examples/gradle/scala + validate_gradle_dotnet_fixture() { + local repo_root="$1" + local project_root="$repo_root/Generated/dotnet/Platform/UserService.Api" + + ./gradlew -p "$repo_root" microsmithGenerate -PmicrosmithVersion="$VERSION" --stacktrace + test -f "$project_root/UserService.Api.csproj" + test -f "$project_root/Controllers/UserServiceController.cs" + test -f "$project_root/Models/Report.cs" + test -f "$project_root/.microsmith/origins.json" + dotnet build "$project_root/UserService.Api.csproj" + } + + validate_gradle_proto_fixture examples/gradle/java + validate_gradle_proto_fixture examples/gradle/kotlin + validate_gradle_proto_fixture examples/gradle/scala + validate_gradle_dotnet_fixture examples/gradle/dotnet - name: Validate native Maven fixtures run: | set -euo pipefail VERSION="$(./gradlew -q properties | sed -n 's/^version: //p' | head -n 1)" - validate_maven_fixture() { + validate_maven_proto_fixture() { local repo_root="$1" - local generated_root="$repo_root/target/generated/microsmith/proto" + local generated_root="$repo_root/proto" mvn -B -f "$repo_root/pom.xml" microsmith:generate -Dmicrosmith.version="$VERSION" test -d "$generated_root" find "$generated_root" -maxdepth 1 -type f -name '*.proto' | grep -q . } - validate_maven_fixture examples/maven/java - validate_maven_fixture examples/maven/kotlin - validate_maven_fixture examples/maven/scala + validate_maven_dotnet_fixture() { + local repo_root="$1" + local project_root="$repo_root/Generated/dotnet/Platform/UserService.Api" + + mvn -B -f "$repo_root/pom.xml" microsmith:generate -Dmicrosmith.version="$VERSION" + test -f "$project_root/UserService.Api.csproj" + test -f "$project_root/Controllers/UserServiceController.cs" + test -f "$project_root/Models/Report.cs" + test -f "$project_root/.microsmith/origins.json" + dotnet build "$project_root/UserService.Api.csproj" + } + + validate_maven_proto_fixture examples/maven/java + validate_maven_proto_fixture examples/maven/kotlin + validate_maven_proto_fixture examples/maven/scala + validate_maven_dotnet_fixture examples/maven/dotnet - name: Validate native sbt fixtures run: | set -euo pipefail VERSION="$(./gradlew -q properties | sed -n 's/^version: //p' | head -n 1)" - validate_sbt_fixture() { + validate_sbt_proto_fixture() { local repo_root="$1" local generated_root="$repo_root/proto" @@ -84,7 +114,20 @@ jobs: find "$generated_root" -maxdepth 1 -type f -name '*.proto' | grep -q . } - validate_sbt_fixture examples/sbt/scala + validate_sbt_dotnet_fixture() { + local repo_root="$1" + local project_root="$repo_root/Generated/dotnet/Platform/UserService.Api" + + (cd "$repo_root" && sbt -batch -Dsbt.supershell=false -Dmicrosmith.version="$VERSION" microsmithGenerate) + test -f "$project_root/UserService.Api.csproj" + test -f "$project_root/Controllers/UserServiceController.cs" + test -f "$project_root/Models/Report.cs" + test -f "$project_root/.microsmith/origins.json" + dotnet build "$project_root/UserService.Api.csproj" + } + + validate_sbt_proto_fixture examples/sbt/scala + validate_sbt_dotnet_fixture examples/sbt/dotnet - name: Validate IDE fallback release assets run: | ./gradlew :runtime-scripting:ideFallbackArtifacts :runtime-scripting:generateIdeFallbackChecksums --stacktrace @@ -132,6 +175,10 @@ jobs: with: distribution: temurin java-version: 24 + - name: Setup .NET SDK + uses: actions/setup-dotnet@v5 + with: + dotnet-version: 8.0.x - name: Setup Gradle uses: gradle/actions/setup-gradle@v5 - name: Build CLI release assets (Unix) @@ -169,6 +216,15 @@ jobs: test -f proto/CiSmoke.proto ./modules/cli/build/microsmith-cli-dist/bin/microsmith run schema.microsmith.kts --isolation process test -f proto/CiSmoke.proto + - name: Smoke test dist ASP.NET generation (Unix) + if: runner.os != 'Windows' + run: | + ./modules/cli/build/microsmith-cli-dist/bin/microsmith run examples/non-gradle/dotnet/schema.microsmith.kts --out aspnet-dist + test -f aspnet-dist/dotnet/Platform/UserService.Api/UserService.Api.csproj + test -f aspnet-dist/dotnet/Platform/UserService.Api/Controllers/UserServiceController.cs + test -f aspnet-dist/dotnet/Platform/UserService.Api/Models/Report.cs + test -f aspnet-dist/dotnet/Platform/UserService.Api/.microsmith/origins.json + dotnet build aspnet-dist/dotnet/Platform/UserService.Api/UserService.Api.csproj - name: Smoke test dist doctor diagnostics mode (Unix) if: runner.os != 'Windows' run: | @@ -219,6 +275,32 @@ jobs: Write-Error "Expected generated-process\\proto\\CiSmoke.proto to exist." exit 1 } + - name: Smoke test dist ASP.NET generation (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + & .\modules\cli\build\microsmith-cli-dist\bin\microsmith.bat run examples/non-gradle/dotnet\schema.microsmith.kts --out aspnet-dist + if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE + } + $projectRoot = ".\aspnet-dist\dotnet\Platform\UserService.Api" + if (-not (Test-Path "$projectRoot\UserService.Api.csproj")) { + Write-Error "Expected aspnet-dist\\dotnet\\Platform\\UserService.Api\\UserService.Api.csproj to exist." + exit 1 + } + if (-not (Test-Path "$projectRoot\Controllers\UserServiceController.cs")) { + Write-Error "Expected aspnet-dist\\dotnet\\Platform\\UserService.Api\\Controllers\\UserServiceController.cs to exist." + exit 1 + } + if (-not (Test-Path "$projectRoot\Models\Report.cs")) { + Write-Error "Expected aspnet-dist\\dotnet\\Platform\\UserService.Api\\Models\\Report.cs to exist." + exit 1 + } + if (-not (Test-Path "$projectRoot\.microsmith\origins.json")) { + Write-Error "Expected aspnet-dist\\dotnet\\Platform\\UserService.Api\\.microsmith\\origins.json to exist." + exit 1 + } + dotnet build "$projectRoot\UserService.Api.csproj" - name: Smoke test dist doctor diagnostics mode (Windows) if: runner.os == 'Windows' shell: pwsh @@ -255,7 +337,7 @@ jobs: test -f install-smoke-repo/build.microsmith.kts test -f install-smoke-repo/settings.microsmith.kts ./.microsmith-bin/microsmith run install-smoke-repo/build.microsmith.kts - test -f proto/UserCreated.proto + test -f proto/UserCreated.proto - name: Smoke test Java, Kotlin, Scala, Node, Go, Python, Ruby, and Rust onboarding fixtures (Linux) if: ${{ always() && matrix.os == 'ubuntu-latest' && steps.install-unix.outcome == 'success' }} run: | @@ -288,6 +370,26 @@ jobs: smoke_unix_fixture python examples/non-gradle/python PythonUserCreated.proto python-ide-doctor.json smoke_unix_fixture ruby examples/non-gradle/ruby RubyUserCreated.proto ruby-ide-doctor.json smoke_unix_fixture rust examples/non-gradle/rust RustUserCreated.proto rust-ide-doctor.json + - name: Smoke test .NET onboarding fixture (Unix) + if: ${{ always() && runner.os != 'Windows' && steps.install-unix.outcome == 'success' }} + run: | + set -euo pipefail + ./.microsmith-bin/microsmith init --repo-root examples/non-gradle/dotnet + ./.microsmith-bin/microsmith init --repo-root examples/non-gradle/dotnet + test -f examples/non-gradle/dotnet/build.microsmith.kts + test -f examples/non-gradle/dotnet/settings.microsmith.kts + test -f examples/non-gradle/dotnet/.microsmith/ide/build.gradle.kts + test -f examples/non-gradle/dotnet/.microsmith/ide/README.md + ./.microsmith-bin/microsmith ide refresh --repo-root examples/non-gradle/dotnet + ./.microsmith-bin/microsmith ide doctor --repo-root examples/non-gradle/dotnet --diagnostics json > dotnet-ide-doctor.json + grep -q "JetBrains IDE helper doctor checks passed." dotnet-ide-doctor.json + ./.microsmith-bin/microsmith run examples/non-gradle/dotnet/build.microsmith.kts --out examples/non-gradle/dotnet/Generated + project_root="examples/non-gradle/dotnet/Generated/dotnet/Platform/UserService.Api" + test -f "$project_root/UserService.Api.csproj" + test -f "$project_root/Controllers/UserServiceController.cs" + test -f "$project_root/Models/Report.cs" + test -f "$project_root/.microsmith/origins.json" + dotnet build "$project_root/UserService.Api.csproj" - name: Install and verify from installer (Windows) id: install-windows if: ${{ always() && runner.os == 'Windows' && steps.build-release-windows.outcome == 'success' }} @@ -387,34 +489,24 @@ jobs: if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - if (-not (Test-Path .\examples\non-gradle\dotnet\Generated\proto\DotnetUserCreated.proto)) { - Write-Error "Expected Generated\\proto\\DotnetUserCreated.proto to exist for the .NET fixture." - exit 1 - } - $generatedProjectRoot = '.\examples\non-gradle\dotnet\Generated\dotnet\Platform\UserService.Api' - if (-not (Test-Path "$generatedProjectRoot\UserService.Api.csproj")) { - Write-Error "Expected generated ASP.NET project file to exist for the .NET fixture." + $projectRoot = ".\examples\non-gradle\dotnet\Generated\dotnet\Platform\UserService.Api" + if (-not (Test-Path "$projectRoot\UserService.Api.csproj")) { + Write-Error "Expected Generated\\dotnet\\Platform\\UserService.Api\\UserService.Api.csproj to exist for the .NET fixture." exit 1 } - if (-not (Test-Path "$generatedProjectRoot\Generated\Hosting\MicrosmithHostingExtensions.cs")) { - Write-Error "Expected generated hosting extensions to exist for the .NET fixture." + if (-not (Test-Path "$projectRoot\Controllers\UserServiceController.cs")) { + Write-Error "Expected Generated\\dotnet\\Platform\\UserService.Api\\Controllers\\UserServiceController.cs to exist for the .NET fixture." exit 1 } - if (-not (Test-Path "$generatedProjectRoot\Generated\Controllers\UserServiceApiControllerBase.cs")) { - Write-Error "Expected generated controller base to exist for the .NET fixture." + if (-not (Test-Path "$projectRoot\Models\Report.cs")) { + Write-Error "Expected Generated\\dotnet\\Platform\\UserService.Api\\Models\\Report.cs to exist for the .NET fixture." exit 1 } - if (-not (Test-Path "$generatedProjectRoot\Generated\Contracts\RequestModels.cs")) { - Write-Error "Expected generated request contracts to exist for the .NET fixture." + if (-not (Test-Path "$projectRoot\.microsmith\origins.json")) { + Write-Error "Expected Generated\\dotnet\\Platform\\UserService.Api\\.microsmith\\origins.json to exist for the .NET fixture." exit 1 } - $hostingExtensions = Get-Content -Path "$generatedProjectRoot\Generated\Hosting\MicrosmithHostingExtensions.cs" -Raw - if ($hostingExtensions -notmatch 'AddMicrosmith' -or $hostingExtensions -notmatch 'MapMicrosmith') { - Write-Error "Expected generated hosting extensions to expose AddMicrosmith and MapMicrosmith." - exit 1 - } - $controllerBase = Get-Content -Path "$generatedProjectRoot\Generated\Controllers\UserServiceApiControllerBase.cs" -Raw - if ($controllerBase -notmatch 'OnGetUserAsync') { - Write-Error "Expected generated controller base to expose OnGetUserAsync." - exit 1 + dotnet build "$projectRoot\UserService.Api.csproj" + if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE } diff --git a/README.md b/README.md index b42bb153..cd4573a7 100644 --- a/README.md +++ b/README.md @@ -223,7 +223,7 @@ This keeps the layering explicit: - `dsl-schemas-protobuf-rpc`: protobuf service and RPC DSL extensions on top of `dsl-schemas-protobuf` - `dsl-services`: generic service registry and service-oriented DSL surface - `dsl-services-dotnet`: .NET service defaults, identity, and model DSL support -- `dsl-services-dotnet-asp`: ASP.NET scaffold opt-in DSL support on top of `dsl-services-dotnet` +- `dsl-services-dotnet-asp`: ASP.NET service-generation DSL support on top of `dsl-services-dotnet` - `dsl-services-dotnet-packages`: .NET package ownership and package reference DSL support - `resolve`: base resolution contracts and resolved-model orchestration - `resolve-schemas`: schema-domain resolved model support @@ -231,7 +231,7 @@ This keeps the layering explicit: - `resolve-schemas-protobuf-rpc`: protobuf RPC finalization and validation - `resolve-services`: service-domain resolved model support - `resolve-services-dotnet`: finalized .NET service workspace resolution -- `resolve-services-dotnet-asp`: finalized ASP.NET scaffold workspace resolution +- `resolve-services-dotnet-asp`: finalized ASP.NET service workspace resolution - `resolve-services-dotnet-packages`: finalized .NET package workspace resolution - `artifact`: shared artifact contracts, assembly, and contribution infrastructure - `artifact-schemas`: schema-domain artifact contracts @@ -239,7 +239,7 @@ This keeps the layering explicit: - `artifact-schemas-protobuf-rpc`: protobuf RPC artifact types and contributions - `artifact-services`: service-domain artifact contracts - `artifact-services-dotnet`: .NET/MSBuild artifact types and assembly -- `artifact-services-dotnet-asp`: ASP.NET scaffold artifact types and contributions +- `artifact-services-dotnet-asp`: ASP.NET service artifact types and contributions - `artifact-services-dotnet-packages`: .NET package artifact types and contributions - `compile`: artifact compiler contracts and recursive artifact compilation - `compile-schemas`: schema-domain compiler contracts and shared schema compilation entrypoints @@ -247,7 +247,7 @@ This keeps the layering explicit: - `compile-schemas-protobuf-rpc`: protobuf RPC artifact compilation into file artifacts - `compile-services`: service-domain compiler contracts and shared service compilation entrypoints - `compile-services-dotnet`: .NET/MSBuild artifact compilation into file artifacts -- `compile-services-dotnet-asp`: ASP.NET scaffold compilation into project and source file artifacts +- `compile-services-dotnet-asp`: ASP.NET project and source-file compilation from normalized REST models - `compile-services-dotnet-packages`: .NET package artifact compilation into file artifacts - `gen`: final file rendering, output routing, and generation orchestration - `runtime-scripting`: Kotlin scripting host for `.microsmith.kts` execution and built-in provider loading @@ -439,7 +439,7 @@ microsmith { } ``` -### ASP.NET service scaffolding DSL +### ASP.NET service generation DSL ```kotlin microsmith { @@ -528,78 +528,43 @@ REST DSL contract notes: - route groups can be nested, verb helpers are lower-case, and every endpoint requires an explicit operation name - request bindings are modeled explicitly through `path(...)`, `query(...)`, `headers(...)`, and `body(...)` - inline body and response models stay endpoint-local in the normalized model; shared service models must be declared under `models { ... }` -- route placeholders are validated against `path(...)` bindings, response/header declarations are normalized, and invalid REST declarations fail before Microsmith emits ASP.NET endpoint glue +- route placeholders are validated against `path(...)` bindings, response/header declarations are normalized, and invalid REST declarations fail during resolution before any ASP.NET source files are emitted -The ASP.NET scaffold currently emits this canonical layout under the run output root: +The generated ASP.NET project currently emits this canonical layout under the run output root: ```text dotnet/ Platform/ UserService.Api/ UserService.Api.csproj + Program.cs appsettings.json + Controllers/ + UserServiceController.cs + Models/ + ... + Bindings/ + ... + Generated/ + MicrosmithRequestParser.cs Properties/ launchSettings.json - Generated/ - Hosting/ - MicrosmithHostingExtensions.cs - Contracts/ - ServiceModels.cs - RequestModels.cs - ResponseModels.cs - Controllers/ - MicrosmithControllerBase.cs - UserServiceApiControllerBase.cs -``` - -Canonical scaffold policy: - -- Microsmith does not generate `Program.cs`; the ASP.NET entrypoint is user-owned -- `Generated/Hosting/MicrosmithHostingExtensions.cs` contains Microsmith-owned hosting extensions for `builder.AddMicrosmith()` and `app.MapMicrosmith()` -- `Generated/Contracts/*.cs` contains Microsmith-owned service-local records, request bindings, inline response models, and typed per-operation result contracts -- `Generated/Controllers/MicrosmithControllerBase.cs` contains Microsmith-owned shared controller helpers such as `Respond(...)` and `ReadHeader(...)` -- `Generated/Controllers/*ControllerBase.cs` contains Microsmith-owned route attributes, request binding glue, and response mapping that forwards to abstract handler methods such as `OnGetUserAsync(...)` -- everything under `Generated/` is owned by Microsmith and is overwritten in place on rerun -- user-authored ASP.NET implementation code should live outside `Generated/`, for example under `Controllers/`, by deriving from the generated base controller and implementing the abstract handlers - -Typical user-owned `Program.cs` shape: - -```csharp -var builder = WebApplication.CreateBuilder(args); -builder.AddMicrosmith(); - -var app = builder.Build(); -app.MapMicrosmith(); - -app.Run(); + .microsmith/ + origins.json ``` -Typical user-owned controller shape: - -```csharp -using System.Threading; -using System.Threading.Tasks; -using UserService.Api.Generated.Controllers; -using UserService.Api.Generated.Contracts; - -namespace UserService.Api.Controllers; +Canonical generation policy: -public sealed class UserServiceApiController : UserServiceApiControllerBase -{ - protected override Task OnGetUserAsync( - GetUserPath path, - CancellationToken cancellationToken) - { - throw new NotImplementedException(); - } -} -``` +- `Program.cs` uses top-level hosting with `WebApplication.CreateBuilder(args)`, `AddControllers()`, a bad-request guard for generated request parsing, `MapControllers()`, and `Run()` +- controller actions, request binding DTOs, response DTOs, and the request parser helper are generated from the normalized REST model +- generated files under the project root are generator-owned and are overwritten in place on rerun +- `.microsmith/origins.json` records the structural Microsmith origins associated with each generated file ### Script defaults Inside `.microsmith.kts` scripts: -- default imports include `microsmith {}`, `schemas {}`, and `protobuf {}` +- default imports include `microsmith {}`, `services {}`, `.NET`/ASP.NET entrypoints, package helpers, `schemas {}`, and `protobuf {}` - a script can return a `MicrosmithModel` - a script can also call `emit(model)` or `generate(model)` @@ -780,7 +745,7 @@ Coexistence with the CLI, helper, and fallback paths: - `microsmith init` still works in Gradle repositories and is the fastest way to scaffold `build.microsmith.kts` - once native Gradle integration is adopted, use `./gradlew microsmithGenerate` as the primary generation path - the JetBrains IDE helper and fallback jar remain useful when you intentionally keep Microsmith outside the Gradle build, or when you are validating CLI-managed flows -- representative native Gradle fixtures live in `examples/gradle/java`, `examples/gradle/kotlin`, and `examples/gradle/scala` +- representative native Gradle fixtures live in `examples/gradle/java`, `examples/gradle/kotlin`, `examples/gradle/scala`, and `examples/gradle/dotnet` ## Native Maven integration @@ -879,7 +844,7 @@ Coexistence with the CLI, helper, and fallback paths: - `microsmith init` still works in Maven repositories and is the fastest way to scaffold `build.microsmith.kts` - once native Maven integration is adopted, use `mvn microsmith:generate` as the primary generation path - the JetBrains IDE helper and fallback jar remain useful when you intentionally keep Microsmith outside Maven, or when a Maven-imported IDE project needs plugin-provided types that are not mirrored as project dependencies -- representative native Maven fixtures live in `examples/maven/java`, `examples/maven/kotlin`, and `examples/maven/scala` +- representative native Maven fixtures live in `examples/maven/java`, `examples/maven/kotlin`, `examples/maven/scala`, and `examples/maven/dotnet` ## Native sbt integration @@ -980,7 +945,7 @@ Coexistence with the CLI, helper, and fallback paths: - `microsmith init` still works in sbt repositories and is the fastest way to scaffold `build.microsmith.kts` - once native sbt integration is adopted, use `sbt microsmithGenerate` as the primary generation path - the JetBrains IDE helper and fallback jar remain useful when you intentionally keep Microsmith outside sbt, or when sbt-imported IDE indexing still needs explicit script-definition support -- the representative native sbt fixture lives in `examples/sbt/scala` +- representative native sbt fixtures live in `examples/sbt/scala` and `examples/sbt/dotnet` ### Direct script execution @@ -1610,10 +1575,13 @@ CLI-managed fixtures exercise `microsmith init` and direct `microsmith run` flow | Java (native Gradle) | `examples/gradle/java` | `./gradlew microsmithGenerate -PmicrosmithVersion=` | `examples/gradle/java/.github/workflows/microsmith.yml` | | Kotlin (native Gradle) | `examples/gradle/kotlin` | `./gradlew microsmithGenerate -PmicrosmithVersion=` | `examples/gradle/kotlin/.github/workflows/microsmith.yml` | | Scala (native Gradle) | `examples/gradle/scala` | `./gradlew microsmithGenerate -PmicrosmithVersion=` | `examples/gradle/scala/.github/workflows/microsmith.yml` | +| .NET (native Gradle) | `examples/gradle/dotnet` | `./gradlew microsmithGenerate -PmicrosmithVersion=` | `examples/gradle/dotnet/.github/workflows/microsmith.yml` | | Java (native Maven) | `examples/maven/java` | `mvn microsmith:generate -Dmicrosmith.version=` | `examples/maven/java/.github/workflows/microsmith.yml` | | Kotlin (native Maven) | `examples/maven/kotlin` | `mvn microsmith:generate -Dmicrosmith.version=` | `examples/maven/kotlin/.github/workflows/microsmith.yml` | | Scala (native Maven) | `examples/maven/scala` | `mvn microsmith:generate -Dmicrosmith.version=` | `examples/maven/scala/.github/workflows/microsmith.yml` | +| .NET (native Maven) | `examples/maven/dotnet` | `mvn microsmith:generate -Dmicrosmith.version=` | `examples/maven/dotnet/.github/workflows/microsmith.yml` | | Scala (native sbt) | `examples/sbt/scala` | `sbt -Dmicrosmith.version= microsmithGenerate` | `examples/sbt/scala/.github/workflows/microsmith.yml` | +| .NET (native sbt) | `examples/sbt/dotnet` | `sbt -Dmicrosmith.version= microsmithGenerate` | `examples/sbt/dotnet/.github/workflows/microsmith.yml` | | Java | `examples/jvm/java-maven` | `microsmith init` then `microsmith run build.microsmith.kts` | `examples/jvm/java-maven/.github/workflows/microsmith.yml` | | Kotlin | `examples/jvm/kotlin-gradle` | `microsmith init` then `microsmith run build.microsmith.kts` | `examples/jvm/kotlin-gradle/.github/workflows/microsmith.yml` | | Scala | `examples/jvm/scala-sbt` | `microsmith init` then `microsmith run build.microsmith.kts` | `examples/jvm/scala-sbt/.github/workflows/microsmith.yml` | @@ -1633,11 +1601,11 @@ CLI-managed fixtures exercise `microsmith init` and direct `microsmith run` flow | CLI help and README contract | `build-and-qodana` on Ubuntu | `scripts/verify_readme_cli_usage.py` compares the README usage block to built `--help`. | | CLI distribution smoke | `cli-smoke` on Ubuntu, macOS, and Windows | Dist launcher, generation, process isolation, and `doctor --diagnostics json`. | | Installer and bootstrap smoke | `cli-smoke` on Ubuntu, macOS, and Windows | Installer, `--version`, `microsmith init`, and canonical `init -> run` generation. | -| Native Gradle fixture smoke | `build-and-qodana` on Ubuntu | Publishes required packages to `mavenLocal`, then runs `microsmithGenerate` for Java, Kotlin, and Scala Gradle fixtures. | -| Native Maven fixture smoke | `build-and-qodana` on Ubuntu | Publishes required packages to `mavenLocal`, then runs `mvn microsmith:generate` for Java, Kotlin, and Scala Maven fixtures. | -| Native sbt fixture smoke | `build-and-qodana` on Ubuntu | Publishes required packages to `mavenLocal`, then runs `sbt microsmithGenerate` for the Scala sbt fixture. | -| Consumer fixture onboarding | `cli-smoke` on Ubuntu and Windows | Ubuntu covers Java, Kotlin, Scala, Node, Go, Python, Ruby, and Rust fixtures; Windows covers the .NET fixture. | -| JetBrains helper lifecycle | `cli-smoke` on Ubuntu and Windows | Ubuntu runs `ide refresh` and `ide doctor` for Java, Kotlin, Scala, Node, Go, Python, Ruby, and Rust fixtures; Windows runs the .NET helper path. | +| Native Gradle fixture smoke | `build-and-qodana` on Ubuntu | Publishes required packages to `mavenLocal`, then runs `microsmithGenerate` for Java, Kotlin, Scala, and .NET Gradle fixtures. | +| Native Maven fixture smoke | `build-and-qodana` on Ubuntu | Publishes required packages to `mavenLocal`, then runs `mvn microsmith:generate` for Java, Kotlin, Scala, and .NET Maven fixtures. | +| Native sbt fixture smoke | `build-and-qodana` on Ubuntu | Publishes required packages to `mavenLocal`, then runs `sbt microsmithGenerate` for Scala and .NET sbt fixtures. | +| Consumer fixture onboarding | `cli-smoke` on Ubuntu, macOS, and Windows | Ubuntu covers Java, Kotlin, Scala, Node, Go, Python, Ruby, Rust, and .NET; macOS and Windows cover the .NET fixture through dist or installer flows. | +| JetBrains helper lifecycle | `cli-smoke` on Ubuntu, macOS, and Windows | Ubuntu covers Java, Kotlin, Scala, Node, Go, Python, Ruby, Rust, and .NET helper flows; macOS and Windows validate the .NET helper path. | | Fallback artifact packaging | `build-and-qodana` on Ubuntu | `:runtime-scripting:ideFallbackArtifacts` and `:runtime-scripting:generateIdeFallbackChecksums`. | | Resolver, auth, lock, offline | `./gradlew build` and module regression suites | `:cli:jvmKotest` covers authenticated repositories, lockfiles, cache policy, and offline behavior. | diff --git a/examples/gradle/dotnet/.github/workflows/microsmith.yml b/examples/gradle/dotnet/.github/workflows/microsmith.yml new file mode 100644 index 00000000..9a8347b0 --- /dev/null +++ b/examples/gradle/dotnet/.github/workflows/microsmith.yml @@ -0,0 +1,28 @@ +name: Microsmith Generate (.NET Gradle Native Fixture) +on: + workflow_dispatch: + pull_request: + +jobs: + generate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - name: Setup JDK + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: 24 + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v5 + - name: Setup .NET SDK + uses: actions/setup-dotnet@v5 + with: + dotnet-version: 8.0.x + - name: Generate ASP.NET service + run: ./gradlew microsmithGenerate -PmicrosmithVersion="$MICROSMITH_VERSION" + env: + GITHUB_ACTOR: ${{ github.actor }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Build generated ASP.NET project + run: dotnet build Generated/dotnet/Platform/UserService.Api/UserService.Api.csproj diff --git a/examples/gradle/dotnet/build.gradle.kts b/examples/gradle/dotnet/build.gradle.kts new file mode 100644 index 00000000..f3315d91 --- /dev/null +++ b/examples/gradle/dotnet/build.gradle.kts @@ -0,0 +1,12 @@ +plugins { + base + id("io.github.lmliam.microsmith") +} + +microsmith { + outputDirectory.set(layout.projectDirectory.dir("Generated")) +} + +tasks.named("check") { + dependsOn(tasks.named("microsmithGenerate")) +} diff --git a/examples/gradle/dotnet/build.microsmith.kts b/examples/gradle/dotnet/build.microsmith.kts new file mode 100644 index 00000000..4d9d4cf2 --- /dev/null +++ b/examples/gradle/dotnet/build.microsmith.kts @@ -0,0 +1,95 @@ +microsmith { + services { + dotnet { + target(NET8) + solutions { + "Platform" {} + } + } + + "UserService" { + dotnet { + solution("Platform") + project("UserService.Api") + models { + "User" { + string("id") + string("email") + } + "Problem" { + string("detail") + } + "Report" { + string("id") + string("title") + } + } + asp { + rest { + "/users" { + get("/{id}", "GetUser") { + path("GetUserPath") { + string("id") + } + query("GetUserQuery") { + bool("includeDetails") { + optional() + default(false) + } + } + headers("GetUserHeaders") { + header("X-Correlation-Id") + } + responses { + ok("User") { + headers { + header("ETag") + } + } + notFound("Problem") + } + } + + post("CreateUser") { + body("CreateUserBody") { + string("email") + } + responses { + created("User") { + headers { + header("Location") + } + } + badRequest("Problem") + } + } + } + + "/reports" { + get("/{reportId}", "GetReport") { + path("GetReportPath") { + guid("reportId") + } + query("GetReportQuery") { + int("days") + dateOnly("since") + dateTimeOffset("requestedAt") + decimal("threshold") { + optional() + default(1.5) + } + timeSpan("window") { + optional() + } + } + responses { + ok("Report") + } + } + } + } + } + } + } + } +} diff --git a/examples/gradle/dotnet/settings.gradle.kts b/examples/gradle/dotnet/settings.gradle.kts new file mode 100644 index 00000000..317a881b --- /dev/null +++ b/examples/gradle/dotnet/settings.gradle.kts @@ -0,0 +1,43 @@ +pluginManagement { + val microsmithVersion = providers.gradleProperty("microsmithVersion").orNull ?: error( + "Set microsmithVersion in gradle.properties or pass -PmicrosmithVersion=.", + ) + + repositories { + mavenLocal() + gradlePluginPortal() + mavenCentral() + maven(url = "https://maven.pkg.github.com/lmliam/microsmith") { + credentials { + username = providers.gradleProperty("gpr.user") + .orElse(providers.environmentVariable("GITHUB_ACTOR")) + .orNull + password = providers.gradleProperty("gpr.key") + .orElse(providers.environmentVariable("GITHUB_TOKEN")) + .orNull + } + } + } + plugins { + id("io.github.lmliam.microsmith") version microsmithVersion + } +} + +dependencyResolutionManagement { + repositories { + mavenLocal() + mavenCentral() + maven(url = "https://maven.pkg.github.com/lmliam/microsmith") { + credentials { + username = providers.gradleProperty("gpr.user") + .orElse(providers.environmentVariable("GITHUB_ACTOR")) + .orNull + password = providers.gradleProperty("gpr.key") + .orElse(providers.environmentVariable("GITHUB_TOKEN")) + .orNull + } + } + } +} + +rootProject.name = "dotnet-gradle-native-fixture" diff --git a/examples/maven/dotnet/.github/workflows/microsmith.yml b/examples/maven/dotnet/.github/workflows/microsmith.yml new file mode 100644 index 00000000..bbf9045a --- /dev/null +++ b/examples/maven/dotnet/.github/workflows/microsmith.yml @@ -0,0 +1,37 @@ +name: Microsmith (.NET Maven Native Fixture) + +on: + workflow_dispatch: + push: + paths: + - "build.microsmith.kts" + - "pom.xml" + - ".github/workflows/microsmith.yml" + +jobs: + generate: + runs-on: ubuntu-latest + permissions: + contents: read + packages: read + steps: + - uses: actions/checkout@v5 + - name: Setup JDK + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: 24 + server-id: github-microsmith + server-username: GITHUB_ACTOR + server-password: GITHUB_TOKEN + - name: Setup .NET SDK + uses: actions/setup-dotnet@v5 + with: + dotnet-version: 8.0.x + - name: Generate with Microsmith Maven plugin + run: mvn -B microsmith:generate -Dmicrosmith.version= + env: + GITHUB_ACTOR: ${{ github.actor }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Build generated ASP.NET project + run: dotnet build Generated/dotnet/Platform/UserService.Api/UserService.Api.csproj diff --git a/examples/maven/dotnet/build.microsmith.kts b/examples/maven/dotnet/build.microsmith.kts new file mode 100644 index 00000000..4d9d4cf2 --- /dev/null +++ b/examples/maven/dotnet/build.microsmith.kts @@ -0,0 +1,95 @@ +microsmith { + services { + dotnet { + target(NET8) + solutions { + "Platform" {} + } + } + + "UserService" { + dotnet { + solution("Platform") + project("UserService.Api") + models { + "User" { + string("id") + string("email") + } + "Problem" { + string("detail") + } + "Report" { + string("id") + string("title") + } + } + asp { + rest { + "/users" { + get("/{id}", "GetUser") { + path("GetUserPath") { + string("id") + } + query("GetUserQuery") { + bool("includeDetails") { + optional() + default(false) + } + } + headers("GetUserHeaders") { + header("X-Correlation-Id") + } + responses { + ok("User") { + headers { + header("ETag") + } + } + notFound("Problem") + } + } + + post("CreateUser") { + body("CreateUserBody") { + string("email") + } + responses { + created("User") { + headers { + header("Location") + } + } + badRequest("Problem") + } + } + } + + "/reports" { + get("/{reportId}", "GetReport") { + path("GetReportPath") { + guid("reportId") + } + query("GetReportQuery") { + int("days") + dateOnly("since") + dateTimeOffset("requestedAt") + decimal("threshold") { + optional() + default(1.5) + } + timeSpan("window") { + optional() + } + } + responses { + ok("Report") + } + } + } + } + } + } + } + } +} diff --git a/examples/maven/dotnet/pom.xml b/examples/maven/dotnet/pom.xml new file mode 100644 index 00000000..9fe14004 --- /dev/null +++ b/examples/maven/dotnet/pom.xml @@ -0,0 +1,57 @@ + + 4.0.0 + + example.microsmith + dotnet-maven-native-fixture + 0.1.0-SNAPSHOT + + + 21 + + + + + local-m2 + file://${user.home}/.m2/repository + + + github-microsmith + https://maven.pkg.github.com/lmliam/microsmith + + + + + + local-m2 + file://${user.home}/.m2/repository + + + github-microsmith + https://maven.pkg.github.com/lmliam/microsmith + + + + + + io.github.lmliam.microsmith + runtime-scripting + ${microsmith.version} + provided + + + + + + + io.github.lmliam.microsmith + maven-plugin + ${microsmith.version} + + ${project.basedir}/Generated + + + + + diff --git a/examples/non-gradle/dotnet/.github/workflows/microsmith.yml b/examples/non-gradle/dotnet/.github/workflows/microsmith.yml index 3761c3d5..2ebe53c2 100644 --- a/examples/non-gradle/dotnet/.github/workflows/microsmith.yml +++ b/examples/non-gradle/dotnet/.github/workflows/microsmith.yml @@ -8,6 +8,10 @@ jobs: runs-on: windows-latest steps: - uses: actions/checkout@v5 + - name: Setup .NET SDK + uses: actions/setup-dotnet@v5 + with: + dotnet-version: 8.0.x - name: Install Microsmith shell: pwsh run: | @@ -18,30 +22,17 @@ jobs: shell: pwsh working-directory: examples/non-gradle/dotnet run: microsmith init - - name: Generate protobuf and ASP.NET scaffold + - name: Generate ASP.NET scaffold shell: pwsh working-directory: examples/non-gradle/dotnet run: | microsmith run build.microsmith.kts --out .\Generated - if (-not (Test-Path .\Generated\proto\DotnetUserCreated.proto)) { - Write-Error "Expected Generated\\proto\\DotnetUserCreated.proto to exist." + if (-not (Test-Path .\Generated\dotnet\Platform\UserService.Api\UserService.Api.csproj)) { + Write-Error "Expected Generated\\dotnet\\Platform\\UserService.Api\\UserService.Api.csproj to exist." exit 1 } - $generatedProjectRoot = '.\Generated\dotnet\Platform\UserService.Api' - if (-not (Test-Path "$generatedProjectRoot\UserService.Api.csproj")) { - Write-Error "Expected generated ASP.NET project file to exist." - exit 1 - } - if (-not (Test-Path "$generatedProjectRoot\Generated\Hosting\MicrosmithHostingExtensions.cs")) { - Write-Error "Expected generated hosting extensions to exist." - exit 1 - } - if (-not (Test-Path "$generatedProjectRoot\Generated\Controllers\UserServiceApiControllerBase.cs")) { - Write-Error "Expected generated controller base to exist." - exit 1 - } - $hostingExtensions = Get-Content -Path "$generatedProjectRoot\Generated\Hosting\MicrosmithHostingExtensions.cs" -Raw - if ($hostingExtensions -notmatch 'AddMicrosmith' -or $hostingExtensions -notmatch 'MapMicrosmith') { - Write-Error "Expected generated hosting extensions to expose AddMicrosmith and MapMicrosmith." + if (-not (Test-Path .\Generated\dotnet\Platform\UserService.Api\Controllers\UserServiceController.cs)) { + Write-Error "Expected Generated\\dotnet\\Platform\\UserService.Api\\Controllers\\UserServiceController.cs to exist." exit 1 } + dotnet build .\Generated\dotnet\Platform\UserService.Api\UserService.Api.csproj diff --git a/examples/non-gradle/dotnet/schema.microsmith.kts b/examples/non-gradle/dotnet/schema.microsmith.kts index c3d7367c..fc13dc39 100644 --- a/examples/non-gradle/dotnet/schema.microsmith.kts +++ b/examples/non-gradle/dotnet/schema.microsmith.kts @@ -1,9 +1,94 @@ microsmith { - schemas { - protobuf { - message("DotnetUserCreated") { - int32("id") { index(1) } - string("email") { index(2) } + services { + dotnet { + target(NET8) + solutions { + "Platform" { } + } + } + + "UserService" { + dotnet { + solution("Platform") + project("UserService.Api") + models { + "User" { + string("id") + string("email") + } + "Problem" { + string("detail") + } + "Report" { + string("id") + string("title") + } + } + asp { + rest { + "/users" { + get("/{id}", "GetUser") { + path("GetUserPath") { + string("id") + } + query("GetUserQuery") { + bool("includeDetails") { + optional() + default(false) + } + } + headers("GetUserHeaders") { + header("X-Correlation-Id") + } + responses { + ok("User") { + headers { + header("ETag") + } + } + notFound("Problem") + } + } + + post("CreateUser") { + body("CreateUserBody") { + string("email") + } + responses { + created("User") { + headers { + header("Location") + } + } + badRequest("Problem") + } + } + } + + "/reports" { + get("/{reportId}", "GetReport") { + path("GetReportPath") { + guid("reportId") + } + query("GetReportQuery") { + int("days") + dateOnly("since") + dateTimeOffset("requestedAt") + decimal("threshold") { + optional() + default(1.5) + } + timeSpan("window") { + optional() + } + } + responses { + ok("Report") + } + } + } + } + } } } } diff --git a/examples/sbt/dotnet/.github/workflows/microsmith.yml b/examples/sbt/dotnet/.github/workflows/microsmith.yml new file mode 100644 index 00000000..bd4856a9 --- /dev/null +++ b/examples/sbt/dotnet/.github/workflows/microsmith.yml @@ -0,0 +1,39 @@ +name: Microsmith (.NET sbt Native Fixture) + +on: + workflow_dispatch: + push: + paths: + - "build.microsmith.kts" + - "build.sbt" + - "project/build.properties" + - "project/plugins.sbt" + - ".github/workflows/microsmith.yml" + +jobs: + generate: + runs-on: ubuntu-latest + permissions: + contents: read + packages: read + steps: + - uses: actions/checkout@v5 + - name: Setup JDK + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: 24 + cache: sbt + - name: Setup sbt + uses: sbt/setup-sbt@v1 + - name: Setup .NET SDK + uses: actions/setup-dotnet@v5 + with: + dotnet-version: 8.0.x + - name: Generate with Microsmith sbt plugin + run: sbt -batch -Dmicrosmith.version= microsmithGenerate + env: + GITHUB_ACTOR: ${{ github.actor }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Build generated ASP.NET project + run: dotnet build Generated/dotnet/Platform/UserService.Api/UserService.Api.csproj diff --git a/examples/sbt/dotnet/build.microsmith.kts b/examples/sbt/dotnet/build.microsmith.kts new file mode 100644 index 00000000..4d9d4cf2 --- /dev/null +++ b/examples/sbt/dotnet/build.microsmith.kts @@ -0,0 +1,95 @@ +microsmith { + services { + dotnet { + target(NET8) + solutions { + "Platform" {} + } + } + + "UserService" { + dotnet { + solution("Platform") + project("UserService.Api") + models { + "User" { + string("id") + string("email") + } + "Problem" { + string("detail") + } + "Report" { + string("id") + string("title") + } + } + asp { + rest { + "/users" { + get("/{id}", "GetUser") { + path("GetUserPath") { + string("id") + } + query("GetUserQuery") { + bool("includeDetails") { + optional() + default(false) + } + } + headers("GetUserHeaders") { + header("X-Correlation-Id") + } + responses { + ok("User") { + headers { + header("ETag") + } + } + notFound("Problem") + } + } + + post("CreateUser") { + body("CreateUserBody") { + string("email") + } + responses { + created("User") { + headers { + header("Location") + } + } + badRequest("Problem") + } + } + } + + "/reports" { + get("/{reportId}", "GetReport") { + path("GetReportPath") { + guid("reportId") + } + query("GetReportQuery") { + int("days") + dateOnly("since") + dateTimeOffset("requestedAt") + decimal("threshold") { + optional() + default(1.5) + } + timeSpan("window") { + optional() + } + } + responses { + ok("Report") + } + } + } + } + } + } + } + } +} diff --git a/examples/sbt/dotnet/build.sbt b/examples/sbt/dotnet/build.sbt new file mode 100644 index 00000000..14260aaf --- /dev/null +++ b/examples/sbt/dotnet/build.sbt @@ -0,0 +1,24 @@ +ThisBuild / scalaVersion := "3.7.1" + +val microsmithVersion = + sys.props.getOrElse( + "microsmith.version", + sys.error("Pass -Dmicrosmith.version=."), + ) + +def githubMicrosmithCredentials: Seq[Credentials] = + for { + actor <- sys.env.get("GITHUB_ACTOR").toSeq + token <- sys.env.get("GITHUB_TOKEN").toSeq + } yield Credentials("GitHub Package Registry", "maven.pkg.github.com", actor, token) + +lazy val root = (project in file(".")) + .enablePlugins(MicrosmithSbtPlugin) + .settings( + name := "dotnet-sbt-native-fixture", + microsmithOutputDirectory := baseDirectory.value / "Generated", + resolvers += Resolver.mavenLocal, + resolvers += "GitHub Microsmith" at "https://maven.pkg.github.com/lmliam/microsmith", + credentials ++= githubMicrosmithCredentials, + libraryDependencies += "io.github.lmliam.microsmith" % "runtime-scripting" % microsmithVersion % Provided, + ) diff --git a/examples/sbt/dotnet/project/build.properties b/examples/sbt/dotnet/project/build.properties new file mode 100644 index 00000000..01a16ed1 --- /dev/null +++ b/examples/sbt/dotnet/project/build.properties @@ -0,0 +1 @@ +sbt.version=1.11.7 diff --git a/examples/sbt/dotnet/project/plugins.sbt b/examples/sbt/dotnet/project/plugins.sbt new file mode 100644 index 00000000..ac202480 --- /dev/null +++ b/examples/sbt/dotnet/project/plugins.sbt @@ -0,0 +1,17 @@ +val microsmithVersion = + sys.props.getOrElse( + "microsmith.version", + sys.error("Pass -Dmicrosmith.version=."), + ) + +def githubMicrosmithCredentials: Seq[Credentials] = + for { + actor <- sys.env.get("GITHUB_ACTOR").toSeq + token <- sys.env.get("GITHUB_TOKEN").toSeq + } yield Credentials("GitHub Package Registry", "maven.pkg.github.com", actor, token) + +resolvers += Resolver.mavenLocal +resolvers += "GitHub Microsmith" at "https://maven.pkg.github.com/lmliam/microsmith" +credentials ++= githubMicrosmithCredentials + +addSbtPlugin("io.github.lmliam.microsmith" % "sbt-plugin" % microsmithVersion) diff --git a/modules/gen/src/test/kotlin/io/github/lmliam/microsmith/gen/helpers/DotnetAspRuntimeE2eTests.kt b/modules/gen/src/test/kotlin/io/github/lmliam/microsmith/gen/helpers/DotnetAspRuntimeE2eTests.kt new file mode 100644 index 00000000..9c0c4d8f --- /dev/null +++ b/modules/gen/src/test/kotlin/io/github/lmliam/microsmith/gen/helpers/DotnetAspRuntimeE2eTests.kt @@ -0,0 +1,323 @@ +package io.github.lmliam.microsmith.gen.helpers + +import io.github.lmliam.microsmith.dsl.core.microsmith +import io.github.lmliam.microsmith.dsl.services.core.services +import io.github.lmliam.microsmith.dsl.services.dotnet.core.dotnet +import io.github.lmliam.microsmith.dsl.services.dotnet.core.service.asp +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain +import java.net.ServerSocket +import java.net.URI +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse +import java.nio.file.Files +import java.nio.file.Path +import java.time.Duration +import kotlin.io.path.ExperimentalPathApi +import kotlin.io.path.createDirectories +import kotlin.io.path.deleteRecursively +import kotlin.io.path.exists +import kotlin.io.path.readText +import kotlin.io.path.writeText + +@OptIn(ExperimentalPathApi::class) +class DotnetAspRuntimeE2eTests : + StringSpec({ + "generated ASP.NET services handle valid and invalid requests end to end".config(enabled = dotnetAvailable()) { + val outputDir = Files.createTempDirectory("microsmith-dotnet-asp-runtime-") + try { + runtimeE2eModel().generateTo(outputDir) + + val projectRoot = outputDir.resolve("dotnet/Platform/UserService.Api") + val projectFile = projectRoot.resolve("UserService.Api.csproj") + val logFile = outputDir.resolve("dotnet-runtime.log") + val port = availablePort() + val baseUri = URI("http://127.0.0.1:$port") + + runDotnetCommand( + projectRoot = projectRoot, + logFile = logFile, + "build", + projectFile.toString(), + "--nologo", + ) + + val process = startDotnetService(projectFile, port, logFile) + try { + val client = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(2)) + .build() + + awaitServiceReady(client, baseUri, logFile, process) + + val getUser = client.send( + request(baseUri, "/users/user-123?includeDetails=true") + .header("X-Correlation-Id", "corr-123") + .GET() + .build(), + HttpResponse.BodyHandlers.ofString(), + ) + getUser.statusCode() shouldBe 200 + getUser.headers().firstValue("ETag").orElseThrow() shouldBe "sample-etag" + getUser.body().shouldContain("\"id\":\"\"") + getUser.body().shouldContain("\"email\":\"\"") + + val notFound = client.send( + request(baseUri, "/users/user-123") + .header("X-Microsmith-Response-Status", "404") + .GET() + .build(), + HttpResponse.BodyHandlers.ofString(), + ) + notFound.statusCode() shouldBe 404 + notFound.body().shouldContain("\"detail\":\"\"") + + val createUser = client.send( + request(baseUri, "/users") + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString("""{"email":"runtime@example.com"}""")) + .build(), + HttpResponse.BodyHandlers.ofString(), + ) + createUser.statusCode() shouldBe 201 + createUser.headers().firstValue("Location").orElseThrow() shouldBe "sample-location" + + val getReport = client.send( + request( + baseUri, + "/reports/550e8400-e29b-41d4-a716-446655440000" + + "?days=7" + + "&since=2026-04-20" + + "&requestedAt=2026-04-20T12:34:56%2B00:00" + + "&threshold=9.5" + + "&window=01:30:00", + ).GET().build(), + HttpResponse.BodyHandlers.ofString(), + ) + getReport.statusCode() shouldBe 200 + getReport.body().shouldContain("\"title\":\"\"") + + val invalidGuid = client.send( + request( + baseUri, + "/reports/not-a-guid" + + "?days=7" + + "&since=2026-04-20" + + "&requestedAt=2026-04-20T12:34:56%2B00:00", + ).GET().build(), + HttpResponse.BodyHandlers.ofString(), + ) + invalidGuid.statusCode() shouldBe 400 + invalidGuid.body().shouldContain("path.reportId") + + val invalidDecimal = client.send( + request( + baseUri, + "/reports/550e8400-e29b-41d4-a716-446655440000" + + "?days=7" + + "&since=2026-04-20" + + "&requestedAt=2026-04-20T12:34:56%2B00:00" + + "&threshold=bad", + ).GET().build(), + HttpResponse.BodyHandlers.ofString(), + ) + invalidDecimal.statusCode() shouldBe 400 + invalidDecimal.body().shouldContain("query.threshold") + } finally { + stopProcess(process, logFile) + } + } finally { + runCatching { outputDir.deleteRecursively() } + } + } + }) + +private fun runtimeE2eModel() = + microsmith { + services { + dotnet { + target(NET8) + solutions { + "Platform" {} + } + } + + "UserService" { + dotnet { + solution("Platform") + project("UserService.Api") + models { + "User" { + string("id") + string("email") + } + "Problem" { + string("detail") + } + "Report" { + string("id") + string("title") + } + } + asp { + rest { + "/users" { + get("/{id}", "GetUser") { + path("GetUserPath") { + string("id") + } + query("GetUserQuery") { + bool("includeDetails") { + optional() + default(false) + } + } + headers("GetUserHeaders") { + header("X-Correlation-Id") + } + responses { + ok("User") { + headers { + header("ETag") + } + } + notFound("Problem") + } + } + + post("CreateUser") { + body("CreateUserBody") { + string("email") + } + responses { + created("User") { + headers { + header("Location") + } + } + badRequest("Problem") + } + } + } + + "/reports" { + get("/{reportId}", "GetReport") { + path("GetReportPath") { + guid("reportId") + } + query("GetReportQuery") { + int("days") + dateOnly("since") + dateTimeOffset("requestedAt") + decimal("threshold") { + optional() + default(1.5) + } + timeSpan("window") { + optional() + } + } + responses { + ok("Report") + } + } + } + } + } + } + } + } + } + +private fun availablePort(): Int = ServerSocket(0).use { socket -> socket.localPort } + +private fun dotnetAvailable(): Boolean = runCatching { + val process = ProcessBuilder("dotnet", "--version") + .redirectErrorStream(true) + .start() + process.waitFor() == 0 +}.getOrDefault(false) + +private fun runDotnetCommand(projectRoot: Path, logFile: Path, vararg command: String) { + val process = ProcessBuilder(listOf("dotnet") + command) + .directory(projectRoot.toFile()) + .redirectErrorStream(true) + .redirectOutput(logFile.toFile()) + .start() + val completed = process.waitFor(2, java.util.concurrent.TimeUnit.MINUTES) + check(completed) { + "Timed out running '${command.joinToString(" ")}'.\n${logFileContents(logFile)}" + } + check(process.exitValue() == 0) { + "Command 'dotnet ${command.joinToString(" ")}' failed.\n${logFileContents(logFile)}" + } +} + +private fun startDotnetService(projectFile: Path, port: Int, logFile: Path): Process { + logFile.parent?.createDirectories() + logFile.writeText("") + return ProcessBuilder( + "dotnet", + "run", + "--project", + projectFile.toString(), + "--no-build", + "--no-launch-profile", + "--nologo", + ).directory(projectFile.parent.toFile()) + .redirectErrorStream(true) + .redirectOutput(ProcessBuilder.Redirect.appendTo(logFile.toFile())) + .apply { + environment()["ASPNETCORE_URLS"] = "http://127.0.0.1:$port" + environment()["DOTNET_CLI_TELEMETRY_OPTOUT"] = "1" + environment()["DOTNET_NOLOGO"] = "1" + }.start() +} + +private fun awaitServiceReady(client: HttpClient, baseUri: URI, logFile: Path, process: Process) { + val deadline = System.nanoTime() + Duration.ofSeconds(60).toNanos() + while (System.nanoTime() < deadline) { + check(process.isAlive) { + "Generated ASP.NET service exited before becoming ready.\n${logFileContents(logFile)}" + } + + val ready = runCatching { + client.send( + request(baseUri, "/users/readiness").GET().build(), + HttpResponse.BodyHandlers.discarding(), + ) + }.getOrNull() + + if (ready != null && ready.statusCode() in 200..499) { + return + } + + Thread.sleep(250) + } + + error("Generated ASP.NET service did not become ready in time.\n${logFileContents(logFile)}") +} + +private fun request(baseUri: URI, pathAndQuery: String): HttpRequest.Builder = + HttpRequest.newBuilder(baseUri.resolve(pathAndQuery)) + .timeout(Duration.ofSeconds(10)) + +private fun stopProcess(process: Process, logFile: Path) { + if (!process.isAlive) { + return + } + process.destroy() + if (!process.waitFor(10, java.util.concurrent.TimeUnit.SECONDS)) { + process.destroyForcibly() + check(process.waitFor(10, java.util.concurrent.TimeUnit.SECONDS)) { + "Unable to stop generated ASP.NET service.\n${logFileContents(logFile)}" + } + } +} + +private fun logFileContents(logFile: Path): String = when { + !logFile.exists() -> "" + else -> logFile.readText() +} diff --git a/modules/gradle-plugin/src/test/kotlin/io/github/lmliam/microsmith/gradle/MicrosmithGradlePluginFunctionalTests.kt b/modules/gradle-plugin/src/test/kotlin/io/github/lmliam/microsmith/gradle/MicrosmithGradlePluginFunctionalTests.kt index 6d47e806..b75f9fba 100644 --- a/modules/gradle-plugin/src/test/kotlin/io/github/lmliam/microsmith/gradle/MicrosmithGradlePluginFunctionalTests.kt +++ b/modules/gradle-plugin/src/test/kotlin/io/github/lmliam/microsmith/gradle/MicrosmithGradlePluginFunctionalTests.kt @@ -117,6 +117,64 @@ class MicrosmithGradlePluginFunctionalTests : StringSpec() { project.file("custom-generated/proto/GradleConfiguredUserCreated.proto").toFile().shouldExist() } + "microsmithGenerate supports ASP.NET service generation" { + val project = MicrosmithGradleFunctionalTestProject.create( + name = "microsmith-gradle-plugin-dotnet-asp", + buildScript = """ + plugins { + id("io.github.lmliam.microsmith") + } + """.trimIndent(), + ) + project.writeFile( + "build.microsmith.kts", + """ + microsmith { + services { + dotnet { + target(NET8) + solutions { + "Platform" {} + } + } + + "UserService" { + dotnet { + solution("Platform") + project("UserService.Api") + models { + "User" { + string("id") + } + } + asp { + rest { + "/users" { + get("/{id}", "GetUser") { + path("GetUserPath") { + string("id") + } + responses { + ok("User") + } + } + } + } + } + } + } + } + } + """.trimIndent(), + ) + + val result = project.build("microsmithGenerate") + + result.task(":microsmithGenerate")?.outcome shouldBe TaskOutcome.SUCCESS + project.file("dotnet/Platform/UserService.Api/Program.cs").toFile().shouldExist() + project.file("dotnet/Platform/UserService.Api/Controllers/UserServiceController.cs").toFile().shouldExist() + } + "microsmithGenerate fails with script diagnostics" { val project = MicrosmithGradleFunctionalTestProject.create( name = "microsmith-gradle-plugin-failure", diff --git a/modules/maven-plugin/src/test/kotlin/io/github/lmliam/microsmith/maven/MicrosmithGenerateMojoTests.kt b/modules/maven-plugin/src/test/kotlin/io/github/lmliam/microsmith/maven/MicrosmithGenerateMojoTests.kt index 295d0b8f..54d71b32 100644 --- a/modules/maven-plugin/src/test/kotlin/io/github/lmliam/microsmith/maven/MicrosmithGenerateMojoTests.kt +++ b/modules/maven-plugin/src/test/kotlin/io/github/lmliam/microsmith/maven/MicrosmithGenerateMojoTests.kt @@ -75,6 +75,56 @@ class MicrosmithGenerateMojoTests : StringSpec() { fixture.file("custom-generated/proto/MavenConfiguredUserCreated.proto").toFile().shouldExist() } + "execute supports ASP.NET service generation" { + val fixture = MicrosmithMavenTestProject.create("maven-plugin-dotnet-asp") + fixture.writeFile( + "build.microsmith.kts", + """ + microsmith { + services { + dotnet { + target(NET8) + solutions { + "Platform" {} + } + } + + "UserService" { + dotnet { + solution("Platform") + project("UserService.Api") + models { + "User" { + string("id") + } + } + asp { + rest { + "/users" { + get("/{id}", "GetUser") { + path("GetUserPath") { + string("id") + } + responses { + ok("User") + } + } + } + } + } + } + } + } + } + """.trimIndent(), + ) + + fixture.createMojo().execute() + + fixture.file("dotnet/Platform/UserService.Api/Program.cs").toFile().shouldExist() + fixture.file("dotnet/Platform/UserService.Api/Controllers/UserServiceController.cs").toFile().shouldExist() + } + "script compilation failures surface as MojoFailureException" { val fixture = MicrosmithMavenTestProject.create("maven-plugin-compilation-failure") fixture.writeFile( diff --git a/modules/sbt-plugin/src/test/kotlin/io/github/lmliam/microsmith/sbt/MicrosmithSbtExecutionServiceTests.kt b/modules/sbt-plugin/src/test/kotlin/io/github/lmliam/microsmith/sbt/MicrosmithSbtExecutionServiceTests.kt index af27eb4d..336e912c 100644 --- a/modules/sbt-plugin/src/test/kotlin/io/github/lmliam/microsmith/sbt/MicrosmithSbtExecutionServiceTests.kt +++ b/modules/sbt-plugin/src/test/kotlin/io/github/lmliam/microsmith/sbt/MicrosmithSbtExecutionServiceTests.kt @@ -69,6 +69,57 @@ class MicrosmithSbtExecutionServiceTests : StringSpec() { fixture.file("target/generated/custom/proto/SbtConfiguredUserCreated.proto").toFile().shouldExist() } + "execute supports ASP.NET service generation" { + val fixture = MicrosmithSbtTestProject.create("microsmith-sbt-plugin-dotnet-asp") + fixture.writeFile( + "build.microsmith.kts", + """ + microsmith { + services { + dotnet { + target(NET8) + solutions { + "Platform" {} + } + } + + "UserService" { + dotnet { + solution("Platform") + project("UserService.Api") + models { + "User" { + string("id") + } + } + asp { + rest { + "/users" { + get("/{id}", "GetUser") { + path("GetUserPath") { + string("id") + } + responses { + ok("User") + } + } + } + } + } + } + } + } + } + """.trimIndent(), + ) + + val result = MicrosmithSbtExecutionService().execute(fixture.executionConfiguration()) + + result.outputDirectory shouldBe fixture.file(".").normalize() + fixture.file("dotnet/Platform/UserService.Api/Program.cs").toFile().shouldExist() + fixture.file("dotnet/Platform/UserService.Api/Controllers/UserServiceController.cs").toFile().shouldExist() + } + "script compilation failures surface as MicrosmithSbtScriptFailureException" { val fixture = MicrosmithSbtTestProject.create("microsmith-sbt-plugin-compilation-failure") fixture.writeFile( From 99a44d6ee62e2cee85a680c4e7b51de2bcca43c4 Mon Sep 17 00:00:00 2001 From: LMLiam <46268350+TheRealEmissions@users.noreply.github.com> Date: Tue, 21 Apr 2026 00:39:56 +0100 Subject: [PATCH 05/26] refactor(services-dotnet-asp): drop obsolete endpoint extension surface --- .../dotnet/asp/DotnetAspAllocatedPorts.kt | 3 - .../dotnet/asp/DotnetAspPortAllocation.kt | 79 -------- .../asp/DotnetAspArtifactContributorTests.kt | 118 ------------ .../compile-services-dotnet-asp/build.gradle | 3 - .../dotnet/asp/DotnetAspCSharpAttributes.kt | 35 ---- .../DotnetAspCSharpDefaultValueAccessors.kt | 71 ------- .../dotnet/asp/DotnetAspCSharpEscaping.kt | 53 ------ .../dotnet/asp/DotnetAspCSharpLiterals.kt | 127 ------------- .../dotnet/asp/DotnetAspCSharpNames.kt | 71 ------- .../dotnet/asp/DotnetAspCSharpNamespaces.kt | 23 --- .../dotnet/asp/DotnetAspCSharpRendering.kt | 23 --- .../asp/DotnetAspCSharpTemporalLiterals.kt | 65 ------- .../dotnet/asp/DotnetAspCSharpTypes.kt | 31 ---- .../dotnet/asp/DotnetAspEndpointNaming.kt | 40 ---- .../asp/DotnetAspEndpointTextFileRenderer.kt | 36 ---- .../dotnet/asp/DotnetAspEndpointValidation.kt | 148 --------------- .../DotnetAspGeneratedContractsRenderer.kt | 71 ------- ...netAspGeneratedControllerActionRenderer.kt | 130 ------------- .../DotnetAspGeneratedControllerRenderer.kt | 54 ------ ...netAspGeneratedControllerResultRenderer.kt | 153 --------------- .../asp/DotnetAspGeneratedHostingRenderer.kt | 54 ------ ...otnetAspGeneratedModelContractsRenderer.kt | 41 ---- ...netAspGeneratedRequestContractsRenderer.kt | 55 ------ ...etAspGeneratedResponseContractsRenderer.kt | 44 ----- .../dotnet/asp/DotnetAspGeneratedTextFile.kt | 3 - .../asp/templates/appsettings.json.template | 12 -- .../templates/launchSettings.json.template | 15 -- .../asp/DotnetAspEndpointValidationTests.kt | 175 ------------------ .../asp/DotnetAspGeneratedGoldenTests.kt | 136 -------------- .../resources/golden/GetUserControllerBase.cs | 34 ---- .../resources/golden/GetUserRequestModels.cs | 13 -- .../golden/MicrosmithControllerBase.cs | 29 --- .../golden/MicrosmithHostingExtensions.cs | 19 -- .../host/MicrosmithScriptHostTests.kt | 8 +- 34 files changed, 3 insertions(+), 1969 deletions(-) delete mode 100644 modules/artifact-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspAllocatedPorts.kt delete mode 100644 modules/artifact-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspPortAllocation.kt delete mode 100644 modules/artifact-services-dotnet-asp/src/test/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspArtifactContributorTests.kt delete mode 100644 modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspCSharpAttributes.kt delete mode 100644 modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspCSharpDefaultValueAccessors.kt delete mode 100644 modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspCSharpEscaping.kt delete mode 100644 modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspCSharpLiterals.kt delete mode 100644 modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspCSharpNames.kt delete mode 100644 modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspCSharpNamespaces.kt delete mode 100644 modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspCSharpRendering.kt delete mode 100644 modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspCSharpTemporalLiterals.kt delete mode 100644 modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspCSharpTypes.kt delete mode 100644 modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspEndpointNaming.kt delete mode 100644 modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspEndpointTextFileRenderer.kt delete mode 100644 modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspEndpointValidation.kt delete mode 100644 modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspGeneratedContractsRenderer.kt delete mode 100644 modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspGeneratedControllerActionRenderer.kt delete mode 100644 modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspGeneratedControllerRenderer.kt delete mode 100644 modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspGeneratedControllerResultRenderer.kt delete mode 100644 modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspGeneratedHostingRenderer.kt delete mode 100644 modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspGeneratedModelContractsRenderer.kt delete mode 100644 modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspGeneratedRequestContractsRenderer.kt delete mode 100644 modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspGeneratedResponseContractsRenderer.kt delete mode 100644 modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspGeneratedTextFile.kt delete mode 100644 modules/compile-services-dotnet-asp/src/main/resources/io/github/lmliam/microsmith/compile/services/dotnet/asp/templates/appsettings.json.template delete mode 100644 modules/compile-services-dotnet-asp/src/main/resources/io/github/lmliam/microsmith/compile/services/dotnet/asp/templates/launchSettings.json.template delete mode 100644 modules/compile-services-dotnet-asp/src/test/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspEndpointValidationTests.kt delete mode 100644 modules/compile-services-dotnet-asp/src/test/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspGeneratedGoldenTests.kt delete mode 100644 modules/compile-services-dotnet-asp/src/test/resources/golden/GetUserControllerBase.cs delete mode 100644 modules/compile-services-dotnet-asp/src/test/resources/golden/GetUserRequestModels.cs delete mode 100644 modules/compile-services-dotnet-asp/src/test/resources/golden/MicrosmithControllerBase.cs delete mode 100644 modules/compile-services-dotnet-asp/src/test/resources/golden/MicrosmithHostingExtensions.cs diff --git a/modules/artifact-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspAllocatedPorts.kt b/modules/artifact-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspAllocatedPorts.kt deleted file mode 100644 index 3c29ff3e..00000000 --- a/modules/artifact-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspAllocatedPorts.kt +++ /dev/null @@ -1,3 +0,0 @@ -package io.github.lmliam.microsmith.artifact.services.dotnet.asp - -internal data class DotnetAspAllocatedPorts(val http: Int, val https: Int) diff --git a/modules/artifact-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspPortAllocation.kt b/modules/artifact-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspPortAllocation.kt deleted file mode 100644 index 8778c173..00000000 --- a/modules/artifact-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspPortAllocation.kt +++ /dev/null @@ -1,79 +0,0 @@ -package io.github.lmliam.microsmith.artifact.services.dotnet.asp - -import io.github.lmliam.microsmith.resolve.services.dotnet.asp.ResolvedDotnetAspPorts - -internal fun allocateDotnetAspPorts( - artifactId: DotnetAspServiceArtifactId, - configuredPorts: ResolvedDotnetAspPorts?, -): DotnetAspAllocatedPorts { - val defaultHttp = dotnetAspHttpPortFor(artifactId) - val http = - configuredPorts?.http - ?: configuredPorts?.https?.minus(HTTPS_PORT_OFFSET) - ?: defaultHttp - val https = - configuredPorts?.https - ?: configuredPorts?.http?.plus(HTTPS_PORT_OFFSET) - ?: (defaultHttp + HTTPS_PORT_OFFSET) - - require(http in MIN_DOTNET_ASP_PORT..MAX_DOTNET_ASP_PORT) { - "ASP.NET HTTP port for '${artifactId.stablePortIdentity()}' must be between " + - "$MIN_DOTNET_ASP_PORT and $MAX_DOTNET_ASP_PORT." - } - require(https in MIN_DOTNET_ASP_PORT..MAX_DOTNET_ASP_PORT) { - "ASP.NET HTTPS port for '${artifactId.stablePortIdentity()}' must be between " + - "$MIN_DOTNET_ASP_PORT and $MAX_DOTNET_ASP_PORT." - } - require(http != https) { - "ASP.NET service '${artifactId.stablePortIdentity()}' resolves to the same HTTP and HTTPS port '$http'." - } - - return DotnetAspAllocatedPorts(http = http, https = https) -} - -internal fun dotnetAspHttpPortFor(artifactId: DotnetAspServiceArtifactId): Int { - val slot = - artifactId - .stablePortIdentity() - .fold(0L) { hash, character -> - ((hash * PORT_HASH_MULTIPLIER) + character.code) % PORT_SLOT_COUNT - }.toInt() - return BASE_HTTP_PORT + (slot * PORT_STRIDE) -} - -internal fun validateUniqueDotnetAspPorts( - servicePorts: List>, -) { - val portOwners = mutableMapOf>() - servicePorts.forEach { (artifactId, ports) -> - portOwners.getOrPut(ports.http, ::mutableListOf) += artifactId - portOwners.getOrPut(ports.https, ::mutableListOf) += artifactId - } - - val collisions = - portOwners - .filterValues { it.size > 1 } - .toSortedMap() - - require(collisions.isEmpty()) { - "ASP.NET services produce colliding launch ports: " + - collisions.entries.joinToString("; ") { (port, artifactIds) -> - val owners = - artifactIds - .distinct() - .sortedBy(DotnetAspServiceArtifactId::stablePortIdentity) - .joinToString(", ") { it.stablePortIdentity() } - "$owners share localhost:$port" - } + "." - } -} - -internal fun DotnetAspServiceArtifactId.stablePortIdentity(): String = "$solutionName/$projectName" - -private const val BASE_HTTP_PORT = 5_000 -private const val PORT_STRIDE = 10 -private const val HTTPS_PORT_OFFSET = 1 -private const val PORT_SLOT_COUNT = 1_500L -private const val PORT_HASH_MULTIPLIER = 31L -private const val MIN_DOTNET_ASP_PORT = 1 -private const val MAX_DOTNET_ASP_PORT = 65_535 diff --git a/modules/artifact-services-dotnet-asp/src/test/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspArtifactContributorTests.kt b/modules/artifact-services-dotnet-asp/src/test/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspArtifactContributorTests.kt deleted file mode 100644 index bfac28a0..00000000 --- a/modules/artifact-services-dotnet-asp/src/test/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspArtifactContributorTests.kt +++ /dev/null @@ -1,118 +0,0 @@ -package io.github.lmliam.microsmith.artifact.services.dotnet.asp - -import io.github.lmliam.microsmith.resolve.services.dotnet.asp.DotnetAspWorkspace -import io.github.lmliam.microsmith.resolve.services.dotnet.asp.ResolvedDotnetAspPorts -import io.github.lmliam.microsmith.resolve.services.dotnet.asp.ResolvedDotnetAspRest -import io.github.lmliam.microsmith.resolve.services.dotnet.asp.ResolvedDotnetAspService -import io.kotest.assertions.throwables.shouldThrow -import io.kotest.core.spec.style.StringSpec -import io.kotest.matchers.shouldBe -import io.kotest.matchers.string.shouldContain -import java.nio.file.Path - -class DotnetAspArtifactContributorTests : - StringSpec({ - "contribute keeps existing service ports stable when unrelated services are added" { - val contributor = DotnetAspArtifactContributor() - val userOnlyWorkspace = - DotnetAspWorkspace( - servicesByName = - linkedMapOf( - "UserService" to resolvedAspService("UserService", "UserService.Api"), - ), - ) - val expandedWorkspace = - DotnetAspWorkspace( - servicesByName = - linkedMapOf( - "AdminService" to resolvedAspService("AdminService", "AdminService.Api"), - "UserService" to resolvedAspService("UserService", "UserService.Api"), - ), - ) - - val userOnlyPort = - contributor - .contribute(userOnlyWorkspace) - .single() - .let { it as DotnetAspServiceContribution } - .httpPort - val expandedUserPort = - contributor - .contribute(expandedWorkspace) - .map { it as DotnetAspServiceContribution } - .single { it.artifactId.projectName == "UserService.Api" } - .httpPort - - userOnlyPort shouldBe expandedUserPort - } - - "contribute rejects stable launch-port collisions instead of silently reshuffling services" { - val collision = requireNotNull(findCollidingServiceIds()) - val contributor = DotnetAspArtifactContributor() - val workspace = - DotnetAspWorkspace( - servicesByName = - linkedMapOf( - "LeftService" to resolvedAspService("LeftService", collision.first.projectName), - "RightService" to resolvedAspService("RightService", collision.second.projectName), - ), - ) - - val error = - shouldThrow { - contributor.contribute(workspace) - } - - error.message.shouldContain("colliding launch ports") - } - - "contribute respects explicit service ports" { - val contributor = DotnetAspArtifactContributor() - val workspace = - DotnetAspWorkspace( - servicesByName = - linkedMapOf( - "UserService" to resolvedAspService( - name = "UserService", - projectName = "UserService.Api", - ports = ResolvedDotnetAspPorts(http = 7000, https = 7443), - ), - ), - ) - - val contribution = - contributor - .contribute(workspace) - .single() as DotnetAspServiceContribution - - contribution.httpPort shouldBe 7000 - contribution.httpsPort shouldBe 7443 - } - }) - -private fun resolvedAspService( - name: String, - projectName: String, - ports: ResolvedDotnetAspPorts? = null, -): ResolvedDotnetAspService = ResolvedDotnetAspService( - name = name, - solutionName = "Platform", - projectName = projectName, - targetFrameworkMoniker = "net8.0", - outputRoot = Path.of("dotnet", "Platform", projectName), - ports = ports, - models = emptyMap(), - rest = ResolvedDotnetAspRest.empty(), -) - -private fun findCollidingServiceIds(): Pair? { - val byPort = mutableMapOf() - for (index in 0..10_000) { - val artifactId = DotnetAspServiceArtifactId("Platform", "Collision$index.Api") - val existing = byPort.putIfAbsent(dotnetAspHttpPortFor(artifactId), artifactId) - if (existing != null) { - return existing to artifactId - } - } - return null -} diff --git a/modules/compile-services-dotnet-asp/build.gradle b/modules/compile-services-dotnet-asp/build.gradle index edbb1064..40fea185 100644 --- a/modules/compile-services-dotnet-asp/build.gradle +++ b/modules/compile-services-dotnet-asp/build.gradle @@ -4,9 +4,6 @@ dependencies { api project(":compile-services") api project(":compile-services-dotnet") api project(":artifact-services-dotnet-asp") - implementation project(":dsl-services-dotnet") - implementation project(":dsl-services-dotnet-asp") - implementation project(":resolve-services-dotnet-asp") api libs.spi.tooling.annotations kapt libs.spi.tooling.processor } diff --git a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspCSharpAttributes.kt b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspCSharpAttributes.kt deleted file mode 100644 index e41c12f6..00000000 --- a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspCSharpAttributes.kt +++ /dev/null @@ -1,35 +0,0 @@ -package io.github.lmliam.microsmith.compile.services.dotnet.asp - -import io.github.lmliam.microsmith.compile.services.dotnet.csharp.CSharp -import io.github.lmliam.microsmith.dsl.services.dotnet.asp.core.rest.endpoint.DotnetAspHttpMethod - -internal object DotnetAspCSharpAttributes { - object Microsoft { - object AspNetCore { - object Mvc { - val ApiController: CSharp.Attribute = CSharp.attribute("ApiController") - val FromBody: CSharp.Attribute = CSharp.attribute("FromBody") - val FromQuery: CSharp.Attribute = CSharp.attribute("FromQuery") - val FromRoute: CSharp.Attribute = CSharp.attribute("FromRoute") - - fun endpointRoute( - method: DotnetAspHttpMethod, - route: String, - operationName: String, - ): CSharp.Attribute = CSharp.attribute( - name = httpMethodAttributeName(method), - CSharp.positionalArgument(CSharp.stringLiteral(route)), - CSharp.namedArgument("Name", CSharp.stringLiteral(operationName)), - ) - } - } - } -} - -private fun httpMethodAttributeName(method: DotnetAspHttpMethod): String = when (method) { - DotnetAspHttpMethod.GET -> "HttpGet" - DotnetAspHttpMethod.POST -> "HttpPost" - DotnetAspHttpMethod.PUT -> "HttpPut" - DotnetAspHttpMethod.PATCH -> "HttpPatch" - DotnetAspHttpMethod.DELETE -> "HttpDelete" -} diff --git a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspCSharpDefaultValueAccessors.kt b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspCSharpDefaultValueAccessors.kt deleted file mode 100644 index 86ed42a3..00000000 --- a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspCSharpDefaultValueAccessors.kt +++ /dev/null @@ -1,71 +0,0 @@ -package io.github.lmliam.microsmith.compile.services.dotnet.asp - -import io.github.lmliam.microsmith.dsl.services.dotnet.asp.core.rest.request.DotnetAspDefaultValue - -internal fun DotnetAspDefaultValue.requireString(): String = when (this) { - is DotnetAspDefaultValue.StringValue -> value - - is DotnetAspDefaultValue.CharValue, - is DotnetAspDefaultValue.NumericValue, - is DotnetAspDefaultValue.BooleanValue, - is DotnetAspDefaultValue.UuidValue, - is DotnetAspDefaultValue.LocalDateValue, - is DotnetAspDefaultValue.LocalTimeValue, - is DotnetAspDefaultValue.LocalDateTimeValue, - is DotnetAspDefaultValue.InstantValue, - is DotnetAspDefaultValue.OffsetDateTimeValue, - is DotnetAspDefaultValue.DurationValue, - -> invalidAspDefaultValue("string", this) -} - -internal fun DotnetAspDefaultValue.requireChar(): Char = when (this) { - is DotnetAspDefaultValue.CharValue -> value - - is DotnetAspDefaultValue.StringValue, - is DotnetAspDefaultValue.NumericValue, - is DotnetAspDefaultValue.BooleanValue, - is DotnetAspDefaultValue.UuidValue, - is DotnetAspDefaultValue.LocalDateValue, - is DotnetAspDefaultValue.LocalTimeValue, - is DotnetAspDefaultValue.LocalDateTimeValue, - is DotnetAspDefaultValue.InstantValue, - is DotnetAspDefaultValue.OffsetDateTimeValue, - is DotnetAspDefaultValue.DurationValue, - -> invalidAspDefaultValue("char", this) -} - -internal fun DotnetAspDefaultValue.requireNumber(): Number = when (this) { - is DotnetAspDefaultValue.NumericValue -> value - - is DotnetAspDefaultValue.StringValue, - is DotnetAspDefaultValue.CharValue, - is DotnetAspDefaultValue.BooleanValue, - is DotnetAspDefaultValue.UuidValue, - is DotnetAspDefaultValue.LocalDateValue, - is DotnetAspDefaultValue.LocalTimeValue, - is DotnetAspDefaultValue.LocalDateTimeValue, - is DotnetAspDefaultValue.InstantValue, - is DotnetAspDefaultValue.OffsetDateTimeValue, - is DotnetAspDefaultValue.DurationValue, - -> invalidAspDefaultValue("numeric", this) -} - -internal fun DotnetAspDefaultValue.requireBoolean(): Boolean = when (this) { - is DotnetAspDefaultValue.BooleanValue -> value - - is DotnetAspDefaultValue.StringValue, - is DotnetAspDefaultValue.CharValue, - is DotnetAspDefaultValue.NumericValue, - is DotnetAspDefaultValue.UuidValue, - is DotnetAspDefaultValue.LocalDateValue, - is DotnetAspDefaultValue.LocalTimeValue, - is DotnetAspDefaultValue.LocalDateTimeValue, - is DotnetAspDefaultValue.InstantValue, - is DotnetAspDefaultValue.OffsetDateTimeValue, - is DotnetAspDefaultValue.DurationValue, - -> invalidAspDefaultValue("boolean", this) -} - -private fun invalidAspDefaultValue(expected: String, actual: DotnetAspDefaultValue): Nothing { - error("Expected a $expected ASP.NET default value, but was ${actual::class.simpleName}.") -} diff --git a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspCSharpEscaping.kt b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspCSharpEscaping.kt deleted file mode 100644 index 834f992b..00000000 --- a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspCSharpEscaping.kt +++ /dev/null @@ -1,53 +0,0 @@ -package io.github.lmliam.microsmith.compile.services.dotnet.asp - -private const val FIRST_PRINTABLE_ASCII_CODE_POINT = 0x20 -private const val UNICODE_ESCAPE_WIDTH = 4 - -internal fun dotnetAspEscapeStringContents(value: String): String = buildString { - value.forEach { char -> - append(dotnetAspEscapedCharacter(char)) - } -} - -internal fun dotnetAspStringLiteral(value: String): String = buildString { - append('"') - append(dotnetAspEscapeStringContents(value)) - append('"') -} - -internal fun dotnetAspCharLiteral(value: Char): String { - val escaped = when (value) { - '\\' -> "\\\\" - '\'' -> "\\'" - '\b' -> "\\b" - '\u000C' -> "\\f" - '\n' -> "\\n" - '\r' -> "\\r" - '\t' -> "\\t" - else -> value.toString() - } - return "'$escaped'" -} - -private fun dotnetAspEscapedCharacter(char: Char): String = when (char) { - '\\' -> "\\\\" - - '"' -> "\\\"" - - '\b' -> "\\b" - - '\u000C' -> "\\f" - - '\n' -> "\\n" - - '\r' -> "\\r" - - '\t' -> "\\t" - - else -> - if (char.code < FIRST_PRINTABLE_ASCII_CODE_POINT) { - "\\u${char.code.toString(radix = 16).padStart(UNICODE_ESCAPE_WIDTH, '0')}" - } else { - char.toString() - } -} diff --git a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspCSharpLiterals.kt b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspCSharpLiterals.kt deleted file mode 100644 index be2542c3..00000000 --- a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspCSharpLiterals.kt +++ /dev/null @@ -1,127 +0,0 @@ -package io.github.lmliam.microsmith.compile.services.dotnet.asp - -import io.github.lmliam.microsmith.compile.services.dotnet.csharp.requireDotnetRepresentableInteger -import io.github.lmliam.microsmith.dsl.services.dotnet.asp.core.rest.request.DotnetAspDefaultValue -import io.github.lmliam.microsmith.dsl.services.dotnet.core.model.DotnetFieldType -import java.math.BigDecimal -import java.util.Locale - -internal fun dotnetAspLiteral(type: DotnetFieldType, value: DotnetAspDefaultValue): String = when (type) { - DotnetFieldType.String -> dotnetAspStringLiteral(value.requireString()) - - DotnetFieldType.Char -> dotnetAspCharLiteral(value.requireChar()) - - DotnetFieldType.Byte, - DotnetFieldType.SignedByte, - DotnetFieldType.Short, - DotnetFieldType.UnsignedShort, - DotnetFieldType.Int, - DotnetFieldType.UnsignedInt, - DotnetFieldType.Long, - DotnetFieldType.UnsignedLong, - DotnetFieldType.NativeInt, - DotnetFieldType.UnsignedNativeInt, - DotnetFieldType.Float, - DotnetFieldType.Double, - DotnetFieldType.Decimal, - -> dotnetAspNumericLiteral(type, value.requireNumber()) - - DotnetFieldType.Bool -> value.requireBoolean().toString().lowercase(Locale.ROOT) - - DotnetFieldType.Guid -> dotnetAspGuidLiteral(value) - - DotnetFieldType.DateOnly -> dotnetAspDateOnlyLiteral(value) - - DotnetFieldType.TimeOnly -> dotnetAspTimeOnlyLiteral(value) - - DotnetFieldType.DateTime -> dotnetAspDateTimeLiteral(value) - - DotnetFieldType.DateTimeOffset -> dotnetAspDateTimeOffsetLiteral(value) - - DotnetFieldType.TimeSpan -> dotnetAspTimeSpanLiteral(value) - - is DotnetFieldType.Reference -> - error("ASP.NET request defaults cannot target shared model '${type.target}'.") -} -private fun dotnetAspNumericLiteral(type: DotnetFieldType, value: Number): String = when (type) { - DotnetFieldType.Float -> "${dotnetAspFloatingLiteral(value)}f" - - DotnetFieldType.Double -> dotnetAspFloatingLiteral(value) - - DotnetFieldType.Decimal -> "${dotnetAspDecimalLiteral(value)}m" - - DotnetFieldType.Byte, - DotnetFieldType.SignedByte, - DotnetFieldType.Short, - DotnetFieldType.UnsignedShort, - DotnetFieldType.Int, - DotnetFieldType.UnsignedInt, - DotnetFieldType.Long, - DotnetFieldType.UnsignedLong, - DotnetFieldType.NativeInt, - DotnetFieldType.UnsignedNativeInt, - -> dotnetAspIntegerLiteral(type, value) - - DotnetFieldType.String, - DotnetFieldType.Char, - DotnetFieldType.Bool, - DotnetFieldType.Guid, - DotnetFieldType.DateOnly, - DotnetFieldType.TimeOnly, - DotnetFieldType.DateTime, - DotnetFieldType.DateTimeOffset, - DotnetFieldType.TimeSpan, - is DotnetFieldType.Reference, - -> error("Unsupported ASP.NET numeric literal type '$type'.") -} - -private fun dotnetAspIntegerLiteral(type: DotnetFieldType, value: Number): String { - val integer = requireDotnetRepresentableInteger(type, value, "ASP.NET request default") - return when (type) { - DotnetFieldType.Byte, - DotnetFieldType.SignedByte, - DotnetFieldType.Short, - DotnetFieldType.UnsignedShort, - DotnetFieldType.Int, - DotnetFieldType.NativeInt, - -> integer.toString() - - DotnetFieldType.UnsignedInt, - DotnetFieldType.UnsignedNativeInt, - -> "${integer}u" - - DotnetFieldType.Long -> "${integer}L" - - DotnetFieldType.UnsignedLong -> "${integer}UL" - - DotnetFieldType.Float, - DotnetFieldType.Double, - DotnetFieldType.Decimal, - DotnetFieldType.String, - DotnetFieldType.Char, - DotnetFieldType.Bool, - DotnetFieldType.Guid, - DotnetFieldType.DateOnly, - DotnetFieldType.TimeOnly, - DotnetFieldType.DateTime, - DotnetFieldType.DateTimeOffset, - DotnetFieldType.TimeSpan, - is DotnetFieldType.Reference, - -> error("Unsupported ASP.NET integer literal type '$type'.") - } -} - -private fun dotnetAspFloatingLiteral(value: Number): String { - val rendered = value.toString() - return if (rendered.any { it == '.' || it == 'e' || it == 'E' }) { - rendered - } else { - "$rendered.0" - } -} - -private fun dotnetAspDecimalLiteral(value: Number): String = if (value is BigDecimal) { - value.toPlainString() -} else { - value.toString() -} diff --git a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspCSharpNames.kt b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspCSharpNames.kt deleted file mode 100644 index d06e2610..00000000 --- a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspCSharpNames.kt +++ /dev/null @@ -1,71 +0,0 @@ -package io.github.lmliam.microsmith.compile.services.dotnet.asp - -import java.util.Locale - -private const val HTTP_STATUS_OK = 200 -private const val HTTP_STATUS_CREATED = 201 -private const val HTTP_STATUS_ACCEPTED = 202 -private const val HTTP_STATUS_NO_CONTENT = 204 -private const val HTTP_STATUS_BAD_REQUEST = 400 -private const val HTTP_STATUS_UNAUTHORIZED = 401 -private const val HTTP_STATUS_FORBIDDEN = 403 -private const val HTTP_STATUS_NOT_FOUND = 404 -private const val HTTP_STATUS_CONFLICT = 409 -private const val HTTP_STATUS_INTERNAL_SERVER_ERROR = 500 - -internal fun dotnetAspPascalIdentifier(identifier: String): String = when { - identifier.startsWith("@") && identifier.length > 1 -> - "@${identifier.substring(1).replaceFirstChar { firstChar -> - firstChar.titlecase(Locale.ROOT) - }}" - - else -> - identifier.replaceFirstChar { firstChar -> - firstChar.titlecase(Locale.ROOT) - } -} - -internal fun dotnetAspTypeName(raw: String): String = raw - .split('.', '-', '_', ' ') - .filter(String::isNotBlank) - .joinToString("") { segment -> - segment - .removePrefix("@") - .replaceFirstChar { firstChar -> firstChar.titlecase(Locale.ROOT) } - }.ifBlank { - error("Unable to derive a generated ASP.NET type name from '$raw'.") - } - -internal fun dotnetAspHeaderPropertyName(headerName: String): String = headerName - .trim() - .split(HEADER_PROPERTY_DELIMITER_PATTERN) - .filter(String::isNotBlank) - .joinToString("") { segment -> - segment.lowercase(Locale.ROOT).replaceFirstChar { firstChar -> - firstChar.titlecase(Locale.ROOT) - } - }.let { identifier -> - if (identifier.firstOrNull()?.isDigit() == true) { - "Header$identifier" - } else { - identifier - } - }.ifBlank { - error("Unable to derive an ASP.NET response header property name from '$headerName'.") - } - -internal fun dotnetAspStatusName(statusCode: Int): String = when (statusCode) { - HTTP_STATUS_OK -> "Ok" - HTTP_STATUS_CREATED -> "Created" - HTTP_STATUS_ACCEPTED -> "Accepted" - HTTP_STATUS_NO_CONTENT -> "NoContent" - HTTP_STATUS_BAD_REQUEST -> "BadRequest" - HTTP_STATUS_UNAUTHORIZED -> "Unauthorized" - HTTP_STATUS_FORBIDDEN -> "Forbidden" - HTTP_STATUS_NOT_FOUND -> "NotFound" - HTTP_STATUS_CONFLICT -> "Conflict" - HTTP_STATUS_INTERNAL_SERVER_ERROR -> "InternalServerError" - else -> "Status$statusCode" -} - -private val HEADER_PROPERTY_DELIMITER_PATTERN = Regex("[^A-Za-z0-9]+") diff --git a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspCSharpNamespaces.kt b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspCSharpNamespaces.kt deleted file mode 100644 index 14d278f1..00000000 --- a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspCSharpNamespaces.kt +++ /dev/null @@ -1,23 +0,0 @@ -package io.github.lmliam.microsmith.compile.services.dotnet.asp - -import io.github.lmliam.microsmith.compile.services.dotnet.csharp.DotnetCSharpNamespace - -internal object DotnetAspCSharpNamespaces { - object Microsoft { - object AspNetCore { - data object Builder : DotnetCSharpNamespace { - override val value = "Microsoft.AspNetCore.Builder" - } - - data object Mvc : DotnetCSharpNamespace { - override val value = "Microsoft.AspNetCore.Mvc" - } - } - - object Extensions { - data object DependencyInjection : DotnetCSharpNamespace { - override val value = "Microsoft.Extensions.DependencyInjection" - } - } - } -} diff --git a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspCSharpRendering.kt b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspCSharpRendering.kt deleted file mode 100644 index 2988266a..00000000 --- a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspCSharpRendering.kt +++ /dev/null @@ -1,23 +0,0 @@ -package io.github.lmliam.microsmith.compile.services.dotnet.asp - -import io.github.lmliam.microsmith.dsl.services.dotnet.core.model.DotnetFieldType - -internal fun dotnetAspCSharpType(type: DotnetFieldType, nullable: Boolean = false): String { - val baseType = type.csharpType - return if (nullable) { - "$baseType?" - } else { - baseType - } -} - -internal fun dotnetAspIndent(text: String, spaces: Int = 4): String { - val prefix = " ".repeat(spaces) - return text.lines().joinToString("\n") { line -> - if (line.isBlank()) { - line - } else { - prefix + line - } - } -} diff --git a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspCSharpTemporalLiterals.kt b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspCSharpTemporalLiterals.kt deleted file mode 100644 index 59b25e26..00000000 --- a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspCSharpTemporalLiterals.kt +++ /dev/null @@ -1,65 +0,0 @@ -package io.github.lmliam.microsmith.compile.services.dotnet.asp - -import io.github.lmliam.microsmith.dsl.services.dotnet.asp.core.rest.request.DotnetAspDefaultValue - -internal fun dotnetAspGuidLiteral(value: DotnetAspDefaultValue): String = - "Guid.Parse(${dotnetAspStringLiteral(value.temporalTextValue())})" - -internal fun dotnetAspDateOnlyLiteral(value: DotnetAspDefaultValue): String = - "DateOnly.Parse(${dotnetAspStringLiteral(value.temporalTextValue())})" - -internal fun dotnetAspTimeOnlyLiteral(value: DotnetAspDefaultValue): String = - "TimeOnly.Parse(${dotnetAspStringLiteral(value.temporalTextValue())})" - -internal fun dotnetAspDateTimeLiteral(value: DotnetAspDefaultValue): String = - "DateTime.Parse(${dotnetAspStringLiteral(value.temporalTextValue())})" - -internal fun dotnetAspDateTimeOffsetLiteral(value: DotnetAspDefaultValue): String = - "DateTimeOffset.Parse(${dotnetAspStringLiteral(value.temporalTextValue())})" - -internal fun dotnetAspTimeSpanLiteral(value: DotnetAspDefaultValue): String = when (value) { - is DotnetAspDefaultValue.DurationValue -> - "System.Xml.XmlConvert.ToTimeSpan(${dotnetAspStringLiteral(value.value.toString())})" - - is DotnetAspDefaultValue.StringValue, - is DotnetAspDefaultValue.UuidValue, - is DotnetAspDefaultValue.LocalDateValue, - is DotnetAspDefaultValue.LocalTimeValue, - is DotnetAspDefaultValue.LocalDateTimeValue, - is DotnetAspDefaultValue.InstantValue, - is DotnetAspDefaultValue.OffsetDateTimeValue, - -> - "TimeSpan.Parse(${dotnetAspStringLiteral(value.temporalTextValue())})" - - is DotnetAspDefaultValue.CharValue, - is DotnetAspDefaultValue.NumericValue, - is DotnetAspDefaultValue.BooleanValue, - -> invalidTemporalDefaultValue(value) -} - -private fun DotnetAspDefaultValue.temporalTextValue(): String = when (this) { - is DotnetAspDefaultValue.StringValue -> value - - is DotnetAspDefaultValue.UuidValue -> value.toString() - - is DotnetAspDefaultValue.LocalDateValue -> value.toString() - - is DotnetAspDefaultValue.LocalTimeValue -> value.toString() - - is DotnetAspDefaultValue.LocalDateTimeValue -> value.toString() - - is DotnetAspDefaultValue.InstantValue -> value.toString() - - is DotnetAspDefaultValue.OffsetDateTimeValue -> value.toString() - - is DotnetAspDefaultValue.DurationValue -> value.toString() - - is DotnetAspDefaultValue.CharValue, - is DotnetAspDefaultValue.NumericValue, - is DotnetAspDefaultValue.BooleanValue, - -> invalidTemporalDefaultValue(this) -} - -private fun invalidTemporalDefaultValue(value: DotnetAspDefaultValue): Nothing { - error("Expected a temporal/string ASP.NET default value, but was ${value::class.simpleName}.") -} diff --git a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspCSharpTypes.kt b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspCSharpTypes.kt deleted file mode 100644 index d64b096d..00000000 --- a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspCSharpTypes.kt +++ /dev/null @@ -1,31 +0,0 @@ -package io.github.lmliam.microsmith.compile.services.dotnet.asp - -import io.github.lmliam.microsmith.compile.services.dotnet.csharp.DotnetCSharpTypeName - -internal object DotnetAspCSharpTypes { - object AspNetCore { - object Mvc { - data object ActionResult : DotnetCSharpTypeName { - override val value = "ActionResult" - } - - data object ControllerBase : DotnetCSharpTypeName { - override val value = "ControllerBase" - } - - data object ObjectResult : DotnetCSharpTypeName { - override val value = "ObjectResult" - } - } - - object Builder { - data object WebApplication : DotnetCSharpTypeName { - override val value = "WebApplication" - } - - data object WebApplicationBuilder : DotnetCSharpTypeName { - override val value = "WebApplicationBuilder" - } - } - } -} diff --git a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspEndpointNaming.kt b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspEndpointNaming.kt deleted file mode 100644 index 38b207fe..00000000 --- a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspEndpointNaming.kt +++ /dev/null @@ -1,40 +0,0 @@ -package io.github.lmliam.microsmith.compile.services.dotnet.asp - -import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspServiceArtifact -import io.github.lmliam.microsmith.resolve.services.dotnet.asp.ResolvedDotnetAspEndpoint -import io.github.lmliam.microsmith.resolve.services.dotnet.asp.ResolvedDotnetAspResponse - -internal const val MICROSMITH_CONTROLLER_BASE_TYPE_NAME: String = "MicrosmithControllerBase" -internal const val RESULT_BODY_PROPERTY_NAME: String = "Body" - -internal fun contractsNamespace(artifact: DotnetAspServiceArtifact) = "${artifact.id.projectName}.Generated.Contracts" - -internal fun controllersNamespace(artifact: DotnetAspServiceArtifact) = - "${artifact.id.projectName}.Generated.Controllers" - -internal fun hostingNamespace(artifact: DotnetAspServiceArtifact): String = - "${artifact.id.projectName}.Generated.Hosting" - -internal fun controllerPrefix(artifact: DotnetAspServiceArtifact) = dotnetAspTypeName(artifact.id.projectName) - -internal fun controllerBaseTypeName(artifact: DotnetAspServiceArtifact): String = - "${controllerPrefix(artifact)}ControllerBase" - -internal fun microsmithControllerBaseRelativePath(): String = - "Generated/Controllers/$MICROSMITH_CONTROLLER_BASE_TYPE_NAME.cs" - -internal fun controllerBaseRelativePath(artifact: DotnetAspServiceArtifact): String = - "Generated/Controllers/${controllerBaseTypeName(artifact)}.cs" - -internal fun resultBaseTypeName(endpoint: ResolvedDotnetAspEndpoint) = "${endpoint.operationName}Result" - -internal fun resultVariantTypeName(endpoint: ResolvedDotnetAspEndpoint, response: ResolvedDotnetAspResponse) = - endpoint.operationName + dotnetAspStatusName(response.statusCode) - -internal fun inlineBodyTypeName(endpoint: ResolvedDotnetAspEndpoint) = - endpoint.operationName + requireNotNull(endpoint.bindings.body).model.name - -internal fun inlineResponseTypeName(endpoint: ResolvedDotnetAspEndpoint, response: ResolvedDotnetAspResponse) = - endpoint.operationName + dotnetAspStatusName(response.statusCode) + response.model.model.name - -internal fun dotnetAspRouteLiteral(value: String): String = "\"${value.replace("\\", "\\\\").replace("\"", "\\\"")}\"" diff --git a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspEndpointTextFileRenderer.kt b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspEndpointTextFileRenderer.kt deleted file mode 100644 index 823cfc62..00000000 --- a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspEndpointTextFileRenderer.kt +++ /dev/null @@ -1,36 +0,0 @@ -package io.github.lmliam.microsmith.compile.services.dotnet.asp - -import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspServiceArtifact - -internal class DotnetAspEndpointTextFileRenderer { - fun render(artifact: DotnetAspServiceArtifact): List { - validateEndpointGenerationInputs(artifact) - - return listOf( - DotnetAspGeneratedTextFile( - relativePath = "Generated/Hosting/MicrosmithHostingExtensions.cs", - contents = renderHostingExtensionsFile(artifact), - ), - DotnetAspGeneratedTextFile( - relativePath = "Generated/Contracts/ServiceModels.cs", - contents = renderSharedModelsFile(artifact), - ), - DotnetAspGeneratedTextFile( - relativePath = "Generated/Contracts/RequestModels.cs", - contents = renderRequestModelsFile(artifact), - ), - DotnetAspGeneratedTextFile( - relativePath = "Generated/Contracts/ResponseModels.cs", - contents = renderResponseModelsFile(artifact), - ), - DotnetAspGeneratedTextFile( - relativePath = microsmithControllerBaseRelativePath(), - contents = renderMicrosmithControllerBaseFile(artifact), - ), - DotnetAspGeneratedTextFile( - relativePath = controllerBaseRelativePath(artifact), - contents = renderControllerBaseFile(artifact), - ), - ) - } -} diff --git a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspEndpointValidation.kt b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspEndpointValidation.kt deleted file mode 100644 index 96afd915..00000000 --- a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspEndpointValidation.kt +++ /dev/null @@ -1,148 +0,0 @@ -package io.github.lmliam.microsmith.compile.services.dotnet.asp - -import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspServiceArtifact -import io.github.lmliam.microsmith.dsl.services.dotnet.core.model.DotnetFieldType -import io.github.lmliam.microsmith.resolve.services.dotnet.asp.ResolvedDotnetAspHeadersBinding -import io.github.lmliam.microsmith.resolve.services.dotnet.asp.ResolvedDotnetAspModelLocality -import io.github.lmliam.microsmith.resolve.services.dotnet.asp.ResolvedDotnetAspRequestBinding - -internal fun validateEndpointGenerationInputs(artifact: DotnetAspServiceArtifact) { - validateRequestBindings(artifact) - validateResponseHeaderNames(artifact) - validateGeneratedContractTypeNames(artifact) - validateGeneratedControllerTypeNames(artifact) -} - -private fun validateRequestBindings(artifact: DotnetAspServiceArtifact) { - artifact.rest.endpoints.forEach { endpoint -> - listOfNotNull(endpoint.bindings.path, endpoint.bindings.query).forEach { binding -> - binding.fields.forEach { field -> - val referenceTarget = (field.type as? DotnetFieldType.Reference)?.target - require(referenceTarget == null) { - "ASP.NET request binding '${binding.name}' in operation " + - "'${endpoint.operationName}' cannot reference shared model " + - "'$referenceTarget'. " + - "Transport bindings must declare scalar fields." - } - } - } - } -} - -private fun validateResponseHeaderNames(artifact: DotnetAspServiceArtifact) { - artifact.rest.endpoints.forEach { endpoint -> - endpoint.responses.forEach { response -> - val headerPropertyNames = response.headers.map { header -> - val generatedName = dotnetAspHeaderPropertyName(header.name) - require(generatedName != RESULT_BODY_PROPERTY_NAME) { - "ASP.NET response ${response.statusCode} in operation " + - "'${endpoint.operationName}' declares header '${header.name}', " + - "which collides with the generated result body property " + - "'$RESULT_BODY_PROPERTY_NAME'." - } - generatedName - } - val collisions = response.headers - .zip(headerPropertyNames) - .groupBy({ (_, generatedName) -> generatedName }, { (header, _) -> header }) - .filterValues { it.size > 1 } - .keys - .sorted() - require(collisions.isEmpty()) { - "ASP.NET response ${response.statusCode} in operation " + - "'${endpoint.operationName}' declares headers with colliding " + - "generated property names: ${collisions.joinToString(", ")}." - } - } - } -} - -private fun validateGeneratedControllerTypeNames(artifact: DotnetAspServiceArtifact) { - require(controllerBaseTypeName(artifact) != MICROSMITH_CONTROLLER_BASE_TYPE_NAME) { - "ASP.NET service '${artifact.serviceName}' project '${artifact.id.projectName}' " + - "generates controller base type '${controllerBaseTypeName(artifact)}', " + - "which collides with shared generated controller base type " + - "'$MICROSMITH_CONTROLLER_BASE_TYPE_NAME'." - } -} - -private fun validateGeneratedContractTypeNames(artifact: DotnetAspServiceArtifact) { - val contractOwners = linkedMapOf>() - - fun register(typeName: String, owner: String) { - contractOwners.getOrPut(typeName) { mutableListOf() }.add(owner) - } - - artifact.models.keys.forEach { register(it, "shared model '$it'") } - collectRequestBindings(artifact).forEach { register(it.name, "request binding '${it.name}'") } - collectHeaderBindings(artifact).forEach { register(it.name, "headers binding '${it.name}'") } - artifact.rest.endpoints.forEach { endpoint -> - register( - resultBaseTypeName(endpoint), - "result base for operation '${endpoint.operationName}'", - ) - endpoint.bindings.body - ?.takeIf { it.locality == ResolvedDotnetAspModelLocality.INLINE } - ?.let { - register( - inlineBodyTypeName(endpoint), - "inline body model for operation '${endpoint.operationName}'", - ) - } - endpoint.responses.forEach { response -> - register( - resultVariantTypeName(endpoint, response), - "response result for operation '${endpoint.operationName}' " + - "status ${response.statusCode}", - ) - if (response.model.locality == ResolvedDotnetAspModelLocality.INLINE) { - register( - inlineResponseTypeName(endpoint, response), - "inline response model for operation '${endpoint.operationName}' " + - "status ${response.statusCode}", - ) - } - } - } - - val collisions = contractOwners - .filterValues { it.size > 1 } - .entries - .sortedBy { it.key } - require(collisions.isEmpty()) { - "ASP.NET service '${artifact.serviceName}' produces colliding generated contract types: " + - collisions.joinToString("; ") { (typeName, owners) -> - "$typeName from ${owners.sorted().joinToString(", ")}" - } + "." - } -} - -internal fun collectRequestBindings(artifact: DotnetAspServiceArtifact): List = - artifact - .rest - .endpoints - .flatMap { endpoint -> - listOfNotNull(endpoint.bindings.path, endpoint.bindings.query) - }.groupBy(ResolvedDotnetAspRequestBinding::name) - .map { (name, bindings) -> - val first = bindings.first() - require(bindings.all { it == first }) { - "ASP.NET service '${artifact.serviceName}' declares conflicting " + - "request binding shapes for '$name'." - } - first - }.sortedBy(ResolvedDotnetAspRequestBinding::name) - -internal fun collectHeaderBindings(artifact: DotnetAspServiceArtifact): List = artifact - .rest - .endpoints - .mapNotNull { it.bindings.headers } - .groupBy(ResolvedDotnetAspHeadersBinding::name) - .map { (name, bindings) -> - val first = bindings.first() - require(bindings.all { it == first }) { - "ASP.NET service '${artifact.serviceName}' declares conflicting " + - "headers binding shapes for '$name'." - } - first - }.sortedBy(ResolvedDotnetAspHeadersBinding::name) diff --git a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspGeneratedContractsRenderer.kt b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspGeneratedContractsRenderer.kt deleted file mode 100644 index 0787596e..00000000 --- a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspGeneratedContractsRenderer.kt +++ /dev/null @@ -1,71 +0,0 @@ -package io.github.lmliam.microsmith.compile.services.dotnet.asp - -import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspServiceArtifact -import io.github.lmliam.microsmith.compile.services.dotnet.csharp.CSharp -import io.github.lmliam.microsmith.compile.services.dotnet.csharp.CSharpFileBuilder -import io.github.lmliam.microsmith.compile.services.dotnet.csharp.DotnetCSharpNamespace -import io.github.lmliam.microsmith.compile.services.dotnet.csharp.DotnetCSharpNamespaces -import io.github.lmliam.microsmith.compile.services.dotnet.csharp.using -import io.github.lmliam.microsmith.dsl.services.dotnet.core.model.DotnetModel -import io.github.lmliam.microsmith.resolve.services.dotnet.asp.ResolvedDotnetAspModelLocality - -internal fun renderSharedModelsFile(artifact: DotnetAspServiceArtifact): String = buildContractsFile(artifact) { - if (artifact.models.isNotEmpty()) { - artifact.models.values.sortedBy(DotnetModel::name).forEach { model -> - addType(renderModelClass(model.name, model.fields)) - } - } -} - -internal fun renderRequestModelsFile(artifact: DotnetAspServiceArtifact): String { - val requestTypes = buildList { - artifact.rest.endpoints.forEach { endpoint -> - endpoint.bindings.path?.let { add(renderRequestBindingClass(it)) } - endpoint.bindings.query?.let { add(renderRequestBindingClass(it)) } - endpoint.bindings.headers?.let { add(renderHeadersBindingClass(it)) } - endpoint.bindings.body - ?.takeIf { it.locality == ResolvedDotnetAspModelLocality.INLINE } - ?.let { add(renderModelClass(inlineBodyTypeName(endpoint), it.model.fields)) } - } - }.distinct() - - return buildContractsFile(artifact) { - requestTypes.forEach(::addType) - } -} - -internal fun renderResponseModelsFile(artifact: DotnetAspServiceArtifact): String { - val endpoints = artifact.rest.endpoints - val inlineResponseModels = buildList { - endpoints.forEach { endpoint -> - endpoint.responses - .filter { it.model.locality == ResolvedDotnetAspModelLocality.INLINE } - .forEach { response -> - add( - renderModelClass( - inlineResponseTypeName(endpoint, response), - response.model.model.fields, - ), - ) - } - } - }.distinct() - - return buildContractsFile(artifact) { - inlineResponseModels.forEach(::addType) - endpoints.forEach { endpoint -> - renderOperationResultTypes(endpoint).forEach(::addType) - } - } -} - -internal fun buildContractsFile( - artifact: DotnetAspServiceArtifact, - usings: Set = setOf(DotnetCSharpNamespaces.System.Root), - build: CSharpFileBuilder.() -> Unit, -): String = CSharp.render( - CSharp.file(contractsNamespace(artifact)) { - usings.forEach(::using) - build() - }, -) diff --git a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspGeneratedControllerActionRenderer.kt b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspGeneratedControllerActionRenderer.kt deleted file mode 100644 index 80c2e146..00000000 --- a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspGeneratedControllerActionRenderer.kt +++ /dev/null @@ -1,130 +0,0 @@ -package io.github.lmliam.microsmith.compile.services.dotnet.asp - -import io.github.lmliam.microsmith.compile.services.dotnet.csharp.CSharp -import io.github.lmliam.microsmith.compile.services.dotnet.csharp.DotnetCSharpTypes -import io.github.lmliam.microsmith.compile.services.dotnet.csharp.csharpGenericType -import io.github.lmliam.microsmith.compile.services.dotnet.csharp.csharpParameter -import io.github.lmliam.microsmith.compile.services.dotnet.csharp.csharpType -import io.github.lmliam.microsmith.resolve.services.dotnet.asp.ResolvedDotnetAspEndpoint -import io.github.lmliam.microsmith.resolve.services.dotnet.asp.ResolvedDotnetAspHeadersBinding -import io.github.lmliam.microsmith.resolve.services.dotnet.asp.ResolvedDotnetAspModelLocality - -internal fun renderActionMethod(endpoint: ResolvedDotnetAspEndpoint): CSharp.Method = CSharp.Method( - name = endpoint.operationName, - modifiers = listOf(CSharp.Modifier.PUBLIC, CSharp.Modifier.ASYNC), - returnType = csharpGenericType( - DotnetCSharpTypes.Threading.Task, - csharpGenericType( - DotnetAspCSharpTypes.AspNetCore.Mvc.ActionResult, - csharpType(resultBaseTypeName(endpoint)), - ), - ), - attributes = listOf( - DotnetAspCSharpAttributes.Microsoft.AspNetCore.Mvc.endpointRoute( - method = endpoint.method, - route = endpoint.route, - operationName = endpoint.operationName, - ), - ), - parameters = buildList { - endpoint.bindings.path?.let { - add( - csharpParameter( - it.name, - "path", - attributes = listOf(DotnetAspCSharpAttributes.Microsoft.AspNetCore.Mvc.FromRoute), - ), - ) - } - endpoint.bindings.query?.let { - add( - csharpParameter( - it.name, - "query", - attributes = listOf(DotnetAspCSharpAttributes.Microsoft.AspNetCore.Mvc.FromQuery), - ), - ) - } - endpoint.bindings.body?.let { - add( - csharpParameter( - resolveBodyTypeName(endpoint), - "body", - attributes = listOf(DotnetAspCSharpAttributes.Microsoft.AspNetCore.Mvc.FromBody), - ), - ) - } - add(csharpParameter(DotnetCSharpTypes.Threading.CancellationToken, "cancellationToken")) - }, - body = CSharp.codeBlock { - renderHeadersPrelude(endpoint)?.let { headerInitializer -> - local(name = "headers", initializer = headerInitializer) - blankLine() - } - local( - name = "result", - initializer = CSharp.await( - CSharp.callValues( - CSharp.identifier("On${endpoint.operationName}Async"), - handlerArguments(endpoint), - ), - ), - ) - returnStatement( - CSharp.call( - CSharp.identifier("Map${endpoint.operationName}Result"), - CSharp.identifier("result"), - ), - ) - }, -) - -internal fun renderAbstractHandler(endpoint: ResolvedDotnetAspEndpoint): CSharp.Method = CSharp.Method( - name = "On${endpoint.operationName}Async", - modifiers = listOf(CSharp.Modifier.PROTECTED, CSharp.Modifier.ABSTRACT), - returnType = csharpGenericType(DotnetCSharpTypes.Threading.Task, csharpType(resultBaseTypeName(endpoint))), - parameters = buildList { - endpoint.bindings.path?.let { add(csharpParameter(it.name, "path")) } - endpoint.bindings.query?.let { add(csharpParameter(it.name, "query")) } - endpoint.bindings.headers?.let { add(csharpParameter(it.name, "headers")) } - endpoint.bindings.body?.let { add(csharpParameter(resolveBodyTypeName(endpoint), "body")) } - add(csharpParameter(DotnetCSharpTypes.Threading.CancellationToken, "cancellationToken")) - }, -) - -internal fun handlerArguments(endpoint: ResolvedDotnetAspEndpoint): List = buildList { - endpoint.bindings.path?.let { add(CSharp.identifier("path")) } - endpoint.bindings.query?.let { add(CSharp.identifier("query")) } - endpoint.bindings.headers?.let { add(CSharp.identifier("headers")) } - endpoint.bindings.body?.let { add(CSharp.identifier("body")) } - add(CSharp.identifier("cancellationToken")) -} - -private fun renderHeadersPrelude(endpoint: ResolvedDotnetAspEndpoint): CSharp.Expression? = - endpoint.bindings.headers?.let(::renderHeadersInstantiation) - -private fun renderHeadersInstantiation(binding: ResolvedDotnetAspHeadersBinding): CSharp.Expression = CSharp.new( - type = csharpType(binding.name), - initializers = binding.headers.map { header -> - CSharp.init( - memberName = dotnetAspPascalIdentifier(header.name), - value = CSharp.call( - CSharp.identifier("ReadHeader"), - CSharp.stringLiteral(header.headerName), - ), - ) - }, -) - -internal fun resolveBodyTypeName(endpoint: ResolvedDotnetAspEndpoint): String = - when (endpoint.bindings.body?.locality) { - ResolvedDotnetAspModelLocality.SHARED -> - requireNotNull(endpoint.bindings.body).model.name - - ResolvedDotnetAspModelLocality.INLINE -> inlineBodyTypeName(endpoint) - - null -> - error( - "ASP.NET endpoint '${endpoint.operationName}' does not declare a body binding.", - ) - } diff --git a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspGeneratedControllerRenderer.kt b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspGeneratedControllerRenderer.kt deleted file mode 100644 index f75e59a5..00000000 --- a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspGeneratedControllerRenderer.kt +++ /dev/null @@ -1,54 +0,0 @@ -package io.github.lmliam.microsmith.compile.services.dotnet.asp - -import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspServiceArtifact -import io.github.lmliam.microsmith.compile.services.dotnet.csharp.CSharp -import io.github.lmliam.microsmith.compile.services.dotnet.csharp.DotnetCSharpNamespaces -import io.github.lmliam.microsmith.compile.services.dotnet.csharp.csharpType -import io.github.lmliam.microsmith.compile.services.dotnet.csharp.using - -internal fun renderControllerBaseFile(artifact: DotnetAspServiceArtifact): String { - val endpoints = artifact.rest.endpoints - val sections = buildList { - endpoints.forEach { endpoint -> - add(renderActionMethod(endpoint)) - } - endpoints.forEach { endpoint -> - add(renderAbstractHandler(endpoint)) - } - endpoints.forEach { endpoint -> - add(renderResultMapper(endpoint)) - } - } - - return CSharp.render( - CSharp.file(controllersNamespace(artifact)) { - using(DotnetCSharpNamespaces.System.Root) - using(DotnetCSharpNamespaces.System.Threading.Root) - using(DotnetCSharpNamespaces.System.Threading.Tasks) - using(DotnetAspCSharpNamespaces.Microsoft.AspNetCore.Mvc) - using(contractsNamespace(artifact)) - classType( - name = controllerBaseTypeName(artifact), - modifiers = listOf(CSharp.Modifier.PUBLIC, CSharp.Modifier.ABSTRACT), - baseTypes = listOf(csharpType(MICROSMITH_CONTROLLER_BASE_TYPE_NAME)), - attributes = listOf(DotnetAspCSharpAttributes.Microsoft.AspNetCore.Mvc.ApiController), - ) { - sections.forEach(::addMember) - } - }, - ) -} - -internal fun renderMicrosmithControllerBaseFile(artifact: DotnetAspServiceArtifact): String = CSharp.render( - CSharp.file(controllersNamespace(artifact)) { - using(DotnetAspCSharpNamespaces.Microsoft.AspNetCore.Mvc) - classType( - name = MICROSMITH_CONTROLLER_BASE_TYPE_NAME, - modifiers = listOf(CSharp.Modifier.PUBLIC, CSharp.Modifier.ABSTRACT), - baseTypes = listOf(csharpType(DotnetAspCSharpTypes.AspNetCore.Mvc.ControllerBase)), - ) { - addMember(renderRespondHelper()) - addMember(renderReadHeaderHelper()) - } - }, -) diff --git a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspGeneratedControllerResultRenderer.kt b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspGeneratedControllerResultRenderer.kt deleted file mode 100644 index 1332a413..00000000 --- a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspGeneratedControllerResultRenderer.kt +++ /dev/null @@ -1,153 +0,0 @@ -package io.github.lmliam.microsmith.compile.services.dotnet.asp - -import io.github.lmliam.microsmith.compile.services.dotnet.csharp.CSharp -import io.github.lmliam.microsmith.compile.services.dotnet.csharp.DotnetCSharpTypes -import io.github.lmliam.microsmith.compile.services.dotnet.csharp.csharpArrayType -import io.github.lmliam.microsmith.compile.services.dotnet.csharp.csharpGenericType -import io.github.lmliam.microsmith.compile.services.dotnet.csharp.csharpNullableType -import io.github.lmliam.microsmith.compile.services.dotnet.csharp.csharpParameter -import io.github.lmliam.microsmith.compile.services.dotnet.csharp.csharpTupleElement -import io.github.lmliam.microsmith.compile.services.dotnet.csharp.csharpTupleType -import io.github.lmliam.microsmith.compile.services.dotnet.csharp.csharpType -import io.github.lmliam.microsmith.resolve.services.dotnet.asp.ResolvedDotnetAspEndpoint -import io.github.lmliam.microsmith.resolve.services.dotnet.asp.ResolvedDotnetAspResponse - -internal fun renderResultMapper(endpoint: ResolvedDotnetAspEndpoint): CSharp.Method = CSharp.Method( - name = "Map${endpoint.operationName}Result", - modifiers = listOf(CSharp.Modifier.PRIVATE), - returnType = csharpGenericType( - DotnetAspCSharpTypes.AspNetCore.Mvc.ActionResult, - csharpType(resultBaseTypeName(endpoint)), - ), - parameters = listOf(csharpParameter(resultBaseTypeName(endpoint), "result")), - body = CSharp.codeBlock { - returnStatement( - CSharp.switch(subject = CSharp.identifier("result"), arms = renderResultSwitchArms(endpoint)), - ) - }, -) - -internal fun renderRespondHelper(): CSharp.Method = CSharp.Method( - name = "Respond", - modifiers = listOf(CSharp.Modifier.PROTECTED), - returnType = csharpType(DotnetAspCSharpTypes.AspNetCore.Mvc.ObjectResult), - parameters = listOf( - csharpParameter(DotnetCSharpTypes.Primitives.Object, "body"), - csharpParameter(DotnetCSharpTypes.Primitives.Int, "statusCode"), - csharpParameter( - csharpArrayType( - csharpTupleType( - csharpTupleElement(csharpType(DotnetCSharpTypes.Primitives.String), "Name"), - csharpTupleElement(csharpNullableType(DotnetCSharpTypes.Primitives.String), "Value"), - ), - ), - "headers", - modifiers = listOf(CSharp.Modifier.PARAMS), - ), - ), - body = CSharp.codeBlock { - foreachDeconstruction("name", "value", source = CSharp.identifier("headers")) { - ifStatement( - CSharp.binary( - CSharp.identifier("value"), - CSharp.BinaryOperator.IS_NOT, - CSharp.nullLiteral(), - ), - ) { - expression( - CSharp.assignment( - CSharp.index( - CSharp.member(CSharp.identifier("Response"), "Headers"), - CSharp.identifier("name"), - ), - CSharp.identifier("value"), - ), - ) - } - } - blankLine() - returnStatement( - CSharp.new( - type = csharpType(DotnetAspCSharpTypes.AspNetCore.Mvc.ObjectResult), - arguments = listOf(CSharp.identifier("body")), - initializers = listOf( - CSharp.init( - memberName = "StatusCode", - value = CSharp.identifier("statusCode"), - ), - ), - ), - ) - }, -) - -internal fun renderReadHeaderHelper(): CSharp.Method = CSharp.Method( - name = "ReadHeader", - modifiers = listOf(CSharp.Modifier.PROTECTED), - returnType = csharpNullableType(DotnetCSharpTypes.Primitives.String), - parameters = listOf(csharpParameter(DotnetCSharpTypes.Primitives.String, "headerName")), - body = CSharp.codeBlock { - returnStatement( - CSharp.conditional( - condition = CSharp.call( - callee = CSharp.member( - CSharp.member(CSharp.identifier("Request"), "Headers"), - "TryGetValue", - ), - arguments = listOf( - CSharp.argument(CSharp.identifier("headerName")), - CSharp.outVariable("values"), - ), - ), - whenTrue = CSharp.call( - CSharp.member(CSharp.identifier("values"), "ToString"), - ), - whenFalse = CSharp.nullLiteral(), - ), - ) - }, -) - -private fun renderResultSwitchArms(endpoint: ResolvedDotnetAspEndpoint): List = buildList { - addAll(renderResponseSwitchArms(endpoint)) - add(renderUnsupportedResultArm(endpoint)) -} - -private fun renderResponseSwitchArms(endpoint: ResolvedDotnetAspEndpoint): List = - endpoint.responses.map { response -> - CSharp.switchArm( - pattern = "${resultVariantTypeName(endpoint, response)} response", - expression = CSharp.callValues( - CSharp.identifier("Respond"), - responseArguments(response), - ), - ) - } - -private fun responseArguments(response: ResolvedDotnetAspResponse): List = buildList { - add(CSharp.member(CSharp.identifier("response"), RESULT_BODY_PROPERTY_NAME)) - add(CSharp.intLiteral(response.statusCode)) - addAll(responseHeaderArguments(response)) -} - -private fun responseHeaderArguments(response: ResolvedDotnetAspResponse): List = - response.headers.map { header -> - CSharp.tupleLiteral( - CSharp.stringLiteral(header.name), - CSharp.member(CSharp.identifier("response"), dotnetAspHeaderPropertyName(header.name)), - ) - } - -private fun renderUnsupportedResultArm(endpoint: ResolvedDotnetAspEndpoint): CSharp.SwitchArm = CSharp.switchArm( - pattern = "_", - expression = CSharp.throwExpression( - CSharp.new( - type = csharpType(DotnetCSharpTypes.System.InvalidOperationException), - arguments = listOf( - CSharp.rawExpression( - "${'$'}\"Unsupported ${endpoint.operationName} result type '{result.GetType().FullName}'.\"", - ), - ), - ), - ), -) diff --git a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspGeneratedHostingRenderer.kt b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspGeneratedHostingRenderer.kt deleted file mode 100644 index 2dd10e50..00000000 --- a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspGeneratedHostingRenderer.kt +++ /dev/null @@ -1,54 +0,0 @@ -package io.github.lmliam.microsmith.compile.services.dotnet.asp - -import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspServiceArtifact -import io.github.lmliam.microsmith.compile.services.dotnet.csharp.CSharp -import io.github.lmliam.microsmith.compile.services.dotnet.csharp.csharpType -import io.github.lmliam.microsmith.compile.services.dotnet.csharp.extensionParameter -import io.github.lmliam.microsmith.compile.services.dotnet.csharp.using - -internal fun renderHostingExtensionsFile(artifact: DotnetAspServiceArtifact): String = CSharp.render( - CSharp.file(hostingNamespace(artifact)) { - using(DotnetAspCSharpNamespaces.Microsoft.AspNetCore.Builder) - using(DotnetAspCSharpNamespaces.Microsoft.Extensions.DependencyInjection) - classType( - name = "MicrosmithHostingExtensions", - modifiers = listOf(CSharp.Modifier.PUBLIC, CSharp.Modifier.STATIC), - ) { - method( - name = "AddMicrosmith", - returnType = csharpType(DotnetAspCSharpTypes.AspNetCore.Builder.WebApplicationBuilder), - modifiers = listOf(CSharp.Modifier.PUBLIC, CSharp.Modifier.STATIC), - parameters = listOf( - extensionParameter(DotnetAspCSharpTypes.AspNetCore.Builder.WebApplicationBuilder, "builder"), - ), - body = CSharp.codeBlock { - expression( - CSharp.call( - CSharp.member( - CSharp.member(CSharp.identifier("builder"), "Services"), - "AddControllers", - ), - ), - ) - returnStatement(CSharp.identifier("builder")) - }, - ) - method( - name = "MapMicrosmith", - returnType = csharpType(DotnetAspCSharpTypes.AspNetCore.Builder.WebApplication), - modifiers = listOf(CSharp.Modifier.PUBLIC, CSharp.Modifier.STATIC), - parameters = listOf( - extensionParameter(DotnetAspCSharpTypes.AspNetCore.Builder.WebApplication, "app"), - ), - body = CSharp.codeBlock { - expression( - CSharp.call( - CSharp.member(CSharp.identifier("app"), "MapControllers"), - ), - ) - returnStatement(CSharp.identifier("app")) - }, - ) - } - }, -) diff --git a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspGeneratedModelContractsRenderer.kt b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspGeneratedModelContractsRenderer.kt deleted file mode 100644 index cd34cfd2..00000000 --- a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspGeneratedModelContractsRenderer.kt +++ /dev/null @@ -1,41 +0,0 @@ -package io.github.lmliam.microsmith.compile.services.dotnet.asp - -import io.github.lmliam.microsmith.compile.services.dotnet.csharp.CSharp -import io.github.lmliam.microsmith.compile.services.dotnet.csharp.csharpAutoProperty -import io.github.lmliam.microsmith.compile.services.dotnet.csharp.csharpType -import io.github.lmliam.microsmith.dsl.services.dotnet.core.model.DotnetField -import io.github.lmliam.microsmith.dsl.services.dotnet.core.model.DotnetFieldType -import io.github.lmliam.microsmith.resolve.services.dotnet.asp.ResolvedDotnetAspEndpoint -import io.github.lmliam.microsmith.resolve.services.dotnet.asp.ResolvedDotnetAspModelLocality -import io.github.lmliam.microsmith.resolve.services.dotnet.asp.ResolvedDotnetAspResponse - -internal fun renderModelClass(name: String, fields: List): CSharp.Type = CSharp.Type( - kind = CSharp.TypeKind.RECORD, - name = name, - modifiers = listOf(CSharp.Modifier.PUBLIC, CSharp.Modifier.SEALED), - members = fields.map(::renderModelFieldProperty), -) - -internal fun resolveResponseModelTypeName( - endpoint: ResolvedDotnetAspEndpoint, - response: ResolvedDotnetAspResponse, -): String = when (response.model.locality) { - ResolvedDotnetAspModelLocality.SHARED -> response.model.model.name - ResolvedDotnetAspModelLocality.INLINE -> inlineResponseTypeName(endpoint, response) -} - -private fun renderModelFieldProperty(field: DotnetField): CSharp.Property { - val initializer = - if (field.type is DotnetFieldType.String || field.type is DotnetFieldType.Reference) { - "null!" - } else { - null - } - - return csharpAutoProperty( - type = csharpType(dotnetAspCSharpType(field.type)), - name = dotnetAspPascalIdentifier(field.name), - modifiers = listOf(CSharp.Modifier.PUBLIC), - initializer = initializer, - ) -} diff --git a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspGeneratedRequestContractsRenderer.kt b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspGeneratedRequestContractsRenderer.kt deleted file mode 100644 index 54f0f717..00000000 --- a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspGeneratedRequestContractsRenderer.kt +++ /dev/null @@ -1,55 +0,0 @@ -package io.github.lmliam.microsmith.compile.services.dotnet.asp - -import io.github.lmliam.microsmith.compile.services.dotnet.csharp.CSharp -import io.github.lmliam.microsmith.compile.services.dotnet.csharp.DotnetCSharpTypes -import io.github.lmliam.microsmith.compile.services.dotnet.csharp.csharpAutoProperty -import io.github.lmliam.microsmith.compile.services.dotnet.csharp.csharpNullableType -import io.github.lmliam.microsmith.compile.services.dotnet.csharp.csharpType -import io.github.lmliam.microsmith.dsl.services.dotnet.core.model.DotnetFieldType -import io.github.lmliam.microsmith.resolve.services.dotnet.asp.ResolvedDotnetAspHeadersBinding -import io.github.lmliam.microsmith.resolve.services.dotnet.asp.ResolvedDotnetAspRequestBinding -import io.github.lmliam.microsmith.resolve.services.dotnet.asp.ResolvedDotnetAspRequestField - -internal fun renderRequestBindingClass(binding: ResolvedDotnetAspRequestBinding): CSharp.Type = CSharp.Type( - kind = CSharp.TypeKind.RECORD, - name = binding.name, - modifiers = listOf(CSharp.Modifier.PUBLIC, CSharp.Modifier.SEALED), - members = binding.fields.map(::renderRequestFieldProperty), -) - -internal fun renderHeadersBindingClass(binding: ResolvedDotnetAspHeadersBinding): CSharp.Type = CSharp.Type( - kind = CSharp.TypeKind.RECORD, - name = binding.name, - modifiers = listOf(CSharp.Modifier.PUBLIC, CSharp.Modifier.SEALED), - members = binding.headers.map { header -> - csharpAutoProperty( - type = csharpNullableType(DotnetCSharpTypes.Primitives.String), - name = dotnetAspPascalIdentifier(header.name), - modifiers = listOf(CSharp.Modifier.PUBLIC), - ) - }, -) - -private fun renderRequestFieldProperty(field: ResolvedDotnetAspRequestField): CSharp.Property { - val nullable = field.optional && field.defaultValue == null - return csharpAutoProperty( - type = csharpType(dotnetAspCSharpType(field.type, nullable = nullable)), - name = dotnetAspPascalIdentifier(field.name), - modifiers = listOf(CSharp.Modifier.PUBLIC), - initializer = requestFieldInitializer(field, nullable), - ) -} - -private fun requestFieldInitializer(field: ResolvedDotnetAspRequestField, nullable: Boolean): String? { - val defaultValue = field.defaultValue - return when { - defaultValue != null -> dotnetAspLiteral(field.type, defaultValue) - - nullable -> null - - field.type is DotnetFieldType.String || - field.type is DotnetFieldType.Reference -> "null!" - - else -> null - } -} diff --git a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspGeneratedResponseContractsRenderer.kt b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspGeneratedResponseContractsRenderer.kt deleted file mode 100644 index 4ffc44cb..00000000 --- a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspGeneratedResponseContractsRenderer.kt +++ /dev/null @@ -1,44 +0,0 @@ -package io.github.lmliam.microsmith.compile.services.dotnet.asp - -import io.github.lmliam.microsmith.compile.services.dotnet.csharp.CSharp -import io.github.lmliam.microsmith.compile.services.dotnet.csharp.DotnetCSharpTypes -import io.github.lmliam.microsmith.compile.services.dotnet.csharp.csharpNullableType -import io.github.lmliam.microsmith.compile.services.dotnet.csharp.csharpType -import io.github.lmliam.microsmith.resolve.services.dotnet.asp.ResolvedDotnetAspEndpoint - -internal fun renderOperationResultTypes(endpoint: ResolvedDotnetAspEndpoint): List = buildList { - add( - CSharp.Type( - kind = CSharp.TypeKind.RECORD, - name = resultBaseTypeName(endpoint), - modifiers = listOf(CSharp.Modifier.PUBLIC, CSharp.Modifier.ABSTRACT), - ), - ) - endpoint.responses.forEach { response -> - add( - CSharp.Type( - kind = CSharp.TypeKind.RECORD, - name = resultVariantTypeName(endpoint, response), - modifiers = listOf(CSharp.Modifier.PUBLIC, CSharp.Modifier.SEALED), - baseTypes = listOf(csharpType(resultBaseTypeName(endpoint))), - primaryConstructorParameters = buildList { - add( - CSharp.Parameter( - type = csharpType(resolveResponseModelTypeName(endpoint, response)), - name = RESULT_BODY_PROPERTY_NAME, - ), - ) - response.headers.forEach { header -> - add( - CSharp.Parameter( - type = csharpNullableType(DotnetCSharpTypes.Primitives.String), - name = dotnetAspHeaderPropertyName(header.name), - defaultValue = "null", - ), - ) - } - }, - ), - ) - } -} diff --git a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspGeneratedTextFile.kt b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspGeneratedTextFile.kt deleted file mode 100644 index 3a89b7a8..00000000 --- a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspGeneratedTextFile.kt +++ /dev/null @@ -1,3 +0,0 @@ -package io.github.lmliam.microsmith.compile.services.dotnet.asp - -internal data class DotnetAspGeneratedTextFile(val relativePath: String, val contents: String) diff --git a/modules/compile-services-dotnet-asp/src/main/resources/io/github/lmliam/microsmith/compile/services/dotnet/asp/templates/appsettings.json.template b/modules/compile-services-dotnet-asp/src/main/resources/io/github/lmliam/microsmith/compile/services/dotnet/asp/templates/appsettings.json.template deleted file mode 100644 index 482d3cb9..00000000 --- a/modules/compile-services-dotnet-asp/src/main/resources/io/github/lmliam/microsmith/compile/services/dotnet/asp/templates/appsettings.json.template +++ /dev/null @@ -1,12 +0,0 @@ -{ - "Microsmith": { - "ServiceName": "{{SERVICE_NAME}}" - }, - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - }, - "AllowedHosts": "*" -} diff --git a/modules/compile-services-dotnet-asp/src/main/resources/io/github/lmliam/microsmith/compile/services/dotnet/asp/templates/launchSettings.json.template b/modules/compile-services-dotnet-asp/src/main/resources/io/github/lmliam/microsmith/compile/services/dotnet/asp/templates/launchSettings.json.template deleted file mode 100644 index e15a4948..00000000 --- a/modules/compile-services-dotnet-asp/src/main/resources/io/github/lmliam/microsmith/compile/services/dotnet/asp/templates/launchSettings.json.template +++ /dev/null @@ -1,15 +0,0 @@ -{ - "$schema": "http://json.schemastore.org/launchsettings.json", - "profiles": { - "{{PROJECT_NAME}}": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": false, - "applicationUrl": - "http://localhost:{{HTTP_PORT}};https://localhost:{{HTTPS_PORT}}", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - } - } -} diff --git a/modules/compile-services-dotnet-asp/src/test/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspEndpointValidationTests.kt b/modules/compile-services-dotnet-asp/src/test/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspEndpointValidationTests.kt deleted file mode 100644 index 0c73d81c..00000000 --- a/modules/compile-services-dotnet-asp/src/test/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspEndpointValidationTests.kt +++ /dev/null @@ -1,175 +0,0 @@ -package io.github.lmliam.microsmith.compile.services.dotnet.asp - -import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspServiceArtifact -import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspServiceArtifactId -import io.github.lmliam.microsmith.dsl.services.dotnet.asp.core.rest.endpoint.DotnetAspHttpMethod -import io.github.lmliam.microsmith.dsl.services.dotnet.core.model.DotnetField -import io.github.lmliam.microsmith.dsl.services.dotnet.core.model.DotnetFieldType -import io.github.lmliam.microsmith.dsl.services.dotnet.core.model.DotnetModel -import io.github.lmliam.microsmith.resolve.services.dotnet.asp.ResolvedDotnetAspEndpoint -import io.github.lmliam.microsmith.resolve.services.dotnet.asp.ResolvedDotnetAspEndpointBindings -import io.github.lmliam.microsmith.resolve.services.dotnet.asp.ResolvedDotnetAspModel -import io.github.lmliam.microsmith.resolve.services.dotnet.asp.ResolvedDotnetAspModelLocality -import io.github.lmliam.microsmith.resolve.services.dotnet.asp.ResolvedDotnetAspRequestBinding -import io.github.lmliam.microsmith.resolve.services.dotnet.asp.ResolvedDotnetAspRequestField -import io.github.lmliam.microsmith.resolve.services.dotnet.asp.ResolvedDotnetAspResponse -import io.github.lmliam.microsmith.resolve.services.dotnet.asp.ResolvedDotnetAspResponseHeader -import io.github.lmliam.microsmith.resolve.services.dotnet.asp.ResolvedDotnetAspRest -import io.kotest.assertions.throwables.shouldThrow -import io.kotest.core.spec.style.StringSpec -import io.kotest.matchers.string.shouldContain -import java.nio.file.Path - -class DotnetAspEndpointValidationTests : - StringSpec({ - "validation rejects generated contract type collisions" { - val artifact = - validationArtifact( - models = - mapOf( - "GetUserResult" to DotnetModel( - name = "GetUserResult", - fields = listOf(DotnetField("id", DotnetFieldType.String)), - ), - ), - rest = ResolvedDotnetAspRest( - listOf( - ResolvedDotnetAspEndpoint( - method = DotnetAspHttpMethod.GET, - route = "/users/{id}", - routePlaceholders = listOf("id"), - operationName = "GetUser", - bindings = ResolvedDotnetAspEndpointBindings( - path = ResolvedDotnetAspRequestBinding( - name = "GetUserPath", - fields = listOf( - ResolvedDotnetAspRequestField( - name = "id", - type = DotnetFieldType.String, - optional = false, - defaultValue = null, - ), - ), - ), - ), - responses = listOf( - ResolvedDotnetAspResponse( - statusCode = 200, - model = ResolvedDotnetAspModel( - locality = ResolvedDotnetAspModelLocality.SHARED, - model = DotnetModel( - name = "User", - fields = listOf(DotnetField("id", DotnetFieldType.String)), - ), - ), - headers = emptyList(), - ), - ), - ), - ), - ), - ) - - val error = - shouldThrow { - validateEndpointGenerationInputs(artifact) - } - - error.message.shouldContain("colliding generated contract types") - error.message.shouldContain("GetUserResult") - error.message.shouldContain("shared model 'GetUserResult'") - error.message.shouldContain("result base for operation 'GetUser'") - } - - "validation rejects response headers that collide with the generated Body property" { - val artifact = - validationArtifact( - models = - mapOf( - "User" to DotnetModel( - name = "User", - fields = listOf(DotnetField("id", DotnetFieldType.String)), - ), - ), - rest = ResolvedDotnetAspRest( - listOf( - ResolvedDotnetAspEndpoint( - method = DotnetAspHttpMethod.GET, - route = "/users/{id}", - routePlaceholders = listOf("id"), - operationName = "GetUser", - bindings = ResolvedDotnetAspEndpointBindings( - path = ResolvedDotnetAspRequestBinding( - name = "GetUserPath", - fields = listOf( - ResolvedDotnetAspRequestField( - name = "id", - type = DotnetFieldType.String, - optional = false, - defaultValue = null, - ), - ), - ), - ), - responses = listOf( - ResolvedDotnetAspResponse( - statusCode = 200, - model = ResolvedDotnetAspModel( - locality = ResolvedDotnetAspModelLocality.SHARED, - model = DotnetModel( - name = "User", - fields = listOf(DotnetField("id", DotnetFieldType.String)), - ), - ), - headers = listOf( - ResolvedDotnetAspResponseHeader("body"), - ), - ), - ), - ), - ), - ), - ) - - val error = - shouldThrow { - validateEndpointGenerationInputs(artifact) - } - - error.message.shouldContain("collides with the generated result body property 'Body'") - error.message.shouldContain("header 'body'") - } - - "validation rejects project names that collide with the shared controller base type" { - val artifact = - validationArtifact( - models = emptyMap(), - projectName = "Microsmith", - rest = ResolvedDotnetAspRest.empty(), - ) - - val error = - shouldThrow { - validateEndpointGenerationInputs(artifact) - } - - error.message.shouldContain("collides with shared generated controller base type") - error.message.shouldContain("project 'Microsmith'") - error.message.shouldContain("MicrosmithControllerBase") - } - }) - -private fun validationArtifact( - models: Map, - projectName: String = "UserService.Api", - rest: ResolvedDotnetAspRest, -): DotnetAspServiceArtifact = DotnetAspServiceArtifact( - id = DotnetAspServiceArtifactId("Platform", projectName), - serviceName = "UserService", - targetFrameworkMoniker = "net8.0", - outputRoot = Path.of("dotnet", "Platform", "UserService.Api"), - httpPort = 5000, - httpsPort = 5001, - models = models, - rest = rest, -) diff --git a/modules/compile-services-dotnet-asp/src/test/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspGeneratedGoldenTests.kt b/modules/compile-services-dotnet-asp/src/test/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspGeneratedGoldenTests.kt deleted file mode 100644 index 50e3a0ba..00000000 --- a/modules/compile-services-dotnet-asp/src/test/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspGeneratedGoldenTests.kt +++ /dev/null @@ -1,136 +0,0 @@ -package io.github.lmliam.microsmith.compile.services.dotnet.asp - -import io.github.lmliam.microsmith.artifact.files.TextFileArtifactContribution -import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspServiceArtifact -import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspServiceArtifactId -import io.github.lmliam.microsmith.dsl.services.dotnet.asp.core.rest.endpoint.DotnetAspHttpMethod -import io.github.lmliam.microsmith.dsl.services.dotnet.core.model.DotnetField -import io.github.lmliam.microsmith.dsl.services.dotnet.core.model.DotnetFieldType -import io.github.lmliam.microsmith.dsl.services.dotnet.core.model.DotnetModel -import io.github.lmliam.microsmith.resolve.services.dotnet.asp.ResolvedDotnetAspEndpoint -import io.github.lmliam.microsmith.resolve.services.dotnet.asp.ResolvedDotnetAspEndpointBindings -import io.github.lmliam.microsmith.resolve.services.dotnet.asp.ResolvedDotnetAspHeaderField -import io.github.lmliam.microsmith.resolve.services.dotnet.asp.ResolvedDotnetAspHeadersBinding -import io.github.lmliam.microsmith.resolve.services.dotnet.asp.ResolvedDotnetAspModel -import io.github.lmliam.microsmith.resolve.services.dotnet.asp.ResolvedDotnetAspModelLocality -import io.github.lmliam.microsmith.resolve.services.dotnet.asp.ResolvedDotnetAspRequestBinding -import io.github.lmliam.microsmith.resolve.services.dotnet.asp.ResolvedDotnetAspRequestField -import io.github.lmliam.microsmith.resolve.services.dotnet.asp.ResolvedDotnetAspResponse -import io.github.lmliam.microsmith.resolve.services.dotnet.asp.ResolvedDotnetAspRest -import io.kotest.core.spec.style.StringSpec -import io.kotest.matchers.shouldBe -import java.nio.charset.StandardCharsets -import java.nio.file.Path - -class DotnetAspGeneratedGoldenTests : - StringSpec({ - "compile keeps hosting extensions output stable" { - val artifact = goldenArtifact(rest = ResolvedDotnetAspRest.empty()) - - val hostingFile = - DotnetAspServiceArtifactCompiler() - .compile(artifact) - .filterIsInstance() - .single { - it.artifactId.relativePath.toString() == - "Generated/Hosting/MicrosmithHostingExtensions.cs" - } - .contents - - normalizeGoldenText(hostingFile) shouldBe - normalizeGoldenText(goldenResource("golden/MicrosmithHostingExtensions.cs")) - } - - "compile keeps header-binding controller output stable" { - val artifact = - goldenArtifact( - rest = ResolvedDotnetAspRest( - listOf( - ResolvedDotnetAspEndpoint( - method = DotnetAspHttpMethod.GET, - route = "/users/{id}", - routePlaceholders = listOf("id"), - operationName = "GetUser", - bindings = ResolvedDotnetAspEndpointBindings( - path = ResolvedDotnetAspRequestBinding( - name = "GetUserPath", - fields = listOf( - ResolvedDotnetAspRequestField( - name = "id", - type = DotnetFieldType.String, - optional = false, - defaultValue = null, - ), - ), - ), - headers = ResolvedDotnetAspHeadersBinding( - name = "GetUserHeaders", - headers = listOf( - ResolvedDotnetAspHeaderField( - name = "xCorrelationId", - headerName = "X-Correlation-Id", - ), - ), - ), - ), - responses = listOf( - ResolvedDotnetAspResponse( - statusCode = 200, - model = ResolvedDotnetAspModel( - locality = ResolvedDotnetAspModelLocality.SHARED, - model = DotnetModel( - name = "User", - fields = listOf( - DotnetField("id", DotnetFieldType.String), - ), - ), - ), - headers = emptyList(), - ), - ), - ), - ), - ), - ) - val textFiles = - DotnetAspServiceArtifactCompiler() - .compile(artifact) - .filterIsInstance() - .associateBy { it.artifactId.relativePath.toString() } - - normalizeGoldenText(textFiles.getValue("Generated/Contracts/RequestModels.cs").contents) shouldBe - normalizeGoldenText(goldenResource("golden/GetUserRequestModels.cs")) - normalizeGoldenText( - textFiles.getValue("Generated/Controllers/MicrosmithControllerBase.cs").contents, - ) shouldBe - normalizeGoldenText(goldenResource("golden/MicrosmithControllerBase.cs")) - normalizeGoldenText( - textFiles.getValue("Generated/Controllers/UserServiceApiControllerBase.cs").contents, - ) shouldBe - normalizeGoldenText(goldenResource("golden/GetUserControllerBase.cs")) - } - }) - -private fun goldenArtifact(rest: ResolvedDotnetAspRest): DotnetAspServiceArtifact = DotnetAspServiceArtifact( - id = DotnetAspServiceArtifactId("Platform", "UserService.Api"), - serviceName = "UserService", - targetFrameworkMoniker = "net8.0", - outputRoot = Path.of("dotnet", "Platform", "UserService.Api"), - httpPort = 5000, - httpsPort = 5001, - models = - mapOf( - "User" to DotnetModel( - name = "User", - fields = listOf(DotnetField("id", DotnetFieldType.String)), - ), - ), - rest = rest, -) - -private fun goldenResource(path: String): String = - requireNotNull(DotnetAspGeneratedGoldenTests::class.java.getResourceAsStream("/$path")) { - "Missing golden resource '$path'." - }.readBytes().toString(StandardCharsets.UTF_8) - -private fun normalizeGoldenText(value: String): String = value.trimEnd('\r', '\n') diff --git a/modules/compile-services-dotnet-asp/src/test/resources/golden/GetUserControllerBase.cs b/modules/compile-services-dotnet-asp/src/test/resources/golden/GetUserControllerBase.cs deleted file mode 100644 index c56d2dd8..00000000 --- a/modules/compile-services-dotnet-asp/src/test/resources/golden/GetUserControllerBase.cs +++ /dev/null @@ -1,34 +0,0 @@ -using Microsoft.AspNetCore.Mvc; -using System; -using System.Threading; -using System.Threading.Tasks; -using UserService.Api.Generated.Contracts; - -namespace UserService.Api.Generated.Controllers; - -[ApiController] -public abstract class UserServiceApiControllerBase : MicrosmithControllerBase -{ - [HttpGet("/users/{id}", Name = "GetUser")] - public async Task> GetUser([FromRoute] GetUserPath path, CancellationToken cancellationToken) - { - var headers = new GetUserHeaders() - { - XCorrelationId = ReadHeader("X-Correlation-Id") - }; - - var result = await OnGetUserAsync(path, headers, cancellationToken); - return MapGetUserResult(result); - } - - protected abstract Task OnGetUserAsync(GetUserPath path, GetUserHeaders headers, CancellationToken cancellationToken); - - private ActionResult MapGetUserResult(GetUserResult result) - { - return result switch - { - GetUserOk response => Respond(response.Body, 200), - _ => throw new InvalidOperationException($"Unsupported GetUser result type '{result.GetType().FullName}'.") - }; - } -} diff --git a/modules/compile-services-dotnet-asp/src/test/resources/golden/GetUserRequestModels.cs b/modules/compile-services-dotnet-asp/src/test/resources/golden/GetUserRequestModels.cs deleted file mode 100644 index d317594f..00000000 --- a/modules/compile-services-dotnet-asp/src/test/resources/golden/GetUserRequestModels.cs +++ /dev/null @@ -1,13 +0,0 @@ -using System; - -namespace UserService.Api.Generated.Contracts; - -public sealed record GetUserPath -{ - public string Id { get; set; } = null!; -} - -public sealed record GetUserHeaders -{ - public string? XCorrelationId { get; set; } -} diff --git a/modules/compile-services-dotnet-asp/src/test/resources/golden/MicrosmithControllerBase.cs b/modules/compile-services-dotnet-asp/src/test/resources/golden/MicrosmithControllerBase.cs deleted file mode 100644 index 80f5814d..00000000 --- a/modules/compile-services-dotnet-asp/src/test/resources/golden/MicrosmithControllerBase.cs +++ /dev/null @@ -1,29 +0,0 @@ -using Microsoft.AspNetCore.Mvc; - -namespace UserService.Api.Generated.Controllers; - -public abstract class MicrosmithControllerBase : ControllerBase -{ - protected ObjectResult Respond(object body, int statusCode, params (string Name, string? Value)[] headers) - { - foreach (var (name, value) in headers) - { - if (value is not null) - { - Response.Headers[name] = value; - } - } - - return new ObjectResult(body) - { - StatusCode = statusCode - }; - } - - protected string? ReadHeader(string headerName) - { - return Request.Headers.TryGetValue(headerName, out var values) - ? values.ToString() - : null; - } -} diff --git a/modules/compile-services-dotnet-asp/src/test/resources/golden/MicrosmithHostingExtensions.cs b/modules/compile-services-dotnet-asp/src/test/resources/golden/MicrosmithHostingExtensions.cs deleted file mode 100644 index 76aa44c3..00000000 --- a/modules/compile-services-dotnet-asp/src/test/resources/golden/MicrosmithHostingExtensions.cs +++ /dev/null @@ -1,19 +0,0 @@ -using Microsoft.AspNetCore.Builder; -using Microsoft.Extensions.DependencyInjection; - -namespace UserService.Api.Generated.Hosting; - -public static class MicrosmithHostingExtensions -{ - public static WebApplicationBuilder AddMicrosmith(this WebApplicationBuilder builder) - { - builder.Services.AddControllers(); - return builder; - } - - public static WebApplication MapMicrosmith(this WebApplication app) - { - app.MapControllers(); - return app; - } -} diff --git a/modules/runtime-scripting/src/test/kotlin/io/github/lmliam/microsmith/runtime/scripting/host/MicrosmithScriptHostTests.kt b/modules/runtime-scripting/src/test/kotlin/io/github/lmliam/microsmith/runtime/scripting/host/MicrosmithScriptHostTests.kt index b5522a6a..23f4f4f2 100644 --- a/modules/runtime-scripting/src/test/kotlin/io/github/lmliam/microsmith/runtime/scripting/host/MicrosmithScriptHostTests.kt +++ b/modules/runtime-scripting/src/test/kotlin/io/github/lmliam/microsmith/runtime/scripting/host/MicrosmithScriptHostTests.kt @@ -138,11 +138,9 @@ class MicrosmithScriptHostTests : ) result.shouldBeTypeOf() - val generatedFile = - output.resolve("dotnet/Platform/UserService.Api/Generated/Hosting/MicrosmithHostingExtensions.cs") - generatedFile.exists() shouldBe true - generatedFile.readText().shouldContain("AddMicrosmith") - generatedFile.readText().shouldContain("MapMicrosmith") + output.resolve("dotnet/Platform/UserService.Api/Program.cs").exists() shouldBe true + output.resolve("dotnet/Platform/UserService.Api/Controllers/UserServiceController.cs").exists() shouldBe true + output.resolve("dotnet/Platform/UserService.Api/.microsmith/origins.json").exists() shouldBe true } finally { runCatching { tempDir.deleteRecursively() } } From f0cb09c7dfa929e15bd55243c99041f00418ac84 Mon Sep 17 00:00:00 2001 From: LMLiam <46268350+TheRealEmissions@users.noreply.github.com> Date: Tue, 21 Apr 2026 13:18:40 +0100 Subject: [PATCH 06/26] fix(artifact-services-dotnet-asp): restore stable port allocation --- .../dotnet/asp/DotnetAspAllocatedPorts.kt | 6 + .../asp/DotnetAspArtifactContributor.kt | 40 ++++-- .../dotnet/asp/DotnetAspPortAllocation.kt | 79 ++++++++++++ .../asp/DotnetAspArtifactContributorTests.kt | 118 ++++++++++++++++++ 4 files changed, 230 insertions(+), 13 deletions(-) create mode 100644 modules/artifact-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspAllocatedPorts.kt create mode 100644 modules/artifact-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspPortAllocation.kt create mode 100644 modules/artifact-services-dotnet-asp/src/test/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspArtifactContributorTests.kt diff --git a/modules/artifact-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspAllocatedPorts.kt b/modules/artifact-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspAllocatedPorts.kt new file mode 100644 index 00000000..dd6f6dc1 --- /dev/null +++ b/modules/artifact-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspAllocatedPorts.kt @@ -0,0 +1,6 @@ +package io.github.lmliam.microsmith.artifact.services.dotnet.asp + +internal data class DotnetAspAllocatedPorts( + val http: Int, + val https: Int, +) diff --git a/modules/artifact-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspArtifactContributor.kt b/modules/artifact-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspArtifactContributor.kt index 783947ab..495932d7 100644 --- a/modules/artifact-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspArtifactContributor.kt +++ b/modules/artifact-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspArtifactContributor.kt @@ -18,12 +18,31 @@ class DotnetAspArtifactContributor : ArtifactContributor { override val resolvedType = DotnetAspWorkspace::class override fun contribute(model: DotnetAspWorkspace): List> = - model.servicesByName.values.sortedBy { - it.name - }.mapIndexed(::toContribution) + model.servicesByName.values + .map { service -> + service to DotnetAspServiceArtifactId(service.solutionName, service.projectName) + }.sortedWith( + compareBy( + { (_, artifactId) -> artifactId.solutionName }, + { (_, artifactId) -> artifactId.projectName }, + ), + ).let { serviceArtifacts -> + val allocatedPorts = + serviceArtifacts.associate { (service, artifactId) -> + artifactId to allocateDotnetAspPorts(artifactId, service.ports) + } + validateUniqueDotnetAspPorts(allocatedPorts.toList()) + serviceArtifacts.map { (service, artifactId) -> + val ports = requireNotNull(allocatedPorts[artifactId]) + toContribution(service, artifactId, ports) + } + } - private fun toContribution(index: Int, service: ResolvedDotnetAspService): DotnetAspServiceContribution { - val httpPort = BASE_HTTP_PORT + (index * PORT_STRIDE) + private fun toContribution( + service: ResolvedDotnetAspService, + artifactId: DotnetAspServiceArtifactId, + ports: DotnetAspAllocatedPorts, + ): DotnetAspServiceContribution { val usedTypeNames = linkedSetOf() val sharedModelsByName = service.models.values .sortedBy(DotnetModel::name) @@ -86,22 +105,17 @@ class DotnetAspArtifactContributor : ArtifactContributor { } return DotnetAspServiceContribution( - artifactId = DotnetAspServiceArtifactId(service.solutionName, service.projectName), + artifactId = artifactId, serviceName = service.name, targetFrameworkMoniker = service.targetFrameworkMoniker, outputRoot = service.outputRoot, - httpPort = httpPort, - httpsPort = httpPort + 1, + httpPort = ports.http, + httpsPort = ports.https, contractModels = contractModels.toList(), endpoints = endpoints, ) } - private companion object { - const val BASE_HTTP_PORT = 5000 - const val PORT_STRIDE = 10 - } - private fun ResolvedDotnetAspRequestBinding.toRequestBindingArtifact( serviceName: String, endpoint: ResolvedDotnetAspEndpoint, diff --git a/modules/artifact-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspPortAllocation.kt b/modules/artifact-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspPortAllocation.kt new file mode 100644 index 00000000..8778c173 --- /dev/null +++ b/modules/artifact-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspPortAllocation.kt @@ -0,0 +1,79 @@ +package io.github.lmliam.microsmith.artifact.services.dotnet.asp + +import io.github.lmliam.microsmith.resolve.services.dotnet.asp.ResolvedDotnetAspPorts + +internal fun allocateDotnetAspPorts( + artifactId: DotnetAspServiceArtifactId, + configuredPorts: ResolvedDotnetAspPorts?, +): DotnetAspAllocatedPorts { + val defaultHttp = dotnetAspHttpPortFor(artifactId) + val http = + configuredPorts?.http + ?: configuredPorts?.https?.minus(HTTPS_PORT_OFFSET) + ?: defaultHttp + val https = + configuredPorts?.https + ?: configuredPorts?.http?.plus(HTTPS_PORT_OFFSET) + ?: (defaultHttp + HTTPS_PORT_OFFSET) + + require(http in MIN_DOTNET_ASP_PORT..MAX_DOTNET_ASP_PORT) { + "ASP.NET HTTP port for '${artifactId.stablePortIdentity()}' must be between " + + "$MIN_DOTNET_ASP_PORT and $MAX_DOTNET_ASP_PORT." + } + require(https in MIN_DOTNET_ASP_PORT..MAX_DOTNET_ASP_PORT) { + "ASP.NET HTTPS port for '${artifactId.stablePortIdentity()}' must be between " + + "$MIN_DOTNET_ASP_PORT and $MAX_DOTNET_ASP_PORT." + } + require(http != https) { + "ASP.NET service '${artifactId.stablePortIdentity()}' resolves to the same HTTP and HTTPS port '$http'." + } + + return DotnetAspAllocatedPorts(http = http, https = https) +} + +internal fun dotnetAspHttpPortFor(artifactId: DotnetAspServiceArtifactId): Int { + val slot = + artifactId + .stablePortIdentity() + .fold(0L) { hash, character -> + ((hash * PORT_HASH_MULTIPLIER) + character.code) % PORT_SLOT_COUNT + }.toInt() + return BASE_HTTP_PORT + (slot * PORT_STRIDE) +} + +internal fun validateUniqueDotnetAspPorts( + servicePorts: List>, +) { + val portOwners = mutableMapOf>() + servicePorts.forEach { (artifactId, ports) -> + portOwners.getOrPut(ports.http, ::mutableListOf) += artifactId + portOwners.getOrPut(ports.https, ::mutableListOf) += artifactId + } + + val collisions = + portOwners + .filterValues { it.size > 1 } + .toSortedMap() + + require(collisions.isEmpty()) { + "ASP.NET services produce colliding launch ports: " + + collisions.entries.joinToString("; ") { (port, artifactIds) -> + val owners = + artifactIds + .distinct() + .sortedBy(DotnetAspServiceArtifactId::stablePortIdentity) + .joinToString(", ") { it.stablePortIdentity() } + "$owners share localhost:$port" + } + "." + } +} + +internal fun DotnetAspServiceArtifactId.stablePortIdentity(): String = "$solutionName/$projectName" + +private const val BASE_HTTP_PORT = 5_000 +private const val PORT_STRIDE = 10 +private const val HTTPS_PORT_OFFSET = 1 +private const val PORT_SLOT_COUNT = 1_500L +private const val PORT_HASH_MULTIPLIER = 31L +private const val MIN_DOTNET_ASP_PORT = 1 +private const val MAX_DOTNET_ASP_PORT = 65_535 diff --git a/modules/artifact-services-dotnet-asp/src/test/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspArtifactContributorTests.kt b/modules/artifact-services-dotnet-asp/src/test/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspArtifactContributorTests.kt new file mode 100644 index 00000000..bfac28a0 --- /dev/null +++ b/modules/artifact-services-dotnet-asp/src/test/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspArtifactContributorTests.kt @@ -0,0 +1,118 @@ +package io.github.lmliam.microsmith.artifact.services.dotnet.asp + +import io.github.lmliam.microsmith.resolve.services.dotnet.asp.DotnetAspWorkspace +import io.github.lmliam.microsmith.resolve.services.dotnet.asp.ResolvedDotnetAspPorts +import io.github.lmliam.microsmith.resolve.services.dotnet.asp.ResolvedDotnetAspRest +import io.github.lmliam.microsmith.resolve.services.dotnet.asp.ResolvedDotnetAspService +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain +import java.nio.file.Path + +class DotnetAspArtifactContributorTests : + StringSpec({ + "contribute keeps existing service ports stable when unrelated services are added" { + val contributor = DotnetAspArtifactContributor() + val userOnlyWorkspace = + DotnetAspWorkspace( + servicesByName = + linkedMapOf( + "UserService" to resolvedAspService("UserService", "UserService.Api"), + ), + ) + val expandedWorkspace = + DotnetAspWorkspace( + servicesByName = + linkedMapOf( + "AdminService" to resolvedAspService("AdminService", "AdminService.Api"), + "UserService" to resolvedAspService("UserService", "UserService.Api"), + ), + ) + + val userOnlyPort = + contributor + .contribute(userOnlyWorkspace) + .single() + .let { it as DotnetAspServiceContribution } + .httpPort + val expandedUserPort = + contributor + .contribute(expandedWorkspace) + .map { it as DotnetAspServiceContribution } + .single { it.artifactId.projectName == "UserService.Api" } + .httpPort + + userOnlyPort shouldBe expandedUserPort + } + + "contribute rejects stable launch-port collisions instead of silently reshuffling services" { + val collision = requireNotNull(findCollidingServiceIds()) + val contributor = DotnetAspArtifactContributor() + val workspace = + DotnetAspWorkspace( + servicesByName = + linkedMapOf( + "LeftService" to resolvedAspService("LeftService", collision.first.projectName), + "RightService" to resolvedAspService("RightService", collision.second.projectName), + ), + ) + + val error = + shouldThrow { + contributor.contribute(workspace) + } + + error.message.shouldContain("colliding launch ports") + } + + "contribute respects explicit service ports" { + val contributor = DotnetAspArtifactContributor() + val workspace = + DotnetAspWorkspace( + servicesByName = + linkedMapOf( + "UserService" to resolvedAspService( + name = "UserService", + projectName = "UserService.Api", + ports = ResolvedDotnetAspPorts(http = 7000, https = 7443), + ), + ), + ) + + val contribution = + contributor + .contribute(workspace) + .single() as DotnetAspServiceContribution + + contribution.httpPort shouldBe 7000 + contribution.httpsPort shouldBe 7443 + } + }) + +private fun resolvedAspService( + name: String, + projectName: String, + ports: ResolvedDotnetAspPorts? = null, +): ResolvedDotnetAspService = ResolvedDotnetAspService( + name = name, + solutionName = "Platform", + projectName = projectName, + targetFrameworkMoniker = "net8.0", + outputRoot = Path.of("dotnet", "Platform", projectName), + ports = ports, + models = emptyMap(), + rest = ResolvedDotnetAspRest.empty(), +) + +private fun findCollidingServiceIds(): Pair? { + val byPort = mutableMapOf() + for (index in 0..10_000) { + val artifactId = DotnetAspServiceArtifactId("Platform", "Collision$index.Api") + val existing = byPort.putIfAbsent(dotnetAspHttpPortFor(artifactId), artifactId) + if (existing != null) { + return existing to artifactId + } + } + return null +} From a9122d8d0482d5603a2e5a9cd84aaa3ae3278d92 Mon Sep 17 00:00:00 2001 From: LMLiam <46268350+TheRealEmissions@users.noreply.github.com> Date: Tue, 21 Apr 2026 13:18:50 +0100 Subject: [PATCH 07/26] refactor(services-dotnet-asp): restore abstract controller generation --- .../dotnet/asp/DotnetAspEndpointValidation.kt | 134 ++++ .../dotnet/asp/DotnetAspGeneratedNames.kt | 91 +++ .../dotnet/asp/DotnetAspProjectRenderer.kt | 754 +++++++----------- .../asp/DotnetAspServiceArtifactCompiler.kt | 113 +-- .../asp/DotnetAspEndpointValidationTests.kt | 171 ++++ .../DotnetAspServiceArtifactCompilerTests.kt | 161 ++-- 6 files changed, 789 insertions(+), 635 deletions(-) create mode 100644 modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspEndpointValidation.kt create mode 100644 modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspGeneratedNames.kt create mode 100644 modules/compile-services-dotnet-asp/src/test/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspEndpointValidationTests.kt diff --git a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspEndpointValidation.kt b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspEndpointValidation.kt new file mode 100644 index 00000000..a1561188 --- /dev/null +++ b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspEndpointValidation.kt @@ -0,0 +1,134 @@ +package io.github.lmliam.microsmith.compile.services.dotnet.asp + +import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspHeadersBindingArtifact +import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspRequestBindingArtifact +import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspServiceArtifact +import io.github.lmliam.microsmith.dsl.services.dotnet.core.model.DotnetFieldType + +internal fun validateEndpointGenerationInputs(artifact: DotnetAspServiceArtifact) { + validateRequestBindings(artifact) + validateResponseHeaderNames(artifact) + validateGeneratedContractTypeNames(artifact) + validateGeneratedControllerTypeNames(artifact) +} + +private fun validateRequestBindings(artifact: DotnetAspServiceArtifact) { + artifact.endpoints.forEach { endpoint -> + listOfNotNull(endpoint.bindings.path, endpoint.bindings.query).forEach { binding -> + binding.fields.forEach { field -> + val referenceTarget = (field.type as? DotnetFieldType.Reference)?.target + require(referenceTarget == null) { + "ASP.NET request binding '${binding.typeName}' in operation " + + "'${endpoint.operationName}' cannot reference shared model " + + "'$referenceTarget'. " + + "Transport bindings must declare scalar fields." + } + } + } + } +} + +private fun validateResponseHeaderNames(artifact: DotnetAspServiceArtifact) { + artifact.endpoints.forEach { endpoint -> + endpoint.responses.forEach { response -> + val headerPropertyNames = response.headers.map { header -> + val generatedName = dotnetAspHeaderPropertyName(header.name) + require(generatedName != RESULT_BODY_PROPERTY_NAME) { + "ASP.NET response ${response.statusCode} in operation " + + "'${endpoint.operationName}' declares header '${header.name}', " + + "which collides with the generated result body property " + + "'$RESULT_BODY_PROPERTY_NAME'." + } + generatedName + } + val collisions = response.headers + .zip(headerPropertyNames) + .groupBy({ (_, generatedName) -> generatedName }, { (header, _) -> header }) + .filterValues { it.size > 1 } + .keys + .sorted() + require(collisions.isEmpty()) { + "ASP.NET response ${response.statusCode} in operation " + + "'${endpoint.operationName}' declares headers with colliding " + + "generated property names: ${collisions.joinToString(", ")}." + } + } + } +} + +private fun validateGeneratedControllerTypeNames(artifact: DotnetAspServiceArtifact) { + require(controllerBaseTypeName(artifact) != MICROSMITH_CONTROLLER_BASE_TYPE_NAME) { + "ASP.NET service '${artifact.serviceName}' project '${artifact.id.projectName}' " + + "generates controller base type '${controllerBaseTypeName(artifact)}', " + + "which collides with shared generated controller base type " + + "'$MICROSMITH_CONTROLLER_BASE_TYPE_NAME'." + } +} + +private fun validateGeneratedContractTypeNames(artifact: DotnetAspServiceArtifact) { + val contractOwners = linkedMapOf>() + + fun register(typeName: String, owner: String) { + contractOwners.getOrPut(typeName) { mutableListOf() }.add(owner) + } + + artifact.contractModels + .distinctBy { it.typeName } + .forEach { model -> + register(model.typeName, "generated contract model '${model.typeName}'") + } + collectRequestBindings(artifact).forEach { register(it.typeName, "request binding '${it.typeName}'") } + collectHeaderBindings(artifact).forEach { register(it.typeName, "headers binding '${it.typeName}'") } + artifact.endpoints.forEach { endpoint -> + register( + resultBaseTypeName(endpoint), + "result base for operation '${endpoint.operationName}'", + ) + endpoint.responses.forEach { response -> + register( + resultVariantTypeName(endpoint, response), + "response result for operation '${endpoint.operationName}' " + + "status ${response.statusCode}", + ) + } + } + + val collisions = contractOwners + .filterValues { it.size > 1 } + .entries + .sortedBy { it.key } + require(collisions.isEmpty()) { + "ASP.NET service '${artifact.serviceName}' produces colliding generated contract types: " + + collisions.joinToString("; ") { (typeName, owners) -> + "$typeName from ${owners.sorted().joinToString(", ")}" + } + "." + } +} + +internal fun collectRequestBindings(artifact: DotnetAspServiceArtifact): List = + artifact + .endpoints + .flatMap { endpoint -> + listOfNotNull(endpoint.bindings.path, endpoint.bindings.query) + }.groupBy(DotnetAspRequestBindingArtifact::typeName) + .map { (typeName, bindings) -> + val first = bindings.first() + require(bindings.all { it == first }) { + "ASP.NET service '${artifact.serviceName}' declares conflicting " + + "request binding shapes for '$typeName'." + } + first + }.sortedBy(DotnetAspRequestBindingArtifact::typeName) + +internal fun collectHeaderBindings(artifact: DotnetAspServiceArtifact): List = artifact + .endpoints + .mapNotNull { it.bindings.headers } + .groupBy(DotnetAspHeadersBindingArtifact::typeName) + .map { (typeName, bindings) -> + val first = bindings.first() + require(bindings.all { it == first }) { + "ASP.NET service '${artifact.serviceName}' declares conflicting " + + "headers binding shapes for '$typeName'." + } + first + }.sortedBy(DotnetAspHeadersBindingArtifact::typeName) diff --git a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspGeneratedNames.kt b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspGeneratedNames.kt new file mode 100644 index 00000000..031215fd --- /dev/null +++ b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspGeneratedNames.kt @@ -0,0 +1,91 @@ +package io.github.lmliam.microsmith.compile.services.dotnet.asp + +import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspEndpointArtifact +import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspResponseArtifact +import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspServiceArtifact +import java.util.Locale + +internal const val MICROSMITH_CONTROLLER_BASE_TYPE_NAME = "MicrosmithControllerBase" +internal const val RESULT_BODY_PROPERTY_NAME = "Body" + +internal fun contractsNamespace(artifact: DotnetAspServiceArtifact): String = + "${artifact.id.projectName}.Generated.Contracts" + +internal fun controllersNamespace(artifact: DotnetAspServiceArtifact): String = + "${artifact.id.projectName}.Generated.Controllers" + +internal fun hostingNamespace(artifact: DotnetAspServiceArtifact): String = + "${artifact.id.projectName}.Generated.Hosting" + +internal fun controllerBaseTypeName(artifact: DotnetAspServiceArtifact): String = + "${dotnetAspTypeName(artifact.id.projectName)}ControllerBase" + +internal fun microsmithControllerBaseRelativePath(): String = + "Generated/Controllers/$MICROSMITH_CONTROLLER_BASE_TYPE_NAME.cs" + +internal fun controllerBaseRelativePath(artifact: DotnetAspServiceArtifact): String = + "Generated/Controllers/${controllerBaseTypeName(artifact)}.cs" + +internal fun resultBaseTypeName(endpoint: DotnetAspEndpointArtifact): String = "${endpoint.operationName}Result" + +internal fun resultVariantTypeName( + endpoint: DotnetAspEndpointArtifact, + response: DotnetAspResponseArtifact, +): String = endpoint.operationName + dotnetAspStatusName(response.statusCode) + +internal fun dotnetAspPascalIdentifier(identifier: String): String = when { + identifier.startsWith("@") && identifier.length > 1 -> + "@${identifier.substring(1).replaceFirstChar { firstChar -> + firstChar.titlecase(Locale.ROOT) + }}" + + else -> + identifier.replaceFirstChar { firstChar -> + firstChar.titlecase(Locale.ROOT) + } +} + +internal fun dotnetAspTypeName(raw: String): String = raw + .split('.', '-', '_', ' ') + .filter(String::isNotBlank) + .joinToString("") { segment -> + segment + .removePrefix("@") + .replaceFirstChar { firstChar -> firstChar.titlecase(Locale.ROOT) } + }.ifBlank { + error("Unable to derive a generated ASP.NET type name from '$raw'.") + } + +internal fun dotnetAspHeaderPropertyName(headerName: String): String = headerName + .trim() + .split(HEADER_PROPERTY_DELIMITER_PATTERN) + .filter(String::isNotBlank) + .joinToString("") { segment -> + segment.lowercase(Locale.ROOT).replaceFirstChar { firstChar -> + firstChar.titlecase(Locale.ROOT) + } + }.let { identifier -> + if (identifier.firstOrNull()?.isDigit() == true) { + "Header$identifier" + } else { + identifier + } + }.ifBlank { + error("Unable to derive an ASP.NET response header property name from '$headerName'.") + } + +internal fun dotnetAspStatusName(statusCode: Int): String = when (statusCode) { + 200 -> "Ok" + 201 -> "Created" + 202 -> "Accepted" + 204 -> "NoContent" + 400 -> "BadRequest" + 401 -> "Unauthorized" + 403 -> "Forbidden" + 404 -> "NotFound" + 409 -> "Conflict" + 500 -> "InternalServerError" + else -> "Status$statusCode" +} + +private val HEADER_PROPERTY_DELIMITER_PATTERN = Regex("[^A-Za-z0-9]+") diff --git a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspProjectRenderer.kt b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspProjectRenderer.kt index a49fa9c9..318731df 100644 --- a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspProjectRenderer.kt +++ b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspProjectRenderer.kt @@ -1,552 +1,338 @@ package io.github.lmliam.microsmith.compile.services.dotnet.asp import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspEndpointArtifact +import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspHeaderFieldArtifact import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspHeadersBindingArtifact import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspModelArtifact +import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspModelLocality import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspRequestBindingArtifact import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspRequestFieldArtifact import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspResponseArtifact import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspServiceArtifact +import io.github.lmliam.microsmith.dsl.services.dotnet.core.model.DotnetField import io.github.lmliam.microsmith.dsl.services.dotnet.core.model.DotnetFieldType import java.util.Locale internal object DotnetAspProjectRenderer { - fun renderProgramFile(): String = """ - var builder = WebApplication.CreateBuilder(args); + fun renderProgramFile(artifact: DotnetAspServiceArtifact): String = """ + using ${hostingNamespace(artifact)}; - builder.Services.AddControllers(); + var builder = WebApplication.CreateBuilder(args); + builder.AddMicrosmith(); var app = builder.Build(); + app.MapMicrosmith(); + app.Run(); - app.Use(async (context, next) => + public partial class Program { } + """.trimIndent() + + fun renderHostingExtensionsFile(artifact: DotnetAspServiceArtifact): String = """ + namespace ${hostingNamespace(artifact)}; + + using Microsoft.AspNetCore.Builder; + using Microsoft.Extensions.DependencyInjection; + + public static class MicrosmithHostingExtensions { - try + public static WebApplicationBuilder AddMicrosmith(this WebApplicationBuilder builder) { - await next(); + builder.Services.AddControllers(); + return builder; } - catch (BadHttpRequestException exception) + + public static WebApplication MapMicrosmith(this WebApplication app) { - await Results.Json( - new - { - detail = exception.Message, - }, - statusCode: StatusCodes.Status400BadRequest).ExecuteAsync(context); + app.MapControllers(); + return app; } - }); + } + """.trimIndent() - app.MapControllers(); + fun renderMicrosmithControllerBaseFile(artifact: DotnetAspServiceArtifact): String = """ + namespace ${controllersNamespace(artifact)}; - app.Run(); + using Microsoft.AspNetCore.Mvc; - public partial class Program { } + public abstract class $MICROSMITH_CONTROLLER_BASE_TYPE_NAME : ControllerBase + { + protected ActionResult Respond(object? body, int statusCode, params (string Name, string? Value)[] headers) + { + foreach (var (name, value) in headers) + { + if (value is not null) + { + Response.Headers[name] = value; + } + } + + if (statusCode == 204) + { + return StatusCode(statusCode); + } + + return new ObjectResult(body) + { + StatusCode = statusCode + }; + } + + protected string? ReadHeader(string headerName) + { + return Request.Headers.TryGetValue(headerName, out var values) + ? values.ToString() + : null; + } + } """.trimIndent() - fun renderControllerFile(artifact: DotnetAspServiceArtifact): String = buildString { - val projectNamespace = artifact.id.projectName - appendLine("namespace $projectNamespace.Controllers;") + fun renderControllerBaseFile(artifact: DotnetAspServiceArtifact): String = buildString { + appendLine("namespace ${controllersNamespace(artifact)};") appendLine() - appendLine("using $projectNamespace.Bindings;") - appendLine("using $projectNamespace.Generated;") - appendLine("using $projectNamespace.Models;") - appendLine("using System.Globalization;") + appendLine("using System;") + appendLine("using System.Threading;") + appendLine("using System.Threading.Tasks;") + appendLine("using ${contractsNamespace(artifact)};") appendLine("using Microsoft.AspNetCore.Mvc;") appendLine() appendLine("[ApiController]") - appendLine("public sealed class ${artifact.serviceName}Controller : ControllerBase") + appendLine("public abstract class ${controllerBaseTypeName(artifact)} : $MICROSMITH_CONTROLLER_BASE_TYPE_NAME") appendLine("{") artifact.endpoints.forEach { endpoint -> - append(renderEndpoint(endpoint)) + append(renderActionMethod(endpoint)) + appendLine() + append(renderAbstractHandler(endpoint)) + appendLine() + append(renderResultMapper(endpoint)) appendLine() } - append(renderRequestedStatusHelper()) appendLine("}") } - fun renderModelFile(projectNamespace: String, model: DotnetAspModelArtifact): String = buildString { - appendLine("namespace $projectNamespace.Models;") + fun renderServiceModelsFile(artifact: DotnetAspServiceArtifact): String = buildString { + appendLine("namespace ${contractsNamespace(artifact)};") appendLine() - appendLine("public sealed class ${model.typeName}") - appendLine("{") - model.model.fields.forEach { field -> - appendLine(" public ${renderModelPropertyType(field.type)} ${field.name} { get; set; }${renderInitializer(field.type)}") - } - appendLine("}") + artifact.contractModels + .distinctBy(DotnetAspModelArtifact::typeName) + .filter { it.locality == DotnetAspModelLocality.SHARED } + .sortedBy(DotnetAspModelArtifact::typeName) + .forEachIndexed { index, model -> + if (index > 0) { + appendLine() + } + append(renderRecordType(model.typeName, model.model.fields)) + } } - fun renderRequestBindingFile(projectNamespace: String, binding: DotnetAspRequestBindingArtifact): String = buildString { - val referenceTargets = binding.fields.any { it.type is DotnetFieldType.Reference } - appendLine("namespace $projectNamespace.Bindings;") + fun renderRequestModelsFile(artifact: DotnetAspServiceArtifact): String = buildString { + appendLine("namespace ${contractsNamespace(artifact)};") appendLine() - if (referenceTargets) { - appendLine("using $projectNamespace.Models;") - appendLine() - } - appendLine("public sealed class ${binding.typeName}") - appendLine("{") - binding.fields.forEach { field -> - appendLine( - " public ${renderBindingPropertyType(field)} ${field.name} { get; set; }${renderBindingInitializer(field)}", - ) + appendLine("using Microsoft.AspNetCore.Mvc.ModelBinding;") + val elements = buildList { + collectRequestBindings(artifact).forEach { add(renderRequestBindingType(it)) } + collectHeaderBindings(artifact).forEach { add(renderHeadersBindingType(it)) } + artifact.endpoints.forEach { endpoint -> + endpoint.bindings.body + ?.takeIf { it.locality == DotnetAspModelLocality.INLINE } + ?.let { add(renderRecordType(it.typeName, it.model.fields)) } + } + }.distinct() + elements.forEachIndexed { index, typeBlock -> + if (index > 0) { + appendLine() + } + append(typeBlock) } - appendLine("}") } - fun renderHeadersBindingFile(projectNamespace: String, binding: DotnetAspHeadersBindingArtifact): String = buildString { - appendLine("namespace $projectNamespace.Bindings;") + fun renderResponseModelsFile(artifact: DotnetAspServiceArtifact): String = buildString { + appendLine("namespace ${contractsNamespace(artifact)};") appendLine() - appendLine("public sealed class ${binding.typeName}") - appendLine("{") - binding.headers.forEach { header -> - appendLine(" public string? ${header.name} { get; set; } = null;") - } - appendLine("}") - } - - fun renderRequestParserFile(projectNamespace: String): String = """ - namespace $projectNamespace.Generated; - - using System; - using System.Globalization; - using System.Text.Json; - using Microsoft.AspNetCore.Http; - using Microsoft.AspNetCore.Routing; - using Microsoft.Extensions.Primitives; - - internal static class MicrosmithRequestParser - { - private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); - - internal static string? ReadRouteValue(RouteValueDictionary routeValues, string name) => - routeValues.TryGetValue(name, out var value) ? value?.ToString() : null; - - internal static string? ReadQueryValue(IQueryCollection query, string name) => - ReadStringValues(query[name]); - - internal static string? ReadHeaderValue(IHeaderDictionary headers, string name) => - ReadStringValues(headers[name]); - - internal static string RequireString(string? raw, string bindingName) => - !string.IsNullOrWhiteSpace(raw) ? raw : throw Missing(bindingName); - - internal static string? OptionalString(string? raw) => - string.IsNullOrWhiteSpace(raw) ? null : raw; - - internal static char RequireChar(string? raw, string bindingName) - { - var value = RequireString(raw, bindingName); - if (value.Length != 1) - { - throw Invalid(bindingName, value); - } - - return value[0]; - } - - internal static char? OptionalChar(string? raw, string bindingName) => - string.IsNullOrWhiteSpace(raw) ? null : RequireChar(raw, bindingName); - - internal static byte RequireByte(string? raw, string bindingName) => - ParseRequired(raw, bindingName, value => byte.Parse(value, NumberStyles.Integer, CultureInfo.InvariantCulture)); - - internal static byte? OptionalByte(string? raw, string bindingName) => - ParseOptionalStruct(raw, bindingName, value => byte.Parse(value, NumberStyles.Integer, CultureInfo.InvariantCulture)); - - internal static sbyte RequireSignedByte(string? raw, string bindingName) => - ParseRequired(raw, bindingName, value => sbyte.Parse(value, NumberStyles.Integer, CultureInfo.InvariantCulture)); - - internal static sbyte? OptionalSignedByte(string? raw, string bindingName) => - ParseOptionalStruct(raw, bindingName, value => sbyte.Parse(value, NumberStyles.Integer, CultureInfo.InvariantCulture)); - - internal static short RequireShort(string? raw, string bindingName) => - ParseRequired(raw, bindingName, value => short.Parse(value, NumberStyles.Integer, CultureInfo.InvariantCulture)); - - internal static short? OptionalShort(string? raw, string bindingName) => - ParseOptionalStruct(raw, bindingName, value => short.Parse(value, NumberStyles.Integer, CultureInfo.InvariantCulture)); - - internal static ushort RequireUnsignedShort(string? raw, string bindingName) => - ParseRequired(raw, bindingName, value => ushort.Parse(value, NumberStyles.Integer, CultureInfo.InvariantCulture)); - - internal static ushort? OptionalUnsignedShort(string? raw, string bindingName) => - ParseOptionalStruct(raw, bindingName, value => ushort.Parse(value, NumberStyles.Integer, CultureInfo.InvariantCulture)); - - internal static int RequireInt(string? raw, string bindingName) => - ParseRequired(raw, bindingName, value => int.Parse(value, NumberStyles.Integer, CultureInfo.InvariantCulture)); - - internal static int? OptionalInt(string? raw, string bindingName) => - ParseOptionalStruct(raw, bindingName, value => int.Parse(value, NumberStyles.Integer, CultureInfo.InvariantCulture)); - - internal static uint RequireUnsignedInt(string? raw, string bindingName) => - ParseRequired(raw, bindingName, value => uint.Parse(value, NumberStyles.Integer, CultureInfo.InvariantCulture)); - - internal static uint? OptionalUnsignedInt(string? raw, string bindingName) => - ParseOptionalStruct(raw, bindingName, value => uint.Parse(value, NumberStyles.Integer, CultureInfo.InvariantCulture)); - - internal static long RequireLong(string? raw, string bindingName) => - ParseRequired(raw, bindingName, value => long.Parse(value, NumberStyles.Integer, CultureInfo.InvariantCulture)); - - internal static long? OptionalLong(string? raw, string bindingName) => - ParseOptionalStruct(raw, bindingName, value => long.Parse(value, NumberStyles.Integer, CultureInfo.InvariantCulture)); - - internal static ulong RequireUnsignedLong(string? raw, string bindingName) => - ParseRequired(raw, bindingName, value => ulong.Parse(value, NumberStyles.Integer, CultureInfo.InvariantCulture)); - - internal static ulong? OptionalUnsignedLong(string? raw, string bindingName) => - ParseOptionalStruct(raw, bindingName, value => ulong.Parse(value, NumberStyles.Integer, CultureInfo.InvariantCulture)); - - internal static nint RequireNativeInt(string? raw, string bindingName) => - ParseRequired(raw, bindingName, value => nint.Parse(value, CultureInfo.InvariantCulture)); - - internal static nint? OptionalNativeInt(string? raw, string bindingName) => - ParseOptionalStruct(raw, bindingName, value => nint.Parse(value, CultureInfo.InvariantCulture)); - - internal static nuint RequireUnsignedNativeInt(string? raw, string bindingName) => - ParseRequired(raw, bindingName, value => nuint.Parse(value, CultureInfo.InvariantCulture)); - - internal static nuint? OptionalUnsignedNativeInt(string? raw, string bindingName) => - ParseOptionalStruct(raw, bindingName, value => nuint.Parse(value, CultureInfo.InvariantCulture)); - - internal static float RequireFloat(string? raw, string bindingName) => - ParseRequired(raw, bindingName, value => float.Parse(value, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture)); - - internal static float? OptionalFloat(string? raw, string bindingName) => - ParseOptionalStruct(raw, bindingName, value => float.Parse(value, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture)); - - internal static double RequireDouble(string? raw, string bindingName) => - ParseRequired(raw, bindingName, value => double.Parse(value, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture)); - - internal static double? OptionalDouble(string? raw, string bindingName) => - ParseOptionalStruct(raw, bindingName, value => double.Parse(value, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture)); - - internal static decimal RequireDecimal(string? raw, string bindingName) => - ParseRequired(raw, bindingName, value => decimal.Parse(value, NumberStyles.Number, CultureInfo.InvariantCulture)); - - internal static decimal? OptionalDecimal(string? raw, string bindingName) => - ParseOptionalStruct(raw, bindingName, value => decimal.Parse(value, NumberStyles.Number, CultureInfo.InvariantCulture)); - - internal static bool RequireBool(string? raw, string bindingName) => - ParseRequired(raw, bindingName, value => bool.Parse(value)); - - internal static bool? OptionalBool(string? raw, string bindingName) => - ParseOptionalStruct(raw, bindingName, value => bool.Parse(value)); - - internal static Guid RequireGuid(string? raw, string bindingName) => - ParseRequired(raw, bindingName, value => Guid.Parse(value)); - - internal static Guid? OptionalGuid(string? raw, string bindingName) => - ParseOptionalStruct(raw, bindingName, value => Guid.Parse(value)); - - internal static DateOnly RequireDateOnly(string? raw, string bindingName) => - ParseRequired(raw, bindingName, value => DateOnly.Parse(value, CultureInfo.InvariantCulture)); - - internal static DateOnly? OptionalDateOnly(string? raw, string bindingName) => - ParseOptionalStruct(raw, bindingName, value => DateOnly.Parse(value, CultureInfo.InvariantCulture)); - - internal static TimeOnly RequireTimeOnly(string? raw, string bindingName) => - ParseRequired(raw, bindingName, value => TimeOnly.Parse(value, CultureInfo.InvariantCulture)); - - internal static TimeOnly? OptionalTimeOnly(string? raw, string bindingName) => - ParseOptionalStruct(raw, bindingName, value => TimeOnly.Parse(value, CultureInfo.InvariantCulture)); - - internal static DateTime RequireDateTime(string? raw, string bindingName) => - ParseRequired(raw, bindingName, value => DateTime.Parse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind)); - - internal static DateTime? OptionalDateTime(string? raw, string bindingName) => - ParseOptionalStruct(raw, bindingName, value => DateTime.Parse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind)); - - internal static DateTimeOffset RequireDateTimeOffset(string? raw, string bindingName) => - ParseRequired(raw, bindingName, value => DateTimeOffset.Parse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind)); - - internal static DateTimeOffset? OptionalDateTimeOffset(string? raw, string bindingName) => - ParseOptionalStruct(raw, bindingName, value => DateTimeOffset.Parse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind)); - - internal static TimeSpan RequireTimeSpan(string? raw, string bindingName) => - ParseRequired(raw, bindingName, value => TimeSpan.Parse(value, CultureInfo.InvariantCulture)); - - internal static TimeSpan? OptionalTimeSpan(string? raw, string bindingName) => - ParseOptionalStruct(raw, bindingName, value => TimeSpan.Parse(value, CultureInfo.InvariantCulture)); - - internal static T RequireJson(string? raw, string bindingName) where T : class - { - var value = JsonSerializer.Deserialize(RequireString(raw, bindingName), JsonOptions); - return value ?? throw Invalid(bindingName, raw ?? string.Empty); + val elements = buildList { + artifact.endpoints.forEach { endpoint -> + endpoint.responses + .map(DotnetAspResponseArtifact::model) + .filter { it.locality == DotnetAspModelLocality.INLINE } + .distinctBy(DotnetAspModelArtifact::typeName) + .forEach { model -> add(renderRecordType(model.typeName, model.model.fields)) } } - - internal static T? OptionalJson(string? raw, string bindingName) where T : class => - string.IsNullOrWhiteSpace(raw) ? null : RequireJson(raw, bindingName); - - internal static Guid ParseGuidLiteral(string raw) => Guid.Parse(raw); - - internal static DateOnly ParseDateOnlyLiteral(string raw) => - DateOnly.Parse(raw, CultureInfo.InvariantCulture); - - internal static TimeOnly ParseTimeOnlyLiteral(string raw) => - TimeOnly.Parse(raw, CultureInfo.InvariantCulture); - - internal static DateTime ParseDateTimeLiteral(string raw) => - DateTime.Parse(raw, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind); - - internal static DateTimeOffset ParseDateTimeOffsetLiteral(string raw) => - DateTimeOffset.Parse(raw, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind); - - internal static TimeSpan ParseTimeSpanLiteral(string raw) => - TimeSpan.Parse(raw, CultureInfo.InvariantCulture); - - internal static T ParseJsonLiteral(string raw) where T : class => - JsonSerializer.Deserialize(raw, JsonOptions) ?? throw new InvalidOperationException($"Invalid JSON literal for {typeof(T).Name}."); - - private static T ParseRequired(string? raw, string bindingName, Func parser) - { - try - { - return parser(RequireString(raw, bindingName)); - } - catch (Exception) when (!string.IsNullOrWhiteSpace(raw)) - { - throw Invalid(bindingName, raw ?? string.Empty); + artifact.endpoints.forEach { endpoint -> + add(renderResultBaseType(endpoint)) + endpoint.responses.forEach { response -> + add(renderResultVariantType(endpoint, response)) } } - - private static T? ParseOptionalStruct(string? raw, string bindingName, Func parser) - where T : struct - { - if (string.IsNullOrWhiteSpace(raw)) - { - return null; - } - - return ParseRequired(raw, bindingName, parser); + }.distinct() + elements.forEachIndexed { index, typeBlock -> + if (index > 0) { + appendLine() } - - private static string? ReadStringValues(StringValues values) => - StringValues.IsNullOrEmpty(values) ? null : values[0]; - - private static BadHttpRequestException Missing(string bindingName) => - new($"Missing required value for '{bindingName}'."); - - private static BadHttpRequestException Invalid(string bindingName, string raw) => - new($"Invalid value '{raw}' for '{bindingName}'."); + append(typeBlock) } - """.trimIndent() + } - private fun renderEndpoint(endpoint: DotnetAspEndpointArtifact): String = buildString { - appendLine(" [Http${endpoint.method.lowercase().replaceFirstChar(Char::uppercase)}(${escapeCsharpStringLiteral(endpoint.route)}, Name = ${escapeCsharpStringLiteral(endpoint.operationName)})]") + private fun renderActionMethod(endpoint: DotnetAspEndpointArtifact): String = buildString { + appendLine(" [${httpAttributeName(endpoint.method)}(${escapeCsharpStringLiteral(endpoint.route)}, Name = ${escapeCsharpStringLiteral(endpoint.operationName)})]") endpoint.responses.forEach { response -> - appendLine(" [ProducesResponseType(typeof(${response.model.typeName}), ${response.statusCode})]") - } - append(" public IActionResult ${endpoint.operationName}(") - val parameters = buildList { - endpoint.bindings.body?.let { bodyModel -> - add("[FromBody] ${bodyModel.typeName} body") - } + appendLine( + " [ProducesResponseType(typeof(${responseAttributeType(response)}), ${response.statusCode})]", + ) } - append(parameters.joinToString(", ")) + append(" public async Task> ${endpoint.operationName}(") + append(actionParameters(endpoint).joinToString(", ")) appendLine(")") appendLine(" {") - endpoint.bindings.path?.let { binding -> - append(renderRequestBindingAssignment(binding, RequestSource.PATH)) - } - endpoint.bindings.query?.let { binding -> - append(renderRequestBindingAssignment(binding, RequestSource.QUERY)) - } endpoint.bindings.headers?.let { binding -> - append(renderHeadersBindingAssignment(binding)) - } - endpoint.bindings.body?.let { - appendLine(" _ = body;") - } - if (endpoint.responses.size > 1) { - appendLine(" var requestedStatusCode = RequestedStatusCode();") - endpoint.responses.forEachIndexed { index, response -> - val condition = - if (index == 0) { - "requestedStatusCode == ${response.statusCode} || requestedStatusCode is null" - } else { - "requestedStatusCode == ${response.statusCode}" - } - appendLine(" if ($condition)") - appendLine(" {") - append(renderResponseBlock(response)) - appendLine(" }") - } - append(renderResponseBlock(primaryResponse(endpoint.responses))) - } else { - append(renderResponseBlock(endpoint.responses.single())) + append(renderHeadersInitializer(binding)) + appendLine() } + appendLine(" var result = await On${endpoint.operationName}Async(${handlerArguments(endpoint).joinToString(", ")});") + appendLine(" return Map${endpoint.operationName}Result(result);") appendLine(" }") } - private fun renderRequestBindingAssignment( - binding: DotnetAspRequestBindingArtifact, - requestSource: RequestSource, - ): String = buildString { - appendLine(" var ${bindingVariableName(binding.name)} = new ${binding.typeName}") + private fun renderAbstractHandler(endpoint: DotnetAspEndpointArtifact): String = buildString { + append(" protected abstract Task<${resultBaseTypeName(endpoint)}> On${endpoint.operationName}Async(") + append(handlerParameters(endpoint).joinToString(", ")) + appendLine(");") + } + + private fun renderResultMapper(endpoint: DotnetAspEndpointArtifact): String = buildString { + appendLine(" private ActionResult<${resultBaseTypeName(endpoint)}> Map${endpoint.operationName}Result(${resultBaseTypeName(endpoint)} result)") + appendLine(" {") + appendLine(" return result switch") appendLine(" {") - binding.fields.forEachIndexed { index, field -> - val suffix = if (index == binding.fields.lastIndex) "" else "," - appendLine( - " ${field.name} = ${renderParsedFieldExpression(field, requestSource)}$suffix", + endpoint.responses.forEach { response -> + append(" ${resultVariantTypeName(endpoint, response)} response => Respond(") + append( + if (response.statusCode == 204) { + "null" + } else { + "response.$RESULT_BODY_PROPERTY_NAME" + }, ) + append(", ${response.statusCode}") + response.headers.forEach { header -> + append(", (${escapeCsharpStringLiteral(header.name)}, response.${dotnetAspHeaderPropertyName(header.name)})") + } + appendLine("),") } + appendLine( + " _ => throw new InvalidOperationException(" + + "${escapeCsharpStringLiteral("Unsupported ${endpoint.operationName} result type.")} + " + + "result.GetType().FullName + \".\"),", + ) appendLine(" };") - appendLine(" _ = ${bindingVariableName(binding.name)};") + appendLine(" }") } - private fun renderHeadersBindingAssignment(binding: DotnetAspHeadersBindingArtifact): String = buildString { - appendLine(" var ${bindingVariableName(binding.name)} = new ${binding.typeName}") + private fun renderHeadersInitializer(binding: DotnetAspHeadersBindingArtifact): String = buildString { + appendLine(" var headers = new ${binding.typeName}") appendLine(" {") binding.headers.forEachIndexed { index, header -> val suffix = if (index == binding.headers.lastIndex) "" else "," appendLine( - " ${header.name} = MicrosmithRequestParser.OptionalString(" + - "MicrosmithRequestParser.ReadHeaderValue(Request.Headers, ${escapeCsharpStringLiteral(header.headerName)}))$suffix", + " ${dotnetAspPascalIdentifier(header.name)} = " + + "ReadHeader(${escapeCsharpStringLiteral(header.headerName)})$suffix", ) } appendLine(" };") - appendLine(" _ = ${bindingVariableName(binding.name)};") } - private fun renderResponseBlock(response: DotnetAspResponseArtifact): String = buildString { - response.headers.forEach { header -> + private fun renderRecordType(typeName: String, fields: List): String = buildString { + appendLine("public sealed record $typeName") + appendLine("{") + fields.forEach { field -> appendLine( - " Response.Headers[${escapeCsharpStringLiteral(header.name)}] = " + - "${escapeCsharpStringLiteral(sampleHeaderValue(header.name))};", + " public ${renderModelPropertyType(field.type)} ${dotnetAspPascalIdentifier(field.name)} { get; set; }" + + renderInitializer(field.type), ) } - if (response.statusCode == 204) { - appendLine(" return StatusCode(204);") - } else { - appendLine(" return StatusCode(${response.statusCode}, new ${response.model.typeName}());") + appendLine("}") + } + + private fun renderRequestBindingType(binding: DotnetAspRequestBindingArtifact): String = buildString { + appendLine("public sealed record ${binding.typeName}") + appendLine("{") + binding.fields.forEach { field -> + if (!field.optional && field.defaultValue == null) { + appendLine(" [BindRequired]") + } + appendLine( + " public ${renderBindingPropertyType(field)} ${dotnetAspPascalIdentifier(field.name)} { get; set; }" + + renderBindingInitializer(field), + ) } + appendLine("}") } - private fun renderRequestedStatusHelper(): String = """ - private int? RequestedStatusCode() - { - if (!Request.Headers.TryGetValue("X-Microsmith-Response-Status", out var values)) - { - return null; - } + private fun renderHeadersBindingType(binding: DotnetAspHeadersBindingArtifact): String = buildString { + appendLine("public sealed record ${binding.typeName}") + appendLine("{") + binding.headers.forEach { header -> + appendLine(" public string? ${dotnetAspPascalIdentifier(header.name)} { get; set; } = null;") + } + appendLine("}") + } - var candidate = values.Count > 0 ? values[0] : null; - if (string.IsNullOrWhiteSpace(candidate)) - { - return null; - } + private fun renderResultBaseType(endpoint: DotnetAspEndpointArtifact): String = + "public abstract record ${resultBaseTypeName(endpoint)};" - return int.TryParse(candidate, NumberStyles.Integer, CultureInfo.InvariantCulture, out var statusCode) - ? statusCode - : null; + private fun renderResultVariantType( + endpoint: DotnetAspEndpointArtifact, + response: DotnetAspResponseArtifact, + ): String = buildString { + append("public sealed record ${resultVariantTypeName(endpoint, response)}(") + val parameters = buildList { + if (response.statusCode != 204) { + add("${response.model.typeName} $RESULT_BODY_PROPERTY_NAME") + } + response.headers.forEach { header -> + add("string? ${dotnetAspHeaderPropertyName(header.name)} = null") } - """.trimIndent().prependIndent(" ") + "\n" - - private fun renderParsedFieldExpression( - field: DotnetAspRequestFieldArtifact, - requestSource: RequestSource, - ): String { - val rawExpression = when (requestSource) { - RequestSource.PATH -> - "MicrosmithRequestParser.ReadRouteValue(RouteData.Values, ${escapeCsharpStringLiteral(field.name)})" - - RequestSource.QUERY -> - "MicrosmithRequestParser.ReadQueryValue(Request.Query, ${escapeCsharpStringLiteral(field.name)})" } - val bindingName = "${requestSource.label}.${field.name}" - val parsedExpression = when (val type = field.type) { - DotnetFieldType.String -> - if (field.optional || field.defaultValue != null) { - "MicrosmithRequestParser.OptionalString($rawExpression)" - } else { - "MicrosmithRequestParser.RequireString($rawExpression, ${escapeCsharpStringLiteral(bindingName)})" - } + append(parameters.joinToString(", ")) + append(") : ${resultBaseTypeName(endpoint)};") + } - DotnetFieldType.Char -> renderScalarParse("Char", field, rawExpression, bindingName) - DotnetFieldType.Byte -> renderScalarParse("Byte", field, rawExpression, bindingName) - DotnetFieldType.SignedByte -> renderScalarParse("SignedByte", field, rawExpression, bindingName) - DotnetFieldType.Short -> renderScalarParse("Short", field, rawExpression, bindingName) - DotnetFieldType.UnsignedShort -> renderScalarParse("UnsignedShort", field, rawExpression, bindingName) - DotnetFieldType.Int -> renderScalarParse("Int", field, rawExpression, bindingName) - DotnetFieldType.UnsignedInt -> renderScalarParse("UnsignedInt", field, rawExpression, bindingName) - DotnetFieldType.Long -> renderScalarParse("Long", field, rawExpression, bindingName) - DotnetFieldType.UnsignedLong -> renderScalarParse("UnsignedLong", field, rawExpression, bindingName) - DotnetFieldType.NativeInt -> renderScalarParse("NativeInt", field, rawExpression, bindingName) - DotnetFieldType.UnsignedNativeInt -> renderScalarParse("UnsignedNativeInt", field, rawExpression, bindingName) - DotnetFieldType.Float -> renderScalarParse("Float", field, rawExpression, bindingName) - DotnetFieldType.Double -> renderScalarParse("Double", field, rawExpression, bindingName) - DotnetFieldType.Decimal -> renderScalarParse("Decimal", field, rawExpression, bindingName) - DotnetFieldType.Bool -> renderScalarParse("Bool", field, rawExpression, bindingName) - DotnetFieldType.Guid -> renderScalarParse("Guid", field, rawExpression, bindingName) - DotnetFieldType.DateOnly -> renderScalarParse("DateOnly", field, rawExpression, bindingName) - DotnetFieldType.TimeOnly -> renderScalarParse("TimeOnly", field, rawExpression, bindingName) - DotnetFieldType.DateTime -> renderScalarParse("DateTime", field, rawExpression, bindingName) - DotnetFieldType.DateTimeOffset -> renderScalarParse("DateTimeOffset", field, rawExpression, bindingName) - DotnetFieldType.TimeSpan -> renderScalarParse("TimeSpan", field, rawExpression, bindingName) - is DotnetFieldType.Reference -> { - val typeName = type.target - if (field.optional || field.defaultValue != null) { - "MicrosmithRequestParser.OptionalJson<$typeName>($rawExpression, ${escapeCsharpStringLiteral(bindingName)})" - } else { - "MicrosmithRequestParser.RequireJson<$typeName>($rawExpression, ${escapeCsharpStringLiteral(bindingName)})" - } - } + private fun actionParameters(endpoint: DotnetAspEndpointArtifact): List = buildList { + endpoint.bindings.path?.let { + add("[FromRoute] ${it.typeName} path") } - val defaultValue = field.defaultValue - if (defaultValue == null) { - return parsedExpression + endpoint.bindings.query?.let { + add("[FromQuery] ${it.typeName} query") + } + endpoint.bindings.body?.let { + add("[FromBody] ${it.typeName} body") } - return "($parsedExpression ?? ${renderDefaultExpression(field.type, defaultValue)})" + add("CancellationToken cancellationToken") } - private fun renderScalarParse( - parserSuffix: String, - field: DotnetAspRequestFieldArtifact, - rawExpression: String, - bindingName: String, - ): String { - val mode = if (field.optional || field.defaultValue != null) "Optional" else "Require" - return "MicrosmithRequestParser.$mode$parserSuffix($rawExpression, ${escapeCsharpStringLiteral(bindingName)})" + private fun handlerParameters(endpoint: DotnetAspEndpointArtifact): List = buildList { + endpoint.bindings.path?.let { add("${it.typeName} path") } + endpoint.bindings.query?.let { add("${it.typeName} query") } + endpoint.bindings.headers?.let { add("${it.typeName} headers") } + endpoint.bindings.body?.let { add("${it.typeName} body") } + add("CancellationToken cancellationToken") } - private fun renderDefaultExpression(type: DotnetFieldType, defaultValue: Any): String = when (type) { - DotnetFieldType.String -> escapeCsharpStringLiteral(defaultValue.toString()) - DotnetFieldType.Char -> escapeCsharpCharLiteral(defaultValue.toString().first()) - DotnetFieldType.Byte, - DotnetFieldType.SignedByte, - DotnetFieldType.Short, - DotnetFieldType.UnsignedShort, - DotnetFieldType.Int, - DotnetFieldType.NativeInt, - -> defaultValue.toString() - - DotnetFieldType.UnsignedInt -> "${defaultValue}U" - DotnetFieldType.Long -> "${defaultValue}L" - DotnetFieldType.UnsignedLong -> "${defaultValue}UL" - DotnetFieldType.UnsignedNativeInt -> "${defaultValue}U" - DotnetFieldType.Float -> "${defaultValue.toString().ensureDecimal()}F" - DotnetFieldType.Double -> "${defaultValue.toString().ensureDecimal()}D" - DotnetFieldType.Decimal -> "${defaultValue.toString().ensureDecimal()}M" - DotnetFieldType.Bool -> defaultValue.toString().lowercase(Locale.ROOT) - DotnetFieldType.Guid -> - "MicrosmithRequestParser.ParseGuidLiteral(${escapeCsharpStringLiteral(defaultValue.toString())})" - - DotnetFieldType.DateOnly -> - "MicrosmithRequestParser.ParseDateOnlyLiteral(${escapeCsharpStringLiteral(defaultValue.toString())})" - - DotnetFieldType.TimeOnly -> - "MicrosmithRequestParser.ParseTimeOnlyLiteral(${escapeCsharpStringLiteral(defaultValue.toString())})" - - DotnetFieldType.DateTime -> - "MicrosmithRequestParser.ParseDateTimeLiteral(${escapeCsharpStringLiteral(defaultValue.toString())})" - - DotnetFieldType.DateTimeOffset -> - "MicrosmithRequestParser.ParseDateTimeOffsetLiteral(${escapeCsharpStringLiteral(defaultValue.toString())})" + private fun handlerArguments(endpoint: DotnetAspEndpointArtifact): List = buildList { + endpoint.bindings.path?.let { add("path") } + endpoint.bindings.query?.let { add("query") } + endpoint.bindings.headers?.let { add("headers") } + endpoint.bindings.body?.let { add("body") } + add("cancellationToken") + } - DotnetFieldType.TimeSpan -> - "MicrosmithRequestParser.ParseTimeSpanLiteral(${escapeCsharpStringLiteral(defaultValue.toString())})" + private fun responseAttributeType(response: DotnetAspResponseArtifact): String = + if (response.statusCode == 204) { + "void" + } else { + response.model.typeName + } - is DotnetFieldType.Reference -> - "MicrosmithRequestParser.ParseJsonLiteral<${type.target}>(${escapeCsharpStringLiteral(defaultValue.toString())})" - } + private fun httpAttributeName(method: String): String = + "Http" + method.lowercase(Locale.ROOT).replaceFirstChar(Char::uppercase) private fun renderModelPropertyType(type: DotnetFieldType): String = when (type) { is DotnetFieldType.Reference -> type.target @@ -555,7 +341,7 @@ internal object DotnetAspProjectRenderer { private fun renderBindingPropertyType(field: DotnetAspRequestFieldArtifact): String { val baseType = renderModelPropertyType(field.type) - return if (field.optional) "$baseType?" else baseType + return if (field.optional && field.defaultValue == null) "$baseType?" else baseType } private fun renderInitializer(type: DotnetFieldType): String = when (type) { @@ -572,7 +358,6 @@ internal object DotnetAspProjectRenderer { DotnetFieldType.NativeInt, DotnetFieldType.UnsignedNativeInt, -> " = 0;" - DotnetFieldType.Float -> " = 0F;" DotnetFieldType.Double -> " = 0D;" DotnetFieldType.Decimal -> " = 0M;" @@ -589,17 +374,45 @@ internal object DotnetAspProjectRenderer { private fun renderBindingInitializer(field: DotnetAspRequestFieldArtifact): String { val defaultValue = field.defaultValue return when { - field.optional -> " = null;" - defaultValue != null -> " = ${renderDefaultExpression(field.type, defaultValue)};" - else -> renderInitializer(field.type) - } + defaultValue != null -> " = ${renderDefaultExpression(field.type, defaultValue)};" + field.optional -> " = null;" + else -> renderInitializer(field.type) + } } - private fun primaryResponse(responses: List): DotnetAspResponseArtifact = - responses.firstOrNull { it.statusCode in 200..299 } ?: responses.first() - - private fun bindingVariableName(bindingName: String): String = - bindingName.replaceFirstChar { char -> char.lowercase(Locale.ROOT) } + private fun renderDefaultExpression(type: DotnetFieldType, defaultValue: Any): String = when (type) { + DotnetFieldType.String -> escapeCsharpStringLiteral(defaultValue.toString()) + DotnetFieldType.Char -> escapeCsharpCharLiteral(defaultValue.toString().first()) + DotnetFieldType.Byte, + DotnetFieldType.SignedByte, + DotnetFieldType.Short, + DotnetFieldType.UnsignedShort, + DotnetFieldType.Int, + -> defaultValue.toString() + DotnetFieldType.UnsignedInt -> "${defaultValue}U" + DotnetFieldType.Long -> "${defaultValue}L" + DotnetFieldType.UnsignedLong -> "${defaultValue}UL" + DotnetFieldType.NativeInt -> "(nint)$defaultValue" + DotnetFieldType.UnsignedNativeInt -> "(nuint)${defaultValue}UL" + DotnetFieldType.Float -> "${defaultValue.toString().ensureDecimal()}F" + DotnetFieldType.Double -> "${defaultValue.toString().ensureDecimal()}D" + DotnetFieldType.Decimal -> "${defaultValue.toString().ensureDecimal()}M" + DotnetFieldType.Bool -> defaultValue.toString().lowercase(Locale.ROOT) + DotnetFieldType.Guid -> "Guid.Parse(${escapeCsharpStringLiteral(defaultValue.toString())})" + DotnetFieldType.DateOnly -> + "DateOnly.Parse(${escapeCsharpStringLiteral(defaultValue.toString())}, global::System.Globalization.CultureInfo.InvariantCulture)" + DotnetFieldType.TimeOnly -> + "TimeOnly.Parse(${escapeCsharpStringLiteral(defaultValue.toString())}, global::System.Globalization.CultureInfo.InvariantCulture)" + DotnetFieldType.DateTime -> + "DateTime.Parse(${escapeCsharpStringLiteral(defaultValue.toString())}, global::System.Globalization.CultureInfo.InvariantCulture, global::System.Globalization.DateTimeStyles.RoundtripKind)" + DotnetFieldType.DateTimeOffset -> + "DateTimeOffset.Parse(${escapeCsharpStringLiteral(defaultValue.toString())}, global::System.Globalization.CultureInfo.InvariantCulture, global::System.Globalization.DateTimeStyles.RoundtripKind)" + DotnetFieldType.TimeSpan -> + "TimeSpan.Parse(${escapeCsharpStringLiteral(defaultValue.toString())}, global::System.Globalization.CultureInfo.InvariantCulture)" + is DotnetFieldType.Reference -> + "global::System.Text.Json.JsonSerializer.Deserialize<${type.target}>(" + + "${escapeCsharpStringLiteral(defaultValue.toString())})!" + } private fun escapeCsharpStringLiteral(value: String): String = buildString { append('"') @@ -635,13 +448,6 @@ internal object DotnetAspProjectRenderer { else -> if (value.code < 0x20) "'\\u%04x'".format(value.code) else "'$value'" } - private fun sampleHeaderValue(headerName: String): String = - "sample-" + headerName.lowercase(Locale.ROOT).replace(Regex("[^a-z0-9]+"), "-").trim('-') - - private fun String.ensureDecimal(): String = if (contains('.') || contains('E', ignoreCase = true)) this else "$this.0" - - private enum class RequestSource(val label: String) { - PATH("path"), - QUERY("query"), - } + private fun String.ensureDecimal(): String = + if (contains('.') || contains('E', ignoreCase = true)) this else "$this.0" } diff --git a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspServiceArtifactCompiler.kt b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspServiceArtifactCompiler.kt index 15c1a1c9..1d5b6e3f 100644 --- a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspServiceArtifactCompiler.kt +++ b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspServiceArtifactCompiler.kt @@ -5,10 +5,7 @@ import io.github.lmliam.microsmith.artifact.core.Artifact import io.github.lmliam.microsmith.artifact.core.ArtifactContribution import io.github.lmliam.microsmith.artifact.files.TextFileArtifactContribution import io.github.lmliam.microsmith.artifact.files.TextFileArtifactId -import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspEndpointArtifact -import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspHeadersBindingArtifact import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspModelArtifact -import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspRequestBindingArtifact import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspServiceArtifact import io.github.lmliam.microsmith.artifact.services.dotnet.msbuild.MsBuildNames import io.github.lmliam.microsmith.artifact.services.dotnet.msbuild.MsBuildProjectArtifactId @@ -27,9 +24,22 @@ class DotnetAspServiceArtifactCompiler : ServicesArtifactCompiler> { + validateEndpointGenerationInputs(artifact) val serviceOrigin = setOf("services.${artifact.serviceName}") - val requestBindings = artifact.requestBindings() - val headerBindings = artifact.headerBindings() + val requestModelOrigins = serviceOrigin + + collectRequestBindings(artifact).flatMapTo(linkedSetOf()) { it.origins } + + collectHeaderBindings(artifact).flatMapTo(linkedSetOf()) { it.origins } + + artifact.endpoints.mapNotNull { endpoint -> + endpoint.bindings.body + ?.takeIf { it.locality == io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspModelLocality.INLINE } + ?.origins + }.flatten() + val responseModelOrigins = serviceOrigin + + artifact.endpoints.flatMapTo(linkedSetOf()) { endpoint -> + endpoint.responses.flatMap { response -> + response.origins + response.model.origins + } + } val controllerOrigins = serviceOrigin + artifact.endpoints.flatMapTo(linkedSetOf()) { endpoint -> endpoint.origins + @@ -59,7 +69,14 @@ class DotnetAspServiceArtifactCompiler : ServicesArtifactCompiler - add( - textContribution( - artifact, - "Models/${model.typeName}.cs", - DotnetAspProjectRenderer.renderModelFile(artifact.id.projectName, model), - model.origins, - ), - ) - } - requestBindings.forEach { binding -> - add( - textContribution( - artifact, - "Bindings/${binding.typeName}.cs", - DotnetAspProjectRenderer.renderRequestBindingFile(artifact.id.projectName, binding), - binding.origins, - ), - ) - } - headerBindings.forEach { binding -> - add( - textContribution( - artifact, - "Bindings/${binding.typeName}.cs", - DotnetAspProjectRenderer.renderHeadersBindingFile(artifact.id.projectName, binding), - binding.origins, - ), - ) - } add( textContribution( artifact, - "Generated/MicrosmithRequestParser.cs", - DotnetAspProjectRenderer.renderRequestParserFile(artifact.id.projectName), + "Generated/Contracts/ServiceModels.cs", + DotnetAspProjectRenderer.renderServiceModelsFile(artifact), + artifact.contractModels + .distinctBy(DotnetAspModelArtifact::typeName) + .filter { it.locality == io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspModelLocality.SHARED } + .flatMapTo(linkedSetOf()) { it.origins } + serviceOrigin, + ), + ) + add( + textContribution( + artifact, + "Generated/Contracts/RequestModels.cs", + DotnetAspProjectRenderer.renderRequestModelsFile(artifact), + requestModelOrigins, + ), + ) + add( + textContribution( + artifact, + "Generated/Contracts/ResponseModels.cs", + DotnetAspProjectRenderer.renderResponseModelsFile(artifact), + responseModelOrigins, + ), + ) + add( + textContribution( + artifact, + microsmithControllerBaseRelativePath(), + DotnetAspProjectRenderer.renderMicrosmithControllerBaseFile(artifact), + serviceOrigin, + ), + ) + add( + textContribution( + artifact, + controllerBaseRelativePath(artifact), + DotnetAspProjectRenderer.renderControllerBaseFile(artifact), controllerOrigins, ), ) @@ -187,13 +209,4 @@ class DotnetAspServiceArtifactCompiler : ServicesArtifactCompiler = endpoints - .flatMap { endpoint -> - listOfNotNull(endpoint.bindings.path, endpoint.bindings.query) - }.distinctBy(DotnetAspRequestBindingArtifact::typeName) - - private fun DotnetAspServiceArtifact.headerBindings(): List = endpoints - .map(DotnetAspEndpointArtifact::bindings) - .mapNotNull { it.headers } - .distinctBy(DotnetAspHeadersBindingArtifact::typeName) } diff --git a/modules/compile-services-dotnet-asp/src/test/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspEndpointValidationTests.kt b/modules/compile-services-dotnet-asp/src/test/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspEndpointValidationTests.kt new file mode 100644 index 00000000..e7ca77c1 --- /dev/null +++ b/modules/compile-services-dotnet-asp/src/test/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspEndpointValidationTests.kt @@ -0,0 +1,171 @@ +package io.github.lmliam.microsmith.compile.services.dotnet.asp + +import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspEndpointArtifact +import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspEndpointBindingsArtifact +import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspHeaderFieldArtifact +import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspModelArtifact +import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspModelLocality +import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspRequestBindingArtifact +import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspRequestFieldArtifact +import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspResponseArtifact +import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspResponseHeaderArtifact +import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspServiceArtifact +import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspServiceArtifactId +import io.github.lmliam.microsmith.dsl.services.dotnet.core.model.DotnetField +import io.github.lmliam.microsmith.dsl.services.dotnet.core.model.DotnetFieldType +import io.github.lmliam.microsmith.dsl.services.dotnet.core.model.DotnetModel +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.string.shouldContain +import java.nio.file.Path + +class DotnetAspEndpointValidationTests : + StringSpec({ + "validation rejects path and query bindings that reference models" { + val userModel = sharedModel("User") + val artifact = + validationArtifact( + contractModels = listOf(userModel), + endpoints = listOf( + DotnetAspEndpointArtifact( + method = "GET", + route = "/users/{id}", + operationName = "GetUser", + bindings = DotnetAspEndpointBindingsArtifact( + query = DotnetAspRequestBindingArtifact( + typeName = "GetUserQuery", + name = "GetUserQuery", + fields = listOf( + DotnetAspRequestFieldArtifact( + name = "user", + type = DotnetFieldType.Reference("User"), + optional = false, + defaultValue = null, + ), + ), + origins = setOf("services.UserService.rest.GetUser.query.GetUserQuery"), + ), + ), + responses = listOf( + DotnetAspResponseArtifact( + statusCode = 200, + model = userModel, + headers = emptyList(), + origins = setOf("services.UserService.rest.GetUser.responses.200"), + ), + ), + origins = setOf("services.UserService.rest.GetUser"), + ), + ), + ) + + val error = + shouldThrow { + validateEndpointGenerationInputs(artifact) + } + + error.message.shouldContain("Transport bindings must declare scalar fields") + error.message.shouldContain("cannot reference shared model 'User'") + } + + "validation rejects response headers that collide with the generated Body property" { + val userModel = sharedModel("User") + val artifact = + validationArtifact( + contractModels = listOf(userModel), + endpoints = listOf( + DotnetAspEndpointArtifact( + method = "GET", + route = "/users/{id}", + operationName = "GetUser", + bindings = DotnetAspEndpointBindingsArtifact(), + responses = listOf( + DotnetAspResponseArtifact( + statusCode = 200, + model = userModel, + headers = listOf(DotnetAspResponseHeaderArtifact("body")), + origins = setOf("services.UserService.rest.GetUser.responses.200"), + ), + ), + origins = setOf("services.UserService.rest.GetUser"), + ), + ), + ) + + val error = + shouldThrow { + validateEndpointGenerationInputs(artifact) + } + + error.message.shouldContain("collides with the generated result body property 'Body'") + error.message.shouldContain("header 'body'") + } + + "validation rejects project names that collide with the shared controller base type" { + val artifact = validationArtifact(projectName = "Microsmith") + + val error = + shouldThrow { + validateEndpointGenerationInputs(artifact) + } + + error.message.shouldContain("collides with shared generated controller base type") + error.message.shouldContain("project 'Microsmith'") + } + + "validation rejects colliding generated contract types" { + val sharedResultModel = sharedModel("GetUserResult") + val artifact = + validationArtifact( + contractModels = listOf(sharedResultModel, sharedModel("User")), + endpoints = listOf( + DotnetAspEndpointArtifact( + method = "GET", + route = "/users/{id}", + operationName = "GetUser", + bindings = DotnetAspEndpointBindingsArtifact(), + responses = listOf( + DotnetAspResponseArtifact( + statusCode = 200, + model = sharedModel("User"), + headers = emptyList(), + origins = setOf("services.UserService.rest.GetUser.responses.200"), + ), + ), + origins = setOf("services.UserService.rest.GetUser"), + ), + ), + ) + + val error = + shouldThrow { + validateEndpointGenerationInputs(artifact) + } + + error.message.shouldContain("colliding generated contract types") + error.message.shouldContain("GetUserResult") + error.message.shouldContain("generated contract model 'GetUserResult'") + } + }) + +private fun validationArtifact( + contractModels: List = emptyList(), + endpoints: List = emptyList(), + projectName: String = "UserService.Api", +): DotnetAspServiceArtifact = DotnetAspServiceArtifact( + id = DotnetAspServiceArtifactId("Platform", projectName), + serviceName = "UserService", + targetFrameworkMoniker = "net8.0", + outputRoot = Path.of("dotnet", "Platform", "UserService.Api"), + httpPort = 5000, + httpsPort = 5001, + contractModels = contractModels, + endpoints = endpoints, +) + +private fun sharedModel(name: String): DotnetAspModelArtifact = DotnetAspModelArtifact( + typeName = name, + locality = DotnetAspModelLocality.SHARED, + model = DotnetModel(name = name, fields = listOf(DotnetField("id", DotnetFieldType.String))), + origins = setOf("services.UserService.models.$name"), +) diff --git a/modules/compile-services-dotnet-asp/src/test/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspServiceArtifactCompilerTests.kt b/modules/compile-services-dotnet-asp/src/test/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspServiceArtifactCompilerTests.kt index c24106b6..d2773ac8 100644 --- a/modules/compile-services-dotnet-asp/src/test/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspServiceArtifactCompilerTests.kt +++ b/modules/compile-services-dotnet-asp/src/test/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspServiceArtifactCompilerTests.kt @@ -30,12 +30,13 @@ import java.nio.file.Path class DotnetAspServiceArtifactCompilerTests : StringSpec({ - "compile emits the generated ASP.NET project layout" { + "compile emits the abstract ASP.NET extension surface" { val artifact = sampleArtifact() val contributions = DotnetAspServiceArtifactCompiler().compile(artifact) val msbuild = contributions.filterIsInstance().single() val textFiles = contributions.filterIsInstance() + val byPath = textFiles.associateBy { it.artifactId.relativePath.toString() } msbuild.artifactId.kind shouldBe MsBuildProjectKind.Project msbuild.projectAttributes shouldContainExactly @@ -51,45 +52,49 @@ class DotnetAspServiceArtifactCompilerTests : "Program.cs", "appsettings.json", "Properties/launchSettings.json", - "Controllers/UserServiceController.cs", - "Models/User.cs", - "Models/Problem.cs", - "Models/CreateUserBody.cs", - "Bindings/GetUserPath.cs", - "Bindings/GetUserQuery.cs", - "Bindings/GetUserHeaders.cs", - "Generated/MicrosmithRequestParser.cs", + "Generated/Hosting/MicrosmithHostingExtensions.cs", + "Generated/Contracts/ServiceModels.cs", + "Generated/Contracts/RequestModels.cs", + "Generated/Contracts/ResponseModels.cs", + "Generated/Controllers/MicrosmithControllerBase.cs", + "Generated/Controllers/UserServiceApiControllerBase.cs", ) textFiles.forEach { it.artifactId.outputRoot shouldBe Path.of("dotnet", "Platform", "UserService.Api") } - textFiles.single { it.artifactId.relativePath.toString() == "Program.cs" }.contents - .shouldContain("AddControllers") - textFiles.single { it.artifactId.relativePath.toString() == "Program.cs" }.contents - .shouldContain("public partial class Program { }") - textFiles.single { it.artifactId.relativePath.toString() == "Program.cs" }.contents - .shouldContain("StatusCodes.Status400BadRequest") + byPath.getValue("Program.cs").contents.shouldContain("builder.AddMicrosmith();") + byPath.getValue("Program.cs").contents.shouldContain("app.MapMicrosmith();") + byPath.getValue("Generated/Hosting/MicrosmithHostingExtensions.cs").contents + .shouldContain("builder.Services.AddControllers();") - val controller = textFiles.single { it.artifactId.relativePath.toString() == "Controllers/UserServiceController.cs" } + val controller = byPath.getValue("Generated/Controllers/UserServiceApiControllerBase.cs") controller.contents.shouldContain("""[HttpGet("/users/{id}", Name = "GetUser")]""") - controller.contents.shouldContain("""[HttpPost("/users", Name = "CreateUser")]""") - controller.contents.shouldContain("MicrosmithRequestParser.ReadRouteValue") - controller.contents.shouldContain("""Response.Headers["Location"] = "sample-location";""") + controller.contents.shouldContain("""[ProducesResponseType(typeof(User), 200)]""") + controller.contents.shouldContain("""[ProducesResponseType(typeof(Problem), 404)]""") + controller.contents.shouldContain("protected abstract Task OnGetUserAsync") + controller.contents.shouldContain("CreateUserCreated response => Respond(response.Body, 201, (\"Location\", response.Location))") + controller.contents.shouldNotContain("X-Microsmith-Response-Status") + controller.contents.shouldNotContain("sample-location") controller.origins shouldContain "services.UserService.rest.GetUser" controller.origins shouldContain "services.UserService.rest.CreateUser.body.CreateUserBody" - controller.origins shouldContain "services.UserService.rest.GetUser.responses.200" - textFiles.single { it.artifactId.relativePath.toString() == "Models/CreateUserBody.cs" }.contents - .shouldContain("public sealed class CreateUserBody") - textFiles.single { it.artifactId.relativePath.toString() == "Controllers/UserServiceController.cs" }.contents - .shouldContain("?? false") - textFiles.single { it.artifactId.relativePath.toString() == "Generated/MicrosmithRequestParser.cs" }.contents - .shouldContain("internal static bool? OptionalBool") - textFiles.single { it.artifactId.relativePath.toString() == "appsettings.json" }.contents - .shouldContain("\"ServiceName\": \"UserService\"") - textFiles.single { it.artifactId.relativePath.toString() == "Properties/launchSettings.json" }.contents - .shouldContain("http://localhost:5000;https://localhost:5001") + byPath.getValue("Generated/Contracts/RequestModels.cs").contents + .shouldContain("public bool IncludeDetails { get; set; } = false;") + byPath.getValue("Generated/Contracts/RequestModels.cs").contents + .shouldContain("[BindRequired]") + byPath.getValue("Generated/Contracts/ResponseModels.cs").contents + .shouldContain("public sealed record CreateUserCreated(User Body, string? Location = null) : CreateUserResult;") + } + + "compile emits nuint defaults with a 64-bit-safe literal" { + val requestModels = DotnetAspServiceArtifactCompiler() + .compile(unsignedNativeIntDefaultArtifact()) + .filterIsInstance() + .single { it.artifactId.relativePath.toString() == "Generated/Contracts/RequestModels.cs" } + .contents + + requestModels.shouldContain("public nuint MaxValue { get; set; } = (nuint)4294967296UL;") } "compile escapes service names before embedding them in appsettings json" { @@ -103,29 +108,6 @@ class DotnetAspServiceArtifactCompilerTests : appSettings.shouldContain("\"ServiceName\": \"User\\\"Service\\\\Api\"") appSettings.shouldNotContain("\"ServiceName\": \"User\"Service\\Api\"") } - - "compile emits typed request bindings for supported scalar inputs" { - val artifact = typedBindingArtifact() - - val textFiles = DotnetAspServiceArtifactCompiler() - .compile(artifact) - .filterIsInstance() - - val controller = textFiles.single { it.artifactId.relativePath.toString() == "Controllers/ReportServiceController.cs" }.contents - val parser = textFiles.single { it.artifactId.relativePath.toString() == "Generated/MicrosmithRequestParser.cs" }.contents - - controller.shouldContain("""MicrosmithRequestParser.RequireGuid(MicrosmithRequestParser.ReadRouteValue(RouteData.Values, "reportId"), "path.reportId")""") - controller.shouldContain("""MicrosmithRequestParser.RequireInt(MicrosmithRequestParser.ReadQueryValue(Request.Query, "days"), "query.days")""") - controller.shouldContain("""MicrosmithRequestParser.RequireDateOnly(MicrosmithRequestParser.ReadQueryValue(Request.Query, "since"), "query.since")""") - controller.shouldContain("""MicrosmithRequestParser.RequireDateTimeOffset(MicrosmithRequestParser.ReadQueryValue(Request.Query, "requestedAt"), "query.requestedAt")""") - controller.shouldContain("""(MicrosmithRequestParser.OptionalDecimal(MicrosmithRequestParser.ReadQueryValue(Request.Query, "threshold"), "query.threshold") ?? 1.5M)""") - controller.shouldContain("""MicrosmithRequestParser.OptionalTimeSpan(MicrosmithRequestParser.ReadQueryValue(Request.Query, "window"), "query.window")""") - - parser.shouldContain("internal static Guid RequireGuid") - parser.shouldContain("internal static DateOnly RequireDateOnly") - parser.shouldContain("internal static DateTimeOffset RequireDateTimeOffset") - parser.shouldContain("internal static TimeSpan? OptionalTimeSpan") - } }) private fun sampleArtifact(serviceName: String = "UserService"): DotnetAspServiceArtifact { @@ -185,7 +167,7 @@ private fun sampleArtifact(serviceName: String = "UserService"): DotnetAspServic name = "GetUserHeaders", headers = listOf( DotnetAspHeaderFieldArtifact( - name = "correlationId", + name = "xCorrelationId", headerName = "X-Correlation-Id", ), ), @@ -233,72 +215,30 @@ private fun sampleArtifact(serviceName: String = "UserService"): DotnetAspServic ) } -private fun typedBindingArtifact(): DotnetAspServiceArtifact { - val reportModel = sharedModel("Report", "services.ReportService.models.Report") { - stringField("id") - stringField("title") - } - - return DotnetAspServiceArtifact( +private fun unsignedNativeIntDefaultArtifact(): DotnetAspServiceArtifact = + DotnetAspServiceArtifact( id = DotnetAspServiceArtifactId(solutionName = "Platform", projectName = "ReportService.Api"), serviceName = "ReportService", targetFrameworkMoniker = "net8.0", outputRoot = Path.of("dotnet", "Platform", "ReportService.Api"), httpPort = 5002, httpsPort = 5003, - contractModels = listOf(reportModel), + contractModels = emptyList(), endpoints = listOf( DotnetAspEndpointArtifact( method = "GET", - route = "/reports/{reportId}", + route = "/reports", operationName = "GetReport", bindings = DotnetAspEndpointBindingsArtifact( - path = DotnetAspRequestBindingArtifact( - typeName = "GetReportPath", - name = "GetReportPath", - fields = listOf( - DotnetAspRequestFieldArtifact( - name = "reportId", - type = DotnetFieldType.Guid, - optional = false, - defaultValue = null, - ), - ), - origins = setOf("services.ReportService.rest.GetReport.path.GetReportPath"), - ), query = DotnetAspRequestBindingArtifact( typeName = "GetReportQuery", name = "GetReportQuery", fields = listOf( DotnetAspRequestFieldArtifact( - name = "days", - type = DotnetFieldType.Int, - optional = false, - defaultValue = null, - ), - DotnetAspRequestFieldArtifact( - name = "since", - type = DotnetFieldType.DateOnly, - optional = false, - defaultValue = null, - ), - DotnetAspRequestFieldArtifact( - name = "requestedAt", - type = DotnetFieldType.DateTimeOffset, + name = "maxValue", + type = DotnetFieldType.UnsignedNativeInt, optional = false, - defaultValue = null, - ), - DotnetAspRequestFieldArtifact( - name = "threshold", - type = DotnetFieldType.Decimal, - optional = true, - defaultValue = 1.5, - ), - DotnetAspRequestFieldArtifact( - name = "window", - type = DotnetFieldType.TimeSpan, - optional = true, - defaultValue = null, + defaultValue = 4294967296L, ), ), origins = setOf("services.ReportService.rest.GetReport.query.GetReportQuery"), @@ -307,7 +247,7 @@ private fun typedBindingArtifact(): DotnetAspServiceArtifact { responses = listOf( DotnetAspResponseArtifact( statusCode = 200, - model = reportModel, + model = inlineModel("EmptyReport", "services.ReportService.rest.GetReport.responses.200.EmptyReport") {}, headers = emptyList(), origins = setOf("services.ReportService.rest.GetReport.responses.200"), ), @@ -316,27 +256,26 @@ private fun typedBindingArtifact(): DotnetAspServiceArtifact { ), ), ) -} private fun sharedModel( - name: String, + typeName: String, origin: String, fields: MutableList.() -> Unit, ): DotnetAspModelArtifact = DotnetAspModelArtifact( - typeName = name, + typeName = typeName, locality = DotnetAspModelLocality.SHARED, - model = DotnetModel(name = name, fields = buildList(fields)), + model = DotnetModel(name = typeName, fields = buildList(fields)), origins = setOf(origin), ) private fun inlineModel( - name: String, + typeName: String, origin: String, fields: MutableList.() -> Unit, ): DotnetAspModelArtifact = DotnetAspModelArtifact( - typeName = name, + typeName = typeName, locality = DotnetAspModelLocality.INLINE, - model = DotnetModel(name = name, fields = buildList(fields)), + model = DotnetModel(name = typeName, fields = buildList(fields)), origins = setOf(origin), ) From 4bba301c2c991639aaecfcdcc915cdf729783330 Mon Sep 17 00:00:00 2001 From: LMLiam <46268350+TheRealEmissions@users.noreply.github.com> Date: Tue, 21 Apr 2026 13:18:59 +0100 Subject: [PATCH 08/26] test(services-dotnet-asp): realign fixtures and CI with generated bases --- .github/workflows/build_test_qodana.yml | 36 +++--- README.md | 23 ++-- .../DotnetAspGenerationIntegrationTests.kt | 25 ++-- .../gen/helpers/DotnetAspRuntimeE2eTests.kt | 109 +++++++++++++++--- .../GeneratedOriginsManifestBuilderTests.kt | 4 +- .../MicrosmithGradlePluginFunctionalTests.kt | 2 +- .../maven/MicrosmithGenerateMojoTests.kt | 2 +- .../host/MicrosmithScriptHostTests.kt | 6 +- .../sbt/MicrosmithSbtExecutionServiceTests.kt | 2 +- 9 files changed, 147 insertions(+), 62 deletions(-) diff --git a/.github/workflows/build_test_qodana.yml b/.github/workflows/build_test_qodana.yml index 35363af9..39bedde0 100644 --- a/.github/workflows/build_test_qodana.yml +++ b/.github/workflows/build_test_qodana.yml @@ -60,8 +60,8 @@ jobs: ./gradlew -p "$repo_root" microsmithGenerate -PmicrosmithVersion="$VERSION" --stacktrace test -f "$project_root/UserService.Api.csproj" - test -f "$project_root/Controllers/UserServiceController.cs" - test -f "$project_root/Models/Report.cs" + test -f "$project_root/Generated/Controllers/UserServiceApiControllerBase.cs" + test -f "$project_root/Generated/Contracts/ResponseModels.cs" test -f "$project_root/.microsmith/origins.json" dotnet build "$project_root/UserService.Api.csproj" } @@ -90,8 +90,8 @@ jobs: mvn -B -f "$repo_root/pom.xml" microsmith:generate -Dmicrosmith.version="$VERSION" test -f "$project_root/UserService.Api.csproj" - test -f "$project_root/Controllers/UserServiceController.cs" - test -f "$project_root/Models/Report.cs" + test -f "$project_root/Generated/Controllers/UserServiceApiControllerBase.cs" + test -f "$project_root/Generated/Contracts/ResponseModels.cs" test -f "$project_root/.microsmith/origins.json" dotnet build "$project_root/UserService.Api.csproj" } @@ -120,8 +120,8 @@ jobs: (cd "$repo_root" && sbt -batch -Dsbt.supershell=false -Dmicrosmith.version="$VERSION" microsmithGenerate) test -f "$project_root/UserService.Api.csproj" - test -f "$project_root/Controllers/UserServiceController.cs" - test -f "$project_root/Models/Report.cs" + test -f "$project_root/Generated/Controllers/UserServiceApiControllerBase.cs" + test -f "$project_root/Generated/Contracts/ResponseModels.cs" test -f "$project_root/.microsmith/origins.json" dotnet build "$project_root/UserService.Api.csproj" } @@ -221,8 +221,8 @@ jobs: run: | ./modules/cli/build/microsmith-cli-dist/bin/microsmith run examples/non-gradle/dotnet/schema.microsmith.kts --out aspnet-dist test -f aspnet-dist/dotnet/Platform/UserService.Api/UserService.Api.csproj - test -f aspnet-dist/dotnet/Platform/UserService.Api/Controllers/UserServiceController.cs - test -f aspnet-dist/dotnet/Platform/UserService.Api/Models/Report.cs + test -f aspnet-dist/dotnet/Platform/UserService.Api/Generated/Controllers/UserServiceApiControllerBase.cs + test -f aspnet-dist/dotnet/Platform/UserService.Api/Generated/Contracts/ResponseModels.cs test -f aspnet-dist/dotnet/Platform/UserService.Api/.microsmith/origins.json dotnet build aspnet-dist/dotnet/Platform/UserService.Api/UserService.Api.csproj - name: Smoke test dist doctor diagnostics mode (Unix) @@ -288,12 +288,12 @@ jobs: Write-Error "Expected aspnet-dist\\dotnet\\Platform\\UserService.Api\\UserService.Api.csproj to exist." exit 1 } - if (-not (Test-Path "$projectRoot\Controllers\UserServiceController.cs")) { - Write-Error "Expected aspnet-dist\\dotnet\\Platform\\UserService.Api\\Controllers\\UserServiceController.cs to exist." + if (-not (Test-Path "$projectRoot\Generated\Controllers\UserServiceApiControllerBase.cs")) { + Write-Error "Expected aspnet-dist\\dotnet\\Platform\\UserService.Api\\Generated\\Controllers\\UserServiceApiControllerBase.cs to exist." exit 1 } - if (-not (Test-Path "$projectRoot\Models\Report.cs")) { - Write-Error "Expected aspnet-dist\\dotnet\\Platform\\UserService.Api\\Models\\Report.cs to exist." + if (-not (Test-Path "$projectRoot\Generated\Contracts\ResponseModels.cs")) { + Write-Error "Expected aspnet-dist\\dotnet\\Platform\\UserService.Api\\Generated\\Contracts\\ResponseModels.cs to exist." exit 1 } if (-not (Test-Path "$projectRoot\.microsmith\origins.json")) { @@ -386,8 +386,8 @@ jobs: ./.microsmith-bin/microsmith run examples/non-gradle/dotnet/build.microsmith.kts --out examples/non-gradle/dotnet/Generated project_root="examples/non-gradle/dotnet/Generated/dotnet/Platform/UserService.Api" test -f "$project_root/UserService.Api.csproj" - test -f "$project_root/Controllers/UserServiceController.cs" - test -f "$project_root/Models/Report.cs" + test -f "$project_root/Generated/Controllers/UserServiceApiControllerBase.cs" + test -f "$project_root/Generated/Contracts/ResponseModels.cs" test -f "$project_root/.microsmith/origins.json" dotnet build "$project_root/UserService.Api.csproj" - name: Install and verify from installer (Windows) @@ -494,12 +494,12 @@ jobs: Write-Error "Expected Generated\\dotnet\\Platform\\UserService.Api\\UserService.Api.csproj to exist for the .NET fixture." exit 1 } - if (-not (Test-Path "$projectRoot\Controllers\UserServiceController.cs")) { - Write-Error "Expected Generated\\dotnet\\Platform\\UserService.Api\\Controllers\\UserServiceController.cs to exist for the .NET fixture." + if (-not (Test-Path "$projectRoot\Generated\Controllers\UserServiceApiControllerBase.cs")) { + Write-Error "Expected Generated\\dotnet\\Platform\\UserService.Api\\Generated\\Controllers\\UserServiceApiControllerBase.cs to exist for the .NET fixture." exit 1 } - if (-not (Test-Path "$projectRoot\Models\Report.cs")) { - Write-Error "Expected Generated\\dotnet\\Platform\\UserService.Api\\Models\\Report.cs to exist for the .NET fixture." + if (-not (Test-Path "$projectRoot\Generated\Contracts\ResponseModels.cs")) { + Write-Error "Expected Generated\\dotnet\\Platform\\UserService.Api\\Generated\\Contracts\\ResponseModels.cs to exist for the .NET fixture." exit 1 } if (-not (Test-Path "$projectRoot\.microsmith\origins.json")) { diff --git a/README.md b/README.md index cd4573a7..d56da73e 100644 --- a/README.md +++ b/README.md @@ -539,14 +539,16 @@ dotnet/ UserService.Api.csproj Program.cs appsettings.json - Controllers/ - UserServiceController.cs - Models/ - ... - Bindings/ - ... Generated/ - MicrosmithRequestParser.cs + Hosting/ + MicrosmithHostingExtensions.cs + Contracts/ + ServiceModels.cs + RequestModels.cs + ResponseModels.cs + Controllers/ + MicrosmithControllerBase.cs + UserServiceApiControllerBase.cs Properties/ launchSettings.json .microsmith/ @@ -555,9 +557,10 @@ dotnet/ Canonical generation policy: -- `Program.cs` uses top-level hosting with `WebApplication.CreateBuilder(args)`, `AddControllers()`, a bad-request guard for generated request parsing, `MapControllers()`, and `Run()` -- controller actions, request binding DTOs, response DTOs, and the request parser helper are generated from the normalized REST model -- generated files under the project root are generator-owned and are overwritten in place on rerun +- `Program.cs` uses top-level hosting and delegates ASP.NET registration through generated hosting extensions +- generated files under `Generated/` provide the contract records, abstract controller base, and response/result mapping surface derived from the normalized REST model +- handwritten service behavior belongs outside `Generated/`, typically in a user-authored controller that derives from the generated base type +- generated files are overwritten in place on rerun; handwritten files outside `Generated/` are not generator-owned - `.microsmith/origins.json` records the structural Microsmith origins associated with each generated file ### Script defaults diff --git a/modules/gen/src/test/kotlin/io/github/lmliam/microsmith/gen/helpers/DotnetAspGenerationIntegrationTests.kt b/modules/gen/src/test/kotlin/io/github/lmliam/microsmith/gen/helpers/DotnetAspGenerationIntegrationTests.kt index 62c89901..58d2ecd1 100644 --- a/modules/gen/src/test/kotlin/io/github/lmliam/microsmith/gen/helpers/DotnetAspGenerationIntegrationTests.kt +++ b/modules/gen/src/test/kotlin/io/github/lmliam/microsmith/gen/helpers/DotnetAspGenerationIntegrationTests.kt @@ -13,7 +13,7 @@ import kotlin.io.path.writeText class DotnetAspGenerationIntegrationTests : StringSpec({ - "generateTo emits a runnable ASP.NET project layout with provenance" { + "generateTo emits an abstract ASP.NET extension surface with provenance" { val outputDir = Files.createTempDirectory("microsmith-dotnet-asp-output-") val model = sampleDotnetAspModel() @@ -22,19 +22,20 @@ class DotnetAspGenerationIntegrationTests : val projectRoot = outputDir.resolve("dotnet/Platform/UserService.Api") Files.exists(projectRoot.resolve("UserService.Api.csproj")) shouldBe true Files.exists(projectRoot.resolve("Program.cs")) shouldBe true - Files.exists(projectRoot.resolve("Controllers/UserServiceController.cs")) shouldBe true - Files.exists(projectRoot.resolve("Models/User.cs")) shouldBe true - Files.exists(projectRoot.resolve("Models/CreateUserBody.cs")) shouldBe true - Files.exists(projectRoot.resolve("Bindings/GetUserPath.cs")) shouldBe true - Files.exists(projectRoot.resolve("Generated/MicrosmithRequestParser.cs")) shouldBe true + Files.exists(projectRoot.resolve("Generated/Hosting/MicrosmithHostingExtensions.cs")) shouldBe true + Files.exists(projectRoot.resolve("Generated/Contracts/ServiceModels.cs")) shouldBe true + Files.exists(projectRoot.resolve("Generated/Contracts/RequestModels.cs")) shouldBe true + Files.exists(projectRoot.resolve("Generated/Contracts/ResponseModels.cs")) shouldBe true + Files.exists(projectRoot.resolve("Generated/Controllers/MicrosmithControllerBase.cs")) shouldBe true + Files.exists(projectRoot.resolve("Generated/Controllers/UserServiceApiControllerBase.cs")) shouldBe true Files.exists(projectRoot.resolve(".microsmith/origins.json")) shouldBe true projectRoot.resolve("Program.cs").readText().shouldContain("Generated by Microsmith") - projectRoot.resolve("Program.cs").readText().shouldContain("MapControllers") - projectRoot.resolve("Controllers/UserServiceController.cs").readText() + projectRoot.resolve("Program.cs").readText().shouldContain("AddMicrosmith") + projectRoot.resolve("Generated/Controllers/UserServiceApiControllerBase.cs").readText() .shouldContain("""[HttpGet("/users/{id}", Name = "GetUser")]""") - projectRoot.resolve("Controllers/UserServiceController.cs").readText() - .shouldContain("""[HttpPost("/users", Name = "CreateUser")]""") + projectRoot.resolve("Generated/Controllers/UserServiceApiControllerBase.cs").readText() + .shouldContain("protected abstract Task OnGetUserAsync") projectRoot.resolve(".microsmith/origins.json").readText() .shouldContain("services.UserService.rest.GetUser") projectRoot.resolve(".microsmith/origins.json").readText() @@ -47,12 +48,12 @@ class DotnetAspGenerationIntegrationTests : model.generateTo(outputDir) - val controllerFile = outputDir.resolve("dotnet/Platform/UserService.Api/Controllers/UserServiceController.cs") + val controllerFile = outputDir.resolve("dotnet/Platform/UserService.Api/Generated/Controllers/UserServiceApiControllerBase.cs") controllerFile.writeText("stale") model.generateTo(outputDir) - controllerFile.readText().shouldContain("MicrosmithRequestParser") + controllerFile.readText().shouldContain("protected abstract Task OnGetUserAsync") } }) diff --git a/modules/gen/src/test/kotlin/io/github/lmliam/microsmith/gen/helpers/DotnetAspRuntimeE2eTests.kt b/modules/gen/src/test/kotlin/io/github/lmliam/microsmith/gen/helpers/DotnetAspRuntimeE2eTests.kt index 9c0c4d8f..261f3c69 100644 --- a/modules/gen/src/test/kotlin/io/github/lmliam/microsmith/gen/helpers/DotnetAspRuntimeE2eTests.kt +++ b/modules/gen/src/test/kotlin/io/github/lmliam/microsmith/gen/helpers/DotnetAspRuntimeE2eTests.kt @@ -25,7 +25,7 @@ import kotlin.io.path.writeText @OptIn(ExperimentalPathApi::class) class DotnetAspRuntimeE2eTests : StringSpec({ - "generated ASP.NET services handle valid and invalid requests end to end".config(enabled = dotnetAvailable()) { + "generated ASP.NET controller bases work end to end when a user implements them".config(enabled = dotnetAvailable()) { val outputDir = Files.createTempDirectory("microsmith-dotnet-asp-runtime-") try { runtimeE2eModel().generateTo(outputDir) @@ -36,6 +36,8 @@ class DotnetAspRuntimeE2eTests : val port = availablePort() val baseUri = URI("http://127.0.0.1:$port") + writeUserController(projectRoot) + runDotnetCommand( projectRoot = projectRoot, logFile = logFile, @@ -60,19 +62,18 @@ class DotnetAspRuntimeE2eTests : HttpResponse.BodyHandlers.ofString(), ) getUser.statusCode() shouldBe 200 - getUser.headers().firstValue("ETag").orElseThrow() shouldBe "sample-etag" - getUser.body().shouldContain("\"id\":\"\"") - getUser.body().shouldContain("\"email\":\"\"") + getUser.headers().firstValue("ETag").orElseThrow() shouldBe "etag-corr-123" + getUser.body().shouldContain("\"id\":\"user-123\"") + getUser.body().shouldContain("\"email\":\"details@example.com\"") val notFound = client.send( - request(baseUri, "/users/user-123") - .header("X-Microsmith-Response-Status", "404") + request(baseUri, "/users/missing") .GET() .build(), HttpResponse.BodyHandlers.ofString(), ) notFound.statusCode() shouldBe 404 - notFound.body().shouldContain("\"detail\":\"\"") + notFound.body().shouldContain("\"detail\":\"missing-user\"") val createUser = client.send( request(baseUri, "/users") @@ -82,7 +83,8 @@ class DotnetAspRuntimeE2eTests : HttpResponse.BodyHandlers.ofString(), ) createUser.statusCode() shouldBe 201 - createUser.headers().firstValue("Location").orElseThrow() shouldBe "sample-location" + createUser.headers().firstValue("Location").orElseThrow() shouldBe "/users/generated-user" + createUser.body().shouldContain("\"email\":\"runtime@example.com\"") val getReport = client.send( request( @@ -90,14 +92,12 @@ class DotnetAspRuntimeE2eTests : "/reports/550e8400-e29b-41d4-a716-446655440000" + "?days=7" + "&since=2026-04-20" + - "&requestedAt=2026-04-20T12:34:56%2B00:00" + - "&threshold=9.5" + - "&window=01:30:00", + "&requestedAt=2026-04-20T12:34:56%2B00:00", ).GET().build(), HttpResponse.BodyHandlers.ofString(), ) getReport.statusCode() shouldBe 200 - getReport.body().shouldContain("\"title\":\"\"") + getReport.body().shouldContain("\"title\":\"7:2026-04-20:1.5:none\"") val invalidGuid = client.send( request( @@ -110,7 +110,6 @@ class DotnetAspRuntimeE2eTests : HttpResponse.BodyHandlers.ofString(), ) invalidGuid.statusCode() shouldBe 400 - invalidGuid.body().shouldContain("path.reportId") val invalidDecimal = client.send( request( @@ -124,7 +123,6 @@ class DotnetAspRuntimeE2eTests : HttpResponse.BodyHandlers.ofString(), ) invalidDecimal.statusCode() shouldBe 400 - invalidDecimal.body().shouldContain("query.threshold") } finally { stopProcess(process, logFile) } @@ -134,6 +132,89 @@ class DotnetAspRuntimeE2eTests : } }) +private fun writeUserController(projectRoot: Path) { + val controllerDir = projectRoot.resolve("Controllers") + controllerDir.createDirectories() + controllerDir.resolve("UserServiceController.cs").writeText( + """ + namespace UserService.Api.Controllers; + + using System.Globalization; + using System.Threading; + using System.Threading.Tasks; + using UserService.Api.Generated.Contracts; + using UserService.Api.Generated.Controllers; + + public sealed class UserServiceController : UserServiceApiControllerBase + { + protected override Task OnGetUserAsync( + GetUserPath path, + GetUserQuery query, + GetUserHeaders headers, + CancellationToken cancellationToken) + { + if (path.Id == "missing") + { + return Task.FromResult( + new GetUserNotFound( + new Problem + { + Detail = "missing-user", + })); + } + + return Task.FromResult( + new GetUserOk( + new User + { + Id = path.Id, + Email = query.IncludeDetails ? "details@example.com" : "basic@example.com", + }, + Etag: $"etag-{headers.XCorrelationId ?? path.Id}")); + } + + protected override Task OnCreateUserAsync( + CreateUserBody body, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(body.Email)) + { + return Task.FromResult( + new CreateUserBadRequest( + new Problem + { + Detail = "email-required", + })); + } + + return Task.FromResult( + new CreateUserCreated( + new User + { + Id = "generated-user", + Email = body.Email, + }, + Location: "/users/generated-user")); + } + + protected override Task OnGetReportAsync( + GetReportPath path, + GetReportQuery query, + CancellationToken cancellationToken) + { + return Task.FromResult( + new GetReportOk( + new Report + { + Id = path.ReportId.ToString(), + Title = $"{query.Days}:{query.Since:yyyy-MM-dd}:{query.Threshold.ToString(CultureInfo.InvariantCulture)}:{query.Window?.ToString() ?? "none"}", + })); + } + } + """.trimIndent(), + ) +} + private fun runtimeE2eModel() = microsmith { services { diff --git a/modules/gen/src/test/kotlin/io/github/lmliam/microsmith/gen/helpers/GeneratedOriginsManifestBuilderTests.kt b/modules/gen/src/test/kotlin/io/github/lmliam/microsmith/gen/helpers/GeneratedOriginsManifestBuilderTests.kt index 8fb89619..87fac34e 100644 --- a/modules/gen/src/test/kotlin/io/github/lmliam/microsmith/gen/helpers/GeneratedOriginsManifestBuilderTests.kt +++ b/modules/gen/src/test/kotlin/io/github/lmliam/microsmith/gen/helpers/GeneratedOriginsManifestBuilderTests.kt @@ -18,7 +18,7 @@ class GeneratedOriginsManifestBuilderTests : origins = setOf("schemas.protobuf.pkg.User"), ), GeneratedFile( - relativePath = Path("Controllers/UserServiceController.cs"), + relativePath = Path("Generated/Controllers/UserServiceApiControllerBase.cs"), contents = byteArrayOf(2), outputRoot = Path("repo-a"), origins = setOf("services.UserService.rest.GetUser"), @@ -36,7 +36,7 @@ class GeneratedOriginsManifestBuilderTests : manifests.size shouldBe 2 String(manifests.single { it.outputRoot == Path("repo-a") }.contents, StandardCharsets.UTF_8) - .shouldContain("Controllers/UserServiceController.cs") + .shouldContain("Generated/Controllers/UserServiceApiControllerBase.cs") String(manifests.single { it.outputRoot == Path("repo-a") }.contents, StandardCharsets.UTF_8) .shouldContain("schemas.protobuf.pkg.User") String(manifests.single { it.outputRoot == Path("repo-b") }.contents, StandardCharsets.UTF_8) diff --git a/modules/gradle-plugin/src/test/kotlin/io/github/lmliam/microsmith/gradle/MicrosmithGradlePluginFunctionalTests.kt b/modules/gradle-plugin/src/test/kotlin/io/github/lmliam/microsmith/gradle/MicrosmithGradlePluginFunctionalTests.kt index b75f9fba..4fefd49e 100644 --- a/modules/gradle-plugin/src/test/kotlin/io/github/lmliam/microsmith/gradle/MicrosmithGradlePluginFunctionalTests.kt +++ b/modules/gradle-plugin/src/test/kotlin/io/github/lmliam/microsmith/gradle/MicrosmithGradlePluginFunctionalTests.kt @@ -172,7 +172,7 @@ class MicrosmithGradlePluginFunctionalTests : StringSpec() { result.task(":microsmithGenerate")?.outcome shouldBe TaskOutcome.SUCCESS project.file("dotnet/Platform/UserService.Api/Program.cs").toFile().shouldExist() - project.file("dotnet/Platform/UserService.Api/Controllers/UserServiceController.cs").toFile().shouldExist() + project.file("dotnet/Platform/UserService.Api/Generated/Controllers/UserServiceApiControllerBase.cs").toFile().shouldExist() } "microsmithGenerate fails with script diagnostics" { diff --git a/modules/maven-plugin/src/test/kotlin/io/github/lmliam/microsmith/maven/MicrosmithGenerateMojoTests.kt b/modules/maven-plugin/src/test/kotlin/io/github/lmliam/microsmith/maven/MicrosmithGenerateMojoTests.kt index 54d71b32..9278ac7e 100644 --- a/modules/maven-plugin/src/test/kotlin/io/github/lmliam/microsmith/maven/MicrosmithGenerateMojoTests.kt +++ b/modules/maven-plugin/src/test/kotlin/io/github/lmliam/microsmith/maven/MicrosmithGenerateMojoTests.kt @@ -122,7 +122,7 @@ class MicrosmithGenerateMojoTests : StringSpec() { fixture.createMojo().execute() fixture.file("dotnet/Platform/UserService.Api/Program.cs").toFile().shouldExist() - fixture.file("dotnet/Platform/UserService.Api/Controllers/UserServiceController.cs").toFile().shouldExist() + fixture.file("dotnet/Platform/UserService.Api/Generated/Controllers/UserServiceApiControllerBase.cs").toFile().shouldExist() } "script compilation failures surface as MojoFailureException" { diff --git a/modules/runtime-scripting/src/test/kotlin/io/github/lmliam/microsmith/runtime/scripting/host/MicrosmithScriptHostTests.kt b/modules/runtime-scripting/src/test/kotlin/io/github/lmliam/microsmith/runtime/scripting/host/MicrosmithScriptHostTests.kt index 23f4f4f2..d92e3e45 100644 --- a/modules/runtime-scripting/src/test/kotlin/io/github/lmliam/microsmith/runtime/scripting/host/MicrosmithScriptHostTests.kt +++ b/modules/runtime-scripting/src/test/kotlin/io/github/lmliam/microsmith/runtime/scripting/host/MicrosmithScriptHostTests.kt @@ -139,7 +139,7 @@ class MicrosmithScriptHostTests : result.shouldBeTypeOf() output.resolve("dotnet/Platform/UserService.Api/Program.cs").exists() shouldBe true - output.resolve("dotnet/Platform/UserService.Api/Controllers/UserServiceController.cs").exists() shouldBe true + output.resolve("dotnet/Platform/UserService.Api/Generated/Controllers/UserServiceApiControllerBase.cs").exists() shouldBe true output.resolve("dotnet/Platform/UserService.Api/.microsmith/origins.json").exists() shouldBe true } finally { runCatching { tempDir.deleteRecursively() } @@ -260,9 +260,9 @@ class MicrosmithScriptHostTests : result.shouldBeTypeOf() output.resolve("dotnet/Platform/UserService.Api/Program.cs").exists() shouldBe true - output.resolve("dotnet/Platform/UserService.Api/Controllers/UserServiceController.cs").exists() shouldBe true + output.resolve("dotnet/Platform/UserService.Api/Generated/Controllers/UserServiceApiControllerBase.cs").exists() shouldBe true output.resolve("dotnet/Platform/UserService.Api/.microsmith/origins.json").exists() shouldBe true - output.resolve("dotnet/Platform/UserService.Api/Controllers/UserServiceController.cs") + output.resolve("dotnet/Platform/UserService.Api/Generated/Controllers/UserServiceApiControllerBase.cs") .readText() .shouldContain("""[HttpGet("/users/{id}", Name = "GetUser")]""") } finally { diff --git a/modules/sbt-plugin/src/test/kotlin/io/github/lmliam/microsmith/sbt/MicrosmithSbtExecutionServiceTests.kt b/modules/sbt-plugin/src/test/kotlin/io/github/lmliam/microsmith/sbt/MicrosmithSbtExecutionServiceTests.kt index 336e912c..c381a944 100644 --- a/modules/sbt-plugin/src/test/kotlin/io/github/lmliam/microsmith/sbt/MicrosmithSbtExecutionServiceTests.kt +++ b/modules/sbt-plugin/src/test/kotlin/io/github/lmliam/microsmith/sbt/MicrosmithSbtExecutionServiceTests.kt @@ -117,7 +117,7 @@ class MicrosmithSbtExecutionServiceTests : StringSpec() { result.outputDirectory shouldBe fixture.file(".").normalize() fixture.file("dotnet/Platform/UserService.Api/Program.cs").toFile().shouldExist() - fixture.file("dotnet/Platform/UserService.Api/Controllers/UserServiceController.cs").toFile().shouldExist() + fixture.file("dotnet/Platform/UserService.Api/Generated/Controllers/UserServiceApiControllerBase.cs").toFile().shouldExist() } "script compilation failures surface as MicrosmithSbtScriptFailureException" { From 778617d42869060d570ffd8bdebbcb1ba1766a92 Mon Sep 17 00:00:00 2001 From: LMLiam <46268350+TheRealEmissions@users.noreply.github.com> Date: Tue, 21 Apr 2026 18:12:28 +0100 Subject: [PATCH 09/26] refactor(artifact-services-dotnet-asp): split artifact detail types --- .../asp/DotnetAspArtifactContributor.kt | 193 +----------------- .../dotnet/asp/DotnetAspEndpointArtifact.kt | 10 + .../asp/DotnetAspEndpointBindingsArtifact.kt | 8 + .../asp/DotnetAspHeaderFieldArtifact.kt | 6 + .../asp/DotnetAspHeadersBindingArtifact.kt | 8 + .../dotnet/asp/DotnetAspModelArtifact.kt | 10 + .../dotnet/asp/DotnetAspModelLocality.kt | 6 + .../asp/DotnetAspRequestBindingArtifact.kt | 10 + .../asp/DotnetAspRequestFieldArtifact.kt | 10 + .../dotnet/asp/DotnetAspResponseArtifact.kt | 8 + .../asp/DotnetAspResponseHeaderArtifact.kt | 3 + .../asp/DotnetAspServiceArtifactDetails.kt | 67 ------ .../asp/DotnetAspServiceArtifactFactory.kt | 142 +++++++++++++ 13 files changed, 222 insertions(+), 259 deletions(-) create mode 100644 modules/artifact-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspEndpointArtifact.kt create mode 100644 modules/artifact-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspEndpointBindingsArtifact.kt create mode 100644 modules/artifact-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspHeaderFieldArtifact.kt create mode 100644 modules/artifact-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspHeadersBindingArtifact.kt create mode 100644 modules/artifact-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspModelArtifact.kt create mode 100644 modules/artifact-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspModelLocality.kt create mode 100644 modules/artifact-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspRequestBindingArtifact.kt create mode 100644 modules/artifact-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspRequestFieldArtifact.kt create mode 100644 modules/artifact-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspResponseArtifact.kt create mode 100644 modules/artifact-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspResponseHeaderArtifact.kt delete mode 100644 modules/artifact-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspServiceArtifactDetails.kt create mode 100644 modules/artifact-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspServiceArtifactFactory.kt diff --git a/modules/artifact-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspArtifactContributor.kt b/modules/artifact-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspArtifactContributor.kt index 495932d7..f95b1a5e 100644 --- a/modules/artifact-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspArtifactContributor.kt +++ b/modules/artifact-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspArtifactContributor.kt @@ -3,15 +3,7 @@ package io.github.lmliam.microsmith.artifact.services.dotnet.asp import com.github.eventhorizonlab.spi.ServiceProvider import io.github.lmliam.microsmith.artifact.core.ArtifactContribution import io.github.lmliam.microsmith.artifact.core.ArtifactContributor -import io.github.lmliam.microsmith.dsl.services.dotnet.core.model.DotnetModel import io.github.lmliam.microsmith.resolve.services.dotnet.asp.DotnetAspWorkspace -import io.github.lmliam.microsmith.resolve.services.dotnet.asp.ResolvedDotnetAspEndpoint -import io.github.lmliam.microsmith.resolve.services.dotnet.asp.ResolvedDotnetAspHeadersBinding -import io.github.lmliam.microsmith.resolve.services.dotnet.asp.ResolvedDotnetAspModel -import io.github.lmliam.microsmith.resolve.services.dotnet.asp.ResolvedDotnetAspModelLocality -import io.github.lmliam.microsmith.resolve.services.dotnet.asp.ResolvedDotnetAspRequestBinding -import io.github.lmliam.microsmith.resolve.services.dotnet.asp.ResolvedDotnetAspResponse -import io.github.lmliam.microsmith.resolve.services.dotnet.asp.ResolvedDotnetAspService @ServiceProvider(ArtifactContributor::class) class DotnetAspArtifactContributor : ArtifactContributor { @@ -34,190 +26,7 @@ class DotnetAspArtifactContributor : ArtifactContributor { validateUniqueDotnetAspPorts(allocatedPorts.toList()) serviceArtifacts.map { (service, artifactId) -> val ports = requireNotNull(allocatedPorts[artifactId]) - toContribution(service, artifactId, ports) + DotnetAspServiceArtifactFactory(service, artifactId, ports).createContribution() } } - - private fun toContribution( - service: ResolvedDotnetAspService, - artifactId: DotnetAspServiceArtifactId, - ports: DotnetAspAllocatedPorts, - ): DotnetAspServiceContribution { - val usedTypeNames = linkedSetOf() - val sharedModelsByName = service.models.values - .sortedBy(DotnetModel::name) - .associate { model -> - usedTypeNames += model.name - model.name to DotnetAspModelArtifact( - typeName = model.name, - locality = DotnetAspModelLocality.SHARED, - model = model, - origins = setOf("services.${service.name}.models.${model.name}"), - ) - } - val contractModels = mutableListOf() - contractModels += sharedModelsByName.values - - val endpoints = service.rest.endpoints.map { endpoint -> - val endpointOrigin = "services.${service.name}.rest.${endpoint.operationName}" - DotnetAspEndpointArtifact( - method = endpoint.method.name, - route = endpoint.route, - operationName = endpoint.operationName, - bindings = DotnetAspEndpointBindingsArtifact( - path = endpoint.bindings.path?.toRequestBindingArtifact( - serviceName = service.name, - endpoint = endpoint, - bindingLabel = "path", - usedTypeNames = usedTypeNames, - ), - query = endpoint.bindings.query?.toRequestBindingArtifact( - serviceName = service.name, - endpoint = endpoint, - bindingLabel = "query", - usedTypeNames = usedTypeNames, - ), - headers = endpoint.bindings.headers?.toHeadersBindingArtifact( - serviceName = service.name, - endpoint = endpoint, - usedTypeNames = usedTypeNames, - ), - body = endpoint.bindings.body?.toModelArtifact( - serviceName = service.name, - endpoint = endpoint, - sharedModelsByName = sharedModelsByName, - usedTypeNames = usedTypeNames, - contractModels = contractModels, - originKind = "body", - ), - ), - responses = endpoint.responses.map { response -> - response.toResponseArtifact( - serviceName = service.name, - endpoint = endpoint, - sharedModelsByName = sharedModelsByName, - usedTypeNames = usedTypeNames, - contractModels = contractModels, - ) - }, - origins = setOf(endpointOrigin), - ) - } - - return DotnetAspServiceContribution( - artifactId = artifactId, - serviceName = service.name, - targetFrameworkMoniker = service.targetFrameworkMoniker, - outputRoot = service.outputRoot, - httpPort = ports.http, - httpsPort = ports.https, - contractModels = contractModels.toList(), - endpoints = endpoints, - ) - } - - private fun ResolvedDotnetAspRequestBinding.toRequestBindingArtifact( - serviceName: String, - endpoint: ResolvedDotnetAspEndpoint, - bindingLabel: String, - usedTypeNames: MutableSet, - ): DotnetAspRequestBindingArtifact { - val origin = "services.$serviceName.rest.${endpoint.operationName}.$bindingLabel.$name" - return DotnetAspRequestBindingArtifact( - typeName = allocateTypeName(usedTypeNames, name, "${endpoint.operationName}$name"), - name = name, - fields = fields.map { field -> - DotnetAspRequestFieldArtifact( - name = field.name, - type = field.type, - optional = field.optional, - defaultValue = field.defaultValue, - ) - }, - origins = setOf(origin), - ) - } - - private fun ResolvedDotnetAspHeadersBinding.toHeadersBindingArtifact( - serviceName: String, - endpoint: ResolvedDotnetAspEndpoint, - usedTypeNames: MutableSet, - ): DotnetAspHeadersBindingArtifact { - val origin = "services.$serviceName.rest.${endpoint.operationName}.headers.$name" - return DotnetAspHeadersBindingArtifact( - typeName = allocateTypeName(usedTypeNames, name, "${endpoint.operationName}$name"), - name = name, - headers = headers.map { header -> - DotnetAspHeaderFieldArtifact(name = header.name, headerName = header.headerName) - }, - origins = setOf(origin), - ) - } - - private fun ResolvedDotnetAspModel.toModelArtifact( - serviceName: String, - endpoint: ResolvedDotnetAspEndpoint, - sharedModelsByName: Map, - usedTypeNames: MutableSet, - contractModels: MutableList, - originKind: String, - ): DotnetAspModelArtifact = when (locality) { - ResolvedDotnetAspModelLocality.SHARED -> requireNotNull(sharedModelsByName[model.name]) { - "Missing shared ASP.NET model artifact for '${model.name}'." - } - - ResolvedDotnetAspModelLocality.INLINE -> DotnetAspModelArtifact( - typeName = allocateTypeName( - usedTypeNames, - model.name, - "${endpoint.operationName}${model.name}", - "${endpoint.operationName}${originKind.replaceFirstChar(Char::uppercase)}", - ), - locality = DotnetAspModelLocality.INLINE, - model = model, - origins = setOf("services.$serviceName.rest.${endpoint.operationName}.$originKind.${model.name}"), - ).also(contractModels::add) - } - - private fun ResolvedDotnetAspResponse.toResponseArtifact( - serviceName: String, - endpoint: ResolvedDotnetAspEndpoint, - sharedModelsByName: Map, - usedTypeNames: MutableSet, - contractModels: MutableList, - ): DotnetAspResponseArtifact { - val responseOrigin = "services.$serviceName.rest.${endpoint.operationName}.responses.$statusCode" - val modelArtifact = model.toModelArtifact( - serviceName = serviceName, - endpoint = endpoint, - sharedModelsByName = sharedModelsByName, - usedTypeNames = usedTypeNames, - contractModels = contractModels, - originKind = "responses.$statusCode", - ) - return DotnetAspResponseArtifact( - statusCode = statusCode, - model = modelArtifact, - headers = headers.map { header -> DotnetAspResponseHeaderArtifact(header.name) }, - origins = setOf(responseOrigin) + modelArtifact.origins, - ) - } - - private fun allocateTypeName(usedTypeNames: MutableSet, vararg candidates: String): String { - candidates - .map(String::trim) - .filter(String::isNotBlank) - .firstOrNull { candidate -> usedTypeNames.add(candidate) } - ?.let { return it } - - val fallbackBase = candidates.firstOrNull(String::isNotBlank)?.trim().orEmpty() - var suffix = 2 - while (true) { - val candidate = "$fallbackBase$suffix" - if (usedTypeNames.add(candidate)) { - return candidate - } - suffix += 1 - } - } } diff --git a/modules/artifact-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspEndpointArtifact.kt b/modules/artifact-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspEndpointArtifact.kt new file mode 100644 index 00000000..8c845fd2 --- /dev/null +++ b/modules/artifact-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspEndpointArtifact.kt @@ -0,0 +1,10 @@ +package io.github.lmliam.microsmith.artifact.services.dotnet.asp + +data class DotnetAspEndpointArtifact( + val method: String, + val route: String, + val operationName: String, + val bindings: DotnetAspEndpointBindingsArtifact, + val responses: List, + val origins: Set, +) diff --git a/modules/artifact-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspEndpointBindingsArtifact.kt b/modules/artifact-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspEndpointBindingsArtifact.kt new file mode 100644 index 00000000..ca4f925c --- /dev/null +++ b/modules/artifact-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspEndpointBindingsArtifact.kt @@ -0,0 +1,8 @@ +package io.github.lmliam.microsmith.artifact.services.dotnet.asp + +data class DotnetAspEndpointBindingsArtifact( + val path: DotnetAspRequestBindingArtifact? = null, + val query: DotnetAspRequestBindingArtifact? = null, + val headers: DotnetAspHeadersBindingArtifact? = null, + val body: DotnetAspModelArtifact? = null, +) diff --git a/modules/artifact-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspHeaderFieldArtifact.kt b/modules/artifact-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspHeaderFieldArtifact.kt new file mode 100644 index 00000000..0b8b49d9 --- /dev/null +++ b/modules/artifact-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspHeaderFieldArtifact.kt @@ -0,0 +1,6 @@ +package io.github.lmliam.microsmith.artifact.services.dotnet.asp + +data class DotnetAspHeaderFieldArtifact( + val name: String, + val headerName: String, +) diff --git a/modules/artifact-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspHeadersBindingArtifact.kt b/modules/artifact-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspHeadersBindingArtifact.kt new file mode 100644 index 00000000..daf8c9d8 --- /dev/null +++ b/modules/artifact-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspHeadersBindingArtifact.kt @@ -0,0 +1,8 @@ +package io.github.lmliam.microsmith.artifact.services.dotnet.asp + +data class DotnetAspHeadersBindingArtifact( + val typeName: String, + val name: String, + val headers: List, + val origins: Set, +) diff --git a/modules/artifact-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspModelArtifact.kt b/modules/artifact-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspModelArtifact.kt new file mode 100644 index 00000000..dd77e678 --- /dev/null +++ b/modules/artifact-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspModelArtifact.kt @@ -0,0 +1,10 @@ +package io.github.lmliam.microsmith.artifact.services.dotnet.asp + +import io.github.lmliam.microsmith.dsl.services.dotnet.core.model.DotnetModel + +data class DotnetAspModelArtifact( + val typeName: String, + val locality: DotnetAspModelLocality, + val model: DotnetModel, + val origins: Set, +) diff --git a/modules/artifact-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspModelLocality.kt b/modules/artifact-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspModelLocality.kt new file mode 100644 index 00000000..1335cd82 --- /dev/null +++ b/modules/artifact-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspModelLocality.kt @@ -0,0 +1,6 @@ +package io.github.lmliam.microsmith.artifact.services.dotnet.asp + +enum class DotnetAspModelLocality { + SHARED, + INLINE, +} diff --git a/modules/artifact-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspRequestBindingArtifact.kt b/modules/artifact-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspRequestBindingArtifact.kt new file mode 100644 index 00000000..7fdc7672 --- /dev/null +++ b/modules/artifact-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspRequestBindingArtifact.kt @@ -0,0 +1,10 @@ +package io.github.lmliam.microsmith.artifact.services.dotnet.asp + +import io.github.lmliam.microsmith.dsl.services.dotnet.core.model.DotnetFieldType + +data class DotnetAspRequestBindingArtifact( + val typeName: String, + val name: String, + val fields: List, + val origins: Set, +) diff --git a/modules/artifact-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspRequestFieldArtifact.kt b/modules/artifact-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspRequestFieldArtifact.kt new file mode 100644 index 00000000..3629113e --- /dev/null +++ b/modules/artifact-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspRequestFieldArtifact.kt @@ -0,0 +1,10 @@ +package io.github.lmliam.microsmith.artifact.services.dotnet.asp + +import io.github.lmliam.microsmith.dsl.services.dotnet.core.model.DotnetFieldType + +data class DotnetAspRequestFieldArtifact( + val name: String, + val type: DotnetFieldType, + val optional: Boolean, + val defaultValue: Any?, +) diff --git a/modules/artifact-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspResponseArtifact.kt b/modules/artifact-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspResponseArtifact.kt new file mode 100644 index 00000000..42a9eaa1 --- /dev/null +++ b/modules/artifact-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspResponseArtifact.kt @@ -0,0 +1,8 @@ +package io.github.lmliam.microsmith.artifact.services.dotnet.asp + +data class DotnetAspResponseArtifact( + val statusCode: Int, + val model: DotnetAspModelArtifact, + val headers: List, + val origins: Set, +) diff --git a/modules/artifact-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspResponseHeaderArtifact.kt b/modules/artifact-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspResponseHeaderArtifact.kt new file mode 100644 index 00000000..f3a0e7a0 --- /dev/null +++ b/modules/artifact-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspResponseHeaderArtifact.kt @@ -0,0 +1,3 @@ +package io.github.lmliam.microsmith.artifact.services.dotnet.asp + +data class DotnetAspResponseHeaderArtifact(val name: String) diff --git a/modules/artifact-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspServiceArtifactDetails.kt b/modules/artifact-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspServiceArtifactDetails.kt deleted file mode 100644 index 59f7fb34..00000000 --- a/modules/artifact-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspServiceArtifactDetails.kt +++ /dev/null @@ -1,67 +0,0 @@ -package io.github.lmliam.microsmith.artifact.services.dotnet.asp - -import io.github.lmliam.microsmith.dsl.services.dotnet.core.model.DotnetFieldType -import io.github.lmliam.microsmith.dsl.services.dotnet.core.model.DotnetModel - -data class DotnetAspModelArtifact( - val typeName: String, - val locality: DotnetAspModelLocality, - val model: DotnetModel, - val origins: Set, -) - -enum class DotnetAspModelLocality { - SHARED, - INLINE, -} - -data class DotnetAspEndpointArtifact( - val method: String, - val route: String, - val operationName: String, - val bindings: DotnetAspEndpointBindingsArtifact, - val responses: List, - val origins: Set, -) - -data class DotnetAspEndpointBindingsArtifact( - val path: DotnetAspRequestBindingArtifact? = null, - val query: DotnetAspRequestBindingArtifact? = null, - val headers: DotnetAspHeadersBindingArtifact? = null, - val body: DotnetAspModelArtifact? = null, -) - -data class DotnetAspRequestBindingArtifact( - val typeName: String, - val name: String, - val fields: List, - val origins: Set, -) - -data class DotnetAspRequestFieldArtifact( - val name: String, - val type: DotnetFieldType, - val optional: Boolean, - val defaultValue: Any?, -) - -data class DotnetAspHeadersBindingArtifact( - val typeName: String, - val name: String, - val headers: List, - val origins: Set, -) - -data class DotnetAspHeaderFieldArtifact( - val name: String, - val headerName: String, -) - -data class DotnetAspResponseArtifact( - val statusCode: Int, - val model: DotnetAspModelArtifact, - val headers: List, - val origins: Set, -) - -data class DotnetAspResponseHeaderArtifact(val name: String) diff --git a/modules/artifact-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspServiceArtifactFactory.kt b/modules/artifact-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspServiceArtifactFactory.kt new file mode 100644 index 00000000..646ebdd9 --- /dev/null +++ b/modules/artifact-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspServiceArtifactFactory.kt @@ -0,0 +1,142 @@ +package io.github.lmliam.microsmith.artifact.services.dotnet.asp + +import io.github.lmliam.microsmith.dsl.services.dotnet.core.model.DotnetModel +import io.github.lmliam.microsmith.resolve.services.dotnet.asp.ResolvedDotnetAspEndpoint +import io.github.lmliam.microsmith.resolve.services.dotnet.asp.ResolvedDotnetAspHeadersBinding +import io.github.lmliam.microsmith.resolve.services.dotnet.asp.ResolvedDotnetAspModel +import io.github.lmliam.microsmith.resolve.services.dotnet.asp.ResolvedDotnetAspModelLocality +import io.github.lmliam.microsmith.resolve.services.dotnet.asp.ResolvedDotnetAspRequestBinding +import io.github.lmliam.microsmith.resolve.services.dotnet.asp.ResolvedDotnetAspResponse +import io.github.lmliam.microsmith.resolve.services.dotnet.asp.ResolvedDotnetAspService + +internal class DotnetAspServiceArtifactFactory( + private val service: ResolvedDotnetAspService, + private val artifactId: DotnetAspServiceArtifactId, + private val ports: DotnetAspAllocatedPorts, +) { + private val usedTypeNames = linkedSetOf() + + private val sharedModelsByName = service.models.values + .sortedBy(DotnetModel::name) + .associate { model -> + usedTypeNames += model.name + model.name to DotnetAspModelArtifact( + typeName = model.name, + locality = DotnetAspModelLocality.SHARED, + model = model, + origins = setOf("services.${service.name}.models.${model.name}"), + ) + } + + private val contractModels = mutableListOf().apply { + addAll(sharedModelsByName.values) + } + + fun createContribution(): DotnetAspServiceContribution { + val endpoints = endpointArtifacts() + return DotnetAspServiceContribution( + artifactId = artifactId, + serviceName = service.name, + targetFrameworkMoniker = service.targetFrameworkMoniker, + outputRoot = service.outputRoot, + httpPort = ports.http, + httpsPort = ports.https, + contractModels = contractModels.toList(), + endpoints = endpoints, + ) + } + + private fun endpointArtifacts(): List = service.rest.endpoints.map { endpoint -> + DotnetAspEndpointArtifact( + method = endpoint.method.name, + route = endpoint.route, + operationName = endpoint.operationName, + bindings = DotnetAspEndpointBindingsArtifact( + path = endpoint.bindings.path?.toRequestBindingArtifact(endpoint, "path"), + query = endpoint.bindings.query?.toRequestBindingArtifact(endpoint, "query"), + headers = endpoint.bindings.headers?.toHeadersBindingArtifact(endpoint), + body = endpoint.bindings.body?.toModelArtifact(endpoint, "body"), + ), + responses = endpoint.responses.map { response -> response.toResponseArtifact(endpoint) }, + origins = setOf("services.${service.name}.rest.${endpoint.operationName}"), + ) + } + + private fun ResolvedDotnetAspRequestBinding.toRequestBindingArtifact( + endpoint: ResolvedDotnetAspEndpoint, + bindingLabel: String, + ): DotnetAspRequestBindingArtifact = DotnetAspRequestBindingArtifact( + typeName = allocateDotnetAspTypeName(usedTypeNames, name, "${endpoint.operationName}$name"), + name = name, + fields = fields.map { field -> + DotnetAspRequestFieldArtifact( + name = field.name, + type = field.type, + optional = field.optional, + defaultValue = field.defaultValue, + ) + }, + origins = setOf("services.${service.name}.rest.${endpoint.operationName}.$bindingLabel.$name"), + ) + + private fun ResolvedDotnetAspHeadersBinding.toHeadersBindingArtifact( + endpoint: ResolvedDotnetAspEndpoint, + ): DotnetAspHeadersBindingArtifact = DotnetAspHeadersBindingArtifact( + typeName = allocateDotnetAspTypeName(usedTypeNames, name, "${endpoint.operationName}$name"), + name = name, + headers = headers.map { header -> DotnetAspHeaderFieldArtifact(header.name, header.headerName) }, + origins = setOf("services.${service.name}.rest.${endpoint.operationName}.headers.$name"), + ) + + private fun ResolvedDotnetAspModel.toModelArtifact( + endpoint: ResolvedDotnetAspEndpoint, + originKind: String, + ): DotnetAspModelArtifact = when (locality) { + ResolvedDotnetAspModelLocality.SHARED -> requireNotNull(sharedModelsByName[model.name]) { + "Missing shared ASP.NET model artifact for '${model.name}'." + } + + ResolvedDotnetAspModelLocality.INLINE -> DotnetAspModelArtifact( + typeName = allocateDotnetAspTypeName( + usedTypeNames, + model.name, + "${endpoint.operationName}${model.name}", + "${endpoint.operationName}${originKind.replaceFirstChar(Char::uppercase)}", + ), + locality = DotnetAspModelLocality.INLINE, + model = model, + origins = setOf("services.${service.name}.rest.${endpoint.operationName}.$originKind.${model.name}"), + ).also(contractModels::add) + } + + private fun ResolvedDotnetAspResponse.toResponseArtifact( + endpoint: ResolvedDotnetAspEndpoint, + ): DotnetAspResponseArtifact { + val modelArtifact = model.toModelArtifact(endpoint, "responses.$statusCode") + return DotnetAspResponseArtifact( + statusCode = statusCode, + model = modelArtifact, + headers = headers.map { header -> DotnetAspResponseHeaderArtifact(header.name) }, + origins = setOf("services.${service.name}.rest.${endpoint.operationName}.responses.$statusCode") + + modelArtifact.origins, + ) + } +} + +private fun allocateDotnetAspTypeName(usedTypeNames: MutableSet, vararg candidates: String): String { + candidates + .map(String::trim) + .filter(String::isNotBlank) + .firstOrNull { candidate -> usedTypeNames.add(candidate) } + ?.let { return it } + + val fallbackBase = candidates.firstOrNull(String::isNotBlank)?.trim().orEmpty() + var suffix = 2 + while (true) { + val candidate = "$fallbackBase$suffix" + if (usedTypeNames.add(candidate)) { + return candidate + } + suffix += 1 + } +} From 1a4dbd82423e8f5581794ac2db648f591259f582 Mon Sep 17 00:00:00 2001 From: LMLiam <46268350+TheRealEmissions@users.noreply.github.com> Date: Tue, 21 Apr 2026 18:12:39 +0100 Subject: [PATCH 10/26] refactor(services-dotnet-asp): split compiler and C# renderers --- .../dotnet/asp/DotnetAspCSharpRendering.kt | 122 +++++ .../asp/DotnetAspContractFileRenderer.kt | 138 ++++++ .../asp/DotnetAspContributionOrigins.kt | 50 ++ .../asp/DotnetAspControllerFileRenderer.kt | 120 +++++ .../DotnetAspInfrastructureFileRenderer.kt | 77 +++ .../dotnet/asp/DotnetAspProjectFileSupport.kt | 85 ++++ .../dotnet/asp/DotnetAspProjectRenderer.kt | 455 +----------------- .../asp/DotnetAspServiceArtifactCompiler.kt | 146 +----- .../DotnetAspServiceArtifactCompilerTests.kt | 97 +++- 9 files changed, 714 insertions(+), 576 deletions(-) create mode 100644 modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspCSharpRendering.kt create mode 100644 modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspContractFileRenderer.kt create mode 100644 modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspContributionOrigins.kt create mode 100644 modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspControllerFileRenderer.kt create mode 100644 modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspInfrastructureFileRenderer.kt create mode 100644 modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspProjectFileSupport.kt diff --git a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspCSharpRendering.kt b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspCSharpRendering.kt new file mode 100644 index 00000000..266a8b0e --- /dev/null +++ b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspCSharpRendering.kt @@ -0,0 +1,122 @@ +package io.github.lmliam.microsmith.compile.services.dotnet.asp + +import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspRequestFieldArtifact +import io.github.lmliam.microsmith.dsl.services.dotnet.core.model.DotnetFieldType +import java.util.Locale + +internal fun renderDotnetAspModelPropertyType(type: DotnetFieldType): String = when (type) { + is DotnetFieldType.Reference -> type.target + else -> type.csharpType +} + +internal fun renderDotnetAspBindingPropertyType(field: DotnetAspRequestFieldArtifact): String { + val baseType = renderDotnetAspModelPropertyType(field.type) + return if (field.optional && field.defaultValue == null) "$baseType?" else baseType +} + +internal fun renderDotnetAspInitializer(type: DotnetFieldType): String = when (type) { + DotnetFieldType.String -> " = string.Empty;" + DotnetFieldType.Char -> " = 'A';" + DotnetFieldType.Byte, + DotnetFieldType.SignedByte, + DotnetFieldType.Short, + DotnetFieldType.UnsignedShort, + DotnetFieldType.Int, + DotnetFieldType.UnsignedInt, + DotnetFieldType.Long, + DotnetFieldType.UnsignedLong, + DotnetFieldType.NativeInt, + DotnetFieldType.UnsignedNativeInt, + -> " = 0;" + DotnetFieldType.Float -> " = 0F;" + DotnetFieldType.Double -> " = 0D;" + DotnetFieldType.Decimal -> " = 0M;" + DotnetFieldType.Bool -> " = false;" + DotnetFieldType.Guid -> " = Guid.Empty;" + DotnetFieldType.DateOnly -> " = DateOnly.MinValue;" + DotnetFieldType.TimeOnly -> " = TimeOnly.MinValue;" + DotnetFieldType.DateTime -> " = DateTime.UnixEpoch;" + DotnetFieldType.DateTimeOffset -> " = DateTimeOffset.UnixEpoch;" + DotnetFieldType.TimeSpan -> " = TimeSpan.Zero;" + is DotnetFieldType.Reference -> " = null!;" +} + +internal fun renderDotnetAspBindingInitializer(field: DotnetAspRequestFieldArtifact): String { + val defaultValue = field.defaultValue + return when { + defaultValue != null -> " = ${renderDotnetAspDefaultExpression(field.type, defaultValue)};" + field.optional -> " = null;" + else -> renderDotnetAspInitializer(field.type) + } +} + +internal fun renderDotnetAspDefaultExpression(type: DotnetFieldType, defaultValue: Any): String = when (type) { + DotnetFieldType.String -> escapeDotnetAspCsharpStringLiteral(defaultValue.toString()) + DotnetFieldType.Char -> escapeDotnetAspCsharpCharLiteral(defaultValue.toString().first()) + DotnetFieldType.Byte, + DotnetFieldType.SignedByte, + DotnetFieldType.Short, + DotnetFieldType.UnsignedShort, + DotnetFieldType.Int, + -> defaultValue.toString() + DotnetFieldType.UnsignedInt -> "${defaultValue}U" + DotnetFieldType.Long -> "${defaultValue}L" + DotnetFieldType.UnsignedLong -> "${defaultValue}UL" + DotnetFieldType.NativeInt -> "(nint)$defaultValue" + DotnetFieldType.UnsignedNativeInt -> "(nuint)${defaultValue}UL" + DotnetFieldType.Float -> "${defaultValue.toString().ensureDotnetAspDecimal()}F" + DotnetFieldType.Double -> "${defaultValue.toString().ensureDotnetAspDecimal()}D" + DotnetFieldType.Decimal -> "${defaultValue.toString().ensureDotnetAspDecimal()}M" + DotnetFieldType.Bool -> defaultValue.toString().lowercase(Locale.ROOT) + DotnetFieldType.Guid -> "Guid.Parse(${escapeDotnetAspCsharpStringLiteral(defaultValue.toString())})" + DotnetFieldType.DateOnly -> + "DateOnly.Parse(${escapeDotnetAspCsharpStringLiteral(defaultValue.toString())}, global::System.Globalization.CultureInfo.InvariantCulture)" + DotnetFieldType.TimeOnly -> + "TimeOnly.Parse(${escapeDotnetAspCsharpStringLiteral(defaultValue.toString())}, global::System.Globalization.CultureInfo.InvariantCulture)" + DotnetFieldType.DateTime -> + "DateTime.Parse(${escapeDotnetAspCsharpStringLiteral(defaultValue.toString())}, global::System.Globalization.CultureInfo.InvariantCulture, global::System.Globalization.DateTimeStyles.RoundtripKind)" + DotnetFieldType.DateTimeOffset -> + "DateTimeOffset.Parse(${escapeDotnetAspCsharpStringLiteral(defaultValue.toString())}, global::System.Globalization.CultureInfo.InvariantCulture, global::System.Globalization.DateTimeStyles.RoundtripKind)" + DotnetFieldType.TimeSpan -> + "TimeSpan.Parse(${escapeDotnetAspCsharpStringLiteral(defaultValue.toString())}, global::System.Globalization.CultureInfo.InvariantCulture)" + is DotnetFieldType.Reference -> + "global::System.Text.Json.JsonSerializer.Deserialize<${type.target}>(" + + "${escapeDotnetAspCsharpStringLiteral(defaultValue.toString())})!" +} + +internal fun escapeDotnetAspCsharpStringLiteral(value: String): String = buildString { + append('"') + value.forEach { char -> + when (char) { + '\\' -> append("\\\\") + '"' -> append("\\\"") + '\b' -> append("\\b") + '\u000C' -> append("\\f") + '\n' -> append("\\n") + '\r' -> append("\\r") + '\t' -> append("\\t") + else -> { + if (char.code < 0x20) { + append("\\u%04x".format(char.code)) + } else { + append(char) + } + } + } + } + append('"') +} + +internal fun escapeDotnetAspCsharpCharLiteral(value: Char): String = when (value) { + '\\' -> "'\\\\'" + '\'' -> "'\\''" + '\n' -> "'\\n'" + '\r' -> "'\\r'" + '\t' -> "'\\t'" + '\b' -> "'\\b'" + '\u000C' -> "'\\f'" + else -> if (value.code < 0x20) "'\\u%04x'".format(value.code) else "'$value'" +} + +private fun String.ensureDotnetAspDecimal(): String = + if (contains('.') || contains('E', ignoreCase = true)) this else "$this.0" diff --git a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspContractFileRenderer.kt b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspContractFileRenderer.kt new file mode 100644 index 00000000..22c0119d --- /dev/null +++ b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspContractFileRenderer.kt @@ -0,0 +1,138 @@ +package io.github.lmliam.microsmith.compile.services.dotnet.asp + +import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspEndpointArtifact +import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspHeaderFieldArtifact +import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspHeadersBindingArtifact +import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspModelArtifact +import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspModelLocality +import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspRequestBindingArtifact +import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspResponseArtifact +import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspServiceArtifact +import io.github.lmliam.microsmith.dsl.services.dotnet.core.model.DotnetField + +internal object DotnetAspContractFileRenderer { + fun renderServiceModelsFile(artifact: DotnetAspServiceArtifact): String = buildString { + appendLine("using System;") + appendLine() + appendLine("namespace ${contractsNamespace(artifact)};") + appendLine() + artifact.contractModels + .distinctBy(DotnetAspModelArtifact::typeName) + .filter { it.locality == DotnetAspModelLocality.SHARED } + .sortedBy(DotnetAspModelArtifact::typeName) + .forEachIndexed { index, model -> + if (index > 0) { + appendLine() + } + append(renderRecordType(model.typeName, model.model.fields)) + } + } + + fun renderRequestModelsFile(artifact: DotnetAspServiceArtifact): String = buildString { + appendLine("using System;") + appendLine("using Microsoft.AspNetCore.Mvc.ModelBinding;") + appendLine() + appendLine("namespace ${contractsNamespace(artifact)};") + appendLine() + val elements = buildList { + collectRequestBindings(artifact).forEach { add(renderRequestBindingType(it)) } + collectHeaderBindings(artifact).forEach { add(renderHeadersBindingType(it)) } + artifact.endpoints.forEach { endpoint -> + endpoint.bindings.body + ?.takeIf { it.locality == DotnetAspModelLocality.INLINE } + ?.let { add(renderRecordType(it.typeName, it.model.fields)) } + } + }.distinct() + elements.forEachIndexed { index, typeBlock -> + if (index > 0) { + appendLine() + } + append(typeBlock) + } + } + + fun renderResponseModelsFile(artifact: DotnetAspServiceArtifact): String = buildString { + appendLine("using System;") + appendLine() + appendLine("namespace ${contractsNamespace(artifact)};") + appendLine() + val elements = buildList { + artifact.endpoints.forEach { endpoint -> + endpoint.responses + .map(DotnetAspResponseArtifact::model) + .filter { it.locality == DotnetAspModelLocality.INLINE } + .distinctBy(DotnetAspModelArtifact::typeName) + .forEach { model -> add(renderRecordType(model.typeName, model.model.fields)) } + } + artifact.endpoints.forEach { endpoint -> + add(renderResultBaseType(endpoint)) + endpoint.responses.forEach { response -> + add(renderResultVariantType(endpoint, response)) + } + } + }.distinct() + elements.forEachIndexed { index, typeBlock -> + if (index > 0) { + appendLine() + } + append(typeBlock) + } + } + + private fun renderRecordType(typeName: String, fields: List): String = buildString { + appendLine("public sealed record $typeName") + appendLine("{") + fields.forEach { field -> + appendLine( + " public ${renderDotnetAspModelPropertyType(field.type)} ${dotnetAspPascalIdentifier(field.name)} { get; set; }" + + renderDotnetAspInitializer(field.type), + ) + } + appendLine("}") + } + + private fun renderRequestBindingType(binding: DotnetAspRequestBindingArtifact): String = buildString { + appendLine("public sealed record ${binding.typeName}") + appendLine("{") + binding.fields.forEach { field -> + if (!field.optional && field.defaultValue == null) { + appendLine(" [BindRequired]") + } + appendLine( + " public ${renderDotnetAspBindingPropertyType(field)} ${dotnetAspPascalIdentifier(field.name)} { get; set; }" + + renderDotnetAspBindingInitializer(field), + ) + } + appendLine("}") + } + + private fun renderHeadersBindingType(binding: DotnetAspHeadersBindingArtifact): String = buildString { + appendLine("public sealed record ${binding.typeName}") + appendLine("{") + binding.headers.forEach { header -> + appendLine(" public string? ${dotnetAspPascalIdentifier(header.name)} { get; set; } = null;") + } + appendLine("}") + } + + private fun renderResultBaseType(endpoint: DotnetAspEndpointArtifact): String = + "public abstract record ${resultBaseTypeName(endpoint)};" + + private fun renderResultVariantType( + endpoint: DotnetAspEndpointArtifact, + response: DotnetAspResponseArtifact, + ): String = buildString { + append("public sealed record ${resultVariantTypeName(endpoint, response)}(") + append(responseParameters(response).joinToString(", ")) + append(") : ${resultBaseTypeName(endpoint)};") + } + + private fun responseParameters(response: DotnetAspResponseArtifact): List = buildList { + if (response.statusCode != 204) { + add("${response.model.typeName} $RESULT_BODY_PROPERTY_NAME") + } + response.headers.forEach { header -> + add("string? ${dotnetAspHeaderPropertyName(header.name)} = null") + } + } +} diff --git a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspContributionOrigins.kt b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspContributionOrigins.kt new file mode 100644 index 00000000..0b381621 --- /dev/null +++ b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspContributionOrigins.kt @@ -0,0 +1,50 @@ +package io.github.lmliam.microsmith.compile.services.dotnet.asp + +import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspModelArtifact +import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspModelLocality +import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspServiceArtifact + +internal fun sharedContractModelOriginsFor( + artifact: DotnetAspServiceArtifact, + serviceOrigin: Set, +): Set = artifact.contractModels + .distinctBy(DotnetAspModelArtifact::typeName) + .filter { it.locality == DotnetAspModelLocality.SHARED } + .flatMapTo(linkedSetOf()) { it.origins } + serviceOrigin + +internal fun requestModelOriginsFor( + artifact: DotnetAspServiceArtifact, + serviceOrigin: Set, +): Set = serviceOrigin + + collectRequestBindings(artifact).flatMapTo(linkedSetOf()) { it.origins } + + collectHeaderBindings(artifact).flatMapTo(linkedSetOf()) { it.origins } + + artifact.endpoints.mapNotNull { endpoint -> + endpoint.bindings.body + ?.takeIf { it.locality == DotnetAspModelLocality.INLINE } + ?.origins + }.flatten() + +internal fun responseModelOriginsFor( + artifact: DotnetAspServiceArtifact, + serviceOrigin: Set, +): Set = serviceOrigin + + artifact.endpoints.flatMapTo(linkedSetOf()) { endpoint -> + endpoint.responses.flatMap { response -> + response.origins + response.model.origins + } + } + +internal fun controllerOriginsFor( + artifact: DotnetAspServiceArtifact, + serviceOrigin: Set, +): Set = serviceOrigin + + artifact.endpoints.flatMapTo(linkedSetOf()) { endpoint -> + endpoint.origins + + endpoint.responses.flatMapTo(linkedSetOf()) { it.origins } + + listOfNotNull( + endpoint.bindings.path?.origins, + endpoint.bindings.query?.origins, + endpoint.bindings.headers?.origins, + endpoint.bindings.body?.origins, + ).flatten() + } diff --git a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspControllerFileRenderer.kt b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspControllerFileRenderer.kt new file mode 100644 index 00000000..ffccd162 --- /dev/null +++ b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspControllerFileRenderer.kt @@ -0,0 +1,120 @@ +package io.github.lmliam.microsmith.compile.services.dotnet.asp + +import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspEndpointArtifact +import java.util.Locale + +internal object DotnetAspControllerFileRenderer { + fun renderControllerBaseFile(artifact: io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspServiceArtifact): String = buildString { + appendLine("using System;") + appendLine("using System.Threading;") + appendLine("using System.Threading.Tasks;") + appendLine("using ${contractsNamespace(artifact)};") + appendLine("using Microsoft.AspNetCore.Mvc;") + appendLine() + appendLine("namespace ${controllersNamespace(artifact)};") + appendLine() + appendLine("[ApiController]") + appendLine("public abstract class ${controllerBaseTypeName(artifact)} : $MICROSMITH_CONTROLLER_BASE_TYPE_NAME") + appendLine("{") + artifact.endpoints.forEach { endpoint -> + append(renderActionMethod(endpoint)) + appendLine() + append(renderAbstractHandler(endpoint)) + appendLine() + append(renderResultMapper(endpoint)) + appendLine() + } + appendLine("}") + } + + private fun renderActionMethod(endpoint: DotnetAspEndpointArtifact): String = buildString { + appendLine(" [${httpAttributeName(endpoint.method)}(${escapeDotnetAspCsharpStringLiteral(endpoint.route)}, Name = ${escapeDotnetAspCsharpStringLiteral(endpoint.operationName)})]") + endpoint.responses.forEach { response -> + appendLine( + " [ProducesResponseType(typeof(${responseAttributeType(response)}), ${response.statusCode})]", + ) + } + append(" public async Task> ${endpoint.operationName}(") + append(actionParameters(endpoint).joinToString(", ")) + appendLine(")") + appendLine(" {") + endpoint.bindings.headers?.let { binding -> + append(renderHeadersInitializer(binding)) + appendLine() + } + appendLine(" var result = await On${endpoint.operationName}Async(${handlerArguments(endpoint).joinToString(", ")});") + appendLine(" return Map${endpoint.operationName}Result(result);") + appendLine(" }") + } + + private fun renderAbstractHandler(endpoint: DotnetAspEndpointArtifact): String = buildString { + append(" protected abstract Task<${resultBaseTypeName(endpoint)}> On${endpoint.operationName}Async(") + append(handlerParameters(endpoint).joinToString(", ")) + appendLine(");") + } + + private fun renderResultMapper(endpoint: DotnetAspEndpointArtifact): String = buildString { + appendLine(" private ActionResult<${resultBaseTypeName(endpoint)}> Map${endpoint.operationName}Result(${resultBaseTypeName(endpoint)} result)") + appendLine(" {") + appendLine(" return result switch") + appendLine(" {") + endpoint.responses.forEach { response -> + append(" ${resultVariantTypeName(endpoint, response)} response => Respond(") + append(if (response.statusCode == 204) "null" else "response.$RESULT_BODY_PROPERTY_NAME") + append(", ${response.statusCode}") + response.headers.forEach { header -> + append(", (${escapeDotnetAspCsharpStringLiteral(header.name)}, response.${dotnetAspHeaderPropertyName(header.name)})") + } + appendLine("),") + } + appendLine( + " _ => throw new InvalidOperationException(" + + "${escapeDotnetAspCsharpStringLiteral("Unsupported ${endpoint.operationName} result type.")} + " + + "result.GetType().FullName + \".\"),", + ) + appendLine(" };") + appendLine(" }") + } + + private fun renderHeadersInitializer(binding: io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspHeadersBindingArtifact): String = buildString { + appendLine(" var headers = new ${binding.typeName}") + appendLine(" {") + binding.headers.forEachIndexed { index, header -> + val suffix = if (index == binding.headers.lastIndex) "" else "," + appendLine( + " ${dotnetAspPascalIdentifier(header.name)} = " + + "ReadHeader(${escapeDotnetAspCsharpStringLiteral(header.headerName)})$suffix", + ) + } + appendLine(" };") + } + + private fun actionParameters(endpoint: DotnetAspEndpointArtifact): List = buildList { + endpoint.bindings.path?.let { add("[FromRoute] ${it.typeName} path") } + endpoint.bindings.query?.let { add("[FromQuery] ${it.typeName} query") } + endpoint.bindings.body?.let { add("[FromBody] ${it.typeName} body") } + add("CancellationToken cancellationToken") + } + + private fun handlerParameters(endpoint: DotnetAspEndpointArtifact): List = buildList { + endpoint.bindings.path?.let { add("${it.typeName} path") } + endpoint.bindings.query?.let { add("${it.typeName} query") } + endpoint.bindings.headers?.let { add("${it.typeName} headers") } + endpoint.bindings.body?.let { add("${it.typeName} body") } + add("CancellationToken cancellationToken") + } + + private fun handlerArguments(endpoint: DotnetAspEndpointArtifact): List = buildList { + endpoint.bindings.path?.let { add("path") } + endpoint.bindings.query?.let { add("query") } + endpoint.bindings.headers?.let { add("headers") } + endpoint.bindings.body?.let { add("body") } + add("cancellationToken") + } + + private fun responseAttributeType(response: io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspResponseArtifact): String = + if (response.statusCode == 204) "void" else response.model.typeName + + private fun httpAttributeName(method: String): String = + "Http" + method.lowercase(Locale.ROOT).replaceFirstChar(Char::uppercase) +} diff --git a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspInfrastructureFileRenderer.kt b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspInfrastructureFileRenderer.kt new file mode 100644 index 00000000..16afb462 --- /dev/null +++ b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspInfrastructureFileRenderer.kt @@ -0,0 +1,77 @@ +package io.github.lmliam.microsmith.compile.services.dotnet.asp + +import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspServiceArtifact + +internal object DotnetAspInfrastructureFileRenderer { + fun renderProgramFile(artifact: DotnetAspServiceArtifact): String = """ + using ${hostingNamespace(artifact)}; + + var builder = WebApplication.CreateBuilder(args); + builder.AddMicrosmith(); + + var app = builder.Build(); + app.MapMicrosmith(); + app.Run(); + + public partial class Program { } + """.trimIndent() + + fun renderHostingExtensionsFile(artifact: DotnetAspServiceArtifact): String = """ + using Microsoft.AspNetCore.Builder; + using Microsoft.Extensions.DependencyInjection; + + namespace ${hostingNamespace(artifact)}; + + public static class MicrosmithHostingExtensions + { + public static WebApplicationBuilder AddMicrosmith(this WebApplicationBuilder builder) + { + builder.Services.AddControllers(); + return builder; + } + + public static WebApplication MapMicrosmith(this WebApplication app) + { + app.MapControllers(); + return app; + } + } + """.trimIndent() + + fun renderMicrosmithControllerBaseFile(artifact: DotnetAspServiceArtifact): String = """ + using Microsoft.AspNetCore.Mvc; + + namespace ${controllersNamespace(artifact)}; + + public abstract class $MICROSMITH_CONTROLLER_BASE_TYPE_NAME : ControllerBase + { + protected ActionResult Respond(object? body, int statusCode, params (string Name, string? Value)[] headers) + { + foreach (var (name, value) in headers) + { + if (value is not null) + { + Response.Headers[name] = value; + } + } + + if (statusCode == 204) + { + return StatusCode(statusCode); + } + + return new ObjectResult(body) + { + StatusCode = statusCode + }; + } + + protected string? ReadHeader(string headerName) + { + return Request.Headers.TryGetValue(headerName, out var values) + ? values.ToString() + : null; + } + } + """.trimIndent() +} diff --git a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspProjectFileSupport.kt b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspProjectFileSupport.kt new file mode 100644 index 00000000..54bb9a0c --- /dev/null +++ b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspProjectFileSupport.kt @@ -0,0 +1,85 @@ +package io.github.lmliam.microsmith.compile.services.dotnet.asp + +import io.github.lmliam.microsmith.artifact.files.TextFileArtifactContribution +import io.github.lmliam.microsmith.artifact.files.TextFileArtifactId +import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspServiceArtifact +import io.github.lmliam.microsmith.artifact.services.dotnet.msbuild.MsBuildProjectArtifactId +import io.github.lmliam.microsmith.artifact.services.dotnet.msbuild.MsBuildProjectKind +import java.nio.file.Path + +private const val FIRST_NON_PRINTABLE_ASCII_CODE_POINT = 0x20 + +internal fun DotnetAspServiceArtifact.msBuildProjectArtifactId(kind: MsBuildProjectKind): MsBuildProjectArtifactId = + MsBuildProjectArtifactId( + solutionName = id.solutionName, + projectName = id.projectName, + kind = kind, + ) + +internal fun DotnetAspServiceArtifact.textContribution( + relativePath: String, + contents: String, + origins: Set, +): TextFileArtifactContribution = TextFileArtifactContribution( + artifactId = TextFileArtifactId( + relativePath = Path.of(relativePath), + outputRoot = outputRoot, + ), + contents = contents, + origins = origins, +) + +internal fun renderDotnetAspAppSettingsFile(artifact: DotnetAspServiceArtifact): String = """ + { + "Microsmith": { + "ServiceName": "${escapeDotnetAspJsonString(artifact.serviceName)}" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" + } +""".trimIndent() + +internal fun renderDotnetAspLaunchSettingsFile(artifact: DotnetAspServiceArtifact): String = """ + { + "${'$'}schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "${artifact.id.projectName}": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:${artifact.httpPort};https://localhost:${artifact.httpsPort}", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } + } +""".trimIndent() + +private fun escapeDotnetAspJsonString(value: String): String { + val escaped = StringBuilder(value.length) + value.forEach { char -> + when (char) { + '\\' -> escaped.append("\\\\") + '"' -> escaped.append("\\\"") + '\b' -> escaped.append("\\b") + '\u000C' -> escaped.append("\\f") + '\n' -> escaped.append("\\n") + '\r' -> escaped.append("\\r") + '\t' -> escaped.append("\\t") + else -> { + if (char.code < FIRST_NON_PRINTABLE_ASCII_CODE_POINT) { + escaped.append("\\u%04x".format(char.code)) + } else { + escaped.append(char) + } + } + } + } + return escaped.toString() +} diff --git a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspProjectRenderer.kt b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspProjectRenderer.kt index 318731df..04038873 100644 --- a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspProjectRenderer.kt +++ b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspProjectRenderer.kt @@ -1,453 +1,26 @@ package io.github.lmliam.microsmith.compile.services.dotnet.asp -import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspEndpointArtifact -import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspHeaderFieldArtifact -import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspHeadersBindingArtifact -import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspModelArtifact -import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspModelLocality -import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspRequestBindingArtifact -import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspRequestFieldArtifact -import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspResponseArtifact import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspServiceArtifact -import io.github.lmliam.microsmith.dsl.services.dotnet.core.model.DotnetField -import io.github.lmliam.microsmith.dsl.services.dotnet.core.model.DotnetFieldType -import java.util.Locale internal object DotnetAspProjectRenderer { - fun renderProgramFile(artifact: DotnetAspServiceArtifact): String = """ - using ${hostingNamespace(artifact)}; + fun renderProgramFile(artifact: DotnetAspServiceArtifact): String = + DotnetAspInfrastructureFileRenderer.renderProgramFile(artifact) - var builder = WebApplication.CreateBuilder(args); - builder.AddMicrosmith(); + fun renderHostingExtensionsFile(artifact: DotnetAspServiceArtifact): String = + DotnetAspInfrastructureFileRenderer.renderHostingExtensionsFile(artifact) - var app = builder.Build(); - app.MapMicrosmith(); - app.Run(); + fun renderMicrosmithControllerBaseFile(artifact: DotnetAspServiceArtifact): String = + DotnetAspInfrastructureFileRenderer.renderMicrosmithControllerBaseFile(artifact) - public partial class Program { } - """.trimIndent() + fun renderControllerBaseFile(artifact: DotnetAspServiceArtifact): String = + DotnetAspControllerFileRenderer.renderControllerBaseFile(artifact) - fun renderHostingExtensionsFile(artifact: DotnetAspServiceArtifact): String = """ - namespace ${hostingNamespace(artifact)}; + fun renderServiceModelsFile(artifact: DotnetAspServiceArtifact): String = + DotnetAspContractFileRenderer.renderServiceModelsFile(artifact) - using Microsoft.AspNetCore.Builder; - using Microsoft.Extensions.DependencyInjection; + fun renderRequestModelsFile(artifact: DotnetAspServiceArtifact): String = + DotnetAspContractFileRenderer.renderRequestModelsFile(artifact) - public static class MicrosmithHostingExtensions - { - public static WebApplicationBuilder AddMicrosmith(this WebApplicationBuilder builder) - { - builder.Services.AddControllers(); - return builder; - } - - public static WebApplication MapMicrosmith(this WebApplication app) - { - app.MapControllers(); - return app; - } - } - """.trimIndent() - - fun renderMicrosmithControllerBaseFile(artifact: DotnetAspServiceArtifact): String = """ - namespace ${controllersNamespace(artifact)}; - - using Microsoft.AspNetCore.Mvc; - - public abstract class $MICROSMITH_CONTROLLER_BASE_TYPE_NAME : ControllerBase - { - protected ActionResult Respond(object? body, int statusCode, params (string Name, string? Value)[] headers) - { - foreach (var (name, value) in headers) - { - if (value is not null) - { - Response.Headers[name] = value; - } - } - - if (statusCode == 204) - { - return StatusCode(statusCode); - } - - return new ObjectResult(body) - { - StatusCode = statusCode - }; - } - - protected string? ReadHeader(string headerName) - { - return Request.Headers.TryGetValue(headerName, out var values) - ? values.ToString() - : null; - } - } - """.trimIndent() - - fun renderControllerBaseFile(artifact: DotnetAspServiceArtifact): String = buildString { - appendLine("namespace ${controllersNamespace(artifact)};") - appendLine() - appendLine("using System;") - appendLine("using System.Threading;") - appendLine("using System.Threading.Tasks;") - appendLine("using ${contractsNamespace(artifact)};") - appendLine("using Microsoft.AspNetCore.Mvc;") - appendLine() - appendLine("[ApiController]") - appendLine("public abstract class ${controllerBaseTypeName(artifact)} : $MICROSMITH_CONTROLLER_BASE_TYPE_NAME") - appendLine("{") - artifact.endpoints.forEach { endpoint -> - append(renderActionMethod(endpoint)) - appendLine() - append(renderAbstractHandler(endpoint)) - appendLine() - append(renderResultMapper(endpoint)) - appendLine() - } - appendLine("}") - } - - fun renderServiceModelsFile(artifact: DotnetAspServiceArtifact): String = buildString { - appendLine("namespace ${contractsNamespace(artifact)};") - appendLine() - artifact.contractModels - .distinctBy(DotnetAspModelArtifact::typeName) - .filter { it.locality == DotnetAspModelLocality.SHARED } - .sortedBy(DotnetAspModelArtifact::typeName) - .forEachIndexed { index, model -> - if (index > 0) { - appendLine() - } - append(renderRecordType(model.typeName, model.model.fields)) - } - } - - fun renderRequestModelsFile(artifact: DotnetAspServiceArtifact): String = buildString { - appendLine("namespace ${contractsNamespace(artifact)};") - appendLine() - appendLine("using Microsoft.AspNetCore.Mvc.ModelBinding;") - val elements = buildList { - collectRequestBindings(artifact).forEach { add(renderRequestBindingType(it)) } - collectHeaderBindings(artifact).forEach { add(renderHeadersBindingType(it)) } - artifact.endpoints.forEach { endpoint -> - endpoint.bindings.body - ?.takeIf { it.locality == DotnetAspModelLocality.INLINE } - ?.let { add(renderRecordType(it.typeName, it.model.fields)) } - } - }.distinct() - elements.forEachIndexed { index, typeBlock -> - if (index > 0) { - appendLine() - } - append(typeBlock) - } - } - - fun renderResponseModelsFile(artifact: DotnetAspServiceArtifact): String = buildString { - appendLine("namespace ${contractsNamespace(artifact)};") - appendLine() - val elements = buildList { - artifact.endpoints.forEach { endpoint -> - endpoint.responses - .map(DotnetAspResponseArtifact::model) - .filter { it.locality == DotnetAspModelLocality.INLINE } - .distinctBy(DotnetAspModelArtifact::typeName) - .forEach { model -> add(renderRecordType(model.typeName, model.model.fields)) } - } - artifact.endpoints.forEach { endpoint -> - add(renderResultBaseType(endpoint)) - endpoint.responses.forEach { response -> - add(renderResultVariantType(endpoint, response)) - } - } - }.distinct() - elements.forEachIndexed { index, typeBlock -> - if (index > 0) { - appendLine() - } - append(typeBlock) - } - } - - private fun renderActionMethod(endpoint: DotnetAspEndpointArtifact): String = buildString { - appendLine(" [${httpAttributeName(endpoint.method)}(${escapeCsharpStringLiteral(endpoint.route)}, Name = ${escapeCsharpStringLiteral(endpoint.operationName)})]") - endpoint.responses.forEach { response -> - appendLine( - " [ProducesResponseType(typeof(${responseAttributeType(response)}), ${response.statusCode})]", - ) - } - append(" public async Task> ${endpoint.operationName}(") - append(actionParameters(endpoint).joinToString(", ")) - appendLine(")") - appendLine(" {") - endpoint.bindings.headers?.let { binding -> - append(renderHeadersInitializer(binding)) - appendLine() - } - appendLine(" var result = await On${endpoint.operationName}Async(${handlerArguments(endpoint).joinToString(", ")});") - appendLine(" return Map${endpoint.operationName}Result(result);") - appendLine(" }") - } - - private fun renderAbstractHandler(endpoint: DotnetAspEndpointArtifact): String = buildString { - append(" protected abstract Task<${resultBaseTypeName(endpoint)}> On${endpoint.operationName}Async(") - append(handlerParameters(endpoint).joinToString(", ")) - appendLine(");") - } - - private fun renderResultMapper(endpoint: DotnetAspEndpointArtifact): String = buildString { - appendLine(" private ActionResult<${resultBaseTypeName(endpoint)}> Map${endpoint.operationName}Result(${resultBaseTypeName(endpoint)} result)") - appendLine(" {") - appendLine(" return result switch") - appendLine(" {") - endpoint.responses.forEach { response -> - append(" ${resultVariantTypeName(endpoint, response)} response => Respond(") - append( - if (response.statusCode == 204) { - "null" - } else { - "response.$RESULT_BODY_PROPERTY_NAME" - }, - ) - append(", ${response.statusCode}") - response.headers.forEach { header -> - append(", (${escapeCsharpStringLiteral(header.name)}, response.${dotnetAspHeaderPropertyName(header.name)})") - } - appendLine("),") - } - appendLine( - " _ => throw new InvalidOperationException(" + - "${escapeCsharpStringLiteral("Unsupported ${endpoint.operationName} result type.")} + " + - "result.GetType().FullName + \".\"),", - ) - appendLine(" };") - appendLine(" }") - } - - private fun renderHeadersInitializer(binding: DotnetAspHeadersBindingArtifact): String = buildString { - appendLine(" var headers = new ${binding.typeName}") - appendLine(" {") - binding.headers.forEachIndexed { index, header -> - val suffix = if (index == binding.headers.lastIndex) "" else "," - appendLine( - " ${dotnetAspPascalIdentifier(header.name)} = " + - "ReadHeader(${escapeCsharpStringLiteral(header.headerName)})$suffix", - ) - } - appendLine(" };") - } - - private fun renderRecordType(typeName: String, fields: List): String = buildString { - appendLine("public sealed record $typeName") - appendLine("{") - fields.forEach { field -> - appendLine( - " public ${renderModelPropertyType(field.type)} ${dotnetAspPascalIdentifier(field.name)} { get; set; }" + - renderInitializer(field.type), - ) - } - appendLine("}") - } - - private fun renderRequestBindingType(binding: DotnetAspRequestBindingArtifact): String = buildString { - appendLine("public sealed record ${binding.typeName}") - appendLine("{") - binding.fields.forEach { field -> - if (!field.optional && field.defaultValue == null) { - appendLine(" [BindRequired]") - } - appendLine( - " public ${renderBindingPropertyType(field)} ${dotnetAspPascalIdentifier(field.name)} { get; set; }" + - renderBindingInitializer(field), - ) - } - appendLine("}") - } - - private fun renderHeadersBindingType(binding: DotnetAspHeadersBindingArtifact): String = buildString { - appendLine("public sealed record ${binding.typeName}") - appendLine("{") - binding.headers.forEach { header -> - appendLine(" public string? ${dotnetAspPascalIdentifier(header.name)} { get; set; } = null;") - } - appendLine("}") - } - - private fun renderResultBaseType(endpoint: DotnetAspEndpointArtifact): String = - "public abstract record ${resultBaseTypeName(endpoint)};" - - private fun renderResultVariantType( - endpoint: DotnetAspEndpointArtifact, - response: DotnetAspResponseArtifact, - ): String = buildString { - append("public sealed record ${resultVariantTypeName(endpoint, response)}(") - val parameters = buildList { - if (response.statusCode != 204) { - add("${response.model.typeName} $RESULT_BODY_PROPERTY_NAME") - } - response.headers.forEach { header -> - add("string? ${dotnetAspHeaderPropertyName(header.name)} = null") - } - } - append(parameters.joinToString(", ")) - append(") : ${resultBaseTypeName(endpoint)};") - } - - private fun actionParameters(endpoint: DotnetAspEndpointArtifact): List = buildList { - endpoint.bindings.path?.let { - add("[FromRoute] ${it.typeName} path") - } - endpoint.bindings.query?.let { - add("[FromQuery] ${it.typeName} query") - } - endpoint.bindings.body?.let { - add("[FromBody] ${it.typeName} body") - } - add("CancellationToken cancellationToken") - } - - private fun handlerParameters(endpoint: DotnetAspEndpointArtifact): List = buildList { - endpoint.bindings.path?.let { add("${it.typeName} path") } - endpoint.bindings.query?.let { add("${it.typeName} query") } - endpoint.bindings.headers?.let { add("${it.typeName} headers") } - endpoint.bindings.body?.let { add("${it.typeName} body") } - add("CancellationToken cancellationToken") - } - - private fun handlerArguments(endpoint: DotnetAspEndpointArtifact): List = buildList { - endpoint.bindings.path?.let { add("path") } - endpoint.bindings.query?.let { add("query") } - endpoint.bindings.headers?.let { add("headers") } - endpoint.bindings.body?.let { add("body") } - add("cancellationToken") - } - - private fun responseAttributeType(response: DotnetAspResponseArtifact): String = - if (response.statusCode == 204) { - "void" - } else { - response.model.typeName - } - - private fun httpAttributeName(method: String): String = - "Http" + method.lowercase(Locale.ROOT).replaceFirstChar(Char::uppercase) - - private fun renderModelPropertyType(type: DotnetFieldType): String = when (type) { - is DotnetFieldType.Reference -> type.target - else -> type.csharpType - } - - private fun renderBindingPropertyType(field: DotnetAspRequestFieldArtifact): String { - val baseType = renderModelPropertyType(field.type) - return if (field.optional && field.defaultValue == null) "$baseType?" else baseType - } - - private fun renderInitializer(type: DotnetFieldType): String = when (type) { - DotnetFieldType.String -> " = string.Empty;" - DotnetFieldType.Char -> " = 'A';" - DotnetFieldType.Byte, - DotnetFieldType.SignedByte, - DotnetFieldType.Short, - DotnetFieldType.UnsignedShort, - DotnetFieldType.Int, - DotnetFieldType.UnsignedInt, - DotnetFieldType.Long, - DotnetFieldType.UnsignedLong, - DotnetFieldType.NativeInt, - DotnetFieldType.UnsignedNativeInt, - -> " = 0;" - DotnetFieldType.Float -> " = 0F;" - DotnetFieldType.Double -> " = 0D;" - DotnetFieldType.Decimal -> " = 0M;" - DotnetFieldType.Bool -> " = false;" - DotnetFieldType.Guid -> " = Guid.Empty;" - DotnetFieldType.DateOnly -> " = DateOnly.MinValue;" - DotnetFieldType.TimeOnly -> " = TimeOnly.MinValue;" - DotnetFieldType.DateTime -> " = DateTime.UnixEpoch;" - DotnetFieldType.DateTimeOffset -> " = DateTimeOffset.UnixEpoch;" - DotnetFieldType.TimeSpan -> " = TimeSpan.Zero;" - is DotnetFieldType.Reference -> " = null!;" - } - - private fun renderBindingInitializer(field: DotnetAspRequestFieldArtifact): String { - val defaultValue = field.defaultValue - return when { - defaultValue != null -> " = ${renderDefaultExpression(field.type, defaultValue)};" - field.optional -> " = null;" - else -> renderInitializer(field.type) - } - } - - private fun renderDefaultExpression(type: DotnetFieldType, defaultValue: Any): String = when (type) { - DotnetFieldType.String -> escapeCsharpStringLiteral(defaultValue.toString()) - DotnetFieldType.Char -> escapeCsharpCharLiteral(defaultValue.toString().first()) - DotnetFieldType.Byte, - DotnetFieldType.SignedByte, - DotnetFieldType.Short, - DotnetFieldType.UnsignedShort, - DotnetFieldType.Int, - -> defaultValue.toString() - DotnetFieldType.UnsignedInt -> "${defaultValue}U" - DotnetFieldType.Long -> "${defaultValue}L" - DotnetFieldType.UnsignedLong -> "${defaultValue}UL" - DotnetFieldType.NativeInt -> "(nint)$defaultValue" - DotnetFieldType.UnsignedNativeInt -> "(nuint)${defaultValue}UL" - DotnetFieldType.Float -> "${defaultValue.toString().ensureDecimal()}F" - DotnetFieldType.Double -> "${defaultValue.toString().ensureDecimal()}D" - DotnetFieldType.Decimal -> "${defaultValue.toString().ensureDecimal()}M" - DotnetFieldType.Bool -> defaultValue.toString().lowercase(Locale.ROOT) - DotnetFieldType.Guid -> "Guid.Parse(${escapeCsharpStringLiteral(defaultValue.toString())})" - DotnetFieldType.DateOnly -> - "DateOnly.Parse(${escapeCsharpStringLiteral(defaultValue.toString())}, global::System.Globalization.CultureInfo.InvariantCulture)" - DotnetFieldType.TimeOnly -> - "TimeOnly.Parse(${escapeCsharpStringLiteral(defaultValue.toString())}, global::System.Globalization.CultureInfo.InvariantCulture)" - DotnetFieldType.DateTime -> - "DateTime.Parse(${escapeCsharpStringLiteral(defaultValue.toString())}, global::System.Globalization.CultureInfo.InvariantCulture, global::System.Globalization.DateTimeStyles.RoundtripKind)" - DotnetFieldType.DateTimeOffset -> - "DateTimeOffset.Parse(${escapeCsharpStringLiteral(defaultValue.toString())}, global::System.Globalization.CultureInfo.InvariantCulture, global::System.Globalization.DateTimeStyles.RoundtripKind)" - DotnetFieldType.TimeSpan -> - "TimeSpan.Parse(${escapeCsharpStringLiteral(defaultValue.toString())}, global::System.Globalization.CultureInfo.InvariantCulture)" - is DotnetFieldType.Reference -> - "global::System.Text.Json.JsonSerializer.Deserialize<${type.target}>(" + - "${escapeCsharpStringLiteral(defaultValue.toString())})!" - } - - private fun escapeCsharpStringLiteral(value: String): String = buildString { - append('"') - value.forEach { char -> - when (char) { - '\\' -> append("\\\\") - '"' -> append("\\\"") - '\b' -> append("\\b") - '\u000C' -> append("\\f") - '\n' -> append("\\n") - '\r' -> append("\\r") - '\t' -> append("\\t") - else -> { - if (char.code < 0x20) { - append("\\u%04x".format(char.code)) - } else { - append(char) - } - } - } - } - append('"') - } - - private fun escapeCsharpCharLiteral(value: Char): String = when (value) { - '\\' -> "'\\\\'" - '\'' -> "'\\''" - '\n' -> "'\\n'" - '\r' -> "'\\r'" - '\t' -> "'\\t'" - '\b' -> "'\\b'" - '\u000C' -> "'\\f'" - else -> if (value.code < 0x20) "'\\u%04x'".format(value.code) else "'$value'" - } - - private fun String.ensureDecimal(): String = - if (contains('.') || contains('E', ignoreCase = true)) this else "$this.0" + fun renderResponseModelsFile(artifact: DotnetAspServiceArtifact): String = + DotnetAspContractFileRenderer.renderResponseModelsFile(artifact) } diff --git a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspServiceArtifactCompiler.kt b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspServiceArtifactCompiler.kt index 1d5b6e3f..13400922 100644 --- a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspServiceArtifactCompiler.kt +++ b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspServiceArtifactCompiler.kt @@ -3,63 +3,28 @@ package io.github.lmliam.microsmith.compile.services.dotnet.asp import com.github.eventhorizonlab.spi.ServiceProvider import io.github.lmliam.microsmith.artifact.core.Artifact import io.github.lmliam.microsmith.artifact.core.ArtifactContribution -import io.github.lmliam.microsmith.artifact.files.TextFileArtifactContribution -import io.github.lmliam.microsmith.artifact.files.TextFileArtifactId -import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspModelArtifact import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspServiceArtifact import io.github.lmliam.microsmith.artifact.services.dotnet.msbuild.MsBuildNames -import io.github.lmliam.microsmith.artifact.services.dotnet.msbuild.MsBuildProjectArtifactId import io.github.lmliam.microsmith.artifact.services.dotnet.msbuild.MsBuildProjectContribution import io.github.lmliam.microsmith.artifact.services.dotnet.msbuild.MsBuildProjectKind import io.github.lmliam.microsmith.compile.core.ArtifactCompiler import io.github.lmliam.microsmith.compile.services.core.ServicesArtifactCompiler -import java.nio.file.Path @ServiceProvider(ArtifactCompiler::class) class DotnetAspServiceArtifactCompiler : ServicesArtifactCompiler { - private companion object { - const val FIRST_NON_PRINTABLE_ASCII_CODE_POINT = 0x20 - } - override val artifactType = DotnetAspServiceArtifact::class override fun compile(artifact: DotnetAspServiceArtifact): List> { validateEndpointGenerationInputs(artifact) val serviceOrigin = setOf("services.${artifact.serviceName}") - val requestModelOrigins = serviceOrigin + - collectRequestBindings(artifact).flatMapTo(linkedSetOf()) { it.origins } + - collectHeaderBindings(artifact).flatMapTo(linkedSetOf()) { it.origins } + - artifact.endpoints.mapNotNull { endpoint -> - endpoint.bindings.body - ?.takeIf { it.locality == io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspModelLocality.INLINE } - ?.origins - }.flatten() - val responseModelOrigins = serviceOrigin + - artifact.endpoints.flatMapTo(linkedSetOf()) { endpoint -> - endpoint.responses.flatMap { response -> - response.origins + response.model.origins - } - } - val controllerOrigins = serviceOrigin + - artifact.endpoints.flatMapTo(linkedSetOf()) { endpoint -> - endpoint.origins + - endpoint.responses.flatMapTo(linkedSetOf()) { it.origins } + - listOfNotNull( - endpoint.bindings.path?.origins, - endpoint.bindings.query?.origins, - endpoint.bindings.headers?.origins, - endpoint.bindings.body?.origins, - ).flatten() - } + val requestModelOrigins = requestModelOriginsFor(artifact, serviceOrigin) + val responseModelOrigins = responseModelOriginsFor(artifact, serviceOrigin) + val controllerOrigins = controllerOriginsFor(artifact, serviceOrigin) return buildList { add( MsBuildProjectContribution( - artifactId = MsBuildProjectArtifactId( - solutionName = artifact.id.solutionName, - projectName = artifact.id.projectName, - kind = MsBuildProjectKind.Project, - ), + artifactId = artifact.msBuildProjectArtifactId(MsBuildProjectKind.Project), projectAttributes = mapOf(MsBuildNames.SDK_ATTRIBUTE to "Microsoft.NET.Sdk.Web"), properties = mapOf( MsBuildNames.IMPLICIT_USINGS_PROPERTY to "enable", @@ -70,68 +35,57 @@ class DotnetAspServiceArtifactCompiler : ServicesArtifactCompiler, - ): TextFileArtifactContribution = TextFileArtifactContribution( - artifactId = TextFileArtifactId( - relativePath = Path.of(relativePath), - outputRoot = artifact.outputRoot, - ), - contents = contents, - origins = origins, - ) - - private fun renderAppSettingsFile(artifact: DotnetAspServiceArtifact): String = """ - { - "Microsmith": { - "ServiceName": "${escapeJsonString(artifact.serviceName)}" - }, - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - }, - "AllowedHosts": "*" - } - """.trimIndent() - - private fun renderLaunchSettingsFile(artifact: DotnetAspServiceArtifact): String = """ - { - "${'$'}schema": "http://json.schemastore.org/launchsettings.json", - "profiles": { - "${artifact.id.projectName}": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": false, - "applicationUrl": "http://localhost:${artifact.httpPort};https://localhost:${artifact.httpsPort}", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - } - } - } - """.trimIndent() - - private fun escapeJsonString(value: String): String { - val escaped = StringBuilder(value.length) - value.forEach { char -> - when (char) { - '\\' -> escaped.append("\\\\") - '"' -> escaped.append("\\\"") - '\b' -> escaped.append("\\b") - '\u000C' -> escaped.append("\\f") - '\n' -> escaped.append("\\n") - '\r' -> escaped.append("\\r") - '\t' -> escaped.append("\\t") - else -> { - if (char.code < FIRST_NON_PRINTABLE_ASCII_CODE_POINT) { - escaped.append("\\u%04x".format(char.code)) - } else { - escaped.append(char) - } - } - } - } - return escaped.toString() - } - } diff --git a/modules/compile-services-dotnet-asp/src/test/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspServiceArtifactCompilerTests.kt b/modules/compile-services-dotnet-asp/src/test/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspServiceArtifactCompilerTests.kt index d2773ac8..314de64e 100644 --- a/modules/compile-services-dotnet-asp/src/test/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspServiceArtifactCompilerTests.kt +++ b/modules/compile-services-dotnet-asp/src/test/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspServiceArtifactCompilerTests.kt @@ -20,9 +20,9 @@ import io.github.lmliam.microsmith.dsl.services.dotnet.core.model.DotnetField import io.github.lmliam.microsmith.dsl.services.dotnet.core.model.DotnetFieldType import io.github.lmliam.microsmith.dsl.services.dotnet.core.model.DotnetModel import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.collections.shouldContainExactly import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder import io.kotest.matchers.collections.shouldContain -import io.kotest.matchers.maps.shouldContainExactly import io.kotest.matchers.shouldBe import io.kotest.matchers.string.shouldContain import io.kotest.matchers.string.shouldNotContain @@ -39,9 +39,10 @@ class DotnetAspServiceArtifactCompilerTests : val byPath = textFiles.associateBy { it.artifactId.relativePath.toString() } msbuild.artifactId.kind shouldBe MsBuildProjectKind.Project - msbuild.projectAttributes shouldContainExactly - mapOf(MsBuildNames.SDK_ATTRIBUTE to "Microsoft.NET.Sdk.Web") - msbuild.properties shouldContainExactly mapOf( + msbuild.projectAttributes shouldBe mapOf( + MsBuildNames.SDK_ATTRIBUTE to "Microsoft.NET.Sdk.Web", + ) + msbuild.properties shouldBe mapOf( MsBuildNames.IMPLICIT_USINGS_PROPERTY to "enable", MsBuildNames.NULLABLE_PROPERTY to "enable", MsBuildNames.TARGET_FRAMEWORK_PROPERTY to "net8.0", @@ -83,6 +84,8 @@ class DotnetAspServiceArtifactCompilerTests : .shouldContain("public bool IncludeDetails { get; set; } = false;") byPath.getValue("Generated/Contracts/RequestModels.cs").contents .shouldContain("[BindRequired]") + byPath.getValue("Generated/Contracts/RequestModels.cs").contents + .shouldContain("using System;") byPath.getValue("Generated/Contracts/ResponseModels.cs").contents .shouldContain("public sealed record CreateUserCreated(User Body, string? Location = null) : CreateUserResult;") } @@ -97,6 +100,25 @@ class DotnetAspServiceArtifactCompilerTests : requestModels.shouldContain("public nuint MaxValue { get; set; } = (nuint)4294967296UL;") } + "compile emits CLR usings before the contract namespace when request bindings use system types" { + val requestModels = DotnetAspServiceArtifactCompiler() + .compile(requestBindingTypesArtifact()) + .filterIsInstance() + .single { it.artifactId.relativePath.toString() == "Generated/Contracts/RequestModels.cs" } + .contents + + requestModels.lines().take(4) shouldContainExactly listOf( + "using System;", + "using Microsoft.AspNetCore.Mvc.ModelBinding;", + "", + "namespace UserService.Api.Generated.Contracts;", + ) + requestModels.shouldContain("public Guid ReportId { get; set; } = Guid.Empty;") + requestModels.shouldContain("public DateOnly Since { get; set; } = DateOnly.MinValue;") + requestModels.shouldContain("public DateTimeOffset RequestedAt { get; set; } = DateTimeOffset.UnixEpoch;") + requestModels.shouldContain("public TimeSpan? Window { get; set; } = null;") + } + "compile escapes service names before embedding them in appsettings json" { val appSettings = DotnetAspServiceArtifactCompiler() @@ -257,6 +279,73 @@ private fun unsignedNativeIntDefaultArtifact(): DotnetAspServiceArtifact = ), ) +private fun requestBindingTypesArtifact(): DotnetAspServiceArtifact = + DotnetAspServiceArtifact( + id = DotnetAspServiceArtifactId(solutionName = "Platform", projectName = "UserService.Api"), + serviceName = "UserService", + targetFrameworkMoniker = "net8.0", + outputRoot = Path.of("dotnet", "Platform", "UserService.Api"), + httpPort = 5000, + httpsPort = 5001, + contractModels = emptyList(), + endpoints = listOf( + DotnetAspEndpointArtifact( + method = "GET", + route = "/reports/{reportId}", + operationName = "GetReport", + bindings = DotnetAspEndpointBindingsArtifact( + path = DotnetAspRequestBindingArtifact( + typeName = "GetReportPath", + name = "GetReportPath", + fields = listOf( + DotnetAspRequestFieldArtifact( + name = "reportId", + type = DotnetFieldType.Guid, + optional = false, + defaultValue = null, + ), + ), + origins = setOf("services.UserService.rest.GetReport.path.GetReportPath"), + ), + query = DotnetAspRequestBindingArtifact( + typeName = "GetReportQuery", + name = "GetReportQuery", + fields = listOf( + DotnetAspRequestFieldArtifact( + name = "since", + type = DotnetFieldType.DateOnly, + optional = false, + defaultValue = null, + ), + DotnetAspRequestFieldArtifact( + name = "requestedAt", + type = DotnetFieldType.DateTimeOffset, + optional = false, + defaultValue = null, + ), + DotnetAspRequestFieldArtifact( + name = "window", + type = DotnetFieldType.TimeSpan, + optional = true, + defaultValue = null, + ), + ), + origins = setOf("services.UserService.rest.GetReport.query.GetReportQuery"), + ), + ), + responses = listOf( + DotnetAspResponseArtifact( + statusCode = 200, + model = inlineModel("Report", "services.UserService.rest.GetReport.responses.200.Report") {}, + headers = emptyList(), + origins = setOf("services.UserService.rest.GetReport.responses.200"), + ), + ), + origins = setOf("services.UserService.rest.GetReport"), + ), + ), + ) + private fun sharedModel( typeName: String, origin: String, From c70d9e3c7c13ef2b6b94e0406c6e257a23a8e2e5 Mon Sep 17 00:00:00 2001 From: LMLiam <46268350+TheRealEmissions@users.noreply.github.com> Date: Tue, 21 Apr 2026 18:12:46 +0100 Subject: [PATCH 11/26] fix(cli): align .NET bootstrap templates and fixture scripts --- README.md | 3 +- .../non-gradle/dotnet/schema.microsmith.kts | 38 ---- .../cli/init/BootstrapScriptTemplates.kt | 186 +----------------- .../DotnetBootstrapScriptTemplateRenderer.kt | 112 +++++++++++ .../SchemaBootstrapScriptTemplateRenderer.kt | 16 ++ .../microsmith/cli/init/InitBootstrapTests.kt | 1 + 6 files changed, 133 insertions(+), 223 deletions(-) create mode 100644 modules/cli/src/main/kotlin/io/github/lmliam/microsmith/cli/init/DotnetBootstrapScriptTemplateRenderer.kt create mode 100644 modules/cli/src/main/kotlin/io/github/lmliam/microsmith/cli/init/SchemaBootstrapScriptTemplateRenderer.kt diff --git a/README.md b/README.md index d56da73e..0a25e83f 100644 --- a/README.md +++ b/README.md @@ -560,7 +560,8 @@ Canonical generation policy: - `Program.cs` uses top-level hosting and delegates ASP.NET registration through generated hosting extensions - generated files under `Generated/` provide the contract records, abstract controller base, and response/result mapping surface derived from the normalized REST model - handwritten service behavior belongs outside `Generated/`, typically in a user-authored controller that derives from the generated base type -- generated files are overwritten in place on rerun; handwritten files outside `Generated/` are not generator-owned +- generator-owned scaffold files such as `Program.cs`, `appsettings.json`, `Properties/launchSettings.json`, and everything under `Generated/` are overwritten in place on rerun +- handwritten files outside those generator-owned paths are not overwritten by Microsmith - `.microsmith/origins.json` records the structural Microsmith origins associated with each generated file ### Script defaults diff --git a/examples/non-gradle/dotnet/schema.microsmith.kts b/examples/non-gradle/dotnet/schema.microsmith.kts index fc13dc39..d4a95985 100644 --- a/examples/non-gradle/dotnet/schema.microsmith.kts +++ b/examples/non-gradle/dotnet/schema.microsmith.kts @@ -92,42 +92,4 @@ microsmith { } } } - - services { - dotnet { - target(NET8) - solutions { - "Platform" {} - } - } - - "UserService" { - dotnet { - solution("Platform") - project("UserService.Api") - models { - "User" { - string("id") - string("email") - } - } - - asp { - rest { - "/users" { - get("/{id}", "GetUser") { - path("GetUserPath") { - string("id") - } - - responses { - ok("User") - } - } - } - } - } - } - } - } } diff --git a/modules/cli/src/main/kotlin/io/github/lmliam/microsmith/cli/init/BootstrapScriptTemplates.kt b/modules/cli/src/main/kotlin/io/github/lmliam/microsmith/cli/init/BootstrapScriptTemplates.kt index ea892854..01f346bc 100644 --- a/modules/cli/src/main/kotlin/io/github/lmliam/microsmith/cli/init/BootstrapScriptTemplates.kt +++ b/modules/cli/src/main/kotlin/io/github/lmliam/microsmith/cli/init/BootstrapScriptTemplates.kt @@ -14,7 +14,7 @@ internal object BootstrapScriptTemplates { private fun renderDefaultBuildScript(profile: OnboardingProfile): String = buildString { if (profile == DotnetOnboardingProfile) { - append(renderDotnetBuildScript(profile)) + append(DotnetBootstrapScriptTemplateRenderer.render(profile)) return@buildString } appendLine("// Bootstrapped Microsmith schema for this ${profile.bootstrapTargetDescription}.") @@ -25,188 +25,6 @@ internal object BootstrapScriptTemplates { appendLine("// Common repository-native output path:") appendLine("// microsmith run build.microsmith.kts --out $outputDirectory") } - appendLine(renderDefaultBuildScriptBody(profile)) - } - - private fun renderDefaultBuildScriptBody(profile: OnboardingProfile): String = when (profile) { - DotnetOnboardingProfile -> renderDotnetBuildScriptBody() - else -> renderSchemaOnlyBuildScriptBody(profile) - } - - private fun renderSchemaOnlyBuildScriptBody(profile: OnboardingProfile): String { - return """ - microsmith { - schemas { - protobuf { - message("${profile.sampleMessageName}") { - int32("id") { index(1) } - string("email") { index(2) } - } - } - } - } - """.trimIndent() - } - - private fun renderDotnetBuildScriptBody(): String { - return """ - microsmith { - schemas { - protobuf { - message("${DotnetOnboardingProfile.sampleMessageName}") { - int32("id") { index(1) } - string("email") { index(2) } - } - } - } - - services { - dotnet { - target(NET8) - solutions { - "Platform" {} - } - } - - "UserService" { - dotnet { - solution("Platform") - project("UserService.Api") - models { - "User" { - string("id") - string("email") - } - } - - asp { - rest { - "/users" { - get("/{id}", "GetUser") { - path("GetUserPath") { - string("id") - } - - responses { - ok("User") - } - } - } - } - } - } - } - } - } - """.trimIndent() - } - - private fun renderDotnetBuildScript(profile: OnboardingProfile): String = buildString { - appendLine("// Bootstrapped Microsmith ASP.NET service generation for this ${profile.bootstrapTargetDescription}.") - appendLine("// Canonical first run:") - appendLine("// microsmith run build.microsmith.kts") - profile.recommendedOutputDirectory?.let { outputDirectory -> - appendLine("// Common repository-native output path:") - appendLine("// microsmith run build.microsmith.kts --out $outputDirectory") - } - appendLine( - """ - microsmith { - services { - dotnet { - target(NET8) - solutions { - "Platform" { } - } - } - - "UserService" { - dotnet { - solution("Platform") - project("UserService.Api") - models { - "User" { - string("id") - string("email") - } - "Problem" { - string("detail") - } - "Report" { - string("id") - string("title") - } - } - asp { - rest { - "/users" { - get("/{id}", "GetUser") { - path("GetUserPath") { - string("id") - } - query("GetUserQuery") { - bool("includeDetails") { - optional() - default(false) - } - } - headers("GetUserHeaders") { - header("X-Correlation-Id") - } - responses { - ok("User") { - headers { - header("ETag") - } - } - notFound("Problem") - } - } - - post("CreateUser") { - body("CreateUserBody") { - string("email") - } - responses { - created("User") { - headers { - header("Location") - } - } - badRequest("Problem") - } - } - } - - "/reports" { - get("/{reportId}", "GetReport") { - path("GetReportPath") { - guid("reportId") - } - query("GetReportQuery") { - int("days") - dateOnly("since") - dateTimeOffset("requestedAt") - decimal("threshold") { - optional() - default(1.5) - } - timeSpan("window") { - optional() - } - } - responses { - ok("Report") - } - } - } - } - } - } - } - } - } - """.trimIndent(), - ) + appendLine(SchemaBootstrapScriptTemplateRenderer.render(profile)) } } diff --git a/modules/cli/src/main/kotlin/io/github/lmliam/microsmith/cli/init/DotnetBootstrapScriptTemplateRenderer.kt b/modules/cli/src/main/kotlin/io/github/lmliam/microsmith/cli/init/DotnetBootstrapScriptTemplateRenderer.kt new file mode 100644 index 00000000..44f7f7e7 --- /dev/null +++ b/modules/cli/src/main/kotlin/io/github/lmliam/microsmith/cli/init/DotnetBootstrapScriptTemplateRenderer.kt @@ -0,0 +1,112 @@ +package io.github.lmliam.microsmith.cli.init + +internal object DotnetBootstrapScriptTemplateRenderer { + fun render(profile: OnboardingProfile): String = buildString { + appendLine("// Bootstrapped Microsmith ASP.NET service generation for this ${profile.bootstrapTargetDescription}.") + appendLine("// Canonical first run:") + appendLine("// microsmith run build.microsmith.kts") + profile.recommendedOutputDirectory?.let { outputDirectory -> + appendLine("// Common repository-native output path:") + appendLine("// microsmith run build.microsmith.kts --out $outputDirectory") + } + appendLine( + """ + microsmith { + services { + dotnet { + target(NET8) + solutions { + "Platform" { } + } + } + + "UserService" { + dotnet { + solution("Platform") + project("UserService.Api") + models { + "User" { + string("id") + string("email") + } + "Problem" { + string("detail") + } + "Report" { + string("id") + string("title") + } + } + asp { + rest { + "/users" { + get("/{id}", "GetUser") { + path("GetUserPath") { + string("id") + } + query("GetUserQuery") { + bool("includeDetails") { + optional() + default(false) + } + } + headers("GetUserHeaders") { + header("X-Correlation-Id") + } + responses { + ok("User") { + headers { + header("ETag") + } + } + notFound("Problem") + } + } + + post("CreateUser") { + body("CreateUserBody") { + string("email") + } + responses { + created("User") { + headers { + header("Location") + } + } + badRequest("Problem") + } + } + } + + "/reports" { + get("/{reportId}", "GetReport") { + path("GetReportPath") { + guid("reportId") + } + query("GetReportQuery") { + int("days") + dateOnly("since") + dateTimeOffset("requestedAt") + decimal("threshold") { + optional() + default(1.5) + } + timeSpan("window") { + optional() + } + } + responses { + ok("Report") + } + } + } + } + } + } + } + } + } + """.trimIndent(), + ) + } +} diff --git a/modules/cli/src/main/kotlin/io/github/lmliam/microsmith/cli/init/SchemaBootstrapScriptTemplateRenderer.kt b/modules/cli/src/main/kotlin/io/github/lmliam/microsmith/cli/init/SchemaBootstrapScriptTemplateRenderer.kt new file mode 100644 index 00000000..9af4a8f6 --- /dev/null +++ b/modules/cli/src/main/kotlin/io/github/lmliam/microsmith/cli/init/SchemaBootstrapScriptTemplateRenderer.kt @@ -0,0 +1,16 @@ +package io.github.lmliam.microsmith.cli.init + +internal object SchemaBootstrapScriptTemplateRenderer { + fun render(profile: OnboardingProfile): String = """ + microsmith { + schemas { + protobuf { + message("${profile.sampleMessageName}") { + int32("id") { index(1) } + string("email") { index(2) } + } + } + } + } + """.trimIndent() +} diff --git a/modules/cli/src/test/kotlin/io/github/lmliam/microsmith/cli/init/InitBootstrapTests.kt b/modules/cli/src/test/kotlin/io/github/lmliam/microsmith/cli/init/InitBootstrapTests.kt index e9b73849..b020e0d6 100644 --- a/modules/cli/src/test/kotlin/io/github/lmliam/microsmith/cli/init/InitBootstrapTests.kt +++ b/modules/cli/src/test/kotlin/io/github/lmliam/microsmith/cli/init/InitBootstrapTests.kt @@ -143,6 +143,7 @@ class InitBootstrapTests : buildScript.shouldContain("header(\"ETag\")") buildScript.shouldContain("guid(\"reportId\")") buildScript.shouldContain("dateTimeOffset(\"requestedAt\")") + Regex("""\bservices\s*\{""").findAll(buildScript).count() shouldBe 1 settingsScript.shouldContain("Detected repository profile: .NET") } finally { runCatching { repoRoot.deleteRecursively() } From 65f8f40bf7606087c77b2e276e98201a0e831eb5 Mon Sep 17 00:00:00 2001 From: LMLiam <46268350+TheRealEmissions@users.noreply.github.com> Date: Tue, 21 Apr 2026 18:12:58 +0100 Subject: [PATCH 12/26] fix(dsl-services-dotnet-asp): reject request model references during authoring --- .../DotnetAspRequestFieldSetBuilder.kt | 9 +++--- .../dotnet/asp/core/DotnetAspDslTests.kt | 30 +++++++++++++++++++ 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/modules/dsl-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/dsl/services/dotnet/asp/core/rest/request/DotnetAspRequestFieldSetBuilder.kt b/modules/dsl-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/dsl/services/dotnet/asp/core/rest/request/DotnetAspRequestFieldSetBuilder.kt index 2043f0b0..09d0886d 100644 --- a/modules/dsl-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/dsl/services/dotnet/asp/core/rest/request/DotnetAspRequestFieldSetBuilder.kt +++ b/modules/dsl-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/dsl/services/dotnet/asp/core/rest/request/DotnetAspRequestFieldSetBuilder.kt @@ -104,10 +104,11 @@ internal open class DotnetAspRequestFieldSetBuilder(private val fieldContainerLa return register(createField(fieldName, type, options)) } - private fun registerReference(name: String, target: String): DotnetAspRequestField { - val fieldName = validateDotnetIdentifier(name, "ASP.NET request field name") - return register(createReference(fieldName, target)) - } + private fun registerReference(name: String, target: String): DotnetAspRequestField = + throw IllegalArgumentException( + "ASP.NET request bindings cannot declare reference field '$name' to '$target'. " + + "Declare scalar transport fields instead.", + ) private fun register(field: DotnetAspRequestField): DotnetAspRequestField { require(field.name !in fieldsByName) { diff --git a/modules/dsl-services-dotnet-asp/src/test/kotlin/io/github/lmliam/microsmith/dsl/services/dotnet/asp/core/DotnetAspDslTests.kt b/modules/dsl-services-dotnet-asp/src/test/kotlin/io/github/lmliam/microsmith/dsl/services/dotnet/asp/core/DotnetAspDslTests.kt index 3b802e34..0a9d8990 100644 --- a/modules/dsl-services-dotnet-asp/src/test/kotlin/io/github/lmliam/microsmith/dsl/services/dotnet/asp/core/DotnetAspDslTests.kt +++ b/modules/dsl-services-dotnet-asp/src/test/kotlin/io/github/lmliam/microsmith/dsl/services/dotnet/asp/core/DotnetAspDslTests.kt @@ -360,4 +360,34 @@ class DotnetAspDslTests : error.message.shouldContain("already declares responses") } + + "request bindings reject reference-typed transport fields during DSL authoring" { + val builder = MicrosmithBuilder() + + val error = + shouldThrow { + builder.services { + "UserService" { + dotnet { + asp { + rest { + "/users/{id}" { + get("GetUser") { + query("GetUserQuery") { + "user" ref "User" + } + responses { + ok("User") + } + } + } + } + } + } + } + } + } + + error.message.shouldContain("ASP.NET request bindings cannot declare reference field 'user' to 'User'") + } }) From b74bb99cfbdfb1567d256e42f71cfc3a7fbd3b99 Mon Sep 17 00:00:00 2001 From: LMLiam <46268350+TheRealEmissions@users.noreply.github.com> Date: Tue, 21 Apr 2026 19:28:57 +0100 Subject: [PATCH 13/26] fix(services-dotnet-asp): unwrap request defaults before codegen --- .../asp/DotnetAspServiceArtifactFactory.kt | 17 ++++++++++- .../DotnetAspGenerationIntegrationTests.kt | 29 +++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/modules/artifact-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspServiceArtifactFactory.kt b/modules/artifact-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspServiceArtifactFactory.kt index 646ebdd9..58144877 100644 --- a/modules/artifact-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspServiceArtifactFactory.kt +++ b/modules/artifact-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspServiceArtifactFactory.kt @@ -1,5 +1,6 @@ package io.github.lmliam.microsmith.artifact.services.dotnet.asp +import io.github.lmliam.microsmith.dsl.services.dotnet.asp.core.rest.request.DotnetAspDefaultValue import io.github.lmliam.microsmith.dsl.services.dotnet.core.model.DotnetModel import io.github.lmliam.microsmith.resolve.services.dotnet.asp.ResolvedDotnetAspEndpoint import io.github.lmliam.microsmith.resolve.services.dotnet.asp.ResolvedDotnetAspHeadersBinding @@ -73,7 +74,7 @@ internal class DotnetAspServiceArtifactFactory( name = field.name, type = field.type, optional = field.optional, - defaultValue = field.defaultValue, + defaultValue = field.defaultValue?.unwrapDotnetAspDefaultValue(), ) }, origins = setOf("services.${service.name}.rest.${endpoint.operationName}.$bindingLabel.$name"), @@ -140,3 +141,17 @@ private fun allocateDotnetAspTypeName(usedTypeNames: MutableSet, vararg suffix += 1 } } + +private fun DotnetAspDefaultValue.unwrapDotnetAspDefaultValue(): Any = when (this) { + is DotnetAspDefaultValue.StringValue -> value + is DotnetAspDefaultValue.CharValue -> value + is DotnetAspDefaultValue.NumericValue -> value + is DotnetAspDefaultValue.BooleanValue -> value + is DotnetAspDefaultValue.UuidValue -> value + is DotnetAspDefaultValue.LocalDateValue -> value + is DotnetAspDefaultValue.LocalTimeValue -> value + is DotnetAspDefaultValue.LocalDateTimeValue -> value + is DotnetAspDefaultValue.InstantValue -> value + is DotnetAspDefaultValue.OffsetDateTimeValue -> value + is DotnetAspDefaultValue.DurationValue -> value +} diff --git a/modules/gen/src/test/kotlin/io/github/lmliam/microsmith/gen/helpers/DotnetAspGenerationIntegrationTests.kt b/modules/gen/src/test/kotlin/io/github/lmliam/microsmith/gen/helpers/DotnetAspGenerationIntegrationTests.kt index 58d2ecd1..a0341656 100644 --- a/modules/gen/src/test/kotlin/io/github/lmliam/microsmith/gen/helpers/DotnetAspGenerationIntegrationTests.kt +++ b/modules/gen/src/test/kotlin/io/github/lmliam/microsmith/gen/helpers/DotnetAspGenerationIntegrationTests.kt @@ -36,6 +36,12 @@ class DotnetAspGenerationIntegrationTests : .shouldContain("""[HttpGet("/users/{id}", Name = "GetUser")]""") projectRoot.resolve("Generated/Controllers/UserServiceApiControllerBase.cs").readText() .shouldContain("protected abstract Task OnGetUserAsync") + projectRoot.resolve("Generated/Contracts/RequestModels.cs").readText() + .shouldContain("public bool IncludeDetails { get; set; } = false;") + projectRoot.resolve("Generated/Contracts/RequestModels.cs").readText() + .shouldContain("public decimal Threshold { get; set; } = 1.5M;") + projectRoot.resolve("Generated/Contracts/RequestModels.cs").readText() + .shouldContain("public TimeSpan? Window { get; set; } = null;") projectRoot.resolve(".microsmith/origins.json").readText() .shouldContain("services.UserService.rest.GetUser") projectRoot.resolve(".microsmith/origins.json").readText() @@ -120,6 +126,29 @@ private fun sampleDotnetAspModel() = } } } + + "/reports" { + get("/{reportId}", "GetReport") { + path("GetReportPath") { + guid("reportId") + } + query("GetReportQuery") { + int("days") + dateOnly("since") + dateTimeOffset("requestedAt") + decimal("threshold") { + optional() + default(1.5) + } + timeSpan("window") { + optional() + } + } + responses { + ok("User") + } + } + } } } } From 039691ef569b6e5b05cc1cd9b77035f0cf553db2 Mon Sep 17 00:00:00 2001 From: LMLiam <46268350+TheRealEmissions@users.noreply.github.com> Date: Tue, 21 Apr 2026 21:28:46 +0100 Subject: [PATCH 14/26] fix(runtime): preserve generated roots across execution surfaces --- .../cli/execution/RunCompletionReporter.kt | 2 +- .../GeneratedOriginsManifestBuilder.kt | 8 +- .../gen/helpers/MicrosmithGenerationRunner.kt | 7 +- .../MicrosmithModelGenerationExtensions.kt | 11 +- .../DotnetAspGenerationIntegrationTests.kt | 143 ++++---- .../gen/helpers/DotnetAspRuntimeE2eTests.kt | 346 +++++++++--------- .../gradle/MicrosmithGenerateTask.kt | 37 +- .../gradle/MicrosmithGradleWorkerMain.kt | 1 + .../MicrosmithGradleWorkerResultCodec.kt | 10 + .../gradle/MicrosmithGradleWorkerSuccess.kt | 3 + .../MicrosmithGradlePluginFunctionalTests.kt | 7 +- .../maven/MicrosmithMavenResultHandler.kt | 2 +- .../maven/MicrosmithGenerateMojoTests.kt | 7 +- .../context/MicrosmithScriptContext.kt | 11 +- .../host/ProcessIsolationPropertyNames.kt | 2 + .../host/ProcessIsolationResultCodec.kt | 9 + .../host/ScriptEvaluationSuccessFinalizer.kt | 1 + .../model/GeneratedOutputRootsLocator.kt | 24 +- .../scripting/model/ScriptRunSuccess.kt | 9 +- .../host/MicrosmithScriptHostTests.kt | 10 +- .../host/ProcessIsolationProtocolTests.kt | 6 + .../ScriptEvaluationSuccessFinalizerTests.kt | 39 +- .../sbt/MicrosmithSbtExecutionOutcome.kt | 1 + .../sbt/MicrosmithSbtResultInterpreter.kt | 1 + .../microsmith/sbt/MicrosmithSbtPlugin.scala | 4 +- .../sbt/MicrosmithSbtExecutionServiceTests.kt | 7 +- 26 files changed, 406 insertions(+), 302 deletions(-) diff --git a/modules/cli/src/main/kotlin/io/github/lmliam/microsmith/cli/execution/RunCompletionReporter.kt b/modules/cli/src/main/kotlin/io/github/lmliam/microsmith/cli/execution/RunCompletionReporter.kt index 1d90d2cf..8f583a03 100644 --- a/modules/cli/src/main/kotlin/io/github/lmliam/microsmith/cli/execution/RunCompletionReporter.kt +++ b/modules/cli/src/main/kotlin/io/github/lmliam/microsmith/cli/execution/RunCompletionReporter.kt @@ -46,7 +46,7 @@ internal class RunCompletionReporter(private val eventLogWriter: (Path, RunEvent emitter.warn(warning) } val cacheState = if (runResult.cacheHit) "hit" else "miss" - val generatedOutputRoot = GeneratedOutputRootsLocator.describe(command.outputDir) + val generatedOutputRoot = GeneratedOutputRootsLocator.describe(command.outputDir, runResult.generatedRoots) emitter.info( "Generated script '${command.script}' into '$generatedOutputRoot' " + "(compile-cache=$cacheState, elapsed=${runResult.elapsedMillis}ms).", diff --git a/modules/gen/src/main/kotlin/io/github/lmliam/microsmith/gen/helpers/GeneratedOriginsManifestBuilder.kt b/modules/gen/src/main/kotlin/io/github/lmliam/microsmith/gen/helpers/GeneratedOriginsManifestBuilder.kt index 74038d51..29b745be 100644 --- a/modules/gen/src/main/kotlin/io/github/lmliam/microsmith/gen/helpers/GeneratedOriginsManifestBuilder.kt +++ b/modules/gen/src/main/kotlin/io/github/lmliam/microsmith/gen/helpers/GeneratedOriginsManifestBuilder.kt @@ -5,6 +5,7 @@ import java.nio.charset.StandardCharsets import java.nio.file.Path internal object GeneratedOriginsManifestBuilder { + private const val FIRST_PRINTABLE_CHARACTER_CODE = 0x20 private val manifestRelativePath = Path.of(".microsmith", "origins.json") fun appendTo(outputs: List): List { @@ -14,7 +15,10 @@ internal object GeneratedOriginsManifestBuilder { val tracedFiles = files .filter { it.relativePath != manifestRelativePath } .map { file -> - TracedFile(relativePath = file.relativePath.toString().replace('\\', '/'), origins = file.origins.toList().sorted()) + TracedFile( + relativePath = file.relativePath.toString().replace('\\', '/'), + origins = file.origins.toList().sorted(), + ) }.sortedBy(TracedFile::relativePath) if (tracedFiles.isEmpty()) { return@mapNotNull null @@ -60,7 +64,7 @@ internal object GeneratedOriginsManifestBuilder { '\r' -> append("\\r") '\t' -> append("\\t") else -> { - if (char.code < 0x20) { + if (char.code < FIRST_PRINTABLE_CHARACTER_CODE) { append("\\u%04x".format(char.code)) } else { append(char) diff --git a/modules/gen/src/main/kotlin/io/github/lmliam/microsmith/gen/helpers/MicrosmithGenerationRunner.kt b/modules/gen/src/main/kotlin/io/github/lmliam/microsmith/gen/helpers/MicrosmithGenerationRunner.kt index 5728571d..b3639963 100644 --- a/modules/gen/src/main/kotlin/io/github/lmliam/microsmith/gen/helpers/MicrosmithGenerationRunner.kt +++ b/modules/gen/src/main/kotlin/io/github/lmliam/microsmith/gen/helpers/MicrosmithGenerationRunner.kt @@ -7,6 +7,7 @@ import io.github.lmliam.microsmith.dsl.core.MicrosmithModel import io.github.lmliam.microsmith.gen.files.FileSpace import io.github.lmliam.microsmith.gen.files.TemporaryDirectory import io.github.lmliam.microsmith.resolve.core.DomainResolutionService +import java.nio.file.Path internal class MicrosmithGenerationRunner( private val domainResolutionService: DomainResolutionService = DomainResolutionService(), @@ -18,7 +19,7 @@ internal class MicrosmithGenerationRunner( private val outputWriter: GeneratedOutputWriter = GeneratedOutputWriter(), private val progressReporter: GenerationProgressReporter = GenerationProgressReporter, ) { - suspend fun generate(model: MicrosmithModel, finalDir: FileSpace) { + suspend fun generate(model: MicrosmithModel, finalDir: FileSpace): List { val outputs = TemporaryDirectory.create().use { tempSpace -> val resolvedModels = domainResolutionService.resolve(model) @@ -34,5 +35,9 @@ internal class MicrosmithGenerationRunner( outputWriter.write(outputs, finalDir) progressReporter.reportModelGenerationComplete(finalDir) + return outputs + .map { generatedFile -> finalDir.root.resolve(generatedFile.outputRoot).normalize() } + .distinct() + .sorted() } } diff --git a/modules/gen/src/main/kotlin/io/github/lmliam/microsmith/gen/helpers/MicrosmithModelGenerationExtensions.kt b/modules/gen/src/main/kotlin/io/github/lmliam/microsmith/gen/helpers/MicrosmithModelGenerationExtensions.kt index 037b8184..01c9886b 100644 --- a/modules/gen/src/main/kotlin/io/github/lmliam/microsmith/gen/helpers/MicrosmithModelGenerationExtensions.kt +++ b/modules/gen/src/main/kotlin/io/github/lmliam/microsmith/gen/helpers/MicrosmithModelGenerationExtensions.kt @@ -8,11 +8,14 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.nio.file.Path -suspend fun MicrosmithModel.generate(finalDir: FileSpace) { - MicrosmithGenerationRunner().generate(this, finalDir) +suspend fun MicrosmithModel.generate(finalDir: FileSpace): List { + return MicrosmithGenerationRunner().generate(this, finalDir) } -suspend fun MicrosmithModel.generateTo(outputDir: Path, ioDispatcher: CoroutineDispatcher = Dispatchers.IO) { +suspend fun MicrosmithModel.generateTo( + outputDir: Path, + ioDispatcher: CoroutineDispatcher = Dispatchers.IO, +): List { val directorySpace = withContext(ioDispatcher) { DirectorySpace.from(outputDir) } - generate(directorySpace) + return generate(directorySpace) } diff --git a/modules/gen/src/test/kotlin/io/github/lmliam/microsmith/gen/helpers/DotnetAspGenerationIntegrationTests.kt b/modules/gen/src/test/kotlin/io/github/lmliam/microsmith/gen/helpers/DotnetAspGenerationIntegrationTests.kt index a0341656..371f3a91 100644 --- a/modules/gen/src/test/kotlin/io/github/lmliam/microsmith/gen/helpers/DotnetAspGenerationIntegrationTests.kt +++ b/modules/gen/src/test/kotlin/io/github/lmliam/microsmith/gen/helpers/DotnetAspGenerationIntegrationTests.kt @@ -54,7 +54,11 @@ class DotnetAspGenerationIntegrationTests : model.generateTo(outputDir) - val controllerFile = outputDir.resolve("dotnet/Platform/UserService.Api/Generated/Controllers/UserServiceApiControllerBase.cs") + val controllerFile = + outputDir.resolve( + "dotnet/Platform/UserService.Api/Generated/Controllers/" + + "UserServiceApiControllerBase.cs", + ) controllerFile.writeText("stale") model.generateTo(outputDir) @@ -63,91 +67,89 @@ class DotnetAspGenerationIntegrationTests : } }) -private fun sampleDotnetAspModel() = - microsmith { - services { - dotnet { - target(NET8) - solutions { - "Platform" {} - } +private fun sampleDotnetAspModel() = microsmith { + services { + dotnet { + target(NET8) + solutions { + "Platform" {} } + } - "UserService" { - dotnet { - solution("Platform") - project("UserService.Api") - models { - "User" { - string("id") - string("email") - } - "Problem" { - string("detail") - } + "UserService" { + dotnet { + solution("Platform") + project("UserService.Api") + models { + "User" { + string("id") + string("email") } - asp { - rest { - "/users" { - get("/{id}", "GetUser") { - path("GetUserPath") { - string("id") - } - query("GetUserQuery") { - bool("includeDetails") { - optional() - default(false) - } - } - headers("GetUserHeaders") { - header("X-Correlation-Id") + "Problem" { + string("detail") + } + } + asp { + rest { + "/users" { + get("/{id}", "GetUser") { + path("GetUserPath") { + string("id") + } + query("GetUserQuery") { + bool("includeDetails") { + optional() + default(false) } - responses { - ok("User") { - headers { - header("ETag") - } + } + headers("GetUserHeaders") { + header("X-Correlation-Id") + } + responses { + ok("User") { + headers { + header("ETag") } - notFound("Problem") } + notFound("Problem") } + } - post("CreateUser") { - body("CreateUserBody") { - string("email") - } - responses { - created("User") { - headers { - header("Location") - } + post("CreateUser") { + body("CreateUserBody") { + string("email") + } + responses { + created("User") { + headers { + header("Location") } - badRequest("Problem") } + badRequest("Problem") } } + } - "/reports" { - get("/{reportId}", "GetReport") { - path("GetReportPath") { - guid("reportId") - } - query("GetReportQuery") { - int("days") - dateOnly("since") - dateTimeOffset("requestedAt") - decimal("threshold") { - optional() - default(1.5) - } - timeSpan("window") { - optional() - } + "/reports" { + get("/{reportId}", "GetReport") { + path("GetReportPath") { + guid("reportId") + } + query("GetReportQuery") { + int("days") + dateOnly("since") + dateTimeOffset("requestedAt") + decimal("threshold") { + optional() + default(1.5) } - responses { - ok("User") + timeSpan("window") { + optional() } } + responses { + ok("User") + } } } } @@ -155,3 +157,4 @@ private fun sampleDotnetAspModel() = } } } +} diff --git a/modules/gen/src/test/kotlin/io/github/lmliam/microsmith/gen/helpers/DotnetAspRuntimeE2eTests.kt b/modules/gen/src/test/kotlin/io/github/lmliam/microsmith/gen/helpers/DotnetAspRuntimeE2eTests.kt index 261f3c69..729bb2d0 100644 --- a/modules/gen/src/test/kotlin/io/github/lmliam/microsmith/gen/helpers/DotnetAspRuntimeE2eTests.kt +++ b/modules/gen/src/test/kotlin/io/github/lmliam/microsmith/gen/helpers/DotnetAspRuntimeE2eTests.kt @@ -25,111 +25,112 @@ import kotlin.io.path.writeText @OptIn(ExperimentalPathApi::class) class DotnetAspRuntimeE2eTests : StringSpec({ - "generated ASP.NET controller bases work end to end when a user implements them".config(enabled = dotnetAvailable()) { - val outputDir = Files.createTempDirectory("microsmith-dotnet-asp-runtime-") - try { - runtimeE2eModel().generateTo(outputDir) - - val projectRoot = outputDir.resolve("dotnet/Platform/UserService.Api") - val projectFile = projectRoot.resolve("UserService.Api.csproj") - val logFile = outputDir.resolve("dotnet-runtime.log") - val port = availablePort() - val baseUri = URI("http://127.0.0.1:$port") - - writeUserController(projectRoot) - - runDotnetCommand( - projectRoot = projectRoot, - logFile = logFile, - "build", - projectFile.toString(), - "--nologo", - ) - - val process = startDotnetService(projectFile, port, logFile) + "generated ASP.NET controller bases work end to end when a user implements them" + .config(enabled = dotnetAvailable()) { + val outputDir = Files.createTempDirectory("microsmith-dotnet-asp-runtime-") try { - val client = HttpClient.newBuilder() - .connectTimeout(Duration.ofSeconds(2)) - .build() - - awaitServiceReady(client, baseUri, logFile, process) - - val getUser = client.send( - request(baseUri, "/users/user-123?includeDetails=true") - .header("X-Correlation-Id", "corr-123") - .GET() - .build(), - HttpResponse.BodyHandlers.ofString(), + runtimeE2eModel().generateTo(outputDir) + + val projectRoot = outputDir.resolve("dotnet/Platform/UserService.Api") + val projectFile = projectRoot.resolve("UserService.Api.csproj") + val logFile = outputDir.resolve("dotnet-runtime.log") + val port = availablePort() + val baseUri = URI("http://127.0.0.1:$port") + + writeUserController(projectRoot) + + runDotnetCommand( + projectRoot = projectRoot, + logFile = logFile, + "build", + projectFile.toString(), + "--nologo", ) - getUser.statusCode() shouldBe 200 - getUser.headers().firstValue("ETag").orElseThrow() shouldBe "etag-corr-123" - getUser.body().shouldContain("\"id\":\"user-123\"") - getUser.body().shouldContain("\"email\":\"details@example.com\"") - - val notFound = client.send( - request(baseUri, "/users/missing") - .GET() - .build(), - HttpResponse.BodyHandlers.ofString(), - ) - notFound.statusCode() shouldBe 404 - notFound.body().shouldContain("\"detail\":\"missing-user\"") - - val createUser = client.send( - request(baseUri, "/users") - .header("Content-Type", "application/json") - .POST(HttpRequest.BodyPublishers.ofString("""{"email":"runtime@example.com"}""")) - .build(), - HttpResponse.BodyHandlers.ofString(), - ) - createUser.statusCode() shouldBe 201 - createUser.headers().firstValue("Location").orElseThrow() shouldBe "/users/generated-user" - createUser.body().shouldContain("\"email\":\"runtime@example.com\"") - - val getReport = client.send( - request( - baseUri, - "/reports/550e8400-e29b-41d4-a716-446655440000" + - "?days=7" + - "&since=2026-04-20" + - "&requestedAt=2026-04-20T12:34:56%2B00:00", - ).GET().build(), - HttpResponse.BodyHandlers.ofString(), - ) - getReport.statusCode() shouldBe 200 - getReport.body().shouldContain("\"title\":\"7:2026-04-20:1.5:none\"") - - val invalidGuid = client.send( - request( - baseUri, - "/reports/not-a-guid" + - "?days=7" + - "&since=2026-04-20" + - "&requestedAt=2026-04-20T12:34:56%2B00:00", - ).GET().build(), - HttpResponse.BodyHandlers.ofString(), - ) - invalidGuid.statusCode() shouldBe 400 - - val invalidDecimal = client.send( - request( - baseUri, - "/reports/550e8400-e29b-41d4-a716-446655440000" + - "?days=7" + - "&since=2026-04-20" + - "&requestedAt=2026-04-20T12:34:56%2B00:00" + - "&threshold=bad", - ).GET().build(), - HttpResponse.BodyHandlers.ofString(), - ) - invalidDecimal.statusCode() shouldBe 400 + + val process = startDotnetService(projectFile, port, logFile) + try { + val client = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(2)) + .build() + + awaitServiceReady(client, baseUri, logFile, process) + + val getUser = client.send( + request(baseUri, "/users/user-123?includeDetails=true") + .header("X-Correlation-Id", "corr-123") + .GET() + .build(), + HttpResponse.BodyHandlers.ofString(), + ) + getUser.statusCode() shouldBe 200 + getUser.headers().firstValue("ETag").orElseThrow() shouldBe "etag-corr-123" + getUser.body().shouldContain("\"id\":\"user-123\"") + getUser.body().shouldContain("\"email\":\"details@example.com\"") + + val notFound = client.send( + request(baseUri, "/users/missing") + .GET() + .build(), + HttpResponse.BodyHandlers.ofString(), + ) + notFound.statusCode() shouldBe 404 + notFound.body().shouldContain("\"detail\":\"missing-user\"") + + val createUser = client.send( + request(baseUri, "/users") + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString("""{"email":"runtime@example.com"}""")) + .build(), + HttpResponse.BodyHandlers.ofString(), + ) + createUser.statusCode() shouldBe 201 + createUser.headers().firstValue("Location").orElseThrow() shouldBe "/users/generated-user" + createUser.body().shouldContain("\"email\":\"runtime@example.com\"") + + val getReport = client.send( + request( + baseUri, + "/reports/550e8400-e29b-41d4-a716-446655440000" + + "?days=7" + + "&since=2026-04-20" + + "&requestedAt=2026-04-20T12:34:56%2B00:00", + ).GET().build(), + HttpResponse.BodyHandlers.ofString(), + ) + getReport.statusCode() shouldBe 200 + getReport.body().shouldContain("\"title\":\"7:2026-04-20:1.5:none\"") + + val invalidGuid = client.send( + request( + baseUri, + "/reports/not-a-guid" + + "?days=7" + + "&since=2026-04-20" + + "&requestedAt=2026-04-20T12:34:56%2B00:00", + ).GET().build(), + HttpResponse.BodyHandlers.ofString(), + ) + invalidGuid.statusCode() shouldBe 400 + + val invalidDecimal = client.send( + request( + baseUri, + "/reports/550e8400-e29b-41d4-a716-446655440000" + + "?days=7" + + "&since=2026-04-20" + + "&requestedAt=2026-04-20T12:34:56%2B00:00" + + "&threshold=bad", + ).GET().build(), + HttpResponse.BodyHandlers.ofString(), + ) + invalidDecimal.statusCode() shouldBe 400 + } finally { + stopProcess(process, logFile) + } } finally { - stopProcess(process, logFile) + runCatching { outputDir.deleteRecursively() } } - } finally { - runCatching { outputDir.deleteRecursively() } } - } }) private fun writeUserController(projectRoot: Path) { @@ -215,95 +216,93 @@ private fun writeUserController(projectRoot: Path) { ) } -private fun runtimeE2eModel() = - microsmith { - services { - dotnet { - target(NET8) - solutions { - "Platform" {} - } +private fun runtimeE2eModel() = microsmith { + services { + dotnet { + target(NET8) + solutions { + "Platform" {} } + } - "UserService" { - dotnet { - solution("Platform") - project("UserService.Api") - models { - "User" { - string("id") - string("email") - } - "Problem" { - string("detail") - } - "Report" { - string("id") - string("title") - } + "UserService" { + dotnet { + solution("Platform") + project("UserService.Api") + models { + "User" { + string("id") + string("email") } - asp { - rest { - "/users" { - get("/{id}", "GetUser") { - path("GetUserPath") { - string("id") - } - query("GetUserQuery") { - bool("includeDetails") { - optional() - default(false) - } - } - headers("GetUserHeaders") { - header("X-Correlation-Id") + "Problem" { + string("detail") + } + "Report" { + string("id") + string("title") + } + } + asp { + rest { + "/users" { + get("/{id}", "GetUser") { + path("GetUserPath") { + string("id") + } + query("GetUserQuery") { + bool("includeDetails") { + optional() + default(false) } - responses { - ok("User") { - headers { - header("ETag") - } + } + headers("GetUserHeaders") { + header("X-Correlation-Id") + } + responses { + ok("User") { + headers { + header("ETag") } - notFound("Problem") } + notFound("Problem") } + } - post("CreateUser") { - body("CreateUserBody") { - string("email") - } - responses { - created("User") { - headers { - header("Location") - } + post("CreateUser") { + body("CreateUserBody") { + string("email") + } + responses { + created("User") { + headers { + header("Location") } - badRequest("Problem") } + badRequest("Problem") } } + } - "/reports" { - get("/{reportId}", "GetReport") { - path("GetReportPath") { - guid("reportId") - } - query("GetReportQuery") { - int("days") - dateOnly("since") - dateTimeOffset("requestedAt") - decimal("threshold") { - optional() - default(1.5) - } - timeSpan("window") { - optional() - } + "/reports" { + get("/{reportId}", "GetReport") { + path("GetReportPath") { + guid("reportId") + } + query("GetReportQuery") { + int("days") + dateOnly("since") + dateTimeOffset("requestedAt") + decimal("threshold") { + optional() + default(1.5) } - responses { - ok("Report") + timeSpan("window") { + optional() } } + responses { + ok("Report") + } } } } @@ -311,6 +310,7 @@ private fun runtimeE2eModel() = } } } +} private fun availablePort(): Int = ServerSocket(0).use { socket -> socket.localPort } diff --git a/modules/gradle-plugin/src/main/kotlin/io/github/lmliam/microsmith/gradle/MicrosmithGenerateTask.kt b/modules/gradle-plugin/src/main/kotlin/io/github/lmliam/microsmith/gradle/MicrosmithGenerateTask.kt index 971cc797..8de44814 100644 --- a/modules/gradle-plugin/src/main/kotlin/io/github/lmliam/microsmith/gradle/MicrosmithGenerateTask.kt +++ b/modules/gradle-plugin/src/main/kotlin/io/github/lmliam/microsmith/gradle/MicrosmithGenerateTask.kt @@ -16,11 +16,7 @@ import org.gradle.api.tasks.PathSensitive import org.gradle.api.tasks.PathSensitivity import org.gradle.api.tasks.TaskAction import org.gradle.work.DisableCachingByDefault -import java.nio.file.Files import java.nio.file.Path -import kotlin.io.path.exists -import kotlin.io.path.isRegularFile -import kotlin.io.path.name @DisableCachingByDefault( because = "Microsmith maintains its own compilation cache and task-level caching needs deeper normalization.", @@ -81,7 +77,8 @@ abstract class MicrosmithGenerateTask : DefaultTask() { private fun reportSuccess(result: MicrosmithGradleWorkerSuccess) { result.warnings.forEach(logger::warn) - val generatedOutputRoot = describeGeneratedOutputRoot(outputDirectory.get().asFile.toPath()) + val generatedOutputRoot = + describeGeneratedOutputRoots(outputDirectory.get().asFile.toPath(), result.generatedRoots) logger.lifecycle( "Generated Microsmith outputs into '$generatedOutputRoot'. " + "(compile-cache=${if (result.cacheHit) "hit" else "miss"}, elapsed=${result.elapsedMillis}ms)", @@ -93,38 +90,18 @@ abstract class MicrosmithGenerateTask : DefaultTask() { result.diagnostics.forEach(::appendLine) }.trimEnd() - private fun describeGeneratedOutputRoot(outputDirectory: Path): String { + private fun describeGeneratedOutputRoots(outputDirectory: Path, roots: List): String { val normalizedOutputDirectory = outputDirectory.toAbsolutePath().normalize() - val generatedRoots = locateGeneratedRoots(normalizedOutputDirectory) - return when (generatedRoots.size) { + val normalizedRoots = roots.map { root -> root.toAbsolutePath().normalize() }.distinct().sorted() + return when (normalizedRoots.size) { 0 -> normalizedOutputDirectory.toString() - 1 -> generatedRoots.single().toString() + 1 -> normalizedRoots.single().toString() else -> buildString { append(normalizedOutputDirectory) append(" (roots: ") - append(generatedRoots.joinToString()) + append(normalizedRoots.joinToString()) append(')') } } } - - private fun locateGeneratedRoots(outputDirectory: Path): List { - if (!outputDirectory.exists()) { - return emptyList() - } - - Files.walk(outputDirectory).use { paths -> - return paths - .filter { path -> - path.isRegularFile() && - path.name == "origins.json" && - path.parent?.name == ".microsmith" - }.map { manifestPath -> - manifestPath.parent?.parent ?: outputDirectory - }.map(Path::normalize) - .distinct() - .sorted() - .toList() - } - } } diff --git a/modules/gradle-plugin/src/main/kotlin/io/github/lmliam/microsmith/gradle/MicrosmithGradleWorkerMain.kt b/modules/gradle-plugin/src/main/kotlin/io/github/lmliam/microsmith/gradle/MicrosmithGradleWorkerMain.kt index 1ba772ef..e4d8aaad 100644 --- a/modules/gradle-plugin/src/main/kotlin/io/github/lmliam/microsmith/gradle/MicrosmithGradleWorkerMain.kt +++ b/modules/gradle-plugin/src/main/kotlin/io/github/lmliam/microsmith/gradle/MicrosmithGradleWorkerMain.kt @@ -50,6 +50,7 @@ internal object MicrosmithGradleWorkerMain { warnings = result.warnings, cacheHit = result.cacheHit, elapsedMillis = result.elapsedMillis, + generatedRoots = result.generatedRoots, ) is ScriptRunFailure -> diff --git a/modules/gradle-plugin/src/main/kotlin/io/github/lmliam/microsmith/gradle/MicrosmithGradleWorkerResultCodec.kt b/modules/gradle-plugin/src/main/kotlin/io/github/lmliam/microsmith/gradle/MicrosmithGradleWorkerResultCodec.kt index 27ea60df..316a6304 100644 --- a/modules/gradle-plugin/src/main/kotlin/io/github/lmliam/microsmith/gradle/MicrosmithGradleWorkerResultCodec.kt +++ b/modules/gradle-plugin/src/main/kotlin/io/github/lmliam/microsmith/gradle/MicrosmithGradleWorkerResultCodec.kt @@ -22,6 +22,10 @@ internal class MicrosmithGradleWorkerResultCodec { warnings = properties.readValues(RESULT_WARNING_COUNT, RESULT_WARNING_PREFIX), cacheHit = properties.requiredBoolean(RESULT_CACHE_HIT), elapsedMillis = properties.requiredLong(RESULT_ELAPSED_MILLIS), + generatedRoots = + properties + .readValues(RESULT_GENERATED_ROOT_COUNT, RESULT_GENERATED_ROOT_PREFIX) + .map(Path::of), ) RESULT_STATUS_FAILURE -> MicrosmithGradleWorkerFailure( @@ -38,9 +42,13 @@ internal class MicrosmithGradleWorkerResultCodec { this[RESULT_ELAPSED_MILLIS] = result.elapsedMillis.toString() this[RESULT_CACHE_HIT] = result.cacheHit.toString() this[RESULT_WARNING_COUNT] = result.warnings.size.toString() + this[RESULT_GENERATED_ROOT_COUNT] = result.generatedRoots.size.toString() result.warnings.forEachIndexed { index, warning -> this["$RESULT_WARNING_PREFIX$index"] = warning } + result.generatedRoots.forEachIndexed { index, generatedRoot -> + this["$RESULT_GENERATED_ROOT_PREFIX$index"] = generatedRoot.toString() + } } private fun failureProperties(result: MicrosmithGradleWorkerFailure): Properties = Properties().apply { @@ -60,6 +68,8 @@ private const val RESULT_ELAPSED_MILLIS = "result.elapsedMillis" private const val RESULT_CACHE_HIT = "result.cacheHit" private const val RESULT_WARNING_COUNT = "result.warnings.count" private const val RESULT_WARNING_PREFIX = "result.warning." +private const val RESULT_GENERATED_ROOT_COUNT = "result.generatedRoots.count" +private const val RESULT_GENERATED_ROOT_PREFIX = "result.generatedRoot." private const val RESULT_FAILURE_TYPE = "result.failureType" private const val RESULT_DIAGNOSTIC_COUNT = "result.diagnostics.count" private const val RESULT_DIAGNOSTIC_PREFIX = "result.diagnostic." diff --git a/modules/gradle-plugin/src/main/kotlin/io/github/lmliam/microsmith/gradle/MicrosmithGradleWorkerSuccess.kt b/modules/gradle-plugin/src/main/kotlin/io/github/lmliam/microsmith/gradle/MicrosmithGradleWorkerSuccess.kt index f9208d09..d9d1f34c 100644 --- a/modules/gradle-plugin/src/main/kotlin/io/github/lmliam/microsmith/gradle/MicrosmithGradleWorkerSuccess.kt +++ b/modules/gradle-plugin/src/main/kotlin/io/github/lmliam/microsmith/gradle/MicrosmithGradleWorkerSuccess.kt @@ -1,7 +1,10 @@ package io.github.lmliam.microsmith.gradle +import java.nio.file.Path + internal data class MicrosmithGradleWorkerSuccess( val warnings: List, val cacheHit: Boolean, val elapsedMillis: Long, + val generatedRoots: List = emptyList(), ) : MicrosmithGradleWorkerResult diff --git a/modules/gradle-plugin/src/test/kotlin/io/github/lmliam/microsmith/gradle/MicrosmithGradlePluginFunctionalTests.kt b/modules/gradle-plugin/src/test/kotlin/io/github/lmliam/microsmith/gradle/MicrosmithGradlePluginFunctionalTests.kt index 4fefd49e..dc7b178b 100644 --- a/modules/gradle-plugin/src/test/kotlin/io/github/lmliam/microsmith/gradle/MicrosmithGradlePluginFunctionalTests.kt +++ b/modules/gradle-plugin/src/test/kotlin/io/github/lmliam/microsmith/gradle/MicrosmithGradlePluginFunctionalTests.kt @@ -172,7 +172,12 @@ class MicrosmithGradlePluginFunctionalTests : StringSpec() { result.task(":microsmithGenerate")?.outcome shouldBe TaskOutcome.SUCCESS project.file("dotnet/Platform/UserService.Api/Program.cs").toFile().shouldExist() - project.file("dotnet/Platform/UserService.Api/Generated/Controllers/UserServiceApiControllerBase.cs").toFile().shouldExist() + project + .file( + "dotnet/Platform/UserService.Api/Generated/Controllers/" + + "UserServiceApiControllerBase.cs", + ).toFile() + .shouldExist() } "microsmithGenerate fails with script diagnostics" { diff --git a/modules/maven-plugin/src/main/kotlin/io/github/lmliam/microsmith/maven/MicrosmithMavenResultHandler.kt b/modules/maven-plugin/src/main/kotlin/io/github/lmliam/microsmith/maven/MicrosmithMavenResultHandler.kt index a8cf8d9d..f76076bb 100644 --- a/modules/maven-plugin/src/main/kotlin/io/github/lmliam/microsmith/maven/MicrosmithMavenResultHandler.kt +++ b/modules/maven-plugin/src/main/kotlin/io/github/lmliam/microsmith/maven/MicrosmithMavenResultHandler.kt @@ -21,7 +21,7 @@ internal class MicrosmithMavenResultHandler { private fun handleSuccess(log: Log, outputDirectory: Path, result: ScriptRunSuccess) { result.warnings.forEach(log::warn) - val generatedOutputRoot = GeneratedOutputRootsLocator.describe(outputDirectory) + val generatedOutputRoot = GeneratedOutputRootsLocator.describe(outputDirectory, result.generatedRoots) log.info( "Generated Microsmith outputs into '$generatedOutputRoot'. " + "(compile-cache=${if (result.cacheHit) "hit" else "miss"}, elapsed=${result.elapsedMillis}ms)", diff --git a/modules/maven-plugin/src/test/kotlin/io/github/lmliam/microsmith/maven/MicrosmithGenerateMojoTests.kt b/modules/maven-plugin/src/test/kotlin/io/github/lmliam/microsmith/maven/MicrosmithGenerateMojoTests.kt index 9278ac7e..24a66ee3 100644 --- a/modules/maven-plugin/src/test/kotlin/io/github/lmliam/microsmith/maven/MicrosmithGenerateMojoTests.kt +++ b/modules/maven-plugin/src/test/kotlin/io/github/lmliam/microsmith/maven/MicrosmithGenerateMojoTests.kt @@ -122,7 +122,12 @@ class MicrosmithGenerateMojoTests : StringSpec() { fixture.createMojo().execute() fixture.file("dotnet/Platform/UserService.Api/Program.cs").toFile().shouldExist() - fixture.file("dotnet/Platform/UserService.Api/Generated/Controllers/UserServiceApiControllerBase.cs").toFile().shouldExist() + fixture + .file( + "dotnet/Platform/UserService.Api/Generated/Controllers/" + + "UserServiceApiControllerBase.cs", + ).toFile() + .shouldExist() } "script compilation failures surface as MojoFailureException" { diff --git a/modules/runtime-scripting/src/main/kotlin/io/github/lmliam/microsmith/runtime/scripting/context/MicrosmithScriptContext.kt b/modules/runtime-scripting/src/main/kotlin/io/github/lmliam/microsmith/runtime/scripting/context/MicrosmithScriptContext.kt index 9c79480b..603dc041 100644 --- a/modules/runtime-scripting/src/main/kotlin/io/github/lmliam/microsmith/runtime/scripting/context/MicrosmithScriptContext.kt +++ b/modules/runtime-scripting/src/main/kotlin/io/github/lmliam/microsmith/runtime/scripting/context/MicrosmithScriptContext.kt @@ -7,12 +7,17 @@ class MicrosmithScriptContext( val outDir: Path, val vars: Map, val flags: Set, - private val emitHandler: (MicrosmithModel) -> Unit, + private val emitHandler: (MicrosmithModel) -> List, ) { private var emitted: Boolean = false + private var generatedRoots: List = emptyList() fun emit(model: MicrosmithModel) { - emitHandler(model) + generatedRoots = + (generatedRoots + emitHandler(model)) + .map { path -> path.toAbsolutePath().normalize() } + .distinct() + .sorted() emitted = true } @@ -25,4 +30,6 @@ class MicrosmithScriptContext( fun requireVar(name: String): String = vars[name] ?: error("Missing required --var '$name'.") internal fun emittedAny(): Boolean = emitted + + internal fun generatedRoots(): List = generatedRoots } diff --git a/modules/runtime-scripting/src/main/kotlin/io/github/lmliam/microsmith/runtime/scripting/host/ProcessIsolationPropertyNames.kt b/modules/runtime-scripting/src/main/kotlin/io/github/lmliam/microsmith/runtime/scripting/host/ProcessIsolationPropertyNames.kt index 4475872f..0848254b 100644 --- a/modules/runtime-scripting/src/main/kotlin/io/github/lmliam/microsmith/runtime/scripting/host/ProcessIsolationPropertyNames.kt +++ b/modules/runtime-scripting/src/main/kotlin/io/github/lmliam/microsmith/runtime/scripting/host/ProcessIsolationPropertyNames.kt @@ -19,6 +19,8 @@ internal object ProcessIsolationPropertyNames { const val RESULT_CACHE_HIT = "result.cacheHit" const val RESULT_WARNING_COUNT = "result.warnings.count" const val RESULT_WARNING_PREFIX = "result.warnings." + const val RESULT_GENERATED_ROOT_COUNT = "result.generatedRoots.count" + const val RESULT_GENERATED_ROOT_PREFIX = "result.generatedRoots." const val RESULT_DIAGNOSTIC_COUNT = "result.diagnostics.count" const val RESULT_DIAGNOSTIC_PREFIX = "result.diagnostics." const val RESULT_FAILURE_TYPE = "result.failure.type" diff --git a/modules/runtime-scripting/src/main/kotlin/io/github/lmliam/microsmith/runtime/scripting/host/ProcessIsolationResultCodec.kt b/modules/runtime-scripting/src/main/kotlin/io/github/lmliam/microsmith/runtime/scripting/host/ProcessIsolationResultCodec.kt index 78b53cf4..1067a3e8 100644 --- a/modules/runtime-scripting/src/main/kotlin/io/github/lmliam/microsmith/runtime/scripting/host/ProcessIsolationResultCodec.kt +++ b/modules/runtime-scripting/src/main/kotlin/io/github/lmliam/microsmith/runtime/scripting/host/ProcessIsolationResultCodec.kt @@ -45,9 +45,13 @@ internal class ProcessIsolationResultCodec { properties[ProcessIsolationPropertyNames.RESULT_ELAPSED_MILLIS] = result.elapsedMillis.toString() properties[ProcessIsolationPropertyNames.RESULT_CACHE_HIT] = result.cacheHit.toString() properties[ProcessIsolationPropertyNames.RESULT_WARNING_COUNT] = result.warnings.size.toString() + properties[ProcessIsolationPropertyNames.RESULT_GENERATED_ROOT_COUNT] = result.generatedRoots.size.toString() result.warnings.forEachIndexed { index, warning -> properties["${ProcessIsolationPropertyNames.RESULT_WARNING_PREFIX}$index"] = warning } + result.generatedRoots.forEachIndexed { index, generatedRoot -> + properties["${ProcessIsolationPropertyNames.RESULT_GENERATED_ROOT_PREFIX}$index"] = generatedRoot.toString() + } } private fun writeFailure(properties: Properties, result: ScriptRunFailure) { @@ -67,6 +71,11 @@ internal class ProcessIsolationResultCodec { ), cacheHit = properties.requiredBoolean(ProcessIsolationPropertyNames.RESULT_CACHE_HIT), elapsedMillis = properties.requiredLong(ProcessIsolationPropertyNames.RESULT_ELAPSED_MILLIS), + generatedRoots = + properties.readIndexedList( + countKey = ProcessIsolationPropertyNames.RESULT_GENERATED_ROOT_COUNT, + keyPrefix = ProcessIsolationPropertyNames.RESULT_GENERATED_ROOT_PREFIX, + ).map(Path::of), ) private fun readFailure(properties: Properties): ScriptRunFailure = ScriptRunFailure( diff --git a/modules/runtime-scripting/src/main/kotlin/io/github/lmliam/microsmith/runtime/scripting/host/ScriptEvaluationSuccessFinalizer.kt b/modules/runtime-scripting/src/main/kotlin/io/github/lmliam/microsmith/runtime/scripting/host/ScriptEvaluationSuccessFinalizer.kt index df98049c..7581d560 100644 --- a/modules/runtime-scripting/src/main/kotlin/io/github/lmliam/microsmith/runtime/scripting/host/ScriptEvaluationSuccessFinalizer.kt +++ b/modules/runtime-scripting/src/main/kotlin/io/github/lmliam/microsmith/runtime/scripting/host/ScriptEvaluationSuccessFinalizer.kt @@ -24,6 +24,7 @@ internal class ScriptEvaluationSuccessFinalizer( warnings = warnings, cacheHit = cacheHit, elapsedMillis = elapsedMillis, + generatedRoots = scriptContext.generatedRoots(), ) }, onFailure = { error -> diff --git a/modules/runtime-scripting/src/main/kotlin/io/github/lmliam/microsmith/runtime/scripting/model/GeneratedOutputRootsLocator.kt b/modules/runtime-scripting/src/main/kotlin/io/github/lmliam/microsmith/runtime/scripting/model/GeneratedOutputRootsLocator.kt index 3ea78add..c0f0cef3 100644 --- a/modules/runtime-scripting/src/main/kotlin/io/github/lmliam/microsmith/runtime/scripting/model/GeneratedOutputRootsLocator.kt +++ b/modules/runtime-scripting/src/main/kotlin/io/github/lmliam/microsmith/runtime/scripting/model/GeneratedOutputRootsLocator.kt @@ -7,8 +7,8 @@ import kotlin.io.path.isRegularFile import kotlin.io.path.name object GeneratedOutputRootsLocator { - private val originsDirectoryName = ".microsmith" - private val originsManifestName = "origins.json" + private const val ORIGINS_DIRECTORY_NAME = ".microsmith" + private const val ORIGINS_MANIFEST_NAME = "origins.json" @JvmStatic fun locate(outputDirectory: Path): List { @@ -33,20 +33,26 @@ object GeneratedOutputRootsLocator { fun describe(outputDirectory: Path): String { val normalizedOutputDirectory = outputDirectory.toAbsolutePath().normalize() val roots = locate(normalizedOutputDirectory) - return when (roots.size) { + return describe(normalizedOutputDirectory, roots) + } + + @JvmStatic + fun describe(outputDirectory: Path, roots: List): String { + val normalizedOutputDirectory = outputDirectory.toAbsolutePath().normalize() + val normalizedRoots = roots.map { root -> root.toAbsolutePath().normalize() }.distinct().sorted() + return when (normalizedRoots.size) { 0 -> normalizedOutputDirectory.toString() - 1 -> roots.single().toString() + 1 -> normalizedRoots.single().toString() else -> buildString { append(normalizedOutputDirectory) append(" (roots: ") - append(roots.joinToString()) + append(normalizedRoots.joinToString()) append(')') } } } - private fun isOriginsManifest(path: Path): Boolean = - path.isRegularFile() && - path.name == originsManifestName && - path.parent?.name == originsDirectoryName + private fun isOriginsManifest(path: Path): Boolean = path.isRegularFile() && + path.name == ORIGINS_MANIFEST_NAME && + path.parent?.name == ORIGINS_DIRECTORY_NAME } diff --git a/modules/runtime-scripting/src/main/kotlin/io/github/lmliam/microsmith/runtime/scripting/model/ScriptRunSuccess.kt b/modules/runtime-scripting/src/main/kotlin/io/github/lmliam/microsmith/runtime/scripting/model/ScriptRunSuccess.kt index 15c37741..62d0703b 100644 --- a/modules/runtime-scripting/src/main/kotlin/io/github/lmliam/microsmith/runtime/scripting/model/ScriptRunSuccess.kt +++ b/modules/runtime-scripting/src/main/kotlin/io/github/lmliam/microsmith/runtime/scripting/model/ScriptRunSuccess.kt @@ -1,4 +1,11 @@ package io.github.lmliam.microsmith.runtime.scripting.model -data class ScriptRunSuccess(val warnings: List, val cacheHit: Boolean, val elapsedMillis: Long) : +import java.nio.file.Path + +data class ScriptRunSuccess( + val warnings: List, + val cacheHit: Boolean, + val elapsedMillis: Long, + val generatedRoots: List = emptyList(), +) : ScriptRunResult diff --git a/modules/runtime-scripting/src/test/kotlin/io/github/lmliam/microsmith/runtime/scripting/host/MicrosmithScriptHostTests.kt b/modules/runtime-scripting/src/test/kotlin/io/github/lmliam/microsmith/runtime/scripting/host/MicrosmithScriptHostTests.kt index d92e3e45..12ef339d 100644 --- a/modules/runtime-scripting/src/test/kotlin/io/github/lmliam/microsmith/runtime/scripting/host/MicrosmithScriptHostTests.kt +++ b/modules/runtime-scripting/src/test/kotlin/io/github/lmliam/microsmith/runtime/scripting/host/MicrosmithScriptHostTests.kt @@ -139,7 +139,10 @@ class MicrosmithScriptHostTests : result.shouldBeTypeOf() output.resolve("dotnet/Platform/UserService.Api/Program.cs").exists() shouldBe true - output.resolve("dotnet/Platform/UserService.Api/Generated/Controllers/UserServiceApiControllerBase.cs").exists() shouldBe true + output.resolve( + "dotnet/Platform/UserService.Api/Generated/Controllers/" + + "UserServiceApiControllerBase.cs", + ).exists() shouldBe true output.resolve("dotnet/Platform/UserService.Api/.microsmith/origins.json").exists() shouldBe true } finally { runCatching { tempDir.deleteRecursively() } @@ -260,7 +263,10 @@ class MicrosmithScriptHostTests : result.shouldBeTypeOf() output.resolve("dotnet/Platform/UserService.Api/Program.cs").exists() shouldBe true - output.resolve("dotnet/Platform/UserService.Api/Generated/Controllers/UserServiceApiControllerBase.cs").exists() shouldBe true + output.resolve( + "dotnet/Platform/UserService.Api/Generated/Controllers/" + + "UserServiceApiControllerBase.cs", + ).exists() shouldBe true output.resolve("dotnet/Platform/UserService.Api/.microsmith/origins.json").exists() shouldBe true output.resolve("dotnet/Platform/UserService.Api/Generated/Controllers/UserServiceApiControllerBase.cs") .readText() diff --git a/modules/runtime-scripting/src/test/kotlin/io/github/lmliam/microsmith/runtime/scripting/host/ProcessIsolationProtocolTests.kt b/modules/runtime-scripting/src/test/kotlin/io/github/lmliam/microsmith/runtime/scripting/host/ProcessIsolationProtocolTests.kt index f664782c..b727a2d0 100644 --- a/modules/runtime-scripting/src/test/kotlin/io/github/lmliam/microsmith/runtime/scripting/host/ProcessIsolationProtocolTests.kt +++ b/modules/runtime-scripting/src/test/kotlin/io/github/lmliam/microsmith/runtime/scripting/host/ProcessIsolationProtocolTests.kt @@ -8,6 +8,7 @@ import io.kotest.core.spec.style.StringSpec import io.kotest.matchers.shouldBe import io.kotest.matchers.types.shouldBeInstanceOf import java.nio.file.Files +import java.nio.file.Path import java.util.Properties import kotlin.io.path.ExperimentalPathApi import kotlin.io.path.createTempDirectory @@ -51,6 +52,11 @@ class ProcessIsolationProtocolTests : warnings = listOf("warning"), cacheHit = true, elapsedMillis = Int.MAX_VALUE.toLong() + 1, + generatedRoots = + listOf( + Path.of("/tmp/generated"), + Path.of("/tmp/generated/dotnet/Platform/UserService.Api"), + ), ) ProcessIsolationProtocol.writeResult(resultFile, success) diff --git a/modules/runtime-scripting/src/test/kotlin/io/github/lmliam/microsmith/runtime/scripting/host/ScriptEvaluationSuccessFinalizerTests.kt b/modules/runtime-scripting/src/test/kotlin/io/github/lmliam/microsmith/runtime/scripting/host/ScriptEvaluationSuccessFinalizerTests.kt index e563baa5..aaae24b9 100644 --- a/modules/runtime-scripting/src/test/kotlin/io/github/lmliam/microsmith/runtime/scripting/host/ScriptEvaluationSuccessFinalizerTests.kt +++ b/modules/runtime-scripting/src/test/kotlin/io/github/lmliam/microsmith/runtime/scripting/host/ScriptEvaluationSuccessFinalizerTests.kt @@ -7,6 +7,7 @@ import io.github.lmliam.microsmith.runtime.scripting.model.ScriptRunFailure import io.github.lmliam.microsmith.runtime.scripting.model.ScriptRunSuccess import io.kotest.core.spec.style.StringSpec import io.kotest.matchers.collections.shouldContain +import io.kotest.matchers.collections.shouldContainExactly import io.kotest.matchers.shouldBe import io.kotest.matchers.types.shouldBeTypeOf import kotlin.io.path.createTempDirectory @@ -18,12 +19,16 @@ class ScriptEvaluationSuccessFinalizerTests : StringSpec({ "returns success and auto-emits a returned model when nothing was emitted explicitly" { val emittedModels = mutableListOf() + val generatedRoot = createTempDirectory("microsmith-script-generated-root") val scriptContext = MicrosmithScriptContext( outDir = createTempDirectory("microsmith-script-eval-success"), vars = emptyMap(), flags = emptySet(), - emitHandler = emittedModels::add, + emitHandler = { model -> + emittedModels.add(model) + listOf(generatedRoot) + }, ) val evaluationResult = EvaluationResult( @@ -51,6 +56,38 @@ class ScriptEvaluationSuccessFinalizerTests : result.warnings shouldBe listOf("warning") result.cacheHit shouldBe true result.elapsedMillis shouldBe 42 + result.generatedRoots.shouldContainExactly(listOf(generatedRoot)) + } + + "returns success with every generated root when a script emits multiple models" { + val generatedRootOne = createTempDirectory("microsmith-script-generated-root-one") + val generatedRootTwo = createTempDirectory("microsmith-script-generated-root-two") + val emittedRoots = ArrayDeque(listOf(generatedRootOne, generatedRootTwo)) + val scriptContext = + MicrosmithScriptContext( + outDir = createTempDirectory("microsmith-script-eval-multi-emit"), + vars = emptyMap(), + flags = emptySet(), + emitHandler = { listOf(emittedRoots.removeFirst()) }, + ) + scriptContext.emit(MicrosmithModel.empty()) + scriptContext.generate(MicrosmithModel.empty()) + val evaluationResult = + EvaluationResult( + returnValue = ResultValue.Unit(Any::class, Any()), + configuration = ScriptEvaluationConfiguration {}, + ) + + val result = + ScriptEvaluationSuccessFinalizer().complete( + evaluationResult = evaluationResult, + scriptContext = scriptContext, + warnings = emptyList(), + cacheHit = false, + elapsedMillis = 9, + ).shouldBeTypeOf() + + result.generatedRoots.shouldContainExactly(listOf(generatedRootOne, generatedRootTwo).sorted()) } "returns evaluation failure when no model is returned or emitted" { diff --git a/modules/sbt-plugin/src/main/kotlin/io/github/lmliam/microsmith/sbt/MicrosmithSbtExecutionOutcome.kt b/modules/sbt-plugin/src/main/kotlin/io/github/lmliam/microsmith/sbt/MicrosmithSbtExecutionOutcome.kt index ef758ca7..d3ebfde3 100644 --- a/modules/sbt-plugin/src/main/kotlin/io/github/lmliam/microsmith/sbt/MicrosmithSbtExecutionOutcome.kt +++ b/modules/sbt-plugin/src/main/kotlin/io/github/lmliam/microsmith/sbt/MicrosmithSbtExecutionOutcome.kt @@ -7,4 +7,5 @@ data class MicrosmithSbtExecutionOutcome( val warnings: List, val cacheHit: Boolean, val elapsedMillis: Long, + val generatedRoots: List = emptyList(), ) diff --git a/modules/sbt-plugin/src/main/kotlin/io/github/lmliam/microsmith/sbt/MicrosmithSbtResultInterpreter.kt b/modules/sbt-plugin/src/main/kotlin/io/github/lmliam/microsmith/sbt/MicrosmithSbtResultInterpreter.kt index 78bc7687..381a9c51 100644 --- a/modules/sbt-plugin/src/main/kotlin/io/github/lmliam/microsmith/sbt/MicrosmithSbtResultInterpreter.kt +++ b/modules/sbt-plugin/src/main/kotlin/io/github/lmliam/microsmith/sbt/MicrosmithSbtResultInterpreter.kt @@ -14,6 +14,7 @@ class MicrosmithSbtResultInterpreter { warnings = result.warnings, cacheHit = result.cacheHit, elapsedMillis = result.elapsedMillis, + generatedRoots = result.generatedRoots, ) is ScriptRunFailure -> throw buildFailure(result) diff --git a/modules/sbt-plugin/src/main/scala/io/github/lmliam/microsmith/sbt/MicrosmithSbtPlugin.scala b/modules/sbt-plugin/src/main/scala/io/github/lmliam/microsmith/sbt/MicrosmithSbtPlugin.scala index 17447187..514a2a46 100644 --- a/modules/sbt-plugin/src/main/scala/io/github/lmliam/microsmith/sbt/MicrosmithSbtPlugin.scala +++ b/modules/sbt-plugin/src/main/scala/io/github/lmliam/microsmith/sbt/MicrosmithSbtPlugin.scala @@ -83,8 +83,8 @@ object MicrosmithSbtPlugin extends AutoPlugin { try { val outcome = executionService.execute(configuration) outcome.getWarnings.asScala.foreach(message => logger.warn(message)) - val generatedRoots = GeneratedOutputRootsLocator.locate(outcome.getOutputDirectory).asScala.toSeq - val generatedOutputRoot = GeneratedOutputRootsLocator.describe(outcome.getOutputDirectory) + val generatedRoots = outcome.getGeneratedRoots.asScala.toSeq + val generatedOutputRoot = GeneratedOutputRootsLocator.describe(outcome.getOutputDirectory, outcome.getGeneratedRoots.asScala.toList.asJava) logger.info( s"Generated Microsmith outputs into '$generatedOutputRoot'. " + s"(compile-cache=${if (outcome.getCacheHit) "hit" else "miss"}, elapsed=${outcome.getElapsedMillis}ms)" diff --git a/modules/sbt-plugin/src/test/kotlin/io/github/lmliam/microsmith/sbt/MicrosmithSbtExecutionServiceTests.kt b/modules/sbt-plugin/src/test/kotlin/io/github/lmliam/microsmith/sbt/MicrosmithSbtExecutionServiceTests.kt index c381a944..5a48b283 100644 --- a/modules/sbt-plugin/src/test/kotlin/io/github/lmliam/microsmith/sbt/MicrosmithSbtExecutionServiceTests.kt +++ b/modules/sbt-plugin/src/test/kotlin/io/github/lmliam/microsmith/sbt/MicrosmithSbtExecutionServiceTests.kt @@ -117,7 +117,12 @@ class MicrosmithSbtExecutionServiceTests : StringSpec() { result.outputDirectory shouldBe fixture.file(".").normalize() fixture.file("dotnet/Platform/UserService.Api/Program.cs").toFile().shouldExist() - fixture.file("dotnet/Platform/UserService.Api/Generated/Controllers/UserServiceApiControllerBase.cs").toFile().shouldExist() + fixture + .file( + "dotnet/Platform/UserService.Api/Generated/Controllers/" + + "UserServiceApiControllerBase.cs", + ).toFile() + .shouldExist() } "script compilation failures surface as MicrosmithSbtScriptFailureException" { From 1be96c86e6a6b7df6d1eb8a148b6124b81280667 Mon Sep 17 00:00:00 2001 From: LMLiam <46268350+TheRealEmissions@users.noreply.github.com> Date: Tue, 21 Apr 2026 21:29:07 +0100 Subject: [PATCH 15/26] refactor(services-dotnet-asp): tighten generation guardrails --- .../asp/DotnetAspArtifactContributor.kt | 35 ++- .../asp/DotnetAspRequestBindingArtifact.kt | 2 - .../DotnetBootstrapScriptTemplateRenderer.kt | 227 ++++++++++-------- .../cli/provider/CliProviderValidator.kt | 6 +- .../microsmith/cli/init/InitBootstrapTests.kt | 3 +- .../cli/provider/CliProviderValidatorTests.kt | 12 +- .../dotnet/asp/DotnetAspCSharpRendering.kt | 91 +------ .../asp/DotnetAspContractFileRenderer.kt | 19 +- .../asp/DotnetAspContributionOrigins.kt | 60 +++-- .../asp/DotnetAspControllerFileRenderer.kt | 59 +++-- .../asp/DotnetAspDefaultValueRendering.kt | 134 +++++++++++ .../dotnet/asp/DotnetAspGeneratedNames.kt | 42 ++-- .../asp/DotnetAspEndpointValidationTests.kt | 1 - .../DotnetAspServiceArtifactCompilerTests.kt | 199 +++++++-------- .../MsBuildProjectArtifactCompilerTests.kt | 3 +- .../DotnetAspRequestFieldSetBuilder.kt | 3 +- 16 files changed, 509 insertions(+), 387 deletions(-) create mode 100644 modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspDefaultValueRendering.kt diff --git a/modules/artifact-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspArtifactContributor.kt b/modules/artifact-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspArtifactContributor.kt index f95b1a5e..5e4789b9 100644 --- a/modules/artifact-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspArtifactContributor.kt +++ b/modules/artifact-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspArtifactContributor.kt @@ -9,24 +9,23 @@ import io.github.lmliam.microsmith.resolve.services.dotnet.asp.DotnetAspWorkspac class DotnetAspArtifactContributor : ArtifactContributor { override val resolvedType = DotnetAspWorkspace::class - override fun contribute(model: DotnetAspWorkspace): List> = - model.servicesByName.values - .map { service -> - service to DotnetAspServiceArtifactId(service.solutionName, service.projectName) - }.sortedWith( - compareBy( - { (_, artifactId) -> artifactId.solutionName }, - { (_, artifactId) -> artifactId.projectName }, - ), - ).let { serviceArtifacts -> - val allocatedPorts = - serviceArtifacts.associate { (service, artifactId) -> - artifactId to allocateDotnetAspPorts(artifactId, service.ports) - } - validateUniqueDotnetAspPorts(allocatedPorts.toList()) - serviceArtifacts.map { (service, artifactId) -> - val ports = requireNotNull(allocatedPorts[artifactId]) - DotnetAspServiceArtifactFactory(service, artifactId, ports).createContribution() + override fun contribute(model: DotnetAspWorkspace): List> = model.servicesByName.values + .map { service -> + service to DotnetAspServiceArtifactId(service.solutionName, service.projectName) + }.sortedWith( + compareBy( + { (_, artifactId) -> artifactId.solutionName }, + { (_, artifactId) -> artifactId.projectName }, + ), + ).let { serviceArtifacts -> + val allocatedPorts = + serviceArtifacts.associate { (service, artifactId) -> + artifactId to allocateDotnetAspPorts(artifactId, service.ports) } + validateUniqueDotnetAspPorts(allocatedPorts.toList()) + serviceArtifacts.map { (service, artifactId) -> + val ports = requireNotNull(allocatedPorts[artifactId]) + DotnetAspServiceArtifactFactory(service, artifactId, ports).createContribution() } + } } diff --git a/modules/artifact-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspRequestBindingArtifact.kt b/modules/artifact-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspRequestBindingArtifact.kt index 7fdc7672..7a29e282 100644 --- a/modules/artifact-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspRequestBindingArtifact.kt +++ b/modules/artifact-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/artifact/services/dotnet/asp/DotnetAspRequestBindingArtifact.kt @@ -1,7 +1,5 @@ package io.github.lmliam.microsmith.artifact.services.dotnet.asp -import io.github.lmliam.microsmith.dsl.services.dotnet.core.model.DotnetFieldType - data class DotnetAspRequestBindingArtifact( val typeName: String, val name: String, diff --git a/modules/cli/src/main/kotlin/io/github/lmliam/microsmith/cli/init/DotnetBootstrapScriptTemplateRenderer.kt b/modules/cli/src/main/kotlin/io/github/lmliam/microsmith/cli/init/DotnetBootstrapScriptTemplateRenderer.kt index 44f7f7e7..4f0bbe98 100644 --- a/modules/cli/src/main/kotlin/io/github/lmliam/microsmith/cli/init/DotnetBootstrapScriptTemplateRenderer.kt +++ b/modules/cli/src/main/kotlin/io/github/lmliam/microsmith/cli/init/DotnetBootstrapScriptTemplateRenderer.kt @@ -2,111 +2,144 @@ package io.github.lmliam.microsmith.cli.init internal object DotnetBootstrapScriptTemplateRenderer { fun render(profile: OnboardingProfile): String = buildString { - appendLine("// Bootstrapped Microsmith ASP.NET service generation for this ${profile.bootstrapTargetDescription}.") + appendHeader(profile) + appendLine(scriptBody()) + } + + private fun StringBuilder.appendHeader(profile: OnboardingProfile) { + appendLine("// Bootstrapped Microsmith ASP.NET service generation") + appendLine("// for this ${profile.bootstrapTargetDescription}.") appendLine("// Canonical first run:") appendLine("// microsmith run build.microsmith.kts") profile.recommendedOutputDirectory?.let { outputDirectory -> appendLine("// Common repository-native output path:") appendLine("// microsmith run build.microsmith.kts --out $outputDirectory") } - appendLine( - """ - microsmith { - services { - dotnet { - target(NET8) - solutions { - "Platform" { } - } - } + } + + private fun scriptBody(): String = buildString { + appendLine("microsmith {") + appendServicesBlock() + appendLine("}") + } + + private fun StringBuilder.appendServicesBlock() { + appendLine(" services {") + appendDotnetDefaults() + appendUserService() + appendLine(" }") + } - "UserService" { - dotnet { - solution("Platform") - project("UserService.Api") - models { - "User" { - string("id") - string("email") - } - "Problem" { - string("detail") - } - "Report" { - string("id") - string("title") - } - } - asp { - rest { - "/users" { - get("/{id}", "GetUser") { - path("GetUserPath") { - string("id") - } - query("GetUserQuery") { - bool("includeDetails") { - optional() - default(false) - } - } - headers("GetUserHeaders") { - header("X-Correlation-Id") - } - responses { - ok("User") { - headers { - header("ETag") - } - } - notFound("Problem") - } - } + private fun StringBuilder.appendDotnetDefaults() { + appendLine(" dotnet {") + appendLine(" target(NET8)") + appendLine(" solutions {") + appendLine(" \"Platform\" { }") + appendLine(" }") + appendLine(" }") + appendLine() + } - post("CreateUser") { - body("CreateUserBody") { - string("email") - } - responses { - created("User") { - headers { - header("Location") - } - } - badRequest("Problem") - } - } - } + private fun StringBuilder.appendUserService() { + appendLine(" \"UserService\" {") + appendLine(" dotnet {") + appendLine(" solution(\"Platform\")") + appendLine(" project(\"UserService.Api\")") + appendModelsBlock() + appendAspBlock() + appendLine(" }") + appendLine(" }") + } + + private fun StringBuilder.appendModelsBlock() { + appendLine(" models {") + appendLine(" \"User\" {") + appendLine(" string(\"id\")") + appendLine(" string(\"email\")") + appendLine(" }") + appendLine(" \"Problem\" {") + appendLine(" string(\"detail\")") + appendLine(" }") + appendLine(" \"Report\" {") + appendLine(" string(\"id\")") + appendLine(" string(\"title\")") + appendLine(" }") + appendLine(" }") + } + + private fun StringBuilder.appendAspBlock() { + appendLine(" asp {") + appendLine(" rest {") + appendUsersEndpoints() + appendReportsEndpoints() + appendLine(" }") + appendLine(" }") + } + + private fun StringBuilder.appendUsersEndpoints() { + appendLine(" \"/users\" {") + appendLine(" get(\"/{id}\", \"GetUser\") {") + appendLine(" path(\"GetUserPath\") {") + appendLine(" string(\"id\")") + appendLine(" }") + appendLine(" query(\"GetUserQuery\") {") + appendLine(" bool(\"includeDetails\") {") + appendLine(" optional()") + appendLine(" default(false)") + appendLine(" }") + appendLine(" }") + appendLine(" headers(\"GetUserHeaders\") {") + appendLine(" header(\"X-Correlation-Id\")") + appendLine(" }") + appendLine(" responses {") + appendLine(" ok(\"User\") {") + appendLine(" headers {") + appendLine(" header(\"ETag\")") + appendLine(" }") + appendLine(" }") + appendLine(" notFound(\"Problem\")") + appendLine(" }") + appendLine(" }") + appendLine() + appendLine(" post(\"CreateUser\") {") + appendLine(" body(\"CreateUserBody\") {") + appendLine(" string(\"email\")") + appendLine(" }") + appendLine(" responses {") + appendLine(" created(\"User\") {") + appendLine(" headers {") + appendLine(" header(\"Location\")") + appendLine(" }") + appendLine(" }") + appendLine(" badRequest(\"Problem\")") + appendLine(" }") + appendLine(" }") + appendLine(" }") + appendLine() + } - "/reports" { - get("/{reportId}", "GetReport") { - path("GetReportPath") { - guid("reportId") - } - query("GetReportQuery") { - int("days") - dateOnly("since") - dateTimeOffset("requestedAt") - decimal("threshold") { - optional() - default(1.5) - } - timeSpan("window") { - optional() - } - } - responses { - ok("Report") - } - } - } - } - } - } - } - } - } - """.trimIndent(), - ) + private fun StringBuilder.appendReportsEndpoints() { + appendLine(" \"/reports\" {") + appendLine(" get(\"/{reportId}\", \"GetReport\") {") + appendLine(" path(\"GetReportPath\") {") + appendLine(" guid(\"reportId\")") + appendLine(" }") + appendLine(" query(\"GetReportQuery\") {") + appendLine(" int(\"days\")") + appendLine(" dateOnly(\"since\")") + appendLine(" dateTimeOffset(\"requestedAt\")") + appendLine(" decimal(\"threshold\") {") + appendLine(" optional()") + appendLine(" default(1.5)") + appendLine(" }") + appendLine(" timeSpan(\"window\") {") + appendLine(" optional()") + appendLine(" }") + appendLine(" }") + appendLine(" responses {") + appendLine(" ok(\"Report\")") + appendLine(" }") + appendLine(" }") + appendLine(" }") } } diff --git a/modules/cli/src/main/kotlin/io/github/lmliam/microsmith/cli/provider/CliProviderValidator.kt b/modules/cli/src/main/kotlin/io/github/lmliam/microsmith/cli/provider/CliProviderValidator.kt index 50c5f8aa..7aa71949 100644 --- a/modules/cli/src/main/kotlin/io/github/lmliam/microsmith/cli/provider/CliProviderValidator.kt +++ b/modules/cli/src/main/kotlin/io/github/lmliam/microsmith/cli/provider/CliProviderValidator.kt @@ -10,14 +10,14 @@ import io.github.lmliam.microsmith.artifact.services.dotnet.msbuild.MsBuildProje import io.github.lmliam.microsmith.artifact.services.dotnet.packages.DotnetPackageReferencesArtifact import io.github.lmliam.microsmith.artifact.services.dotnet.packages.DotnetPackageVersionsArtifact import io.github.lmliam.microsmith.compile.core.ArtifactCompiler -import io.github.lmliam.microsmith.dsl.services.core.ServicesExtension import io.github.lmliam.microsmith.dsl.schemas.core.SchemasExtension +import io.github.lmliam.microsmith.dsl.services.core.ServicesExtension import io.github.lmliam.microsmith.gen.core.ArtifactRenderer import io.github.lmliam.microsmith.resolve.core.DomainResolver -import io.github.lmliam.microsmith.resolve.services.dotnet.asp.DotnetAspWorkspace -import io.github.lmliam.microsmith.resolve.services.dotnet.packages.DotnetPackageWorkspace import io.github.lmliam.microsmith.resolve.schemas.protobuf.ResolvedProtobufSchemaModel import io.github.lmliam.microsmith.resolve.schemas.protobuf.rpc.ResolvedProtobufRpcSchemaModel +import io.github.lmliam.microsmith.resolve.services.dotnet.asp.DotnetAspWorkspace +import io.github.lmliam.microsmith.resolve.services.dotnet.packages.DotnetPackageWorkspace import java.util.ServiceLoader internal fun verifyBuiltinProviders( diff --git a/modules/cli/src/test/kotlin/io/github/lmliam/microsmith/cli/init/InitBootstrapTests.kt b/modules/cli/src/test/kotlin/io/github/lmliam/microsmith/cli/init/InitBootstrapTests.kt index b020e0d6..cdb548c8 100644 --- a/modules/cli/src/test/kotlin/io/github/lmliam/microsmith/cli/init/InitBootstrapTests.kt +++ b/modules/cli/src/test/kotlin/io/github/lmliam/microsmith/cli/init/InitBootstrapTests.kt @@ -134,7 +134,8 @@ class InitBootstrapTests : val buildScript = repoRoot.resolve("build.microsmith.kts").readText() val settingsScript = repoRoot.resolve("settings.microsmith.kts").readText() - buildScript.shouldContain("// Bootstrapped Microsmith ASP.NET service generation for this .NET repository.") + buildScript.shouldContain("// Bootstrapped Microsmith ASP.NET service generation") + buildScript.shouldContain("// for this .NET repository.") buildScript.shouldContain("// microsmith run build.microsmith.kts --out ./Generated") buildScript.shouldContain("services {") buildScript.shouldContain("target(NET8)") diff --git a/modules/cli/src/test/kotlin/io/github/lmliam/microsmith/cli/provider/CliProviderValidatorTests.kt b/modules/cli/src/test/kotlin/io/github/lmliam/microsmith/cli/provider/CliProviderValidatorTests.kt index f28741e9..794945d7 100644 --- a/modules/cli/src/test/kotlin/io/github/lmliam/microsmith/cli/provider/CliProviderValidatorTests.kt +++ b/modules/cli/src/test/kotlin/io/github/lmliam/microsmith/cli/provider/CliProviderValidatorTests.kt @@ -14,16 +14,16 @@ import io.github.lmliam.microsmith.artifact.services.dotnet.msbuild.MsBuildProje import io.github.lmliam.microsmith.artifact.services.dotnet.packages.DotnetPackageReferencesArtifact import io.github.lmliam.microsmith.artifact.services.dotnet.packages.DotnetPackageVersionsArtifact import io.github.lmliam.microsmith.compile.core.ArtifactCompiler -import io.github.lmliam.microsmith.dsl.services.core.ServicesExtension import io.github.lmliam.microsmith.dsl.schemas.core.SchemasExtension +import io.github.lmliam.microsmith.dsl.services.core.ServicesExtension import io.github.lmliam.microsmith.gen.core.ArtifactRenderer import io.github.lmliam.microsmith.gen.files.GeneratedFile import io.github.lmliam.microsmith.resolve.core.DomainResolver import io.github.lmliam.microsmith.resolve.core.ResolvedModel -import io.github.lmliam.microsmith.resolve.services.dotnet.asp.DotnetAspWorkspace -import io.github.lmliam.microsmith.resolve.services.dotnet.packages.DotnetPackageWorkspace import io.github.lmliam.microsmith.resolve.schemas.protobuf.ResolvedProtobufSchemaModel import io.github.lmliam.microsmith.resolve.schemas.protobuf.rpc.ResolvedProtobufRpcSchemaModel +import io.github.lmliam.microsmith.resolve.services.dotnet.asp.DotnetAspWorkspace +import io.github.lmliam.microsmith.resolve.services.dotnet.packages.DotnetPackageWorkspace import io.kotest.core.spec.style.StringSpec import io.kotest.matchers.collections.shouldContain import io.kotest.matchers.collections.shouldHaveSize @@ -260,12 +260,6 @@ private class TextFileAssemblerStub : ArtifactAssembler { ): TextFileArtifact = current } -private class ProtoFileCompilerStub : ArtifactCompiler { - override val artifactType = ProtoFileArtifact::class - - override fun compile(artifact: ProtoFileArtifact): List> = emptyList() -} - private class CompilerStub(override val artifactType: KClass) : ArtifactCompiler { override fun compile(artifact: T): List> = emptyList() } diff --git a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspCSharpRendering.kt b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspCSharpRendering.kt index 266a8b0e..8a00049c 100644 --- a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspCSharpRendering.kt +++ b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspCSharpRendering.kt @@ -2,88 +2,15 @@ package io.github.lmliam.microsmith.compile.services.dotnet.asp import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspRequestFieldArtifact import io.github.lmliam.microsmith.dsl.services.dotnet.core.model.DotnetFieldType -import java.util.Locale -internal fun renderDotnetAspModelPropertyType(type: DotnetFieldType): String = when (type) { - is DotnetFieldType.Reference -> type.target - else -> type.csharpType -} +internal fun renderDotnetAspModelPropertyType(type: DotnetFieldType): String = + if (type is DotnetFieldType.Reference) type.target else type.csharpType internal fun renderDotnetAspBindingPropertyType(field: DotnetAspRequestFieldArtifact): String { val baseType = renderDotnetAspModelPropertyType(field.type) return if (field.optional && field.defaultValue == null) "$baseType?" else baseType } -internal fun renderDotnetAspInitializer(type: DotnetFieldType): String = when (type) { - DotnetFieldType.String -> " = string.Empty;" - DotnetFieldType.Char -> " = 'A';" - DotnetFieldType.Byte, - DotnetFieldType.SignedByte, - DotnetFieldType.Short, - DotnetFieldType.UnsignedShort, - DotnetFieldType.Int, - DotnetFieldType.UnsignedInt, - DotnetFieldType.Long, - DotnetFieldType.UnsignedLong, - DotnetFieldType.NativeInt, - DotnetFieldType.UnsignedNativeInt, - -> " = 0;" - DotnetFieldType.Float -> " = 0F;" - DotnetFieldType.Double -> " = 0D;" - DotnetFieldType.Decimal -> " = 0M;" - DotnetFieldType.Bool -> " = false;" - DotnetFieldType.Guid -> " = Guid.Empty;" - DotnetFieldType.DateOnly -> " = DateOnly.MinValue;" - DotnetFieldType.TimeOnly -> " = TimeOnly.MinValue;" - DotnetFieldType.DateTime -> " = DateTime.UnixEpoch;" - DotnetFieldType.DateTimeOffset -> " = DateTimeOffset.UnixEpoch;" - DotnetFieldType.TimeSpan -> " = TimeSpan.Zero;" - is DotnetFieldType.Reference -> " = null!;" -} - -internal fun renderDotnetAspBindingInitializer(field: DotnetAspRequestFieldArtifact): String { - val defaultValue = field.defaultValue - return when { - defaultValue != null -> " = ${renderDotnetAspDefaultExpression(field.type, defaultValue)};" - field.optional -> " = null;" - else -> renderDotnetAspInitializer(field.type) - } -} - -internal fun renderDotnetAspDefaultExpression(type: DotnetFieldType, defaultValue: Any): String = when (type) { - DotnetFieldType.String -> escapeDotnetAspCsharpStringLiteral(defaultValue.toString()) - DotnetFieldType.Char -> escapeDotnetAspCsharpCharLiteral(defaultValue.toString().first()) - DotnetFieldType.Byte, - DotnetFieldType.SignedByte, - DotnetFieldType.Short, - DotnetFieldType.UnsignedShort, - DotnetFieldType.Int, - -> defaultValue.toString() - DotnetFieldType.UnsignedInt -> "${defaultValue}U" - DotnetFieldType.Long -> "${defaultValue}L" - DotnetFieldType.UnsignedLong -> "${defaultValue}UL" - DotnetFieldType.NativeInt -> "(nint)$defaultValue" - DotnetFieldType.UnsignedNativeInt -> "(nuint)${defaultValue}UL" - DotnetFieldType.Float -> "${defaultValue.toString().ensureDotnetAspDecimal()}F" - DotnetFieldType.Double -> "${defaultValue.toString().ensureDotnetAspDecimal()}D" - DotnetFieldType.Decimal -> "${defaultValue.toString().ensureDotnetAspDecimal()}M" - DotnetFieldType.Bool -> defaultValue.toString().lowercase(Locale.ROOT) - DotnetFieldType.Guid -> "Guid.Parse(${escapeDotnetAspCsharpStringLiteral(defaultValue.toString())})" - DotnetFieldType.DateOnly -> - "DateOnly.Parse(${escapeDotnetAspCsharpStringLiteral(defaultValue.toString())}, global::System.Globalization.CultureInfo.InvariantCulture)" - DotnetFieldType.TimeOnly -> - "TimeOnly.Parse(${escapeDotnetAspCsharpStringLiteral(defaultValue.toString())}, global::System.Globalization.CultureInfo.InvariantCulture)" - DotnetFieldType.DateTime -> - "DateTime.Parse(${escapeDotnetAspCsharpStringLiteral(defaultValue.toString())}, global::System.Globalization.CultureInfo.InvariantCulture, global::System.Globalization.DateTimeStyles.RoundtripKind)" - DotnetFieldType.DateTimeOffset -> - "DateTimeOffset.Parse(${escapeDotnetAspCsharpStringLiteral(defaultValue.toString())}, global::System.Globalization.CultureInfo.InvariantCulture, global::System.Globalization.DateTimeStyles.RoundtripKind)" - DotnetFieldType.TimeSpan -> - "TimeSpan.Parse(${escapeDotnetAspCsharpStringLiteral(defaultValue.toString())}, global::System.Globalization.CultureInfo.InvariantCulture)" - is DotnetFieldType.Reference -> - "global::System.Text.Json.JsonSerializer.Deserialize<${type.target}>(" + - "${escapeDotnetAspCsharpStringLiteral(defaultValue.toString())})!" -} - internal fun escapeDotnetAspCsharpStringLiteral(value: String): String = buildString { append('"') value.forEach { char -> @@ -96,7 +23,7 @@ internal fun escapeDotnetAspCsharpStringLiteral(value: String): String = buildSt '\r' -> append("\\r") '\t' -> append("\\t") else -> { - if (char.code < 0x20) { + if (char.code < FIRST_NON_PRINTABLE_ASCII_CODE_POINT) { append("\\u%04x".format(char.code)) } else { append(char) @@ -115,8 +42,14 @@ internal fun escapeDotnetAspCsharpCharLiteral(value: Char): String = when (value '\t' -> "'\\t'" '\b' -> "'\\b'" '\u000C' -> "'\\f'" - else -> if (value.code < 0x20) "'\\u%04x'".format(value.code) else "'$value'" + else -> renderDotnetAspPrintableCharLiteral(value) } -private fun String.ensureDotnetAspDecimal(): String = - if (contains('.') || contains('E', ignoreCase = true)) this else "$this.0" +private fun renderDotnetAspPrintableCharLiteral(value: Char): String = + if (value.code < FIRST_NON_PRINTABLE_ASCII_CODE_POINT) { + "'\\u%04x'".format(value.code) + } else { + "'$value'" + } + +private const val FIRST_NON_PRINTABLE_ASCII_CODE_POINT = 0x20 diff --git a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspContractFileRenderer.kt b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspContractFileRenderer.kt index 22c0119d..067338e8 100644 --- a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspContractFileRenderer.kt +++ b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspContractFileRenderer.kt @@ -1,7 +1,6 @@ package io.github.lmliam.microsmith.compile.services.dotnet.asp import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspEndpointArtifact -import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspHeaderFieldArtifact import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspHeadersBindingArtifact import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspModelArtifact import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspModelLocality @@ -83,10 +82,10 @@ internal object DotnetAspContractFileRenderer { appendLine("public sealed record $typeName") appendLine("{") fields.forEach { field -> - appendLine( - " public ${renderDotnetAspModelPropertyType(field.type)} ${dotnetAspPascalIdentifier(field.name)} { get; set; }" + - renderDotnetAspInitializer(field.type), - ) + val propertyType = renderDotnetAspModelPropertyType(field.type) + val propertyName = dotnetAspPascalIdentifier(field.name) + val initializer = renderDotnetAspInitializer(field.type) + appendLine(" public $propertyType $propertyName { get; set; }$initializer") } appendLine("}") } @@ -98,10 +97,10 @@ internal object DotnetAspContractFileRenderer { if (!field.optional && field.defaultValue == null) { appendLine(" [BindRequired]") } - appendLine( - " public ${renderDotnetAspBindingPropertyType(field)} ${dotnetAspPascalIdentifier(field.name)} { get; set; }" + - renderDotnetAspBindingInitializer(field), - ) + val propertyType = renderDotnetAspBindingPropertyType(field) + val propertyName = dotnetAspPascalIdentifier(field.name) + val initializer = renderDotnetAspBindingInitializer(field) + appendLine(" public $propertyType $propertyName { get; set; }$initializer") } appendLine("}") } @@ -128,7 +127,7 @@ internal object DotnetAspContractFileRenderer { } private fun responseParameters(response: DotnetAspResponseArtifact): List = buildList { - if (response.statusCode != 204) { + if (response.statusCode != HTTP_NO_CONTENT_STATUS_CODE) { add("${response.model.typeName} $RESULT_BODY_PROPERTY_NAME") } response.headers.forEach { header -> diff --git a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspContributionOrigins.kt b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspContributionOrigins.kt index 0b381621..db08689a 100644 --- a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspContributionOrigins.kt +++ b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspContributionOrigins.kt @@ -12,39 +12,33 @@ internal fun sharedContractModelOriginsFor( .filter { it.locality == DotnetAspModelLocality.SHARED } .flatMapTo(linkedSetOf()) { it.origins } + serviceOrigin -internal fun requestModelOriginsFor( - artifact: DotnetAspServiceArtifact, - serviceOrigin: Set, -): Set = serviceOrigin + - collectRequestBindings(artifact).flatMapTo(linkedSetOf()) { it.origins } + - collectHeaderBindings(artifact).flatMapTo(linkedSetOf()) { it.origins } + - artifact.endpoints.mapNotNull { endpoint -> - endpoint.bindings.body - ?.takeIf { it.locality == DotnetAspModelLocality.INLINE } - ?.origins - }.flatten() +internal fun requestModelOriginsFor(artifact: DotnetAspServiceArtifact, serviceOrigin: Set): Set = + serviceOrigin + + collectRequestBindings(artifact).flatMapTo(linkedSetOf()) { it.origins } + + collectHeaderBindings(artifact).flatMapTo(linkedSetOf()) { it.origins } + + artifact.endpoints.mapNotNull { endpoint -> + endpoint.bindings.body + ?.takeIf { it.locality == DotnetAspModelLocality.INLINE } + ?.origins + }.flatten() -internal fun responseModelOriginsFor( - artifact: DotnetAspServiceArtifact, - serviceOrigin: Set, -): Set = serviceOrigin + - artifact.endpoints.flatMapTo(linkedSetOf()) { endpoint -> - endpoint.responses.flatMap { response -> - response.origins + response.model.origins +internal fun responseModelOriginsFor(artifact: DotnetAspServiceArtifact, serviceOrigin: Set): Set = + serviceOrigin + + artifact.endpoints.flatMapTo(linkedSetOf()) { endpoint -> + endpoint.responses.flatMap { response -> + response.origins + response.model.origins + } } - } -internal fun controllerOriginsFor( - artifact: DotnetAspServiceArtifact, - serviceOrigin: Set, -): Set = serviceOrigin + - artifact.endpoints.flatMapTo(linkedSetOf()) { endpoint -> - endpoint.origins + - endpoint.responses.flatMapTo(linkedSetOf()) { it.origins } + - listOfNotNull( - endpoint.bindings.path?.origins, - endpoint.bindings.query?.origins, - endpoint.bindings.headers?.origins, - endpoint.bindings.body?.origins, - ).flatten() - } +internal fun controllerOriginsFor(artifact: DotnetAspServiceArtifact, serviceOrigin: Set): Set = + serviceOrigin + + artifact.endpoints.flatMapTo(linkedSetOf()) { endpoint -> + endpoint.origins + + endpoint.responses.flatMapTo(linkedSetOf()) { it.origins } + + listOfNotNull( + endpoint.bindings.path?.origins, + endpoint.bindings.query?.origins, + endpoint.bindings.headers?.origins, + endpoint.bindings.body?.origins, + ).flatten() + } diff --git a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspControllerFileRenderer.kt b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspControllerFileRenderer.kt index ffccd162..ed2abbf5 100644 --- a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspControllerFileRenderer.kt +++ b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspControllerFileRenderer.kt @@ -1,10 +1,13 @@ package io.github.lmliam.microsmith.compile.services.dotnet.asp import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspEndpointArtifact +import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspHeadersBindingArtifact +import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspResponseArtifact +import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspServiceArtifact import java.util.Locale internal object DotnetAspControllerFileRenderer { - fun renderControllerBaseFile(artifact: io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspServiceArtifact): String = buildString { + fun renderControllerBaseFile(artifact: DotnetAspServiceArtifact): String = buildString { appendLine("using System;") appendLine("using System.Threading;") appendLine("using System.Threading.Tasks;") @@ -14,7 +17,10 @@ internal object DotnetAspControllerFileRenderer { appendLine("namespace ${controllersNamespace(artifact)};") appendLine() appendLine("[ApiController]") - appendLine("public abstract class ${controllerBaseTypeName(artifact)} : $MICROSMITH_CONTROLLER_BASE_TYPE_NAME") + appendLine( + "public abstract class ${controllerBaseTypeName(artifact)} : " + + MICROSMITH_CONTROLLER_BASE_TYPE_NAME, + ) appendLine("{") artifact.endpoints.forEach { endpoint -> append(renderActionMethod(endpoint)) @@ -28,7 +34,11 @@ internal object DotnetAspControllerFileRenderer { } private fun renderActionMethod(endpoint: DotnetAspEndpointArtifact): String = buildString { - appendLine(" [${httpAttributeName(endpoint.method)}(${escapeDotnetAspCsharpStringLiteral(endpoint.route)}, Name = ${escapeDotnetAspCsharpStringLiteral(endpoint.operationName)})]") + val routeLiteral = escapeDotnetAspCsharpStringLiteral(endpoint.route) + val operationNameLiteral = escapeDotnetAspCsharpStringLiteral(endpoint.operationName) + appendLine( + " [${httpAttributeName(endpoint.method)}($routeLiteral, Name = $operationNameLiteral)]", + ) endpoint.responses.forEach { response -> appendLine( " [ProducesResponseType(typeof(${responseAttributeType(response)}), ${response.statusCode})]", @@ -42,7 +52,11 @@ internal object DotnetAspControllerFileRenderer { append(renderHeadersInitializer(binding)) appendLine() } - appendLine(" var result = await On${endpoint.operationName}Async(${handlerArguments(endpoint).joinToString(", ")});") + appendLine( + " var result = await On${endpoint.operationName}Async(" + + handlerArguments(endpoint).joinToString(", ") + + ");", + ) appendLine(" return Map${endpoint.operationName}Result(result);") appendLine(" }") } @@ -54,37 +68,41 @@ internal object DotnetAspControllerFileRenderer { } private fun renderResultMapper(endpoint: DotnetAspEndpointArtifact): String = buildString { - appendLine(" private ActionResult<${resultBaseTypeName(endpoint)}> Map${endpoint.operationName}Result(${resultBaseTypeName(endpoint)} result)") + appendLine( + " private ActionResult<${resultBaseTypeName(endpoint)}> " + + "Map${endpoint.operationName}Result(${resultBaseTypeName(endpoint)} result)", + ) appendLine(" {") appendLine(" return result switch") appendLine(" {") endpoint.responses.forEach { response -> append(" ${resultVariantTypeName(endpoint, response)} response => Respond(") - append(if (response.statusCode == 204) "null" else "response.$RESULT_BODY_PROPERTY_NAME") + append(renderResponseBodyArgument(response)) append(", ${response.statusCode}") response.headers.forEach { header -> - append(", (${escapeDotnetAspCsharpStringLiteral(header.name)}, response.${dotnetAspHeaderPropertyName(header.name)})") + append( + ", (${escapeDotnetAspCsharpStringLiteral(header.name)}, " + + "response.${dotnetAspHeaderPropertyName(header.name)})", + ) } appendLine("),") } - appendLine( - " _ => throw new InvalidOperationException(" + - "${escapeDotnetAspCsharpStringLiteral("Unsupported ${endpoint.operationName} result type.")} + " + - "result.GetType().FullName + \".\"),", - ) + val unsupportedMessage = + escapeDotnetAspCsharpStringLiteral("Unsupported ${endpoint.operationName} result type.") + appendLine(" _ => throw new InvalidOperationException(") + appendLine(" $unsupportedMessage + result.GetType().FullName + \".\"),") appendLine(" };") appendLine(" }") } - private fun renderHeadersInitializer(binding: io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspHeadersBindingArtifact): String = buildString { + private fun renderHeadersInitializer(binding: DotnetAspHeadersBindingArtifact): String = buildString { appendLine(" var headers = new ${binding.typeName}") appendLine(" {") binding.headers.forEachIndexed { index, header -> val suffix = if (index == binding.headers.lastIndex) "" else "," - appendLine( - " ${dotnetAspPascalIdentifier(header.name)} = " + - "ReadHeader(${escapeDotnetAspCsharpStringLiteral(header.headerName)})$suffix", - ) + val headerPropertyName = dotnetAspPascalIdentifier(header.name) + val headerLiteral = escapeDotnetAspCsharpStringLiteral(header.headerName) + appendLine(" $headerPropertyName = ReadHeader($headerLiteral)$suffix") } appendLine(" };") } @@ -112,8 +130,11 @@ internal object DotnetAspControllerFileRenderer { add("cancellationToken") } - private fun responseAttributeType(response: io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspResponseArtifact): String = - if (response.statusCode == 204) "void" else response.model.typeName + private fun responseAttributeType(response: DotnetAspResponseArtifact): String = + if (response.statusCode == HTTP_NO_CONTENT_STATUS_CODE) "void" else response.model.typeName + + private fun renderResponseBodyArgument(response: DotnetAspResponseArtifact): String = + if (response.statusCode == HTTP_NO_CONTENT_STATUS_CODE) "null" else "response.$RESULT_BODY_PROPERTY_NAME" private fun httpAttributeName(method: String): String = "Http" + method.lowercase(Locale.ROOT).replaceFirstChar(Char::uppercase) diff --git a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspDefaultValueRendering.kt b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspDefaultValueRendering.kt new file mode 100644 index 00000000..b4e2cbad --- /dev/null +++ b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspDefaultValueRendering.kt @@ -0,0 +1,134 @@ +package io.github.lmliam.microsmith.compile.services.dotnet.asp + +import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspRequestFieldArtifact +import io.github.lmliam.microsmith.dsl.services.dotnet.core.model.DotnetFieldType +import java.util.Locale + +internal fun renderDotnetAspInitializer(type: DotnetFieldType): String = FIXED_TYPE_INITIALIZERS[type] + ?: if (type in ZERO_INITIALIZER_TYPES) { + " = 0;" + } else { + " = null!;" + } + +internal fun renderDotnetAspBindingInitializer(field: DotnetAspRequestFieldArtifact): String { + val defaultValue = field.defaultValue + return when { + defaultValue != null -> " = ${renderDotnetAspDefaultExpression(field.type, defaultValue)};" + field.optional -> " = null;" + else -> renderDotnetAspInitializer(field.type) + } +} + +internal fun renderDotnetAspDefaultExpression(type: DotnetFieldType, defaultValue: Any): String = + renderDirectDefaultExpression(type, defaultValue) + ?: renderNumericDefaultExpression(type, defaultValue) + ?: renderTemporalDefaultExpression(type, defaultValue) + ?: renderReferenceDefaultExpression(type, defaultValue) + ?: error("Unsupported ASP.NET default expression for type '$type'.") + +private fun String.ensureDotnetAspDecimal(): String = + if (contains('.') || contains('E', ignoreCase = true)) this else "$this.0" + +private fun renderDirectDefaultExpression(type: DotnetFieldType, defaultValue: Any): String? = when { + type == DotnetFieldType.String -> escapeDotnetAspCsharpStringLiteral(defaultValue.toString()) + type == DotnetFieldType.Char -> escapeDotnetAspCsharpCharLiteral(defaultValue.toString().first()) + type == DotnetFieldType.Bool -> defaultValue.toString().lowercase(Locale.ROOT) + type == DotnetFieldType.Guid -> renderParseExpression("Guid", defaultValue) + type is DotnetFieldType.Reference -> null + else -> null +} + +private fun renderNumericDefaultExpression(type: DotnetFieldType, defaultValue: Any): String? = when { + type in UNSUFFIXED_NUMERIC_DEFAULT_TYPES -> defaultValue.toString() + type in SUFFIXED_NUMERIC_DEFAULT_TYPES -> + "${defaultValue}${SUFFIXED_NUMERIC_DEFAULT_TYPES.getValue(type)}" + + else -> when { + type == DotnetFieldType.NativeInt -> "(nint)$defaultValue" + type == DotnetFieldType.UnsignedNativeInt -> "(nuint)${defaultValue}UL" + type == DotnetFieldType.Float -> "${defaultValue.toString().ensureDotnetAspDecimal()}F" + type == DotnetFieldType.Double -> "${defaultValue.toString().ensureDotnetAspDecimal()}D" + type == DotnetFieldType.Decimal -> "${defaultValue.toString().ensureDotnetAspDecimal()}M" + else -> null + } +} + +private fun renderTemporalDefaultExpression(type: DotnetFieldType, defaultValue: Any): String? = when { + type == DotnetFieldType.DateOnly -> renderInvariantCultureParse("DateOnly", defaultValue) + type == DotnetFieldType.TimeOnly -> renderInvariantCultureParse("TimeOnly", defaultValue) + type == DotnetFieldType.DateTime -> renderRoundtripParse("DateTime", defaultValue) + type == DotnetFieldType.DateTimeOffset -> renderRoundtripParse("DateTimeOffset", defaultValue) + type == DotnetFieldType.TimeSpan -> renderInvariantCultureParse("TimeSpan", defaultValue) + else -> null +} + +private fun renderReferenceDefaultExpression(type: DotnetFieldType, defaultValue: Any): String? = when { + type is DotnetFieldType.Reference -> + buildString { + append("global::System.Text.Json.JsonSerializer.Deserialize<") + append(type.target) + append(">(") + append(escapeDotnetAspCsharpStringLiteral(defaultValue.toString())) + append(")!") + } + + else -> null +} + +private fun renderParseExpression(typeName: String, defaultValue: Any): String = + "$typeName.Parse(${escapeDotnetAspCsharpStringLiteral(defaultValue.toString())})" + +private fun renderInvariantCultureParse(typeName: String, defaultValue: Any): String = buildString { + append(typeName) + append(".Parse(") + append(escapeDotnetAspCsharpStringLiteral(defaultValue.toString())) + append(", global::System.Globalization.CultureInfo.InvariantCulture)") +} + +private fun renderRoundtripParse(typeName: String, defaultValue: Any): String = buildString { + append(typeName) + append(".Parse(") + append(escapeDotnetAspCsharpStringLiteral(defaultValue.toString())) + append(", global::System.Globalization.CultureInfo.InvariantCulture, ") + append("global::System.Globalization.DateTimeStyles.RoundtripKind)") +} + +private val ZERO_INITIALIZER_TYPES = setOf( + DotnetFieldType.Byte, + DotnetFieldType.SignedByte, + DotnetFieldType.Short, + DotnetFieldType.UnsignedShort, + DotnetFieldType.Int, + DotnetFieldType.UnsignedInt, + DotnetFieldType.Long, + DotnetFieldType.UnsignedLong, + DotnetFieldType.NativeInt, + DotnetFieldType.UnsignedNativeInt, +) +private val FIXED_TYPE_INITIALIZERS = mapOf( + DotnetFieldType.String to " = string.Empty;", + DotnetFieldType.Char to " = 'A';", + DotnetFieldType.Float to " = 0F;", + DotnetFieldType.Double to " = 0D;", + DotnetFieldType.Decimal to " = 0M;", + DotnetFieldType.Bool to " = false;", + DotnetFieldType.Guid to " = Guid.Empty;", + DotnetFieldType.DateOnly to " = DateOnly.MinValue;", + DotnetFieldType.TimeOnly to " = TimeOnly.MinValue;", + DotnetFieldType.DateTime to " = DateTime.UnixEpoch;", + DotnetFieldType.DateTimeOffset to " = DateTimeOffset.UnixEpoch;", + DotnetFieldType.TimeSpan to " = TimeSpan.Zero;", +) +private val UNSUFFIXED_NUMERIC_DEFAULT_TYPES = setOf( + DotnetFieldType.Byte, + DotnetFieldType.SignedByte, + DotnetFieldType.Short, + DotnetFieldType.UnsignedShort, + DotnetFieldType.Int, +) +private val SUFFIXED_NUMERIC_DEFAULT_TYPES = mapOf( + DotnetFieldType.UnsignedInt to "U", + DotnetFieldType.Long to "L", + DotnetFieldType.UnsignedLong to "UL", +) diff --git a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspGeneratedNames.kt b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspGeneratedNames.kt index 031215fd..fe450c5f 100644 --- a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspGeneratedNames.kt +++ b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspGeneratedNames.kt @@ -7,6 +7,16 @@ import java.util.Locale internal const val MICROSMITH_CONTROLLER_BASE_TYPE_NAME = "MicrosmithControllerBase" internal const val RESULT_BODY_PROPERTY_NAME = "Body" +internal const val HTTP_OK_STATUS_CODE = 200 +internal const val HTTP_CREATED_STATUS_CODE = 201 +internal const val HTTP_ACCEPTED_STATUS_CODE = 202 +internal const val HTTP_NO_CONTENT_STATUS_CODE = 204 +internal const val HTTP_BAD_REQUEST_STATUS_CODE = 400 +internal const val HTTP_UNAUTHORIZED_STATUS_CODE = 401 +internal const val HTTP_FORBIDDEN_STATUS_CODE = 403 +internal const val HTTP_NOT_FOUND_STATUS_CODE = 404 +internal const val HTTP_CONFLICT_STATUS_CODE = 409 +internal const val HTTP_INTERNAL_SERVER_ERROR_STATUS_CODE = 500 internal fun contractsNamespace(artifact: DotnetAspServiceArtifact): String = "${artifact.id.projectName}.Generated.Contracts" @@ -28,10 +38,8 @@ internal fun controllerBaseRelativePath(artifact: DotnetAspServiceArtifact): Str internal fun resultBaseTypeName(endpoint: DotnetAspEndpointArtifact): String = "${endpoint.operationName}Result" -internal fun resultVariantTypeName( - endpoint: DotnetAspEndpointArtifact, - response: DotnetAspResponseArtifact, -): String = endpoint.operationName + dotnetAspStatusName(response.statusCode) +internal fun resultVariantTypeName(endpoint: DotnetAspEndpointArtifact, response: DotnetAspResponseArtifact): String = + endpoint.operationName + dotnetAspStatusName(response.statusCode) internal fun dotnetAspPascalIdentifier(identifier: String): String = when { identifier.startsWith("@") && identifier.length > 1 -> @@ -74,18 +82,18 @@ internal fun dotnetAspHeaderPropertyName(headerName: String): String = headerNam error("Unable to derive an ASP.NET response header property name from '$headerName'.") } -internal fun dotnetAspStatusName(statusCode: Int): String = when (statusCode) { - 200 -> "Ok" - 201 -> "Created" - 202 -> "Accepted" - 204 -> "NoContent" - 400 -> "BadRequest" - 401 -> "Unauthorized" - 403 -> "Forbidden" - 404 -> "NotFound" - 409 -> "Conflict" - 500 -> "InternalServerError" - else -> "Status$statusCode" -} +internal fun dotnetAspStatusName(statusCode: Int): String = COMMON_STATUS_NAMES[statusCode] ?: "Status$statusCode" private val HEADER_PROPERTY_DELIMITER_PATTERN = Regex("[^A-Za-z0-9]+") +private val COMMON_STATUS_NAMES = mapOf( + HTTP_OK_STATUS_CODE to "Ok", + HTTP_CREATED_STATUS_CODE to "Created", + HTTP_ACCEPTED_STATUS_CODE to "Accepted", + HTTP_NO_CONTENT_STATUS_CODE to "NoContent", + HTTP_BAD_REQUEST_STATUS_CODE to "BadRequest", + HTTP_UNAUTHORIZED_STATUS_CODE to "Unauthorized", + HTTP_FORBIDDEN_STATUS_CODE to "Forbidden", + HTTP_NOT_FOUND_STATUS_CODE to "NotFound", + HTTP_CONFLICT_STATUS_CODE to "Conflict", + HTTP_INTERNAL_SERVER_ERROR_STATUS_CODE to "InternalServerError", +) diff --git a/modules/compile-services-dotnet-asp/src/test/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspEndpointValidationTests.kt b/modules/compile-services-dotnet-asp/src/test/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspEndpointValidationTests.kt index e7ca77c1..e2df1f2c 100644 --- a/modules/compile-services-dotnet-asp/src/test/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspEndpointValidationTests.kt +++ b/modules/compile-services-dotnet-asp/src/test/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspEndpointValidationTests.kt @@ -2,7 +2,6 @@ package io.github.lmliam.microsmith.compile.services.dotnet.asp import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspEndpointArtifact import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspEndpointBindingsArtifact -import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspHeaderFieldArtifact import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspModelArtifact import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspModelLocality import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspRequestBindingArtifact diff --git a/modules/compile-services-dotnet-asp/src/test/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspServiceArtifactCompilerTests.kt b/modules/compile-services-dotnet-asp/src/test/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspServiceArtifactCompilerTests.kt index 314de64e..83a727b9 100644 --- a/modules/compile-services-dotnet-asp/src/test/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspServiceArtifactCompilerTests.kt +++ b/modules/compile-services-dotnet-asp/src/test/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspServiceArtifactCompilerTests.kt @@ -20,9 +20,9 @@ import io.github.lmliam.microsmith.dsl.services.dotnet.core.model.DotnetField import io.github.lmliam.microsmith.dsl.services.dotnet.core.model.DotnetFieldType import io.github.lmliam.microsmith.dsl.services.dotnet.core.model.DotnetModel import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.collections.shouldContain import io.kotest.matchers.collections.shouldContainExactly import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder -import io.kotest.matchers.collections.shouldContain import io.kotest.matchers.shouldBe import io.kotest.matchers.string.shouldContain import io.kotest.matchers.string.shouldNotContain @@ -74,7 +74,10 @@ class DotnetAspServiceArtifactCompilerTests : controller.contents.shouldContain("""[ProducesResponseType(typeof(User), 200)]""") controller.contents.shouldContain("""[ProducesResponseType(typeof(Problem), 404)]""") controller.contents.shouldContain("protected abstract Task OnGetUserAsync") - controller.contents.shouldContain("CreateUserCreated response => Respond(response.Body, 201, (\"Location\", response.Location))") + controller.contents.shouldContain( + "CreateUserCreated response => Respond(" + + "response.Body, 201, (\"Location\", response.Location))", + ) controller.contents.shouldNotContain("X-Microsmith-Response-Status") controller.contents.shouldNotContain("sample-location") controller.origins shouldContain "services.UserService.rest.GetUser" @@ -87,7 +90,10 @@ class DotnetAspServiceArtifactCompilerTests : byPath.getValue("Generated/Contracts/RequestModels.cs").contents .shouldContain("using System;") byPath.getValue("Generated/Contracts/ResponseModels.cs").contents - .shouldContain("public sealed record CreateUserCreated(User Body, string? Location = null) : CreateUserResult;") + .shouldContain( + "public sealed record CreateUserCreated(" + + "User Body, string? Location = null) : CreateUserResult;", + ) } "compile emits nuint defaults with a 64-bit-safe literal" { @@ -237,114 +243,115 @@ private fun sampleArtifact(serviceName: String = "UserService"): DotnetAspServic ) } -private fun unsignedNativeIntDefaultArtifact(): DotnetAspServiceArtifact = - DotnetAspServiceArtifact( - id = DotnetAspServiceArtifactId(solutionName = "Platform", projectName = "ReportService.Api"), - serviceName = "ReportService", - targetFrameworkMoniker = "net8.0", - outputRoot = Path.of("dotnet", "Platform", "ReportService.Api"), - httpPort = 5002, - httpsPort = 5003, - contractModels = emptyList(), - endpoints = listOf( - DotnetAspEndpointArtifact( - method = "GET", - route = "/reports", - operationName = "GetReport", - bindings = DotnetAspEndpointBindingsArtifact( - query = DotnetAspRequestBindingArtifact( - typeName = "GetReportQuery", - name = "GetReportQuery", - fields = listOf( - DotnetAspRequestFieldArtifact( - name = "maxValue", - type = DotnetFieldType.UnsignedNativeInt, - optional = false, - defaultValue = 4294967296L, - ), +private fun unsignedNativeIntDefaultArtifact(): DotnetAspServiceArtifact = DotnetAspServiceArtifact( + id = DotnetAspServiceArtifactId(solutionName = "Platform", projectName = "ReportService.Api"), + serviceName = "ReportService", + targetFrameworkMoniker = "net8.0", + outputRoot = Path.of("dotnet", "Platform", "ReportService.Api"), + httpPort = 5002, + httpsPort = 5003, + contractModels = emptyList(), + endpoints = listOf( + DotnetAspEndpointArtifact( + method = "GET", + route = "/reports", + operationName = "GetReport", + bindings = DotnetAspEndpointBindingsArtifact( + query = DotnetAspRequestBindingArtifact( + typeName = "GetReportQuery", + name = "GetReportQuery", + fields = listOf( + DotnetAspRequestFieldArtifact( + name = "maxValue", + type = DotnetFieldType.UnsignedNativeInt, + optional = false, + defaultValue = 4294967296L, ), - origins = setOf("services.ReportService.rest.GetReport.query.GetReportQuery"), ), + origins = setOf("services.ReportService.rest.GetReport.query.GetReportQuery"), ), - responses = listOf( - DotnetAspResponseArtifact( - statusCode = 200, - model = inlineModel("EmptyReport", "services.ReportService.rest.GetReport.responses.200.EmptyReport") {}, - headers = emptyList(), - origins = setOf("services.ReportService.rest.GetReport.responses.200"), - ), + ), + responses = listOf( + DotnetAspResponseArtifact( + statusCode = 200, + model = inlineModel( + "EmptyReport", + "services.ReportService.rest.GetReport.responses.200.EmptyReport", + ) {}, + headers = emptyList(), + origins = setOf("services.ReportService.rest.GetReport.responses.200"), ), - origins = setOf("services.ReportService.rest.GetReport"), ), + origins = setOf("services.ReportService.rest.GetReport"), ), - ) + ), +) -private fun requestBindingTypesArtifact(): DotnetAspServiceArtifact = - DotnetAspServiceArtifact( - id = DotnetAspServiceArtifactId(solutionName = "Platform", projectName = "UserService.Api"), - serviceName = "UserService", - targetFrameworkMoniker = "net8.0", - outputRoot = Path.of("dotnet", "Platform", "UserService.Api"), - httpPort = 5000, - httpsPort = 5001, - contractModels = emptyList(), - endpoints = listOf( - DotnetAspEndpointArtifact( - method = "GET", - route = "/reports/{reportId}", - operationName = "GetReport", - bindings = DotnetAspEndpointBindingsArtifact( - path = DotnetAspRequestBindingArtifact( - typeName = "GetReportPath", - name = "GetReportPath", - fields = listOf( - DotnetAspRequestFieldArtifact( - name = "reportId", - type = DotnetFieldType.Guid, - optional = false, - defaultValue = null, - ), +private fun requestBindingTypesArtifact(): DotnetAspServiceArtifact = DotnetAspServiceArtifact( + id = DotnetAspServiceArtifactId(solutionName = "Platform", projectName = "UserService.Api"), + serviceName = "UserService", + targetFrameworkMoniker = "net8.0", + outputRoot = Path.of("dotnet", "Platform", "UserService.Api"), + httpPort = 5000, + httpsPort = 5001, + contractModels = emptyList(), + endpoints = listOf( + DotnetAspEndpointArtifact( + method = "GET", + route = "/reports/{reportId}", + operationName = "GetReport", + bindings = DotnetAspEndpointBindingsArtifact( + path = DotnetAspRequestBindingArtifact( + typeName = "GetReportPath", + name = "GetReportPath", + fields = listOf( + DotnetAspRequestFieldArtifact( + name = "reportId", + type = DotnetFieldType.Guid, + optional = false, + defaultValue = null, ), - origins = setOf("services.UserService.rest.GetReport.path.GetReportPath"), ), - query = DotnetAspRequestBindingArtifact( - typeName = "GetReportQuery", - name = "GetReportQuery", - fields = listOf( - DotnetAspRequestFieldArtifact( - name = "since", - type = DotnetFieldType.DateOnly, - optional = false, - defaultValue = null, - ), - DotnetAspRequestFieldArtifact( - name = "requestedAt", - type = DotnetFieldType.DateTimeOffset, - optional = false, - defaultValue = null, - ), - DotnetAspRequestFieldArtifact( - name = "window", - type = DotnetFieldType.TimeSpan, - optional = true, - defaultValue = null, - ), + origins = setOf("services.UserService.rest.GetReport.path.GetReportPath"), + ), + query = DotnetAspRequestBindingArtifact( + typeName = "GetReportQuery", + name = "GetReportQuery", + fields = listOf( + DotnetAspRequestFieldArtifact( + name = "since", + type = DotnetFieldType.DateOnly, + optional = false, + defaultValue = null, + ), + DotnetAspRequestFieldArtifact( + name = "requestedAt", + type = DotnetFieldType.DateTimeOffset, + optional = false, + defaultValue = null, + ), + DotnetAspRequestFieldArtifact( + name = "window", + type = DotnetFieldType.TimeSpan, + optional = true, + defaultValue = null, ), - origins = setOf("services.UserService.rest.GetReport.query.GetReportQuery"), ), + origins = setOf("services.UserService.rest.GetReport.query.GetReportQuery"), ), - responses = listOf( - DotnetAspResponseArtifact( - statusCode = 200, - model = inlineModel("Report", "services.UserService.rest.GetReport.responses.200.Report") {}, - headers = emptyList(), - origins = setOf("services.UserService.rest.GetReport.responses.200"), - ), + ), + responses = listOf( + DotnetAspResponseArtifact( + statusCode = 200, + model = inlineModel("Report", "services.UserService.rest.GetReport.responses.200.Report") {}, + headers = emptyList(), + origins = setOf("services.UserService.rest.GetReport.responses.200"), ), - origins = setOf("services.UserService.rest.GetReport"), ), + origins = setOf("services.UserService.rest.GetReport"), ), - ) + ), +) private fun sharedModel( typeName: String, diff --git a/modules/compile-services-dotnet/src/test/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/msbuild/MsBuildProjectArtifactCompilerTests.kt b/modules/compile-services-dotnet/src/test/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/msbuild/MsBuildProjectArtifactCompilerTests.kt index a3071239..7af3443c 100644 --- a/modules/compile-services-dotnet/src/test/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/msbuild/MsBuildProjectArtifactCompilerTests.kt +++ b/modules/compile-services-dotnet/src/test/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/msbuild/MsBuildProjectArtifactCompilerTests.kt @@ -69,7 +69,8 @@ class MsBuildProjectArtifactCompilerTests : textContribution.artifactId.relativePath shouldBe java.nio.file.Path.of("Directory.Build.props") textContribution.artifactId.outputRoot shouldBe java.nio.file.Path.of("dotnet", "Platform", "UserService.Api") - textContribution.origins shouldBe setOf("dotnet.solutions.Platform.projects.UserService.Api.DirectoryBuildProps") + textContribution.origins shouldBe + setOf("dotnet.solutions.Platform.projects.UserService.Api.DirectoryBuildProps") textContribution.contents.shouldContain("") } diff --git a/modules/dsl-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/dsl/services/dotnet/asp/core/rest/request/DotnetAspRequestFieldSetBuilder.kt b/modules/dsl-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/dsl/services/dotnet/asp/core/rest/request/DotnetAspRequestFieldSetBuilder.kt index 09d0886d..4165db58 100644 --- a/modules/dsl-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/dsl/services/dotnet/asp/core/rest/request/DotnetAspRequestFieldSetBuilder.kt +++ b/modules/dsl-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/dsl/services/dotnet/asp/core/rest/request/DotnetAspRequestFieldSetBuilder.kt @@ -104,11 +104,12 @@ internal open class DotnetAspRequestFieldSetBuilder(private val fieldContainerLa return register(createField(fieldName, type, options)) } - private fun registerReference(name: String, target: String): DotnetAspRequestField = + private fun registerReference(name: String, target: String): DotnetAspRequestField { throw IllegalArgumentException( "ASP.NET request bindings cannot declare reference field '$name' to '$target'. " + "Declare scalar transport fields instead.", ) + } private fun register(field: DotnetAspRequestField): DotnetAspRequestField { require(field.name !in fieldsByName) { From a38c6d646d0e3db4dca46cac4323669d7060991f Mon Sep 17 00:00:00 2001 From: LMLiam <46268350+TheRealEmissions@users.noreply.github.com> Date: Wed, 22 Apr 2026 19:44:56 +0100 Subject: [PATCH 16/26] fix(gradle-plugin): select shadowed runtime variant --- .../lmliam/microsmith/gradle/MicrosmithGradlePlugin.kt | 5 +++++ .../gradle/MicrosmithGradlePluginFunctionalTests.kt | 6 ++++++ 2 files changed, 11 insertions(+) diff --git a/modules/gradle-plugin/src/main/kotlin/io/github/lmliam/microsmith/gradle/MicrosmithGradlePlugin.kt b/modules/gradle-plugin/src/main/kotlin/io/github/lmliam/microsmith/gradle/MicrosmithGradlePlugin.kt index 3eb4a64b..4c72e50b 100644 --- a/modules/gradle-plugin/src/main/kotlin/io/github/lmliam/microsmith/gradle/MicrosmithGradlePlugin.kt +++ b/modules/gradle-plugin/src/main/kotlin/io/github/lmliam/microsmith/gradle/MicrosmithGradlePlugin.kt @@ -2,6 +2,7 @@ package io.github.lmliam.microsmith.gradle import org.gradle.api.Plugin import org.gradle.api.Project +import org.gradle.api.attributes.Bundling import org.gradle.api.plugins.JavaBasePlugin import org.gradle.api.plugins.JavaPlugin @@ -33,6 +34,10 @@ class MicrosmithGradlePlugin : Plugin { configuration.isVisible = false configuration.description = "Classpath for the isolated Microsmith worker JVM used by Gradle tasks." + configuration.attributes.attribute( + Bundling.BUNDLING_ATTRIBUTE, + project.objects.named(Bundling::class.java, Bundling.SHADOWED), + ) configuration.defaultDependencies { dependencies -> dependencies.add( project.dependencies.create(MicrosmithRuntimeDependencyNotation.runtimeScripting()), diff --git a/modules/gradle-plugin/src/test/kotlin/io/github/lmliam/microsmith/gradle/MicrosmithGradlePluginFunctionalTests.kt b/modules/gradle-plugin/src/test/kotlin/io/github/lmliam/microsmith/gradle/MicrosmithGradlePluginFunctionalTests.kt index dc7b178b..1940dc22 100644 --- a/modules/gradle-plugin/src/test/kotlin/io/github/lmliam/microsmith/gradle/MicrosmithGradlePluginFunctionalTests.kt +++ b/modules/gradle-plugin/src/test/kotlin/io/github/lmliam/microsmith/gradle/MicrosmithGradlePluginFunctionalTests.kt @@ -4,6 +4,7 @@ import io.kotest.core.spec.style.StringSpec import io.kotest.matchers.file.shouldExist import io.kotest.matchers.shouldBe import io.kotest.matchers.string.shouldContain +import org.gradle.api.attributes.Bundling import org.gradle.testkit.runner.TaskOutcome class MicrosmithGradlePluginFunctionalTests : StringSpec() { @@ -24,6 +25,7 @@ class MicrosmithGradlePluginFunctionalTests : StringSpec() { check(generateTask.group == "$TASK_GROUP_NAME") val ide = project.configurations.getByName("microsmithIde") + val runtime = project.configurations.getByName("microsmithRuntime") val plugins = project.configurations.getByName("microsmithPlugins") val compileOnly = project.configurations.getByName("compileOnly") val extension = project.extensions.getByName("$EXTENSION_NAME") @@ -31,6 +33,10 @@ class MicrosmithGradlePluginFunctionalTests : StringSpec() { check(ide.extendsFrom.contains(plugins)) check(compileOnly.extendsFrom.contains(ide)) check(!ide.isCanBeResolved) + check( + runtime.attributes.getAttribute(Bundling.BUNDLING_ATTRIBUTE)?.name == + Bundling.SHADOWED, + ) check(extension is io.github.lmliam.microsmith.gradle.MicrosmithGradleExtension) } } From 4f497db26495d9cf3e1ed1494edc850908720768 Mon Sep 17 00:00:00 2001 From: LMLiam <46268350+TheRealEmissions@users.noreply.github.com> Date: Wed, 22 Apr 2026 19:45:06 +0100 Subject: [PATCH 17/26] fix(runtime-scripting): merge published service descriptors --- .../build/runtime/RuntimeScriptingPlugin.kt | 91 ++++++++++++++++++- 1 file changed, 89 insertions(+), 2 deletions(-) diff --git a/build-logic/src/main/kotlin/io/github/lmliam/microsmith/build/runtime/RuntimeScriptingPlugin.kt b/build-logic/src/main/kotlin/io/github/lmliam/microsmith/build/runtime/RuntimeScriptingPlugin.kt index 3832b8e4..e8627118 100644 --- a/build-logic/src/main/kotlin/io/github/lmliam/microsmith/build/runtime/RuntimeScriptingPlugin.kt +++ b/build-logic/src/main/kotlin/io/github/lmliam/microsmith/build/runtime/RuntimeScriptingPlugin.kt @@ -1,6 +1,8 @@ package io.github.lmliam.microsmith.build.runtime +import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar import org.gradle.api.DefaultTask +import org.gradle.api.file.DuplicatesStrategy import org.gradle.api.GradleException import org.gradle.api.Plugin import org.gradle.api.Project @@ -64,7 +66,51 @@ class RuntimeScriptingPlugin : Plugin { } val ideFallbackReleaseAssetsDirectory = project.layout.buildDirectory.dir(RuntimeScriptingBuildNames.RELATIVE_RELEASE_ASSETS_DIRECTORY) - val ideFallbackShadowJarTask = project.tasks.named("shadowJar", Jar::class.java) + val runtimeScriptingShadowJarExpectedProviders = mapOf( + "META-INF/services/io.github.lmliam.microsmith.resolve.core.DomainResolver" to + listOf( + "io.github.lmliam.microsmith.resolve.schemas.core.SchemasResolver", + "io.github.lmliam.microsmith.resolve.schemas.protobuf.ProtobufSchemasResolver", + "io.github.lmliam.microsmith.resolve.schemas.protobuf.rpc.ProtobufRpcSchemasResolver", + "io.github.lmliam.microsmith.resolve.services.core.ServicesResolver", + "io.github.lmliam.microsmith.resolve.services.dotnet.DotnetWorkspaceDomainResolver", + "io.github.lmliam.microsmith.resolve.services.dotnet.asp.DotnetAspWorkspaceDomainResolver", + "io.github.lmliam.microsmith.resolve.services.dotnet.packages.DotnetPackageWorkspaceDomainResolver", + ), + "META-INF/services/io.github.lmliam.microsmith.artifact.core.ArtifactContributor" to + listOf( + "io.github.lmliam.microsmith.artifact.schemas.protobuf.ProtobufArtifactContributor", + "io.github.lmliam.microsmith.artifact.schemas.protobuf.rpc.ProtobufRpcArtifactContributor", + "io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspArtifactContributor", + "io.github.lmliam.microsmith.artifact.services.dotnet.packages.DotnetPackageArtifactContributor", + ), + "META-INF/services/io.github.lmliam.microsmith.artifact.core.ArtifactAssembler" to + listOf( + "io.github.lmliam.microsmith.artifact.files.BinaryFileArtifactAssembler", + "io.github.lmliam.microsmith.artifact.files.TextFileArtifactAssembler", + "io.github.lmliam.microsmith.artifact.schemas.protobuf.ProtoFileArtifactAssembler", + "io.github.lmliam.microsmith.artifact.schemas.protobuf.rpc.ProtobufRpcServiceArtifactAssembler", + "io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspServiceArtifactAssembler", + "io.github.lmliam.microsmith.artifact.services.dotnet.msbuild.MsBuildProjectArtifactAssembler", + "io.github.lmliam.microsmith.artifact.services.dotnet.packages.DotnetPackageReferencesArtifactAssembler", + "io.github.lmliam.microsmith.artifact.services.dotnet.packages.DotnetPackageVersionsArtifactAssembler", + ), + "META-INF/services/io.github.lmliam.microsmith.compile.core.ArtifactCompiler" to + listOf( + "io.github.lmliam.microsmith.compile.schemas.protobuf.ProtoFileArtifactCompiler", + "io.github.lmliam.microsmith.compile.schemas.protobuf.rpc.ProtobufRpcServiceArtifactCompiler", + "io.github.lmliam.microsmith.compile.services.dotnet.asp.DotnetAspServiceArtifactCompiler", + "io.github.lmliam.microsmith.compile.services.dotnet.msbuild.MsBuildProjectArtifactCompiler", + "io.github.lmliam.microsmith.compile.services.dotnet.packages.DotnetPackageReferencesArtifactCompiler", + "io.github.lmliam.microsmith.compile.services.dotnet.packages.DotnetPackageVersionsArtifactCompiler", + ), + "META-INF/services/io.github.lmliam.microsmith.gen.core.ArtifactRenderer" to + listOf( + "io.github.lmliam.microsmith.gen.files.render.BinaryFileArtifactRenderer", + "io.github.lmliam.microsmith.gen.files.render.TextFileArtifactRenderer", + ), + ) + val ideFallbackShadowJarTask = project.tasks.named("shadowJar", ShadowJar::class.java) val ideFallbackShadowJarArchive = ideFallbackShadowJarTask.flatMap { it.archiveFile } val ideFallbackExpectedEntries = listOf( RuntimeScriptingBuildNames.classEntryName(scriptTemplateFqcn), @@ -77,6 +123,10 @@ class RuntimeScriptingPlugin : Plugin { ideFallbackShadowJarTask.configure { shadowJarTask -> shadowJarTask.archiveBaseName.set(RuntimeScriptingBuildNames.IDE_FALLBACK_SHADOW_JAR_BASE_NAME) shadowJarTask.archiveClassifier.set(RuntimeScriptingBuildNames.IDE_FALLBACK_SHADOW_JAR_CLASSIFIER) + shadowJarTask.filesMatching("META-INF/services/**") { fileCopyDetails -> + fileCopyDetails.duplicatesStrategy = DuplicatesStrategy.INCLUDE + } + shadowJarTask.mergeServiceFiles() shadowJarTask.manifest.attributes( mapOf( "Implementation-Version" to project.version.toString(), @@ -152,11 +202,17 @@ class RuntimeScriptingPlugin : Plugin { verifyShadowTask.inputs.file(ideFallbackShadowJarArchive) verifyShadowTask.doLast { + val shadowJarFile = ideFallbackShadowJarArchive.get().asFile verifyJarEntries( - ideFallbackShadowJarArchive.get().asFile, + shadowJarFile, ideFallbackExpectedEntries, "IDE fallback shadow jar", ) + verifyMergedServiceProviders( + shadowJarFile, + runtimeScriptingShadowJarExpectedProviders, + "IDE fallback shadow jar", + ) } } @@ -204,3 +260,34 @@ class RuntimeScriptingPlugin : Plugin { } private fun ByteArray.encodeHex(): String = joinToString(separator = "") { byte -> "%02x".format(byte) } + +private fun verifyMergedServiceProviders( + archiveFile: java.io.File, + expectedProvidersByDescriptor: Map>, + artifactDescription: String, +) { + JarFile(archiveFile).use { jarFile -> + expectedProvidersByDescriptor.forEach { (servicePath, expectedProviders) -> + val entry = jarFile.getJarEntry(servicePath) + ?: throw GradleException( + "$artifactDescription '${archiveFile.name}' is missing service descriptor '$servicePath'.", + ) + val providers = + jarFile.getInputStream(entry) + .bufferedReader(StandardCharsets.UTF_8) + .use { reader -> + reader.lineSequence() + .map(String::trim) + .filter { line -> line.isNotEmpty() && !line.startsWith("#") } + .toSet() + } + expectedProviders.forEach { expectedProvider -> + if (expectedProvider !in providers) { + throw GradleException( + "$artifactDescription '${archiveFile.name}' is missing provider '$expectedProvider' in '$servicePath'.", + ) + } + } + } + } +} From 981efff7d529a99fe3a6b6e8a0577ab14004e81c Mon Sep 17 00:00:00 2001 From: LMLiam <46268350+TheRealEmissions@users.noreply.github.com> Date: Wed, 22 Apr 2026 20:12:09 +0100 Subject: [PATCH 18/26] fix(gradle-plugin): drop unused test import --- .../microsmith/gradle/MicrosmithGradlePluginFunctionalTests.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/modules/gradle-plugin/src/test/kotlin/io/github/lmliam/microsmith/gradle/MicrosmithGradlePluginFunctionalTests.kt b/modules/gradle-plugin/src/test/kotlin/io/github/lmliam/microsmith/gradle/MicrosmithGradlePluginFunctionalTests.kt index 1940dc22..d575980f 100644 --- a/modules/gradle-plugin/src/test/kotlin/io/github/lmliam/microsmith/gradle/MicrosmithGradlePluginFunctionalTests.kt +++ b/modules/gradle-plugin/src/test/kotlin/io/github/lmliam/microsmith/gradle/MicrosmithGradlePluginFunctionalTests.kt @@ -4,7 +4,6 @@ import io.kotest.core.spec.style.StringSpec import io.kotest.matchers.file.shouldExist import io.kotest.matchers.shouldBe import io.kotest.matchers.string.shouldContain -import org.gradle.api.attributes.Bundling import org.gradle.testkit.runner.TaskOutcome class MicrosmithGradlePluginFunctionalTests : StringSpec() { From 10d8ac4ca1a0541b1800204bbb7ff882b8e4ea3d Mon Sep 17 00:00:00 2001 From: LMLiam <46268350+TheRealEmissions@users.noreply.github.com> Date: Wed, 22 Apr 2026 20:40:08 +0100 Subject: [PATCH 19/26] refactor(gen): drop origin comments from generated files --- .../files/render/GeneratedByMicrosmithBanner.kt | 14 ++------------ .../gen/files/render/TextFileArtifactRenderer.kt | 6 +----- .../files/render/TextFileArtifactRendererTests.kt | 9 ++------- 3 files changed, 5 insertions(+), 24 deletions(-) diff --git a/modules/gen/src/main/kotlin/io/github/lmliam/microsmith/gen/files/render/GeneratedByMicrosmithBanner.kt b/modules/gen/src/main/kotlin/io/github/lmliam/microsmith/gen/files/render/GeneratedByMicrosmithBanner.kt index df7ef1a6..1315cbb4 100644 --- a/modules/gen/src/main/kotlin/io/github/lmliam/microsmith/gen/files/render/GeneratedByMicrosmithBanner.kt +++ b/modules/gen/src/main/kotlin/io/github/lmliam/microsmith/gen/files/render/GeneratedByMicrosmithBanner.kt @@ -4,7 +4,6 @@ import java.nio.file.Path internal object GeneratedByMicrosmithBanner { private const val HEADER_TEXT = "Generated by Microsmith" - private const val ORIGINS_HEADER = "Origins:" private val xmlExtensions = setOf("csproj", "fsproj", "props", "targets", "vbproj", "xml") private val slashCommentExtensions = setOf( @@ -44,21 +43,12 @@ internal object GeneratedByMicrosmithBanner { "zsh", ) - fun prepend(path: Path, contents: String, origins: Set = emptySet()): String { + fun prepend(path: Path, contents: String): String { val comment = resolveComment(path) ?: return contents if (contents.startsWith(commentText(comment, HEADER_TEXT))) { return contents } - val bannerLines = buildList { - add(commentText(comment, HEADER_TEXT)) - if (origins.isNotEmpty()) { - add(commentText(comment, ORIGINS_HEADER)) - origins.toList().sorted().forEach { origin -> - add(commentText(comment, "- $origin")) - } - } - } - val banner = bannerLines.joinToString(separator = "\n") + val banner = commentText(comment, HEADER_TEXT) if (comment.startsWith("#") && contents.startsWith("#!")) { val shebangEnd = contents.indexOf('\n') if (shebangEnd == -1) { diff --git a/modules/gen/src/main/kotlin/io/github/lmliam/microsmith/gen/files/render/TextFileArtifactRenderer.kt b/modules/gen/src/main/kotlin/io/github/lmliam/microsmith/gen/files/render/TextFileArtifactRenderer.kt index a559f988..cd8bae90 100644 --- a/modules/gen/src/main/kotlin/io/github/lmliam/microsmith/gen/files/render/TextFileArtifactRenderer.kt +++ b/modules/gen/src/main/kotlin/io/github/lmliam/microsmith/gen/files/render/TextFileArtifactRenderer.kt @@ -11,11 +11,7 @@ class TextFileArtifactRenderer : ArtifactRenderer { override val artifactType = TextFileArtifact::class override fun render(artifact: TextFileArtifact): GeneratedFile { - val renderedContents = GeneratedByMicrosmithBanner.prepend( - artifact.id.relativePath, - artifact.contents, - artifact.origins, - ) + val renderedContents = GeneratedByMicrosmithBanner.prepend(artifact.id.relativePath, artifact.contents) return GeneratedFile( relativePath = artifact.id.relativePath, contents = renderedContents.toByteArray(StandardCharsets.UTF_8), diff --git a/modules/gen/src/test/kotlin/io/github/lmliam/microsmith/gen/files/render/TextFileArtifactRendererTests.kt b/modules/gen/src/test/kotlin/io/github/lmliam/microsmith/gen/files/render/TextFileArtifactRendererTests.kt index b97cb155..9d6bfa1a 100644 --- a/modules/gen/src/test/kotlin/io/github/lmliam/microsmith/gen/files/render/TextFileArtifactRendererTests.kt +++ b/modules/gen/src/test/kotlin/io/github/lmliam/microsmith/gen/files/render/TextFileArtifactRendererTests.kt @@ -66,7 +66,7 @@ class TextFileArtifactRendererTests : """.trimIndent() } - "render includes origin comments for supported text formats" { + "render keeps the generated banner stable when origins are present" { val rendered = renderer.render( TextFileArtifact( @@ -79,14 +79,11 @@ class TextFileArtifactRendererTests : String(rendered.contents, StandardCharsets.UTF_8) shouldBe """ // Generated by Microsmith - // Origins: - // - schemas.protobuf.pkg.User - // - schemas.protobuf.pkg.UserCreated syntax = "proto3"; """.trimIndent() } - "render formats origin comments as valid xml comments" { + "render keeps xml banners stable when origins are present" { val rendered = renderer.render( TextFileArtifact( @@ -99,8 +96,6 @@ class TextFileArtifactRendererTests : String(rendered.contents, StandardCharsets.UTF_8) shouldBe """ - - """.trimIndent() } From c2f8ddefd79daf34501fdf00b3fbe0d91152421a Mon Sep 17 00:00:00 2001 From: LMLiam <46268350+TheRealEmissions@users.noreply.github.com> Date: Thu, 23 Apr 2026 13:27:02 +0100 Subject: [PATCH 20/26] fix(ci): align native Maven fixture output checks --- .github/workflows/build_test_qodana.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build_test_qodana.yml b/.github/workflows/build_test_qodana.yml index 39bedde0..af9f5dff 100644 --- a/.github/workflows/build_test_qodana.yml +++ b/.github/workflows/build_test_qodana.yml @@ -77,11 +77,13 @@ jobs: validate_maven_proto_fixture() { local repo_root="$1" - local generated_root="$repo_root/proto" + local output_root="$repo_root/target/generated/microsmith" + local generated_root="$output_root/proto" mvn -B -f "$repo_root/pom.xml" microsmith:generate -Dmicrosmith.version="$VERSION" test -d "$generated_root" find "$generated_root" -maxdepth 1 -type f -name '*.proto' | grep -q . + test -f "$output_root/.microsmith/origins.json" } validate_maven_dotnet_fixture() { From 464eb887beb93b051d07a77d1bb55bf4c90345ba Mon Sep 17 00:00:00 2001 From: LMLiam <46268350+TheRealEmissions@users.noreply.github.com> Date: Thu, 23 Apr 2026 14:46:58 +0100 Subject: [PATCH 21/26] fix(gen): tighten output safety and no-content validation --- .../dotnet/asp/DotnetAspEndpointValidation.kt | 16 +++++++ .../DotnetAspServiceArtifactCompilerTests.kt | 47 +++++++++++++++++++ .../GeneratedOutputUniquenessValidator.kt | 15 +++++- ...GeneratedOutputUniquenessValidatorTests.kt | 31 ++++++++++++ 4 files changed, 108 insertions(+), 1 deletion(-) diff --git a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspEndpointValidation.kt b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspEndpointValidation.kt index a1561188..26dae131 100644 --- a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspEndpointValidation.kt +++ b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspEndpointValidation.kt @@ -7,6 +7,7 @@ import io.github.lmliam.microsmith.dsl.services.dotnet.core.model.DotnetFieldTyp internal fun validateEndpointGenerationInputs(artifact: DotnetAspServiceArtifact) { validateRequestBindings(artifact) + validateNoContentResponses(artifact) validateResponseHeaderNames(artifact) validateGeneratedContractTypeNames(artifact) validateGeneratedControllerTypeNames(artifact) @@ -56,6 +57,21 @@ private fun validateResponseHeaderNames(artifact: DotnetAspServiceArtifact) { } } +private fun validateNoContentResponses(artifact: DotnetAspServiceArtifact) { + artifact.endpoints.forEach { endpoint -> + endpoint.responses + .filter { response -> response.statusCode == HTTP_NO_CONTENT_STATUS_CODE } + .forEach { response -> + require(response.model.model.fields.isEmpty()) { + "ASP.NET response ${response.statusCode} in operation " + + "'${endpoint.operationName}' cannot declare response body fields " + + "for model '${response.model.typeName}'. " + + "HTTP 204 responses are emitted without a response body." + } + } + } +} + private fun validateGeneratedControllerTypeNames(artifact: DotnetAspServiceArtifact) { require(controllerBaseTypeName(artifact) != MICROSMITH_CONTROLLER_BASE_TYPE_NAME) { "ASP.NET service '${artifact.serviceName}' project '${artifact.id.projectName}' " + diff --git a/modules/compile-services-dotnet-asp/src/test/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspServiceArtifactCompilerTests.kt b/modules/compile-services-dotnet-asp/src/test/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspServiceArtifactCompilerTests.kt index 83a727b9..6c263c73 100644 --- a/modules/compile-services-dotnet-asp/src/test/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspServiceArtifactCompilerTests.kt +++ b/modules/compile-services-dotnet-asp/src/test/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspServiceArtifactCompilerTests.kt @@ -19,6 +19,7 @@ import io.github.lmliam.microsmith.artifact.services.dotnet.msbuild.MsBuildProje import io.github.lmliam.microsmith.dsl.services.dotnet.core.model.DotnetField import io.github.lmliam.microsmith.dsl.services.dotnet.core.model.DotnetFieldType import io.github.lmliam.microsmith.dsl.services.dotnet.core.model.DotnetModel +import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.StringSpec import io.kotest.matchers.collections.shouldContain import io.kotest.matchers.collections.shouldContainExactly @@ -136,6 +137,16 @@ class DotnetAspServiceArtifactCompilerTests : appSettings.shouldContain("\"ServiceName\": \"User\\\"Service\\\\Api\"") appSettings.shouldNotContain("\"ServiceName\": \"User\"Service\\Api\"") } + + "compile rejects 204 responses that still declare response body fields" { + val error = + shouldThrow { + DotnetAspServiceArtifactCompiler().compile(noContentBodyArtifact()) + } + + error.message.shouldContain("ASP.NET response 204 in operation 'DeleteUser'") + error.message.shouldContain("cannot declare response body fields") + } }) private fun sampleArtifact(serviceName: String = "UserService"): DotnetAspServiceArtifact { @@ -353,6 +364,42 @@ private fun requestBindingTypesArtifact(): DotnetAspServiceArtifact = DotnetAspS ), ) +private fun noContentBodyArtifact(): DotnetAspServiceArtifact { + val deletedUserModel = inlineModel( + "DeletedUser", + "services.UserService.rest.DeleteUser.responses.204.DeletedUser", + ) { + stringField("id") + } + + return DotnetAspServiceArtifact( + id = DotnetAspServiceArtifactId(solutionName = "Platform", projectName = "UserService.Api"), + serviceName = "UserService", + targetFrameworkMoniker = "net8.0", + outputRoot = Path.of("dotnet", "Platform", "UserService.Api"), + httpPort = 5000, + httpsPort = 5001, + contractModels = listOf(deletedUserModel), + endpoints = listOf( + DotnetAspEndpointArtifact( + method = "DELETE", + route = "/users/{id}", + operationName = "DeleteUser", + bindings = DotnetAspEndpointBindingsArtifact(), + responses = listOf( + DotnetAspResponseArtifact( + statusCode = 204, + model = deletedUserModel, + headers = emptyList(), + origins = setOf("services.UserService.rest.DeleteUser.responses.204"), + ), + ), + origins = setOf("services.UserService.rest.DeleteUser"), + ), + ), + ) +} + private fun sharedModel( typeName: String, origin: String, diff --git a/modules/gen/src/main/kotlin/io/github/lmliam/microsmith/gen/helpers/GeneratedOutputUniquenessValidator.kt b/modules/gen/src/main/kotlin/io/github/lmliam/microsmith/gen/helpers/GeneratedOutputUniquenessValidator.kt index fe912123..91e2ec45 100644 --- a/modules/gen/src/main/kotlin/io/github/lmliam/microsmith/gen/helpers/GeneratedOutputUniquenessValidator.kt +++ b/modules/gen/src/main/kotlin/io/github/lmliam/microsmith/gen/helpers/GeneratedOutputUniquenessValidator.kt @@ -18,9 +18,10 @@ internal object GeneratedOutputUniquenessValidator { private fun outputPathKey(output: GeneratedFile): String { requireValidOutputRoot(output.outputRoot) + val normalizedRelativePath = requireValidRelativePath(output.relativePath) return output.outputRoot.normalize() - .resolve(output.relativePath.normalize()) + .resolve(normalizedRelativePath) .normalize() .toString() } @@ -35,4 +36,16 @@ internal object GeneratedOutputUniquenessValidator { "Generated output root '$outputRoot' escapes the run output root." } } + + private fun requireValidRelativePath(relativePath: Path): Path { + require(!relativePath.isAbsolute) { + "Generated output path must be relative, but was '$relativePath'." + } + + val normalizedRelativePath = relativePath.normalize() + require(!normalizedRelativePath.startsWith(Path.of(".."))) { + "Generated output path '$relativePath' escapes the run output root." + } + return normalizedRelativePath + } } diff --git a/modules/gen/src/test/kotlin/io/github/lmliam/microsmith/gen/helpers/GeneratedOutputUniquenessValidatorTests.kt b/modules/gen/src/test/kotlin/io/github/lmliam/microsmith/gen/helpers/GeneratedOutputUniquenessValidatorTests.kt index 48c91726..c6e116e5 100644 --- a/modules/gen/src/test/kotlin/io/github/lmliam/microsmith/gen/helpers/GeneratedOutputUniquenessValidatorTests.kt +++ b/modules/gen/src/test/kotlin/io/github/lmliam/microsmith/gen/helpers/GeneratedOutputUniquenessValidatorTests.kt @@ -3,6 +3,7 @@ package io.github.lmliam.microsmith.gen.helpers import io.github.lmliam.microsmith.gen.files.GeneratedFile import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.StringSpec +import java.nio.file.Files import kotlin.io.path.Path class GeneratedOutputUniquenessValidatorTests : @@ -54,6 +55,36 @@ class GeneratedOutputUniquenessValidatorTests : } } + "requireUniqueOutputPaths rejects absolute generated file paths" { + val absolutePath = Files.createTempDirectory("microsmith-uniqueness-").resolve("User.proto").toAbsolutePath() + + shouldThrow { + GeneratedOutputUniquenessValidator.requireUniqueOutputPaths( + listOf( + GeneratedFile( + relativePath = absolutePath, + contents = byteArrayOf(1), + outputRoot = Path("proto"), + ), + ), + ) + } + } + + "requireUniqueOutputPaths rejects generated file paths that traverse outside the root" { + shouldThrow { + GeneratedOutputUniquenessValidator.requireUniqueOutputPaths( + listOf( + GeneratedFile( + relativePath = Path("../outside.proto"), + contents = byteArrayOf(1), + outputRoot = Path("proto"), + ), + ), + ) + } + } + "requireUniqueOutputPaths allows the same relative file under distinct output roots" { GeneratedOutputUniquenessValidator.requireUniqueOutputPaths( listOf( From 28bfeb6e18009c7884fe168358e6ebb5472c3a23 Mon Sep 17 00:00:00 2001 From: LMLiam <46268350+TheRealEmissions@users.noreply.github.com> Date: Thu, 23 Apr 2026 19:02:06 +0100 Subject: [PATCH 22/26] refactor(services-dotnet-asp): restore structured C# renderers --- .../asp/DotnetAspContractFileRenderer.kt | 174 ++++++------------ .../asp/DotnetAspContractTypeRendering.kt | 104 +++++++++++ .../asp/DotnetAspControllerActionRendering.kt | 137 ++++++++++++++ .../asp/DotnetAspControllerBaseRendering.kt | 96 ++++++++++ .../asp/DotnetAspControllerFileRenderer.kt | 171 ++++------------- .../asp/DotnetAspControllerResultRendering.kt | 76 ++++++++ .../DotnetAspHostingExtensionsRendering.kt | 38 ++++ .../DotnetAspInfrastructureFileRenderer.kt | 93 ++++------ .../DotnetAspServiceArtifactCompilerTests.kt | 4 +- ...GeneratedOutputUniquenessValidatorTests.kt | 4 +- 10 files changed, 585 insertions(+), 312 deletions(-) create mode 100644 modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspContractTypeRendering.kt create mode 100644 modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspControllerActionRendering.kt create mode 100644 modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspControllerBaseRendering.kt create mode 100644 modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspControllerResultRendering.kt create mode 100644 modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspHostingExtensionsRendering.kt diff --git a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspContractFileRenderer.kt b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspContractFileRenderer.kt index 067338e8..b469a7b5 100644 --- a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspContractFileRenderer.kt +++ b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspContractFileRenderer.kt @@ -1,137 +1,71 @@ package io.github.lmliam.microsmith.compile.services.dotnet.asp import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspEndpointArtifact -import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspHeadersBindingArtifact import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspModelArtifact import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspModelLocality -import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspRequestBindingArtifact import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspResponseArtifact import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspServiceArtifact -import io.github.lmliam.microsmith.dsl.services.dotnet.core.model.DotnetField +import io.github.lmliam.microsmith.compile.services.dotnet.csharp.CSharp +import io.github.lmliam.microsmith.compile.services.dotnet.csharp.CSharpFileBuilder internal object DotnetAspContractFileRenderer { - fun renderServiceModelsFile(artifact: DotnetAspServiceArtifact): String = buildString { - appendLine("using System;") - appendLine() - appendLine("namespace ${contractsNamespace(artifact)};") - appendLine() - artifact.contractModels - .distinctBy(DotnetAspModelArtifact::typeName) - .filter { it.locality == DotnetAspModelLocality.SHARED } - .sortedBy(DotnetAspModelArtifact::typeName) - .forEachIndexed { index, model -> - if (index > 0) { - appendLine() - } - append(renderRecordType(model.typeName, model.model.fields)) - } - } - - fun renderRequestModelsFile(artifact: DotnetAspServiceArtifact): String = buildString { - appendLine("using System;") - appendLine("using Microsoft.AspNetCore.Mvc.ModelBinding;") - appendLine() - appendLine("namespace ${contractsNamespace(artifact)};") - appendLine() - val elements = buildList { - collectRequestBindings(artifact).forEach { add(renderRequestBindingType(it)) } - collectHeaderBindings(artifact).forEach { add(renderHeadersBindingType(it)) } - artifact.endpoints.forEach { endpoint -> - endpoint.bindings.body - ?.takeIf { it.locality == DotnetAspModelLocality.INLINE } - ?.let { add(renderRecordType(it.typeName, it.model.fields)) } - } - }.distinct() - elements.forEachIndexed { index, typeBlock -> - if (index > 0) { - appendLine() - } - append(typeBlock) + fun renderServiceModelsFile(artifact: DotnetAspServiceArtifact): String = + renderContractsFile(artifact, usings = setOf(SYSTEM_NAMESPACE)) { + artifact.contractModels + .distinctBy(DotnetAspModelArtifact::typeName) + .filter { it.locality == DotnetAspModelLocality.SHARED } + .sortedBy(DotnetAspModelArtifact::typeName) + .forEach { addType(renderRecordType(it.typeName, it.model.fields)) } } - } - fun renderResponseModelsFile(artifact: DotnetAspServiceArtifact): String = buildString { - appendLine("using System;") - appendLine() - appendLine("namespace ${contractsNamespace(artifact)};") - appendLine() - val elements = buildList { - artifact.endpoints.forEach { endpoint -> - endpoint.responses - .map(DotnetAspResponseArtifact::model) - .filter { it.locality == DotnetAspModelLocality.INLINE } - .distinctBy(DotnetAspModelArtifact::typeName) - .forEach { model -> add(renderRecordType(model.typeName, model.model.fields)) } - } - artifact.endpoints.forEach { endpoint -> - add(renderResultBaseType(endpoint)) - endpoint.responses.forEach { response -> - add(renderResultVariantType(endpoint, response)) + fun renderRequestModelsFile(artifact: DotnetAspServiceArtifact): String = + renderContractsFile( + artifact, + usings = setOf(SYSTEM_NAMESPACE, MODEL_BINDING_NAMESPACE), + ) { + buildList { + collectRequestBindings(artifact).forEach { add(renderRequestBindingType(it)) } + collectHeaderBindings(artifact).forEach { add(renderHeadersBindingType(it)) } + artifact.endpoints.forEach { endpoint -> + endpoint.bindings.body + ?.takeIf { it.locality == DotnetAspModelLocality.INLINE } + ?.let { add(renderRecordType(it.typeName, it.model.fields)) } } - } - }.distinct() - elements.forEachIndexed { index, typeBlock -> - if (index > 0) { - appendLine() - } - append(typeBlock) - } - } - - private fun renderRecordType(typeName: String, fields: List): String = buildString { - appendLine("public sealed record $typeName") - appendLine("{") - fields.forEach { field -> - val propertyType = renderDotnetAspModelPropertyType(field.type) - val propertyName = dotnetAspPascalIdentifier(field.name) - val initializer = renderDotnetAspInitializer(field.type) - appendLine(" public $propertyType $propertyName { get; set; }$initializer") + }.distinctBy(CSharp.Type::name) + .forEach(::addType) } - appendLine("}") - } - private fun renderRequestBindingType(binding: DotnetAspRequestBindingArtifact): String = buildString { - appendLine("public sealed record ${binding.typeName}") - appendLine("{") - binding.fields.forEach { field -> - if (!field.optional && field.defaultValue == null) { - appendLine(" [BindRequired]") - } - val propertyType = renderDotnetAspBindingPropertyType(field) - val propertyName = dotnetAspPascalIdentifier(field.name) - val initializer = renderDotnetAspBindingInitializer(field) - appendLine(" public $propertyType $propertyName { get; set; }$initializer") - } - appendLine("}") - } - - private fun renderHeadersBindingType(binding: DotnetAspHeadersBindingArtifact): String = buildString { - appendLine("public sealed record ${binding.typeName}") - appendLine("{") - binding.headers.forEach { header -> - appendLine(" public string? ${dotnetAspPascalIdentifier(header.name)} { get; set; } = null;") + fun renderResponseModelsFile(artifact: DotnetAspServiceArtifact): String = + renderContractsFile(artifact, usings = setOf(SYSTEM_NAMESPACE)) { + buildList { + artifact.endpoints.forEach { endpoint -> + endpoint.responses + .map(DotnetAspResponseArtifact::model) + .filter { it.locality == DotnetAspModelLocality.INLINE } + .distinctBy(DotnetAspModelArtifact::typeName) + .forEach { model -> add(renderRecordType(model.typeName, model.model.fields)) } + } + artifact.endpoints.forEach { endpoint -> + add(renderResultBaseType(endpoint)) + endpoint.responses.forEach { response -> + add(renderResultVariantType(endpoint, response)) + } + } + }.distinctBy(CSharp.Type::name) + .forEach(::addType) } - appendLine("}") - } - - private fun renderResultBaseType(endpoint: DotnetAspEndpointArtifact): String = - "public abstract record ${resultBaseTypeName(endpoint)};" - - private fun renderResultVariantType( - endpoint: DotnetAspEndpointArtifact, - response: DotnetAspResponseArtifact, - ): String = buildString { - append("public sealed record ${resultVariantTypeName(endpoint, response)}(") - append(responseParameters(response).joinToString(", ")) - append(") : ${resultBaseTypeName(endpoint)};") - } - private fun responseParameters(response: DotnetAspResponseArtifact): List = buildList { - if (response.statusCode != HTTP_NO_CONTENT_STATUS_CODE) { - add("${response.model.typeName} $RESULT_BODY_PROPERTY_NAME") - } - response.headers.forEach { header -> - add("string? ${dotnetAspHeaderPropertyName(header.name)} = null") - } - } + private fun renderContractsFile( + artifact: DotnetAspServiceArtifact, + usings: Set, + build: CSharpFileBuilder.() -> Unit, + ): String = CSharp.render( + CSharp.file(contractsNamespace(artifact)) { + usings.forEach(::using) + build() + }, + ) } + +private const val MODEL_BINDING_NAMESPACE = "Microsoft.AspNetCore.Mvc.ModelBinding" +private const val SYSTEM_NAMESPACE = "System" diff --git a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspContractTypeRendering.kt b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspContractTypeRendering.kt new file mode 100644 index 00000000..c9e135d8 --- /dev/null +++ b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspContractTypeRendering.kt @@ -0,0 +1,104 @@ +package io.github.lmliam.microsmith.compile.services.dotnet.asp + +import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspEndpointArtifact +import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspHeadersBindingArtifact +import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspRequestBindingArtifact +import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspRequestFieldArtifact +import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspResponseArtifact +import io.github.lmliam.microsmith.compile.services.dotnet.csharp.CSharp +import io.github.lmliam.microsmith.compile.services.dotnet.csharp.csharpAutoProperty +import io.github.lmliam.microsmith.compile.services.dotnet.csharp.csharpNullableType +import io.github.lmliam.microsmith.compile.services.dotnet.csharp.csharpParameter +import io.github.lmliam.microsmith.compile.services.dotnet.csharp.csharpType +import io.github.lmliam.microsmith.dsl.services.dotnet.core.model.DotnetField + +internal fun renderRecordType(typeName: String, fields: List): CSharp.Type = CSharp.Type( + kind = CSharp.TypeKind.RECORD, + name = typeName, + modifiers = listOf(CSharp.Modifier.PUBLIC, CSharp.Modifier.SEALED), + members = fields.map(::renderModelProperty), +) + +private fun renderModelProperty(field: DotnetField): CSharp.Property = csharpAutoProperty( + type = csharpType(renderDotnetAspModelPropertyType(field.type)), + name = dotnetAspPascalIdentifier(field.name), + modifiers = listOf(CSharp.Modifier.PUBLIC), + initializer = renderDotnetAspInitializer(field.type).asInitializerExpression(), +) + +internal fun renderRequestBindingType(binding: DotnetAspRequestBindingArtifact): CSharp.Type = CSharp.Type( + kind = CSharp.TypeKind.RECORD, + name = binding.typeName, + modifiers = listOf(CSharp.Modifier.PUBLIC, CSharp.Modifier.SEALED), + members = binding.fields.map(::renderRequestBindingProperty), +) + +private fun renderRequestBindingProperty(field: DotnetAspRequestFieldArtifact): CSharp.Property = csharpAutoProperty( + type = renderRequestFieldType(field), + name = dotnetAspPascalIdentifier(field.name), + modifiers = listOf(CSharp.Modifier.PUBLIC), + attributes = buildList { + if (!field.optional && field.defaultValue == null) { + add(CSharp.attribute(BIND_REQUIRED_ATTRIBUTE)) + } + }, + initializer = renderDotnetAspBindingInitializer(field).asInitializerExpression(), +) + +private fun renderRequestFieldType(field: DotnetAspRequestFieldArtifact): CSharp.TypeRef { + val baseType = renderDotnetAspModelPropertyType(field.type) + return if (field.optional && field.defaultValue == null) { + csharpNullableType(baseType) + } else { + csharpType(baseType) + } +} + +internal fun renderHeadersBindingType(binding: DotnetAspHeadersBindingArtifact): CSharp.Type = CSharp.Type( + kind = CSharp.TypeKind.RECORD, + name = binding.typeName, + modifiers = listOf(CSharp.Modifier.PUBLIC, CSharp.Modifier.SEALED), + members = binding.headers.map { header -> + csharpAutoProperty( + type = csharpNullableType("string"), + name = dotnetAspPascalIdentifier(header.name), + modifiers = listOf(CSharp.Modifier.PUBLIC), + initializer = NULL_LITERAL, + ) + }, +) + +internal fun renderResultBaseType(endpoint: DotnetAspEndpointArtifact): CSharp.Type = CSharp.Type( + kind = CSharp.TypeKind.RECORD, + name = resultBaseTypeName(endpoint), + modifiers = listOf(CSharp.Modifier.PUBLIC, CSharp.Modifier.ABSTRACT), +) + +internal fun renderResultVariantType( + endpoint: DotnetAspEndpointArtifact, + response: DotnetAspResponseArtifact, +): CSharp.Type = CSharp.Type( + kind = CSharp.TypeKind.RECORD, + name = resultVariantTypeName(endpoint, response), + modifiers = listOf(CSharp.Modifier.PUBLIC, CSharp.Modifier.SEALED), + primaryConstructorParameters = buildList { + if (response.statusCode != HTTP_NO_CONTENT_STATUS_CODE) { + add(csharpParameter(response.model.typeName, RESULT_BODY_PROPERTY_NAME)) + } + response.headers.forEach { header -> + add( + csharpParameter( + type = csharpNullableType("string"), + name = dotnetAspHeaderPropertyName(header.name), + defaultValue = NULL_LITERAL, + ), + ) + } + }, + baseTypes = listOf(csharpType(resultBaseTypeName(endpoint))), +) + +private fun String.asInitializerExpression(): String = removePrefix(" = ").removeSuffix(";") + +private const val BIND_REQUIRED_ATTRIBUTE = "BindRequired" +private const val NULL_LITERAL = "null" diff --git a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspControllerActionRendering.kt b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspControllerActionRendering.kt new file mode 100644 index 00000000..4d9867fa --- /dev/null +++ b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspControllerActionRendering.kt @@ -0,0 +1,137 @@ +package io.github.lmliam.microsmith.compile.services.dotnet.asp + +import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspEndpointArtifact +import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspHeadersBindingArtifact +import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspResponseArtifact +import io.github.lmliam.microsmith.compile.services.dotnet.csharp.CSharp +import io.github.lmliam.microsmith.compile.services.dotnet.csharp.DotnetCSharpTypes +import io.github.lmliam.microsmith.compile.services.dotnet.csharp.csharpGenericType +import io.github.lmliam.microsmith.compile.services.dotnet.csharp.csharpParameter +import io.github.lmliam.microsmith.compile.services.dotnet.csharp.csharpType +import java.util.Locale + +internal fun renderActionMethod(endpoint: DotnetAspEndpointArtifact): CSharp.Method = CSharp.Method( + name = endpoint.operationName, + modifiers = listOf(CSharp.Modifier.PUBLIC, CSharp.Modifier.ASYNC), + returnType = csharpGenericType( + DotnetCSharpTypes.Threading.Task, + csharpGenericType(ACTION_RESULT_TYPE_NAME, csharpType(resultBaseTypeName(endpoint))), + ), + attributes = buildList { + add(renderRouteAttribute(endpoint)) + endpoint.responses.forEach { response -> + add(renderProducesResponseTypeAttribute(response)) + } + }, + parameters = actionParameters(endpoint), + body = CSharp.codeBlock { + endpoint.bindings.headers?.let { binding -> + local(name = "headers", initializer = renderHeadersInitializer(binding)) + blankLine() + } + local( + name = "result", + initializer = CSharp.await( + CSharp.callValues( + CSharp.identifier("On${endpoint.operationName}Async"), + handlerArguments(endpoint), + ), + ), + ) + returnStatement( + CSharp.call( + CSharp.identifier("Map${endpoint.operationName}Result"), + CSharp.identifier("result"), + ), + ) + }, +) + +internal fun renderAbstractHandler(endpoint: DotnetAspEndpointArtifact): CSharp.Method = CSharp.Method( + name = "On${endpoint.operationName}Async", + modifiers = listOf(CSharp.Modifier.PROTECTED, CSharp.Modifier.ABSTRACT), + returnType = csharpGenericType( + DotnetCSharpTypes.Threading.Task, + csharpType(resultBaseTypeName(endpoint)), + ), + parameters = handlerParameters(endpoint), +) + +private fun renderHeadersInitializer(binding: DotnetAspHeadersBindingArtifact): CSharp.Expression = CSharp.new( + type = csharpType(binding.typeName), + initializers = binding.headers.map { header -> + CSharp.init( + memberName = dotnetAspPascalIdentifier(header.name), + value = CSharp.call( + CSharp.identifier("ReadHeader"), + CSharp.stringLiteral(header.headerName), + ), + ) + }, +) + +private fun renderRouteAttribute(endpoint: DotnetAspEndpointArtifact): CSharp.Attribute = CSharp.attribute( + name = httpAttributeName(endpoint.method), + CSharp.positionalArgument(CSharp.stringLiteral(endpoint.route)), + CSharp.namedArgument("Name", CSharp.stringLiteral(endpoint.operationName)), +) + +private fun renderProducesResponseTypeAttribute(response: DotnetAspResponseArtifact): CSharp.Attribute = + CSharp.attribute( + name = PRODUCES_RESPONSE_TYPE_ATTRIBUTE, + CSharp.positionalArgument(CSharp.rawExpression("typeof(${responseAttributeType(response)})")), + CSharp.positionalArgument(CSharp.intLiteral(response.statusCode)), + ) + +private fun actionParameters(endpoint: DotnetAspEndpointArtifact): List = buildList { + endpoint.bindings.path?.let { + add( + csharpParameter( + type = it.typeName, + name = "path", + attributes = listOf(CSharp.attribute(FROM_ROUTE_ATTRIBUTE)), + ), + ) + } + endpoint.bindings.query?.let { + add( + csharpParameter( + type = it.typeName, + name = "query", + attributes = listOf(CSharp.attribute(FROM_QUERY_ATTRIBUTE)), + ), + ) + } + endpoint.bindings.body?.let { + add( + csharpParameter( + type = it.typeName, + name = "body", + attributes = listOf(CSharp.attribute(FROM_BODY_ATTRIBUTE)), + ), + ) + } + add(csharpParameter(DotnetCSharpTypes.Threading.CancellationToken, "cancellationToken")) +} + +private fun handlerParameters(endpoint: DotnetAspEndpointArtifact): List = buildList { + endpoint.bindings.path?.let { add(csharpParameter(it.typeName, "path")) } + endpoint.bindings.query?.let { add(csharpParameter(it.typeName, "query")) } + endpoint.bindings.headers?.let { add(csharpParameter(it.typeName, "headers")) } + endpoint.bindings.body?.let { add(csharpParameter(it.typeName, "body")) } + add(csharpParameter(DotnetCSharpTypes.Threading.CancellationToken, "cancellationToken")) +} + +private fun handlerArguments(endpoint: DotnetAspEndpointArtifact): List = buildList { + endpoint.bindings.path?.let { add(CSharp.identifier("path")) } + endpoint.bindings.query?.let { add(CSharp.identifier("query")) } + endpoint.bindings.headers?.let { add(CSharp.identifier("headers")) } + endpoint.bindings.body?.let { add(CSharp.identifier("body")) } + add(CSharp.identifier("cancellationToken")) +} + +private fun responseAttributeType(response: DotnetAspResponseArtifact): String = + if (response.statusCode == HTTP_NO_CONTENT_STATUS_CODE) VOID_TYPE_NAME else response.model.typeName + +private fun httpAttributeName(method: String): String = + "Http" + method.lowercase(Locale.ROOT).replaceFirstChar(Char::uppercase) diff --git a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspControllerBaseRendering.kt b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspControllerBaseRendering.kt new file mode 100644 index 00000000..02162a43 --- /dev/null +++ b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspControllerBaseRendering.kt @@ -0,0 +1,96 @@ +package io.github.lmliam.microsmith.compile.services.dotnet.asp + +import io.github.lmliam.microsmith.compile.services.dotnet.csharp.CSharp +import io.github.lmliam.microsmith.compile.services.dotnet.csharp.csharpArrayType +import io.github.lmliam.microsmith.compile.services.dotnet.csharp.csharpNullableType +import io.github.lmliam.microsmith.compile.services.dotnet.csharp.csharpParameter +import io.github.lmliam.microsmith.compile.services.dotnet.csharp.csharpTupleElement +import io.github.lmliam.microsmith.compile.services.dotnet.csharp.csharpTupleType +import io.github.lmliam.microsmith.compile.services.dotnet.csharp.csharpType + +internal fun renderRespondHelper(): CSharp.Method = CSharp.Method( + name = "Respond", + modifiers = listOf(CSharp.Modifier.PROTECTED), + returnType = csharpType(CONTROLLER_ACTION_RESULT_TYPE_NAME), + parameters = listOf( + csharpParameter(csharpNullableType("object"), "body"), + csharpParameter("int", "statusCode"), + csharpParameter( + type = csharpArrayType( + csharpTupleType( + csharpTupleElement(csharpType("string"), "Name"), + csharpTupleElement(csharpNullableType("string"), "Value"), + ), + ), + name = "headers", + modifiers = listOf(CSharp.Modifier.PARAMS), + ), + ), + body = CSharp.codeBlock { + foreachDeconstruction("name", "value", source = CSharp.identifier("headers")) { + ifStatement( + CSharp.binary( + CSharp.identifier("value"), + CSharp.BinaryOperator.IS_NOT, + CSharp.nullLiteral(), + ), + ) { + expression( + CSharp.assignment( + CSharp.index( + CSharp.member(CSharp.identifier("Response"), "Headers"), + CSharp.identifier("name"), + ), + CSharp.identifier("value"), + ), + ) + } + } + blankLine() + ifStatement("statusCode == 204") { + returnStatement( + CSharp.call( + CSharp.identifier("StatusCode"), + CSharp.identifier("statusCode"), + ), + ) + } + blankLine() + returnStatement( + CSharp.new( + type = csharpType(OBJECT_RESULT_TYPE_NAME), + arguments = listOf(CSharp.identifier("body")), + initializers = listOf( + CSharp.init("StatusCode", CSharp.identifier("statusCode")), + ), + ), + ) + }, +) + +internal fun renderReadHeaderHelper(): CSharp.Method = CSharp.Method( + name = "ReadHeader", + modifiers = listOf(CSharp.Modifier.PROTECTED), + returnType = csharpNullableType("string"), + parameters = listOf(csharpParameter("string", "headerName")), + body = CSharp.codeBlock { + returnStatement( + CSharp.conditional( + condition = CSharp.call( + callee = CSharp.member( + CSharp.member(CSharp.identifier("Request"), "Headers"), + "TryGetValue", + ), + arguments = listOf( + CSharp.argument(CSharp.identifier("headerName")), + CSharp.outVariable("values"), + ), + ), + whenTrue = CSharp.call( + CSharp.member(CSharp.identifier("values"), "ToString"), + ), + whenFalse = CSharp.nullLiteral(), + ), + ) + }, +) diff --git a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspControllerFileRenderer.kt b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspControllerFileRenderer.kt index ed2abbf5..c71f7dd5 100644 --- a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspControllerFileRenderer.kt +++ b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspControllerFileRenderer.kt @@ -1,141 +1,46 @@ package io.github.lmliam.microsmith.compile.services.dotnet.asp import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspEndpointArtifact -import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspHeadersBindingArtifact -import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspResponseArtifact import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspServiceArtifact -import java.util.Locale +import io.github.lmliam.microsmith.compile.services.dotnet.csharp.CSharp +import io.github.lmliam.microsmith.compile.services.dotnet.csharp.csharpType internal object DotnetAspControllerFileRenderer { - fun renderControllerBaseFile(artifact: DotnetAspServiceArtifact): String = buildString { - appendLine("using System;") - appendLine("using System.Threading;") - appendLine("using System.Threading.Tasks;") - appendLine("using ${contractsNamespace(artifact)};") - appendLine("using Microsoft.AspNetCore.Mvc;") - appendLine() - appendLine("namespace ${controllersNamespace(artifact)};") - appendLine() - appendLine("[ApiController]") - appendLine( - "public abstract class ${controllerBaseTypeName(artifact)} : " + - MICROSMITH_CONTROLLER_BASE_TYPE_NAME, - ) - appendLine("{") - artifact.endpoints.forEach { endpoint -> - append(renderActionMethod(endpoint)) - appendLine() - append(renderAbstractHandler(endpoint)) - appendLine() - append(renderResultMapper(endpoint)) - appendLine() - } - appendLine("}") - } - - private fun renderActionMethod(endpoint: DotnetAspEndpointArtifact): String = buildString { - val routeLiteral = escapeDotnetAspCsharpStringLiteral(endpoint.route) - val operationNameLiteral = escapeDotnetAspCsharpStringLiteral(endpoint.operationName) - appendLine( - " [${httpAttributeName(endpoint.method)}($routeLiteral, Name = $operationNameLiteral)]", - ) - endpoint.responses.forEach { response -> - appendLine( - " [ProducesResponseType(typeof(${responseAttributeType(response)}), ${response.statusCode})]", - ) - } - append(" public async Task> ${endpoint.operationName}(") - append(actionParameters(endpoint).joinToString(", ")) - appendLine(")") - appendLine(" {") - endpoint.bindings.headers?.let { binding -> - append(renderHeadersInitializer(binding)) - appendLine() - } - appendLine( - " var result = await On${endpoint.operationName}Async(" + - handlerArguments(endpoint).joinToString(", ") + - ");", - ) - appendLine(" return Map${endpoint.operationName}Result(result);") - appendLine(" }") - } - - private fun renderAbstractHandler(endpoint: DotnetAspEndpointArtifact): String = buildString { - append(" protected abstract Task<${resultBaseTypeName(endpoint)}> On${endpoint.operationName}Async(") - append(handlerParameters(endpoint).joinToString(", ")) - appendLine(");") - } - - private fun renderResultMapper(endpoint: DotnetAspEndpointArtifact): String = buildString { - appendLine( - " private ActionResult<${resultBaseTypeName(endpoint)}> " + - "Map${endpoint.operationName}Result(${resultBaseTypeName(endpoint)} result)", - ) - appendLine(" {") - appendLine(" return result switch") - appendLine(" {") - endpoint.responses.forEach { response -> - append(" ${resultVariantTypeName(endpoint, response)} response => Respond(") - append(renderResponseBodyArgument(response)) - append(", ${response.statusCode}") - response.headers.forEach { header -> - append( - ", (${escapeDotnetAspCsharpStringLiteral(header.name)}, " + - "response.${dotnetAspHeaderPropertyName(header.name)})", - ) + fun renderControllerBaseFile(artifact: DotnetAspServiceArtifact): String = CSharp.render( + CSharp.file(controllersNamespace(artifact)) { + using(contractsNamespace(artifact)) + using(ASP_NET_MVC_NAMESPACE) + using(SYSTEM_NAMESPACE) + using(SYSTEM_THREADING_NAMESPACE) + using(SYSTEM_TASKS_NAMESPACE) + classType( + name = controllerBaseTypeName(artifact), + modifiers = listOf(CSharp.Modifier.PUBLIC, CSharp.Modifier.ABSTRACT), + baseTypes = listOf(csharpType(MICROSMITH_CONTROLLER_BASE_TYPE_NAME)), + attributes = listOf(CSharp.attribute(API_CONTROLLER_ATTRIBUTE)), + ) { + artifact.endpoints.forEach { endpoint -> + addMember(renderActionMethod(endpoint)) + } + artifact.endpoints.forEach { endpoint -> + addMember(renderAbstractHandler(endpoint)) + } + artifact.endpoints.forEach { endpoint -> + addMember(renderResultMapper(endpoint)) + } } - appendLine("),") - } - val unsupportedMessage = - escapeDotnetAspCsharpStringLiteral("Unsupported ${endpoint.operationName} result type.") - appendLine(" _ => throw new InvalidOperationException(") - appendLine(" $unsupportedMessage + result.GetType().FullName + \".\"),") - appendLine(" };") - appendLine(" }") - } - - private fun renderHeadersInitializer(binding: DotnetAspHeadersBindingArtifact): String = buildString { - appendLine(" var headers = new ${binding.typeName}") - appendLine(" {") - binding.headers.forEachIndexed { index, header -> - val suffix = if (index == binding.headers.lastIndex) "" else "," - val headerPropertyName = dotnetAspPascalIdentifier(header.name) - val headerLiteral = escapeDotnetAspCsharpStringLiteral(header.headerName) - appendLine(" $headerPropertyName = ReadHeader($headerLiteral)$suffix") - } - appendLine(" };") - } - - private fun actionParameters(endpoint: DotnetAspEndpointArtifact): List = buildList { - endpoint.bindings.path?.let { add("[FromRoute] ${it.typeName} path") } - endpoint.bindings.query?.let { add("[FromQuery] ${it.typeName} query") } - endpoint.bindings.body?.let { add("[FromBody] ${it.typeName} body") } - add("CancellationToken cancellationToken") - } - - private fun handlerParameters(endpoint: DotnetAspEndpointArtifact): List = buildList { - endpoint.bindings.path?.let { add("${it.typeName} path") } - endpoint.bindings.query?.let { add("${it.typeName} query") } - endpoint.bindings.headers?.let { add("${it.typeName} headers") } - endpoint.bindings.body?.let { add("${it.typeName} body") } - add("CancellationToken cancellationToken") - } - - private fun handlerArguments(endpoint: DotnetAspEndpointArtifact): List = buildList { - endpoint.bindings.path?.let { add("path") } - endpoint.bindings.query?.let { add("query") } - endpoint.bindings.headers?.let { add("headers") } - endpoint.bindings.body?.let { add("body") } - add("cancellationToken") - } - - private fun responseAttributeType(response: DotnetAspResponseArtifact): String = - if (response.statusCode == HTTP_NO_CONTENT_STATUS_CODE) "void" else response.model.typeName - - private fun renderResponseBodyArgument(response: DotnetAspResponseArtifact): String = - if (response.statusCode == HTTP_NO_CONTENT_STATUS_CODE) "null" else "response.$RESULT_BODY_PROPERTY_NAME" - - private fun httpAttributeName(method: String): String = - "Http" + method.lowercase(Locale.ROOT).replaceFirstChar(Char::uppercase) + }, + ) } + +internal const val ACTION_RESULT_TYPE_NAME = "ActionResult" +internal const val API_CONTROLLER_ATTRIBUTE = "ApiController" +internal const val FROM_BODY_ATTRIBUTE = "FromBody" +internal const val FROM_QUERY_ATTRIBUTE = "FromQuery" +internal const val FROM_ROUTE_ATTRIBUTE = "FromRoute" +internal const val PRODUCES_RESPONSE_TYPE_ATTRIBUTE = "ProducesResponseType" +internal const val VOID_TYPE_NAME = "void" +private const val ASP_NET_MVC_NAMESPACE = "Microsoft.AspNetCore.Mvc" +private const val SYSTEM_NAMESPACE = "System" +private const val SYSTEM_TASKS_NAMESPACE = "System.Threading.Tasks" +private const val SYSTEM_THREADING_NAMESPACE = "System.Threading" diff --git a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspControllerResultRendering.kt b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspControllerResultRendering.kt new file mode 100644 index 00000000..b4cbab76 --- /dev/null +++ b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspControllerResultRendering.kt @@ -0,0 +1,76 @@ +package io.github.lmliam.microsmith.compile.services.dotnet.asp + +import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspEndpointArtifact +import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspResponseArtifact +import io.github.lmliam.microsmith.compile.services.dotnet.csharp.CSharp +import io.github.lmliam.microsmith.compile.services.dotnet.csharp.DotnetCSharpTypes +import io.github.lmliam.microsmith.compile.services.dotnet.csharp.csharpGenericType +import io.github.lmliam.microsmith.compile.services.dotnet.csharp.csharpParameter +import io.github.lmliam.microsmith.compile.services.dotnet.csharp.csharpType + +internal fun renderResultMapper(endpoint: DotnetAspEndpointArtifact): CSharp.Method = CSharp.Method( + name = "Map${endpoint.operationName}Result", + modifiers = listOf(CSharp.Modifier.PRIVATE), + returnType = csharpGenericType( + ACTION_RESULT_TYPE_NAME, + csharpType(resultBaseTypeName(endpoint)), + ), + parameters = listOf(csharpParameter(resultBaseTypeName(endpoint), "result")), + body = CSharp.codeBlock { + returnStatement( + CSharp.switch( + subject = CSharp.identifier("result"), + arms = buildList { + endpoint.responses.forEach { response -> + add(renderResultSwitchArm(endpoint, response)) + } + add(renderUnsupportedResultArm(endpoint)) + }, + ), + ) + }, +) + +private fun renderResultSwitchArm( + endpoint: DotnetAspEndpointArtifact, + response: DotnetAspResponseArtifact, +): CSharp.SwitchArm = CSharp.switchArm( + pattern = "${resultVariantTypeName(endpoint, response)} response", + expression = CSharp.callValues(CSharp.identifier("Respond"), responseArguments(response)), +) + +private fun responseArguments(response: DotnetAspResponseArtifact): List = buildList { + add( + if (response.statusCode == HTTP_NO_CONTENT_STATUS_CODE) { + CSharp.nullLiteral() + } else { + CSharp.member(CSharp.identifier("response"), RESULT_BODY_PROPERTY_NAME) + }, + ) + add(CSharp.intLiteral(response.statusCode)) + response.headers.forEach { header -> + add( + CSharp.tupleLiteral( + CSharp.stringLiteral(header.name), + CSharp.member( + CSharp.identifier("response"), + dotnetAspHeaderPropertyName(header.name), + ), + ), + ) + } +} + +private fun renderUnsupportedResultArm(endpoint: DotnetAspEndpointArtifact): CSharp.SwitchArm = CSharp.switchArm( + pattern = "_", + expression = CSharp.throwExpression( + CSharp.new( + type = csharpType(DotnetCSharpTypes.System.InvalidOperationException), + arguments = listOf( + CSharp.rawExpression( + """$"Unsupported ${endpoint.operationName} result type '{result.GetType().FullName}'."""", + ), + ), + ), + ), +) diff --git a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspHostingExtensionsRendering.kt b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspHostingExtensionsRendering.kt new file mode 100644 index 00000000..5a6d84a8 --- /dev/null +++ b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspHostingExtensionsRendering.kt @@ -0,0 +1,38 @@ +package io.github.lmliam.microsmith.compile.services.dotnet.asp + +import io.github.lmliam.microsmith.compile.services.dotnet.csharp.CSharp +import io.github.lmliam.microsmith.compile.services.dotnet.csharp.csharpType +import io.github.lmliam.microsmith.compile.services.dotnet.csharp.extensionParameter + +internal fun renderAddMicrosmithExtension(): CSharp.Method = CSharp.Method( + name = "AddMicrosmith", + modifiers = listOf(CSharp.Modifier.PUBLIC, CSharp.Modifier.STATIC), + returnType = csharpType(WEB_APPLICATION_BUILDER_TYPE_NAME), + parameters = listOf(extensionParameter(WEB_APPLICATION_BUILDER_TYPE_NAME, "builder")), + body = CSharp.codeBlock { + expression( + CSharp.call( + CSharp.member( + CSharp.member(CSharp.identifier("builder"), "Services"), + "AddControllers", + ), + ), + ) + returnStatement(CSharp.identifier("builder")) + }, +) + +internal fun renderMapMicrosmithExtension(): CSharp.Method = CSharp.Method( + name = "MapMicrosmith", + modifiers = listOf(CSharp.Modifier.PUBLIC, CSharp.Modifier.STATIC), + returnType = csharpType(WEB_APPLICATION_TYPE_NAME), + parameters = listOf(extensionParameter(WEB_APPLICATION_TYPE_NAME, "app")), + body = CSharp.codeBlock { + expression( + CSharp.call( + CSharp.member(CSharp.identifier("app"), "MapControllers"), + ), + ) + returnStatement(CSharp.identifier("app")) + }, +) diff --git a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspInfrastructureFileRenderer.kt b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspInfrastructureFileRenderer.kt index 16afb462..a623e96a 100644 --- a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspInfrastructureFileRenderer.kt +++ b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspInfrastructureFileRenderer.kt @@ -1,6 +1,8 @@ package io.github.lmliam.microsmith.compile.services.dotnet.asp import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspServiceArtifact +import io.github.lmliam.microsmith.compile.services.dotnet.csharp.CSharp +import io.github.lmliam.microsmith.compile.services.dotnet.csharp.csharpType internal object DotnetAspInfrastructureFileRenderer { fun renderProgramFile(artifact: DotnetAspServiceArtifact): String = """ @@ -16,62 +18,41 @@ internal object DotnetAspInfrastructureFileRenderer { public partial class Program { } """.trimIndent() - fun renderHostingExtensionsFile(artifact: DotnetAspServiceArtifact): String = """ - using Microsoft.AspNetCore.Builder; - using Microsoft.Extensions.DependencyInjection; - - namespace ${hostingNamespace(artifact)}; - - public static class MicrosmithHostingExtensions - { - public static WebApplicationBuilder AddMicrosmith(this WebApplicationBuilder builder) - { - builder.Services.AddControllers(); - return builder; - } - - public static WebApplication MapMicrosmith(this WebApplication app) - { - app.MapControllers(); - return app; - } - } - """.trimIndent() - - fun renderMicrosmithControllerBaseFile(artifact: DotnetAspServiceArtifact): String = """ - using Microsoft.AspNetCore.Mvc; - - namespace ${controllersNamespace(artifact)}; - - public abstract class $MICROSMITH_CONTROLLER_BASE_TYPE_NAME : ControllerBase - { - protected ActionResult Respond(object? body, int statusCode, params (string Name, string? Value)[] headers) - { - foreach (var (name, value) in headers) - { - if (value is not null) - { - Response.Headers[name] = value; - } - } - - if (statusCode == 204) - { - return StatusCode(statusCode); - } - - return new ObjectResult(body) - { - StatusCode = statusCode - }; + fun renderHostingExtensionsFile(artifact: DotnetAspServiceArtifact): String = CSharp.render( + CSharp.file(hostingNamespace(artifact)) { + using(ASP_NET_BUILDER_NAMESPACE) + using(DEPENDENCY_INJECTION_NAMESPACE) + classType( + name = MICROSMITH_HOSTING_EXTENSIONS_TYPE_NAME, + modifiers = listOf(CSharp.Modifier.PUBLIC, CSharp.Modifier.STATIC), + ) { + addMember(renderAddMicrosmithExtension()) + addMember(renderMapMicrosmithExtension()) } - - protected string? ReadHeader(string headerName) - { - return Request.Headers.TryGetValue(headerName, out var values) - ? values.ToString() - : null; + }, + ) + + fun renderMicrosmithControllerBaseFile(artifact: DotnetAspServiceArtifact): String = CSharp.render( + CSharp.file(controllersNamespace(artifact)) { + using(ASP_NET_MVC_NAMESPACE) + classType( + name = MICROSMITH_CONTROLLER_BASE_TYPE_NAME, + modifiers = listOf(CSharp.Modifier.PUBLIC, CSharp.Modifier.ABSTRACT), + baseTypes = listOf(csharpType(CONTROLLER_BASE_TYPE_NAME)), + ) { + addMember(renderRespondHelper()) + addMember(renderReadHeaderHelper()) } - } - """.trimIndent() + }, + ) } + +internal const val CONTROLLER_BASE_TYPE_NAME = "ControllerBase" +internal const val CONTROLLER_ACTION_RESULT_TYPE_NAME = "ActionResult" +internal const val OBJECT_RESULT_TYPE_NAME = "ObjectResult" +internal const val WEB_APPLICATION_BUILDER_TYPE_NAME = "WebApplicationBuilder" +internal const val WEB_APPLICATION_TYPE_NAME = "WebApplication" +private const val ASP_NET_MVC_NAMESPACE = "Microsoft.AspNetCore.Mvc" +private const val ASP_NET_BUILDER_NAMESPACE = "Microsoft.AspNetCore.Builder" +private const val DEPENDENCY_INJECTION_NAMESPACE = "Microsoft.Extensions.DependencyInjection" +private const val MICROSMITH_HOSTING_EXTENSIONS_TYPE_NAME = "MicrosmithHostingExtensions" diff --git a/modules/compile-services-dotnet-asp/src/test/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspServiceArtifactCompilerTests.kt b/modules/compile-services-dotnet-asp/src/test/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspServiceArtifactCompilerTests.kt index 6c263c73..767cf121 100644 --- a/modules/compile-services-dotnet-asp/src/test/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspServiceArtifactCompilerTests.kt +++ b/modules/compile-services-dotnet-asp/src/test/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspServiceArtifactCompilerTests.kt @@ -107,7 +107,7 @@ class DotnetAspServiceArtifactCompilerTests : requestModels.shouldContain("public nuint MaxValue { get; set; } = (nuint)4294967296UL;") } - "compile emits CLR usings before the contract namespace when request bindings use system types" { + "compile emits sorted usings before the contract namespace when request bindings use system types" { val requestModels = DotnetAspServiceArtifactCompiler() .compile(requestBindingTypesArtifact()) .filterIsInstance() @@ -115,8 +115,8 @@ class DotnetAspServiceArtifactCompilerTests : .contents requestModels.lines().take(4) shouldContainExactly listOf( - "using System;", "using Microsoft.AspNetCore.Mvc.ModelBinding;", + "using System;", "", "namespace UserService.Api.Generated.Contracts;", ) diff --git a/modules/gen/src/test/kotlin/io/github/lmliam/microsmith/gen/helpers/GeneratedOutputUniquenessValidatorTests.kt b/modules/gen/src/test/kotlin/io/github/lmliam/microsmith/gen/helpers/GeneratedOutputUniquenessValidatorTests.kt index c6e116e5..efbbe49f 100644 --- a/modules/gen/src/test/kotlin/io/github/lmliam/microsmith/gen/helpers/GeneratedOutputUniquenessValidatorTests.kt +++ b/modules/gen/src/test/kotlin/io/github/lmliam/microsmith/gen/helpers/GeneratedOutputUniquenessValidatorTests.kt @@ -56,7 +56,9 @@ class GeneratedOutputUniquenessValidatorTests : } "requireUniqueOutputPaths rejects absolute generated file paths" { - val absolutePath = Files.createTempDirectory("microsmith-uniqueness-").resolve("User.proto").toAbsolutePath() + val absolutePath = Files.createTempDirectory("microsmith-uniqueness-") + .resolve("User.proto") + .toAbsolutePath() shouldThrow { GeneratedOutputUniquenessValidator.requireUniqueOutputPaths( From 47d248dec6208393aa9f6bd9b7addd39f2aa0dfc Mon Sep 17 00:00:00 2001 From: LMLiam <46268350+TheRealEmissions@users.noreply.github.com> Date: Thu, 23 Apr 2026 19:11:19 +0100 Subject: [PATCH 23/26] fix(services-dotnet-asp): satisfy renderer ktlint --- .../asp/DotnetAspContractFileRenderer.kt | 32 +++++++++---------- .../asp/DotnetAspControllerFileRenderer.kt | 1 - 2 files changed, 15 insertions(+), 18 deletions(-) diff --git a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspContractFileRenderer.kt b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspContractFileRenderer.kt index b469a7b5..944bc607 100644 --- a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspContractFileRenderer.kt +++ b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspContractFileRenderer.kt @@ -1,6 +1,5 @@ package io.github.lmliam.microsmith.compile.services.dotnet.asp -import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspEndpointArtifact import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspModelArtifact import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspModelLocality import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspResponseArtifact @@ -18,22 +17,21 @@ internal object DotnetAspContractFileRenderer { .forEach { addType(renderRecordType(it.typeName, it.model.fields)) } } - fun renderRequestModelsFile(artifact: DotnetAspServiceArtifact): String = - renderContractsFile( - artifact, - usings = setOf(SYSTEM_NAMESPACE, MODEL_BINDING_NAMESPACE), - ) { - buildList { - collectRequestBindings(artifact).forEach { add(renderRequestBindingType(it)) } - collectHeaderBindings(artifact).forEach { add(renderHeadersBindingType(it)) } - artifact.endpoints.forEach { endpoint -> - endpoint.bindings.body - ?.takeIf { it.locality == DotnetAspModelLocality.INLINE } - ?.let { add(renderRecordType(it.typeName, it.model.fields)) } - } - }.distinctBy(CSharp.Type::name) - .forEach(::addType) - } + fun renderRequestModelsFile(artifact: DotnetAspServiceArtifact): String = renderContractsFile( + artifact, + usings = setOf(SYSTEM_NAMESPACE, MODEL_BINDING_NAMESPACE), + ) { + buildList { + collectRequestBindings(artifact).forEach { add(renderRequestBindingType(it)) } + collectHeaderBindings(artifact).forEach { add(renderHeadersBindingType(it)) } + artifact.endpoints.forEach { endpoint -> + endpoint.bindings.body + ?.takeIf { it.locality == DotnetAspModelLocality.INLINE } + ?.let { add(renderRecordType(it.typeName, it.model.fields)) } + } + }.distinctBy(CSharp.Type::name) + .forEach(::addType) + } fun renderResponseModelsFile(artifact: DotnetAspServiceArtifact): String = renderContractsFile(artifact, usings = setOf(SYSTEM_NAMESPACE)) { diff --git a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspControllerFileRenderer.kt b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspControllerFileRenderer.kt index c71f7dd5..4ce9e365 100644 --- a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspControllerFileRenderer.kt +++ b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspControllerFileRenderer.kt @@ -1,6 +1,5 @@ package io.github.lmliam.microsmith.compile.services.dotnet.asp -import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspEndpointArtifact import io.github.lmliam.microsmith.artifact.services.dotnet.asp.DotnetAspServiceArtifact import io.github.lmliam.microsmith.compile.services.dotnet.csharp.CSharp import io.github.lmliam.microsmith.compile.services.dotnet.csharp.csharpType From af22419b38a1f506e1c9c709881931b0b7bc8d01 Mon Sep 17 00:00:00 2001 From: LMLiam <46268350+TheRealEmissions@users.noreply.github.com> Date: Thu, 23 Apr 2026 19:54:44 +0100 Subject: [PATCH 24/26] fix(gen): normalize origins manifest output roots --- .../GeneratedOriginsManifestBuilder.kt | 2 +- .../GeneratedOriginsManifestBuilderTests.kt | 23 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/modules/gen/src/main/kotlin/io/github/lmliam/microsmith/gen/helpers/GeneratedOriginsManifestBuilder.kt b/modules/gen/src/main/kotlin/io/github/lmliam/microsmith/gen/helpers/GeneratedOriginsManifestBuilder.kt index 29b745be..a24fde9b 100644 --- a/modules/gen/src/main/kotlin/io/github/lmliam/microsmith/gen/helpers/GeneratedOriginsManifestBuilder.kt +++ b/modules/gen/src/main/kotlin/io/github/lmliam/microsmith/gen/helpers/GeneratedOriginsManifestBuilder.kt @@ -10,7 +10,7 @@ internal object GeneratedOriginsManifestBuilder { fun appendTo(outputs: List): List { val manifests = outputs - .groupBy(GeneratedFile::outputRoot) + .groupBy { generatedFile -> generatedFile.outputRoot.normalize() } .mapNotNull { (outputRoot, files) -> val tracedFiles = files .filter { it.relativePath != manifestRelativePath } diff --git a/modules/gen/src/test/kotlin/io/github/lmliam/microsmith/gen/helpers/GeneratedOriginsManifestBuilderTests.kt b/modules/gen/src/test/kotlin/io/github/lmliam/microsmith/gen/helpers/GeneratedOriginsManifestBuilderTests.kt index 87fac34e..f9583867 100644 --- a/modules/gen/src/test/kotlin/io/github/lmliam/microsmith/gen/helpers/GeneratedOriginsManifestBuilderTests.kt +++ b/modules/gen/src/test/kotlin/io/github/lmliam/microsmith/gen/helpers/GeneratedOriginsManifestBuilderTests.kt @@ -42,4 +42,27 @@ class GeneratedOriginsManifestBuilderTests : String(manifests.single { it.outputRoot == Path("repo-b") }.contents, StandardCharsets.UTF_8) .shouldContain("services.AdminService") } + + "appendTo normalizes equivalent output roots before creating manifests" { + val outputs = listOf( + GeneratedFile( + relativePath = Path("Program.cs"), + contents = byteArrayOf(1), + outputRoot = Path("dotnet/Platform/UserService.Api"), + origins = setOf("services.UserService"), + ), + GeneratedFile( + relativePath = Path("Generated/Contracts/RequestModels.cs"), + contents = byteArrayOf(2), + outputRoot = Path("dotnet/Platform/./UserService.Api"), + origins = setOf("services.UserService.rest.GetUser.query.GetUserQuery"), + ), + ) + + val manifests = GeneratedOriginsManifestBuilder.appendTo(outputs) + .filter { it.relativePath == Path(".microsmith/origins.json") } + + manifests.size shouldBe 1 + manifests.single().outputRoot shouldBe Path("dotnet/Platform/UserService.Api") + } }) From 4b7a3531ae8a0d6a505b10e8d5b6c91db883ca21 Mon Sep 17 00:00:00 2001 From: LMLiam <46268350+TheRealEmissions@users.noreply.github.com> Date: Thu, 23 Apr 2026 21:25:23 +0100 Subject: [PATCH 25/26] refactor(services-dotnet-asp): restore structured C# symbols --- .../dotnet/asp/DotnetAspCSharpNamespaces.kt | 46 +++++++++++++++++++ .../dotnet/asp/DotnetAspCSharpTypes.kt | 31 +++++++++++++ .../asp/DotnetAspContractFileRenderer.kt | 14 +++--- .../asp/DotnetAspControllerActionRendering.kt | 2 +- .../asp/DotnetAspControllerBaseRendering.kt | 4 +- .../asp/DotnetAspControllerFileRenderer.kt | 13 ++---- .../asp/DotnetAspControllerResultRendering.kt | 2 +- .../DotnetAspHostingExtensionsRendering.kt | 8 ++-- .../DotnetAspInfrastructureFileRenderer.kt | 16 ++----- 9 files changed, 100 insertions(+), 36 deletions(-) create mode 100644 modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspCSharpNamespaces.kt create mode 100644 modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspCSharpTypes.kt diff --git a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspCSharpNamespaces.kt b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspCSharpNamespaces.kt new file mode 100644 index 00000000..63907eff --- /dev/null +++ b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspCSharpNamespaces.kt @@ -0,0 +1,46 @@ +package io.github.lmliam.microsmith.compile.services.dotnet.asp + +import io.github.lmliam.microsmith.compile.services.dotnet.csharp.CSharpFileBuilder +import io.github.lmliam.microsmith.compile.services.dotnet.csharp.DotnetCSharpNamespace + +internal object DotnetAspCSharpNamespaces { + data object System : DotnetCSharpNamespace { + override val value = "System" + } + + object SystemThreading { + data object Root : DotnetCSharpNamespace { + override val value = "System.Threading" + } + + data object Tasks : DotnetCSharpNamespace { + override val value = "System.Threading.Tasks" + } + } + + object Microsoft { + object AspNetCore { + data object Builder : DotnetCSharpNamespace { + override val value = "Microsoft.AspNetCore.Builder" + } + + data object Mvc : DotnetCSharpNamespace { + override val value = "Microsoft.AspNetCore.Mvc" + } + + data object ModelBinding : DotnetCSharpNamespace { + override val value = "Microsoft.AspNetCore.Mvc.ModelBinding" + } + } + + object Extensions { + data object DependencyInjection : DotnetCSharpNamespace { + override val value = "Microsoft.Extensions.DependencyInjection" + } + } + } +} + +internal fun CSharpFileBuilder.using(namespace: DotnetCSharpNamespace) { + using(namespace.value) +} diff --git a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspCSharpTypes.kt b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspCSharpTypes.kt new file mode 100644 index 00000000..e93922de --- /dev/null +++ b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspCSharpTypes.kt @@ -0,0 +1,31 @@ +package io.github.lmliam.microsmith.compile.services.dotnet.asp + +import io.github.lmliam.microsmith.compile.services.dotnet.csharp.DotnetCSharpTypeName + +internal object DotnetAspCSharpTypes { + object AspNetCore { + object Builder { + data object WebApplication : DotnetCSharpTypeName { + override val value = "WebApplication" + } + + data object WebApplicationBuilder : DotnetCSharpTypeName { + override val value = "WebApplicationBuilder" + } + } + + object Mvc { + data object ActionResult : DotnetCSharpTypeName { + override val value = "ActionResult" + } + + data object ControllerBase : DotnetCSharpTypeName { + override val value = "ControllerBase" + } + + data object ObjectResult : DotnetCSharpTypeName { + override val value = "ObjectResult" + } + } + } +} diff --git a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspContractFileRenderer.kt b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspContractFileRenderer.kt index 944bc607..d75f173d 100644 --- a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspContractFileRenderer.kt +++ b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspContractFileRenderer.kt @@ -9,7 +9,7 @@ import io.github.lmliam.microsmith.compile.services.dotnet.csharp.CSharpFileBuil internal object DotnetAspContractFileRenderer { fun renderServiceModelsFile(artifact: DotnetAspServiceArtifact): String = - renderContractsFile(artifact, usings = setOf(SYSTEM_NAMESPACE)) { + renderContractsFile(artifact, usings = setOf(DotnetAspCSharpNamespaces.System)) { artifact.contractModels .distinctBy(DotnetAspModelArtifact::typeName) .filter { it.locality == DotnetAspModelLocality.SHARED } @@ -19,7 +19,10 @@ internal object DotnetAspContractFileRenderer { fun renderRequestModelsFile(artifact: DotnetAspServiceArtifact): String = renderContractsFile( artifact, - usings = setOf(SYSTEM_NAMESPACE, MODEL_BINDING_NAMESPACE), + usings = setOf( + DotnetAspCSharpNamespaces.System, + DotnetAspCSharpNamespaces.Microsoft.AspNetCore.ModelBinding, + ), ) { buildList { collectRequestBindings(artifact).forEach { add(renderRequestBindingType(it)) } @@ -34,7 +37,7 @@ internal object DotnetAspContractFileRenderer { } fun renderResponseModelsFile(artifact: DotnetAspServiceArtifact): String = - renderContractsFile(artifact, usings = setOf(SYSTEM_NAMESPACE)) { + renderContractsFile(artifact, usings = setOf(DotnetAspCSharpNamespaces.System)) { buildList { artifact.endpoints.forEach { endpoint -> endpoint.responses @@ -55,7 +58,7 @@ internal object DotnetAspContractFileRenderer { private fun renderContractsFile( artifact: DotnetAspServiceArtifact, - usings: Set, + usings: Set, build: CSharpFileBuilder.() -> Unit, ): String = CSharp.render( CSharp.file(contractsNamespace(artifact)) { @@ -64,6 +67,3 @@ internal object DotnetAspContractFileRenderer { }, ) } - -private const val MODEL_BINDING_NAMESPACE = "Microsoft.AspNetCore.Mvc.ModelBinding" -private const val SYSTEM_NAMESPACE = "System" diff --git a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspControllerActionRendering.kt b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspControllerActionRendering.kt index 4d9867fa..ff71b48b 100644 --- a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspControllerActionRendering.kt +++ b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspControllerActionRendering.kt @@ -15,7 +15,7 @@ internal fun renderActionMethod(endpoint: DotnetAspEndpointArtifact): CSharp.Met modifiers = listOf(CSharp.Modifier.PUBLIC, CSharp.Modifier.ASYNC), returnType = csharpGenericType( DotnetCSharpTypes.Threading.Task, - csharpGenericType(ACTION_RESULT_TYPE_NAME, csharpType(resultBaseTypeName(endpoint))), + csharpGenericType(DotnetAspCSharpTypes.AspNetCore.Mvc.ActionResult, csharpType(resultBaseTypeName(endpoint))), ), attributes = buildList { add(renderRouteAttribute(endpoint)) diff --git a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspControllerBaseRendering.kt b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspControllerBaseRendering.kt index 02162a43..0d0eeca0 100644 --- a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspControllerBaseRendering.kt +++ b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspControllerBaseRendering.kt @@ -11,7 +11,7 @@ import io.github.lmliam.microsmith.compile.services.dotnet.csharp.csharpType internal fun renderRespondHelper(): CSharp.Method = CSharp.Method( name = "Respond", modifiers = listOf(CSharp.Modifier.PROTECTED), - returnType = csharpType(CONTROLLER_ACTION_RESULT_TYPE_NAME), + returnType = csharpType(DotnetAspCSharpTypes.AspNetCore.Mvc.ActionResult), parameters = listOf( csharpParameter(csharpNullableType("object"), "body"), csharpParameter("int", "statusCode"), @@ -58,7 +58,7 @@ internal fun renderRespondHelper(): CSharp.Method = CSharp.Method( blankLine() returnStatement( CSharp.new( - type = csharpType(OBJECT_RESULT_TYPE_NAME), + type = csharpType(DotnetAspCSharpTypes.AspNetCore.Mvc.ObjectResult), arguments = listOf(CSharp.identifier("body")), initializers = listOf( CSharp.init("StatusCode", CSharp.identifier("statusCode")), diff --git a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspControllerFileRenderer.kt b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspControllerFileRenderer.kt index 4ce9e365..68c6087a 100644 --- a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspControllerFileRenderer.kt +++ b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspControllerFileRenderer.kt @@ -8,10 +8,10 @@ internal object DotnetAspControllerFileRenderer { fun renderControllerBaseFile(artifact: DotnetAspServiceArtifact): String = CSharp.render( CSharp.file(controllersNamespace(artifact)) { using(contractsNamespace(artifact)) - using(ASP_NET_MVC_NAMESPACE) - using(SYSTEM_NAMESPACE) - using(SYSTEM_THREADING_NAMESPACE) - using(SYSTEM_TASKS_NAMESPACE) + using(DotnetAspCSharpNamespaces.Microsoft.AspNetCore.Mvc) + using(DotnetAspCSharpNamespaces.System) + using(DotnetAspCSharpNamespaces.SystemThreading.Root) + using(DotnetAspCSharpNamespaces.SystemThreading.Tasks) classType( name = controllerBaseTypeName(artifact), modifiers = listOf(CSharp.Modifier.PUBLIC, CSharp.Modifier.ABSTRACT), @@ -32,14 +32,9 @@ internal object DotnetAspControllerFileRenderer { ) } -internal const val ACTION_RESULT_TYPE_NAME = "ActionResult" internal const val API_CONTROLLER_ATTRIBUTE = "ApiController" internal const val FROM_BODY_ATTRIBUTE = "FromBody" internal const val FROM_QUERY_ATTRIBUTE = "FromQuery" internal const val FROM_ROUTE_ATTRIBUTE = "FromRoute" internal const val PRODUCES_RESPONSE_TYPE_ATTRIBUTE = "ProducesResponseType" internal const val VOID_TYPE_NAME = "void" -private const val ASP_NET_MVC_NAMESPACE = "Microsoft.AspNetCore.Mvc" -private const val SYSTEM_NAMESPACE = "System" -private const val SYSTEM_TASKS_NAMESPACE = "System.Threading.Tasks" -private const val SYSTEM_THREADING_NAMESPACE = "System.Threading" diff --git a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspControllerResultRendering.kt b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspControllerResultRendering.kt index b4cbab76..023dc6c0 100644 --- a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspControllerResultRendering.kt +++ b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspControllerResultRendering.kt @@ -12,7 +12,7 @@ internal fun renderResultMapper(endpoint: DotnetAspEndpointArtifact): CSharp.Met name = "Map${endpoint.operationName}Result", modifiers = listOf(CSharp.Modifier.PRIVATE), returnType = csharpGenericType( - ACTION_RESULT_TYPE_NAME, + DotnetAspCSharpTypes.AspNetCore.Mvc.ActionResult, csharpType(resultBaseTypeName(endpoint)), ), parameters = listOf(csharpParameter(resultBaseTypeName(endpoint), "result")), diff --git a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspHostingExtensionsRendering.kt b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspHostingExtensionsRendering.kt index 5a6d84a8..3e0781bd 100644 --- a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspHostingExtensionsRendering.kt +++ b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspHostingExtensionsRendering.kt @@ -7,8 +7,8 @@ import io.github.lmliam.microsmith.compile.services.dotnet.csharp.extensionParam internal fun renderAddMicrosmithExtension(): CSharp.Method = CSharp.Method( name = "AddMicrosmith", modifiers = listOf(CSharp.Modifier.PUBLIC, CSharp.Modifier.STATIC), - returnType = csharpType(WEB_APPLICATION_BUILDER_TYPE_NAME), - parameters = listOf(extensionParameter(WEB_APPLICATION_BUILDER_TYPE_NAME, "builder")), + returnType = csharpType(DotnetAspCSharpTypes.AspNetCore.Builder.WebApplicationBuilder), + parameters = listOf(extensionParameter(DotnetAspCSharpTypes.AspNetCore.Builder.WebApplicationBuilder, "builder")), body = CSharp.codeBlock { expression( CSharp.call( @@ -25,8 +25,8 @@ internal fun renderAddMicrosmithExtension(): CSharp.Method = CSharp.Method( internal fun renderMapMicrosmithExtension(): CSharp.Method = CSharp.Method( name = "MapMicrosmith", modifiers = listOf(CSharp.Modifier.PUBLIC, CSharp.Modifier.STATIC), - returnType = csharpType(WEB_APPLICATION_TYPE_NAME), - parameters = listOf(extensionParameter(WEB_APPLICATION_TYPE_NAME, "app")), + returnType = csharpType(DotnetAspCSharpTypes.AspNetCore.Builder.WebApplication), + parameters = listOf(extensionParameter(DotnetAspCSharpTypes.AspNetCore.Builder.WebApplication, "app")), body = CSharp.codeBlock { expression( CSharp.call( diff --git a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspInfrastructureFileRenderer.kt b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspInfrastructureFileRenderer.kt index a623e96a..17c04c9b 100644 --- a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspInfrastructureFileRenderer.kt +++ b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspInfrastructureFileRenderer.kt @@ -20,8 +20,8 @@ internal object DotnetAspInfrastructureFileRenderer { fun renderHostingExtensionsFile(artifact: DotnetAspServiceArtifact): String = CSharp.render( CSharp.file(hostingNamespace(artifact)) { - using(ASP_NET_BUILDER_NAMESPACE) - using(DEPENDENCY_INJECTION_NAMESPACE) + using(DotnetAspCSharpNamespaces.Microsoft.AspNetCore.Builder) + using(DotnetAspCSharpNamespaces.Microsoft.Extensions.DependencyInjection) classType( name = MICROSMITH_HOSTING_EXTENSIONS_TYPE_NAME, modifiers = listOf(CSharp.Modifier.PUBLIC, CSharp.Modifier.STATIC), @@ -34,11 +34,11 @@ internal object DotnetAspInfrastructureFileRenderer { fun renderMicrosmithControllerBaseFile(artifact: DotnetAspServiceArtifact): String = CSharp.render( CSharp.file(controllersNamespace(artifact)) { - using(ASP_NET_MVC_NAMESPACE) + using(DotnetAspCSharpNamespaces.Microsoft.AspNetCore.Mvc) classType( name = MICROSMITH_CONTROLLER_BASE_TYPE_NAME, modifiers = listOf(CSharp.Modifier.PUBLIC, CSharp.Modifier.ABSTRACT), - baseTypes = listOf(csharpType(CONTROLLER_BASE_TYPE_NAME)), + baseTypes = listOf(csharpType(DotnetAspCSharpTypes.AspNetCore.Mvc.ControllerBase)), ) { addMember(renderRespondHelper()) addMember(renderReadHeaderHelper()) @@ -47,12 +47,4 @@ internal object DotnetAspInfrastructureFileRenderer { ) } -internal const val CONTROLLER_BASE_TYPE_NAME = "ControllerBase" -internal const val CONTROLLER_ACTION_RESULT_TYPE_NAME = "ActionResult" -internal const val OBJECT_RESULT_TYPE_NAME = "ObjectResult" -internal const val WEB_APPLICATION_BUILDER_TYPE_NAME = "WebApplicationBuilder" -internal const val WEB_APPLICATION_TYPE_NAME = "WebApplication" -private const val ASP_NET_MVC_NAMESPACE = "Microsoft.AspNetCore.Mvc" -private const val ASP_NET_BUILDER_NAMESPACE = "Microsoft.AspNetCore.Builder" -private const val DEPENDENCY_INJECTION_NAMESPACE = "Microsoft.Extensions.DependencyInjection" private const val MICROSMITH_HOSTING_EXTENSIONS_TYPE_NAME = "MicrosmithHostingExtensions" From a7bd231dd1ef89eff62c45f2d7fc8058c6d9e39e Mon Sep 17 00:00:00 2001 From: LMLiam <46268350+TheRealEmissions@users.noreply.github.com> Date: Thu, 23 Apr 2026 21:32:28 +0100 Subject: [PATCH 26/26] refactor(services-dotnet-asp): restore structured C# attributes --- .../dotnet/asp/DotnetAspCSharpAttributes.kt | 33 +++++++++++++++++++ .../asp/DotnetAspControllerActionRendering.kt | 28 +++++++--------- .../asp/DotnetAspControllerFileRenderer.kt | 7 +--- 3 files changed, 46 insertions(+), 22 deletions(-) create mode 100644 modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspCSharpAttributes.kt diff --git a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspCSharpAttributes.kt b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspCSharpAttributes.kt new file mode 100644 index 00000000..ac1a3e7c --- /dev/null +++ b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspCSharpAttributes.kt @@ -0,0 +1,33 @@ +package io.github.lmliam.microsmith.compile.services.dotnet.asp + +import io.github.lmliam.microsmith.compile.services.dotnet.csharp.CSharp +import java.util.Locale + +internal object DotnetAspCSharpAttributes { + object Microsoft { + object AspNetCore { + object Mvc { + val ApiController: CSharp.Attribute = CSharp.attribute("ApiController") + val FromBody: CSharp.Attribute = CSharp.attribute("FromBody") + val FromQuery: CSharp.Attribute = CSharp.attribute("FromQuery") + val FromRoute: CSharp.Attribute = CSharp.attribute("FromRoute") + + fun endpointRoute(method: String, route: String, operationName: String): CSharp.Attribute = + CSharp.attribute( + name = httpMethodAttributeName(method), + CSharp.positionalArgument(CSharp.stringLiteral(route)), + CSharp.namedArgument("Name", CSharp.stringLiteral(operationName)), + ) + + fun producesResponseType(typeName: String, statusCode: Int): CSharp.Attribute = CSharp.attribute( + name = "ProducesResponseType", + CSharp.positionalArgument(CSharp.rawExpression("typeof($typeName)")), + CSharp.positionalArgument(CSharp.intLiteral(statusCode)), + ) + } + } + } +} + +private fun httpMethodAttributeName(method: String): String = + "Http" + method.lowercase(Locale.ROOT).replaceFirstChar(Char::uppercase) diff --git a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspControllerActionRendering.kt b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspControllerActionRendering.kt index ff71b48b..e888f475 100644 --- a/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspControllerActionRendering.kt +++ b/modules/compile-services-dotnet-asp/src/main/kotlin/io/github/lmliam/microsmith/compile/services/dotnet/asp/DotnetAspControllerActionRendering.kt @@ -8,7 +8,6 @@ import io.github.lmliam.microsmith.compile.services.dotnet.csharp.DotnetCSharpTy import io.github.lmliam.microsmith.compile.services.dotnet.csharp.csharpGenericType import io.github.lmliam.microsmith.compile.services.dotnet.csharp.csharpParameter import io.github.lmliam.microsmith.compile.services.dotnet.csharp.csharpType -import java.util.Locale internal fun renderActionMethod(endpoint: DotnetAspEndpointArtifact): CSharp.Method = CSharp.Method( name = endpoint.operationName, @@ -70,17 +69,17 @@ private fun renderHeadersInitializer(binding: DotnetAspHeadersBindingArtifact): }, ) -private fun renderRouteAttribute(endpoint: DotnetAspEndpointArtifact): CSharp.Attribute = CSharp.attribute( - name = httpAttributeName(endpoint.method), - CSharp.positionalArgument(CSharp.stringLiteral(endpoint.route)), - CSharp.namedArgument("Name", CSharp.stringLiteral(endpoint.operationName)), -) +private fun renderRouteAttribute(endpoint: DotnetAspEndpointArtifact): CSharp.Attribute = + DotnetAspCSharpAttributes.Microsoft.AspNetCore.Mvc.endpointRoute( + method = endpoint.method, + route = endpoint.route, + operationName = endpoint.operationName, + ) private fun renderProducesResponseTypeAttribute(response: DotnetAspResponseArtifact): CSharp.Attribute = - CSharp.attribute( - name = PRODUCES_RESPONSE_TYPE_ATTRIBUTE, - CSharp.positionalArgument(CSharp.rawExpression("typeof(${responseAttributeType(response)})")), - CSharp.positionalArgument(CSharp.intLiteral(response.statusCode)), + DotnetAspCSharpAttributes.Microsoft.AspNetCore.Mvc.producesResponseType( + typeName = responseAttributeType(response), + statusCode = response.statusCode, ) private fun actionParameters(endpoint: DotnetAspEndpointArtifact): List = buildList { @@ -89,7 +88,7 @@ private fun actionParameters(endpoint: DotnetAspEndpointArtifact): List addMember(renderActionMethod(endpoint)) @@ -32,9 +32,4 @@ internal object DotnetAspControllerFileRenderer { ) } -internal const val API_CONTROLLER_ATTRIBUTE = "ApiController" -internal const val FROM_BODY_ATTRIBUTE = "FromBody" -internal const val FROM_QUERY_ATTRIBUTE = "FromQuery" -internal const val FROM_ROUTE_ATTRIBUTE = "FromRoute" -internal const val PRODUCES_RESPONSE_TYPE_ATTRIBUTE = "ProducesResponseType" internal const val VOID_TYPE_NAME = "void"