From d5536fcd3fd25fad1e985663c9304fc20508c550 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leonard=20Schu=CC=88tz?= Date: Fri, 28 Apr 2023 14:13:46 +0200 Subject: [PATCH 01/41] feat(codegen): Placeholder files --- build.gradle.kts | 1 + codegen/build.gradle.kts | 8 ++++++++ .../src/main/kotlin/tools/samt/codegen/Codegen.kt | 5 +++++ .../test/kotlin/tools/samt/codegen/CodegenTest.kt | 15 +++++++++++++++ settings.gradle.kts | 1 + 5 files changed, 30 insertions(+) create mode 100644 codegen/build.gradle.kts create mode 100644 codegen/src/main/kotlin/tools/samt/codegen/Codegen.kt create mode 100644 codegen/src/test/kotlin/tools/samt/codegen/CodegenTest.kt diff --git a/build.gradle.kts b/build.gradle.kts index e33109fc..70ad21bc 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -14,6 +14,7 @@ dependencies { kover(project(":cli")) kover(project(":language-server")) kover(project(":samt-config")) + kover(project(":codegen")) } koverReport { diff --git a/codegen/build.gradle.kts b/codegen/build.gradle.kts new file mode 100644 index 00000000..70b1693c --- /dev/null +++ b/codegen/build.gradle.kts @@ -0,0 +1,8 @@ +plugins { + id("samt-core.kotlin-conventions") +} + +dependencies { + implementation(project(":common")) + implementation(project(":parser")) +} diff --git a/codegen/src/main/kotlin/tools/samt/codegen/Codegen.kt b/codegen/src/main/kotlin/tools/samt/codegen/Codegen.kt new file mode 100644 index 00000000..03a25cf8 --- /dev/null +++ b/codegen/src/main/kotlin/tools/samt/codegen/Codegen.kt @@ -0,0 +1,5 @@ +package tools.samt.codegen + +class Codegen { + +} \ No newline at end of file diff --git a/codegen/src/test/kotlin/tools/samt/codegen/CodegenTest.kt b/codegen/src/test/kotlin/tools/samt/codegen/CodegenTest.kt new file mode 100644 index 00000000..7b36ce39 --- /dev/null +++ b/codegen/src/test/kotlin/tools/samt/codegen/CodegenTest.kt @@ -0,0 +1,15 @@ +package tools.samt.codegen + +import kotlin.test.Test +import org.junit.jupiter.api.Nested +import kotlin.test.assertEquals + +class CodegenTest { + @Nested + inner class SomeRandomTests { + @Test + fun `test that two numbers are equal`() { + assertEquals(1, 1) + } + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 9ebe2560..8c573d39 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -7,6 +7,7 @@ include( ":semantic", ":language-server", ":samt-config", + ":codegen", ) dependencyResolutionManagement { From c3dd1cac9864585d98b922aa853d9a1af107d320 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leonard=20Schu=CC=88tz?= Date: Thu, 11 May 2023 17:18:18 +0200 Subject: [PATCH 02/41] feat(codegen): Emit record and enum declarations --- .gitignore | 5 + cli/build.gradle.kts | 1 + .../main/kotlin/tools/samt/cli/CliCompiler.kt | 13 ++- codegen/build.gradle.kts | 1 + .../main/kotlin/tools/samt/codegen/Codegen.kt | 104 +++++++++++++++++- .../kotlin/tools/samt/semantic/Package.kt | 10 +- .../tools/samt/semantic/SemanticModel.kt | 2 +- .../semantic/SemanticModelPreProcessor.kt | 17 +-- .../main/kotlin/tools/samt/semantic/Types.kt | 7 ++ 9 files changed, 149 insertions(+), 11 deletions(-) diff --git a/.gitignore b/.gitignore index 1a49ce5e..363bf1ca 100644 --- a/.gitignore +++ b/.gitignore @@ -14,5 +14,10 @@ build ehthumbs.db Thumbs.db +# SAMT wrapper generated files # +.samt +samtw +samtw.bat + # Random files used for debugging specification/examples/debug.samt diff --git a/cli/build.gradle.kts b/cli/build.gradle.kts index a2cab7e4..a18e1688 100644 --- a/cli/build.gradle.kts +++ b/cli/build.gradle.kts @@ -14,6 +14,7 @@ dependencies { implementation(project(":lexer")) implementation(project(":parser")) implementation(project(":semantic")) + implementation(project(":codegen")) } application { diff --git a/cli/src/main/kotlin/tools/samt/cli/CliCompiler.kt b/cli/src/main/kotlin/tools/samt/cli/CliCompiler.kt index 1e26c0dc..2bf43149 100644 --- a/cli/src/main/kotlin/tools/samt/cli/CliCompiler.kt +++ b/cli/src/main/kotlin/tools/samt/cli/CliCompiler.kt @@ -1,5 +1,7 @@ package tools.samt.cli +import com.github.ajalt.mordant.rendering.TextColors.* +import tools.samt.codegen.Codegen import tools.samt.common.DiagnosticController import tools.samt.common.DiagnosticException import tools.samt.lexer.Lexer @@ -40,7 +42,16 @@ internal fun compile(command: CompileCommand, controller: DiagnosticController) } // build up the semantic model from the AST - SemanticModel.build(fileNodes, controller) + val model = SemanticModel.build(fileNodes, controller) + + // if the semantic model failed to build, exit + if (controller.hasErrors()) { + return + } // Code Generators will be called here + val files = Codegen.generate(model, controller) + for (file in files) { + println("${yellow(file.filepath)}:\n${file.source}\n") + } } diff --git a/codegen/build.gradle.kts b/codegen/build.gradle.kts index 70b1693c..8899f1bc 100644 --- a/codegen/build.gradle.kts +++ b/codegen/build.gradle.kts @@ -5,4 +5,5 @@ plugins { dependencies { implementation(project(":common")) implementation(project(":parser")) + implementation(project(":semantic")) } diff --git a/codegen/src/main/kotlin/tools/samt/codegen/Codegen.kt b/codegen/src/main/kotlin/tools/samt/codegen/Codegen.kt index 03a25cf8..e6694003 100644 --- a/codegen/src/main/kotlin/tools/samt/codegen/Codegen.kt +++ b/codegen/src/main/kotlin/tools/samt/codegen/Codegen.kt @@ -1,5 +1,107 @@ package tools.samt.codegen -class Codegen { +import tools.samt.common.DiagnosticController +import tools.samt.semantic.* +data class CodegenFile(val filepath: String, val source: String) + +/* + * Proof of concept codegen for Kotlin code + * + * Todos: + * - Emit services + * - Emit providers + * - Emit consumers + * - Emit aliases + * - Modular + * - Extendable + * - Configurable + * */ +class Codegen( + private val model: Package, + private val controller: DiagnosticController +) { + private val emittedFiles = mutableListOf() + + private fun generate(): List { + generatePackage(model) + return emittedFiles + } + + private fun generatePackage(pack: Package) { + pack.records.forEach { generateRecord(it) } + pack.enums.forEach { generateEnum(it) } + pack.subPackages.forEach { generatePackage(it) } + } + + private fun generateRecord(record: RecordType) { + val parentPackage = record.parentPackage + val packagePath = parentPackage.nameComponents.joinToString("/") + val filepath = "${packagePath}/${record.name}.kt" + + val source = buildString { + appendLine("package ${parentPackage.nameComponents.joinToString(".")}") + + appendLine("class ${record.name} {") + record.fields.forEach { field -> + val fullyQualifiedName = generateFullyQualifiedNameForTypeReference(field.type) + appendLine(" val ${field.name}: ${fullyQualifiedName}") + } + appendLine("}") + } + emittedFiles.add(CodegenFile(filepath, source)) + } + + private fun generateEnum(enum: EnumType) { + val parentPackage = enum.parentPackage + val packagePath = parentPackage.nameComponents.joinToString("/") + val filepath = "${packagePath}/${enum.name}.kt" + + val source = buildString { + appendLine("package ${parentPackage.nameComponents.joinToString(".")}") + + appendLine("enum ${enum.name} {") + enum.values.forEach { + appendLine(" ${it},") + } + appendLine("}") + } + emittedFiles.add(CodegenFile(filepath, source)) + } + + private fun generateFullyQualifiedNameForTypeReference(reference: TypeReference): String { + require(reference is ResolvedTypeReference) { "Expected type reference to be resolved" } + + val reference: ResolvedTypeReference = reference + val type = reference.type + + return buildString { + val qualifiedName = when (type) { + is PackageType -> type.sourcePackage.nameComponents.joinToString(".") + is LiteralType -> type.humanReadableName + is ListType -> "List<${generateFullyQualifiedNameForTypeReference(type.elementType)}>" + is MapType -> "Map<${generateFullyQualifiedNameForTypeReference(type.keyType)}, ${generateFullyQualifiedNameForTypeReference(type.valueType)}>" + + is UserDeclared -> { + val parentPackage = type.parentPackage + val components = parentPackage.nameComponents + type.name + components.joinToString(".") + } + + is UnknownType -> throw IllegalStateException("Expected type to be known") + } + append(qualifiedName) + + if (reference.isOptional) { + append("?") + } + } + } + + companion object { + fun generate(model: Package, controller: DiagnosticController): List = buildList { + val generator = Codegen(model, controller) + return generator.generate() + } + } } \ No newline at end of file diff --git a/semantic/src/main/kotlin/tools/samt/semantic/Package.kt b/semantic/src/main/kotlin/tools/samt/semantic/Package.kt index ce37db5f..5ef0e072 100644 --- a/semantic/src/main/kotlin/tools/samt/semantic/Package.kt +++ b/semantic/src/main/kotlin/tools/samt/semantic/Package.kt @@ -2,7 +2,7 @@ package tools.samt.semantic import tools.samt.parser.* -class Package(val name: String) { +class Package(val name: String, val parent: Package?) { val subPackages: MutableList = mutableListOf() val records: MutableList = mutableListOf() @@ -84,4 +84,12 @@ class Package(val name: String) { val allSubPackages: List get() = subPackages + subPackages.flatMap { it.allSubPackages } + + val nameComponents: List + get() = if (parent == null) { + require(name == "") + emptyList() + } else { + parent.nameComponents + name + } } diff --git a/semantic/src/main/kotlin/tools/samt/semantic/SemanticModel.kt b/semantic/src/main/kotlin/tools/samt/semantic/SemanticModel.kt index 4c6df4b2..718cd0a4 100644 --- a/semantic/src/main/kotlin/tools/samt/semantic/SemanticModel.kt +++ b/semantic/src/main/kotlin/tools/samt/semantic/SemanticModel.kt @@ -30,7 +30,7 @@ internal class SemanticModelBuilder ( private val files: List, private val controller: DiagnosticController, ) { - private val global = Package(name = "") + private val global = Package(name = "", null) private val preProcessor = SemanticModelPreProcessor(controller) private val postProcessor = SemanticModelPostProcessor(controller) private val referenceResolver = SemanticModelReferenceResolver(controller, global) diff --git a/semantic/src/main/kotlin/tools/samt/semantic/SemanticModelPreProcessor.kt b/semantic/src/main/kotlin/tools/samt/semantic/SemanticModelPreProcessor.kt index b9710b76..e8fcbf73 100644 --- a/semantic/src/main/kotlin/tools/samt/semantic/SemanticModelPreProcessor.kt +++ b/semantic/src/main/kotlin/tools/samt/semantic/SemanticModelPreProcessor.kt @@ -47,7 +47,7 @@ internal class SemanticModelPreProcessor(private val controller: DiagnosticContr for (component in file.packageDeclaration.name.components) { var subPackage = parentPackage.subPackages.find { it.name == component.name } if (subPackage == null) { - subPackage = Package(component.name) + subPackage = Package(component.name, parentPackage) parentPackage.subPackages.add(subPackage) } parentPackage = subPackage @@ -73,7 +73,8 @@ internal class SemanticModelPreProcessor(private val controller: DiagnosticContr } parentPackage += RecordType( fields = fields, - declaration = statement + declaration = statement, + parentPackage = parentPackage, ) } @@ -81,7 +82,7 @@ internal class SemanticModelPreProcessor(private val controller: DiagnosticContr reportDuplicateDeclaration(parentPackage, statement) reportDuplicates(statement.values, "Enum value") { it } val values = statement.values.map { it.name } - parentPackage += EnumType(values, statement) + parentPackage += EnumType(values, statement, parentPackage) } is ServiceDeclarationNode -> { @@ -123,7 +124,7 @@ internal class SemanticModelPreProcessor(private val controller: DiagnosticContr } } } - parentPackage += ServiceType(operations, statement) + parentPackage += ServiceType(operations, statement, parentPackage) } is ProviderDeclarationNode -> { @@ -139,7 +140,7 @@ internal class SemanticModelPreProcessor(private val controller: DiagnosticContr name = statement.transport.protocolName.name, configuration = statement.transport.configuration ) - parentPackage += ProviderType(implements, transport, statement) + parentPackage += ProviderType(implements, transport, statement, parentPackage) } is ConsumerDeclarationNode -> { @@ -152,7 +153,8 @@ internal class SemanticModelPreProcessor(private val controller: DiagnosticContr node = it ) }, - declaration = statement + declaration = statement, + parentPackage = parentPackage, ) } @@ -160,7 +162,8 @@ internal class SemanticModelPreProcessor(private val controller: DiagnosticContr reportDuplicateDeclaration(parentPackage, statement) parentPackage += AliasType( aliasedType = UnresolvedTypeReference(statement.type), - declaration = statement + declaration = statement, + parentPackage = parentPackage, ) } diff --git a/semantic/src/main/kotlin/tools/samt/semantic/Types.kt b/semantic/src/main/kotlin/tools/samt/semantic/Types.kt index 50ed4d7b..23b5df5f 100644 --- a/semantic/src/main/kotlin/tools/samt/semantic/Types.kt +++ b/semantic/src/main/kotlin/tools/samt/semantic/Types.kt @@ -113,6 +113,7 @@ object DurationType : LiteralType { sealed interface UserDeclaredNamedType : UserDeclared, Type { override val humanReadableName: String get() = name override val declaration: NamedDeclarationNode + val parentPackage: Package val name: String get() = declaration.name.name } @@ -146,11 +147,13 @@ class AliasType( /** The fully resolved type, will not contain any type aliases anymore, just the underlying merged type */ var fullyResolvedType: ResolvedTypeReference? = null, override val declaration: TypeAliasNode, + override val parentPackage: Package, ) : UserDeclaredNamedType, UserAnnotated class RecordType( val fields: List, override val declaration: RecordDeclarationNode, + override val parentPackage: Package, ) : UserDeclaredNamedType, UserAnnotated { class Field( val name: String, @@ -162,11 +165,13 @@ class RecordType( class EnumType( val values: List, override val declaration: EnumDeclarationNode, + override val parentPackage: Package, ) : UserDeclaredNamedType, UserAnnotated class ServiceType( val operations: List, override val declaration: ServiceDeclarationNode, + override val parentPackage: Package, ) : UserDeclaredNamedType, UserAnnotated { sealed interface Operation : UserAnnotated { val name: String @@ -200,6 +205,7 @@ class ProviderType( val implements: List, @Suppress("unused") val transport: Transport, override val declaration: ProviderDeclarationNode, + override val parentPackage: Package, ) : UserDeclaredNamedType { class Implements( var service: TypeReference, @@ -216,6 +222,7 @@ class ProviderType( class ConsumerType( var provider: TypeReference, var uses: List, + val parentPackage: Package, override val declaration: ConsumerDeclarationNode, ) : Type, UserDeclared { class Uses( From 33ac176eca12647505aa4be3f5f8dfa5cd2a0bd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leonard=20Schu=CC=88tz?= Date: Fri, 12 May 2023 13:19:44 +0200 Subject: [PATCH 03/41] refactor(semantic): Changed root package name --- .../main/kotlin/tools/samt/cli/TypePrinter.kt | 7 ++++- .../kotlin/tools/samt/semantic/Package.kt | 28 ++++++++++++------- .../tools/samt/semantic/SemanticModel.kt | 2 +- 3 files changed, 25 insertions(+), 12 deletions(-) diff --git a/cli/src/main/kotlin/tools/samt/cli/TypePrinter.kt b/cli/src/main/kotlin/tools/samt/cli/TypePrinter.kt index 15fe1ea1..0a13e3bf 100644 --- a/cli/src/main/kotlin/tools/samt/cli/TypePrinter.kt +++ b/cli/src/main/kotlin/tools/samt/cli/TypePrinter.kt @@ -6,7 +6,12 @@ import tools.samt.semantic.Package internal object TypePrinter { fun dump(samtPackage: Package): String = buildString { - appendLine(blue(samtPackage.name.ifEmpty { "" })) + if (samtPackage.isRootPackage) { + appendLine(red("")) + } else { + appendLine(blue(samtPackage.name)) + } + for (enum in samtPackage.enums) { appendLine(" ${bold("enum")} ${yellow(enum.name)}") } diff --git a/semantic/src/main/kotlin/tools/samt/semantic/Package.kt b/semantic/src/main/kotlin/tools/samt/semantic/Package.kt index 5ef0e072..65073cce 100644 --- a/semantic/src/main/kotlin/tools/samt/semantic/Package.kt +++ b/semantic/src/main/kotlin/tools/samt/semantic/Package.kt @@ -45,51 +45,59 @@ class Package(val name: String, val parent: Package?) { } operator fun plusAssign(record: RecordType) { + require(!isRootPackage) records.add(record) types[record.name] = record - typeByNode[record.declaration] = record + linkType(record.declaration, record) } operator fun plusAssign(enum: EnumType) { + require(!isRootPackage) enums.add(enum) types[enum.name] = enum - typeByNode[enum.declaration] = enum + linkType(enum.declaration, enum) } operator fun plusAssign(service: ServiceType) { + require(!isRootPackage) services.add(service) types[service.name] = service - typeByNode[service.declaration] = service + linkType(service.declaration, service) } operator fun plusAssign(provider: ProviderType) { + require(!isRootPackage) providers.add(provider) types[provider.name] = provider - typeByNode[provider.declaration] = provider + linkType(provider.declaration, provider) } operator fun plusAssign(consumer: ConsumerType) { + require(!isRootPackage) consumers.add(consumer) - typeByNode[consumer.declaration] = consumer + linkType(consumer.declaration, consumer) } operator fun plusAssign(alias: AliasType) { + require(!isRootPackage) aliases.add(alias) types[alias.name] = alias - typeByNode[alias.declaration] = alias + linkType(alias.declaration, alias) } operator fun contains(identifier: IdentifierNode): Boolean = types.containsKey(identifier.name) + val isRootPackage: Boolean + get() = parent == null + val allSubPackages: List get() = subPackages + subPackages.flatMap { it.allSubPackages } val nameComponents: List - get() = if (parent == null) { - require(name == "") - emptyList() + get() = if (isRootPackage) { + emptyList() // root package } else { - parent.nameComponents + name + parent!!.nameComponents + name } } diff --git a/semantic/src/main/kotlin/tools/samt/semantic/SemanticModel.kt b/semantic/src/main/kotlin/tools/samt/semantic/SemanticModel.kt index 718cd0a4..11fdd99d 100644 --- a/semantic/src/main/kotlin/tools/samt/semantic/SemanticModel.kt +++ b/semantic/src/main/kotlin/tools/samt/semantic/SemanticModel.kt @@ -30,7 +30,7 @@ internal class SemanticModelBuilder ( private val files: List, private val controller: DiagnosticController, ) { - private val global = Package(name = "", null) + private val global = Package(name = "root", null) private val preProcessor = SemanticModelPreProcessor(controller) private val postProcessor = SemanticModelPostProcessor(controller) private val referenceResolver = SemanticModelReferenceResolver(controller, global) From 32e7639a2c02957c1da0d38709686be814fcaa02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leonard=20Schu=CC=88tz?= Date: Fri, 12 May 2023 13:20:37 +0200 Subject: [PATCH 04/41] feat(codegen): Emit single file per package Also incorporated feedback by @PascalHonegger --- .../main/kotlin/tools/samt/codegen/Codegen.kt | 139 +++++++++++++----- specification/examples/debug2.samt | 14 ++ 2 files changed, 115 insertions(+), 38 deletions(-) create mode 100644 specification/examples/debug2.samt diff --git a/codegen/src/main/kotlin/tools/samt/codegen/Codegen.kt b/codegen/src/main/kotlin/tools/samt/codegen/Codegen.kt index e6694003..9de90535 100644 --- a/codegen/src/main/kotlin/tools/samt/codegen/Codegen.kt +++ b/codegen/src/main/kotlin/tools/samt/codegen/Codegen.kt @@ -17,7 +17,7 @@ data class CodegenFile(val filepath: String, val source: String) * - Extendable * - Configurable * */ -class Codegen( +class Codegen private constructor( private val model: Package, private val controller: DiagnosticController ) { @@ -29,66 +29,95 @@ class Codegen( } private fun generatePackage(pack: Package) { - pack.records.forEach { generateRecord(it) } - pack.enums.forEach { generateEnum(it) } - pack.subPackages.forEach { generatePackage(it) } - } - private fun generateRecord(record: RecordType) { - val parentPackage = record.parentPackage - val packagePath = parentPackage.nameComponents.joinToString("/") - val filepath = "${packagePath}/${record.name}.kt" - - val source = buildString { - appendLine("package ${parentPackage.nameComponents.joinToString(".")}") + // root package cannot have any types declared in it, only sub-packages + if (pack.isRootPackage) { + check(pack.parent == null) + check(pack.records.isEmpty()) + check(pack.enums.isEmpty()) + check(pack.services.isEmpty()) + check(pack.providers.isEmpty()) + check(pack.consumers.isEmpty()) + check(pack.aliases.isEmpty()) + } else { + if (pack.hasTypes()) { + val packageSource = buildString { + val packageName = pack.nameComponents.joinToString(".") + appendLine("package ${packageName}") + appendLine() + + pack.records.forEach { + appendLine(generateRecord(it)) + appendLine() + } + + pack.enums.forEach { + appendLine(generateEnum(it)) + appendLine() + } + + pack.aliases.forEach { + appendLine(generateAlias(it)) + appendLine() + } + } - appendLine("class ${record.name} {") - record.fields.forEach { field -> - val fullyQualifiedName = generateFullyQualifiedNameForTypeReference(field.type) - appendLine(" val ${field.name}: ${fullyQualifiedName}") + val filePath = pack.nameComponents.joinToString("/") + ".kt" + val file = CodegenFile(filePath, packageSource) + emittedFiles.add(file) } - appendLine("}") } - emittedFiles.add(CodegenFile(filepath, source)) - } - - private fun generateEnum(enum: EnumType) { - val parentPackage = enum.parentPackage - val packagePath = parentPackage.nameComponents.joinToString("/") - val filepath = "${packagePath}/${enum.name}.kt" - val source = buildString { - appendLine("package ${parentPackage.nameComponents.joinToString(".")}") + pack.subPackages.forEach { generatePackage(it) } + } - appendLine("enum ${enum.name} {") - enum.values.forEach { - appendLine(" ${it},") + private fun generateRecord(record: RecordType): String = buildString { + appendLine("class ${record.name}(") + record.fields.forEach { field -> + val type = field.type as ResolvedTypeReference + val fullyQualifiedName = generateFullyQualifiedNameForTypeReference(type) + val isOptional = type.isOptional + + if (isOptional) { + appendLine(" val ${field.name}: ${fullyQualifiedName},") + } else { + appendLine(" val ${field.name}: ${fullyQualifiedName} = null,") } - appendLine("}") } - emittedFiles.add(CodegenFile(filepath, source)) + appendLine(")") + } + + private fun generateEnum(enum: EnumType): String = buildString { + appendLine("enum class ${enum.name} {") + enum.values.forEach { + appendLine(" ${it},") + } + appendLine("}") + } + + private fun generateAlias(alias: AliasType): String = buildString { + val fullyQualifiedName = generateFullyQualifiedNameForTypeReference(alias.aliasedType) + appendLine("typealias ${alias.name} = ${fullyQualifiedName}") } private fun generateFullyQualifiedNameForTypeReference(reference: TypeReference): String { require(reference is ResolvedTypeReference) { "Expected type reference to be resolved" } - - val reference: ResolvedTypeReference = reference val type = reference.type return buildString { val qualifiedName = when (type) { is PackageType -> type.sourcePackage.nameComponents.joinToString(".") - is LiteralType -> type.humanReadableName - is ListType -> "List<${generateFullyQualifiedNameForTypeReference(type.elementType)}>" - is MapType -> "Map<${generateFullyQualifiedNameForTypeReference(type.keyType)}, ${generateFullyQualifiedNameForTypeReference(type.valueType)}>" + is LiteralType -> mapSamtLiteralTypeToNativeType(type) + is ListType -> mapSamtListTypeToNativeType(type.elementType) + is MapType -> mapSamtMapTypeToNativeType(type.keyType, type.valueType) + + is UnknownType -> throw IllegalStateException("Expected type to be known") is UserDeclared -> { val parentPackage = type.parentPackage val components = parentPackage.nameComponents + type.name components.joinToString(".") } - - is UnknownType -> throw IllegalStateException("Expected type to be known") } append(qualifiedName) @@ -98,6 +127,40 @@ class Codegen( } } + private fun mapSamtLiteralTypeToNativeType(type: LiteralType): String = when (type) { + StringType -> "String" + BytesType -> "ByteArray" + + IntType -> "Int" + LongType -> "Long" + + FloatType -> "Float" + DoubleType -> "Double" + + DecimalType -> "java.math.BigDecimal" + BooleanType -> "Boolean" + + DateType -> "java.time.LocalDate" + DateTimeType -> "java.time.LocalDateTime" + + DurationType -> "java.time.Duration" + } + + private fun mapSamtListTypeToNativeType(elementType: TypeReference): String { + val element = generateFullyQualifiedNameForTypeReference(elementType) + return "List<${element}>" + } + + private fun mapSamtMapTypeToNativeType(keyType: TypeReference, valueType: TypeReference): String { + val key = generateFullyQualifiedNameForTypeReference(keyType) + val value = generateFullyQualifiedNameForTypeReference(valueType) + return "Map<${key}, ${value}>" + } + + private fun Package.hasTypes(): Boolean { + return records.isNotEmpty() || enums.isNotEmpty() || services.isNotEmpty() || providers.isNotEmpty() || consumers.isNotEmpty() || aliases.isNotEmpty() + } + companion object { fun generate(model: Package, controller: DiagnosticController): List = buildList { val generator = Codegen(model, controller) diff --git a/specification/examples/debug2.samt b/specification/examples/debug2.samt new file mode 100644 index 00000000..5753408c --- /dev/null +++ b/specification/examples/debug2.samt @@ -0,0 +1,14 @@ +import debug.foo.bar.baz.* + +package debug.foo.bar.boz + +record MyTestRecord { + myPersonList: PersonList + myCarMap: CarMap + myGarageMap: GarageMap + myOtherPerson: OtherPerson + myOtherCar: OtherCar? + myOtherGarage: OtherGarage? + myShortString: ShortString? + myColor: MyColor +} \ No newline at end of file From 243a618196f231749f0390a4ff30bc94a1618d8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leonard=20Schu=CC=88tz?= Date: Fri, 12 May 2023 14:00:28 +0200 Subject: [PATCH 05/41] feat(codegen): Emit service declaration interfaces --- .../main/kotlin/tools/samt/codegen/Codegen.kt | 59 +++++++++++++++++-- specification/examples/debug2.samt | 9 +++ 2 files changed, 62 insertions(+), 6 deletions(-) diff --git a/codegen/src/main/kotlin/tools/samt/codegen/Codegen.kt b/codegen/src/main/kotlin/tools/samt/codegen/Codegen.kt index 9de90535..01398f29 100644 --- a/codegen/src/main/kotlin/tools/samt/codegen/Codegen.kt +++ b/codegen/src/main/kotlin/tools/samt/codegen/Codegen.kt @@ -9,10 +9,8 @@ data class CodegenFile(val filepath: String, val source: String) * Proof of concept codegen for Kotlin code * * Todos: - * - Emit services * - Emit providers * - Emit consumers - * - Emit aliases * - Modular * - Extendable * - Configurable @@ -35,10 +33,11 @@ class Codegen private constructor( check(pack.parent == null) check(pack.records.isEmpty()) check(pack.enums.isEmpty()) + check(pack.aliases.isEmpty()) + check(pack.services.isEmpty()) check(pack.providers.isEmpty()) check(pack.consumers.isEmpty()) - check(pack.aliases.isEmpty()) } else { if (pack.hasTypes()) { val packageSource = buildString { @@ -48,17 +47,18 @@ class Codegen private constructor( pack.records.forEach { appendLine(generateRecord(it)) - appendLine() } pack.enums.forEach { appendLine(generateEnum(it)) - appendLine() } pack.aliases.forEach { appendLine(generateAlias(it)) - appendLine() + } + + pack.services.forEach { + appendLine(generateService(it)) } } @@ -100,6 +100,53 @@ class Codegen private constructor( appendLine("typealias ${alias.name} = ${fullyQualifiedName}") } + private fun generateService(service: ServiceType): String = buildString { + appendLine("interface ${service.name} {") + service.operations.forEach { operation -> + append(generateServiceOperation(operation)) + } + appendLine("}") + } + + private fun generateServiceOperation(operation: ServiceType.Operation): String = buildString { + when (operation) { + is ServiceType.RequestResponseOperation -> { + // method head + if (operation.isAsync) { + appendLine(" suspend fun ${operation.name}(") + } else { + appendLine(" fun ${operation.name}(") + } + + // parameters + append(generateServiceOperationParameterList(operation.parameters)) + + // return type + if (operation.returnType != null) { + val returnType = operation.returnType as ResolvedTypeReference + val returnName = generateFullyQualifiedNameForTypeReference(returnType) + appendLine(" ): ${returnName}") + } else { + appendLine(" )") + } + } + + is ServiceType.OnewayOperation -> { + appendLine(" fun ${operation.name}(") + append(generateServiceOperationParameterList(operation.parameters)) + appendLine(" )") + } + } + } + + private fun generateServiceOperationParameterList(parameters: List): String = buildString { + parameters.forEach { parameter -> + val type = parameter.type as ResolvedTypeReference + val fullyQualifiedName = generateFullyQualifiedNameForTypeReference(type) + appendLine(" ${parameter.name}: ${fullyQualifiedName},") + } + } + private fun generateFullyQualifiedNameForTypeReference(reference: TypeReference): String { require(reference is ResolvedTypeReference) { "Expected type reference to be resolved" } val type = reference.type diff --git a/specification/examples/debug2.samt b/specification/examples/debug2.samt index 5753408c..f513dc11 100644 --- a/specification/examples/debug2.samt +++ b/specification/examples/debug2.samt @@ -11,4 +11,13 @@ record MyTestRecord { myOtherGarage: OtherGarage? myShortString: ShortString? myColor: MyColor +} + +service MyCoolService { + GetPersonByName(name: ShortString): Person? + async GetCarsOfPerson(name: ShortString): CarList + + oneway UploadCar(car: Car?) + + oneway SendPulse() } \ No newline at end of file From 17fe0bbfa0b0ad709c920f68d4bdc1fd4f63c9c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leonard=20Schu=CC=88tz?= Date: Fri, 12 May 2023 14:44:02 +0200 Subject: [PATCH 06/41] refactor(codegen): Codestyle --- codegen/src/main/kotlin/tools/samt/codegen/Codegen.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/codegen/src/main/kotlin/tools/samt/codegen/Codegen.kt b/codegen/src/main/kotlin/tools/samt/codegen/Codegen.kt index 01398f29..791dd23c 100644 --- a/codegen/src/main/kotlin/tools/samt/codegen/Codegen.kt +++ b/codegen/src/main/kotlin/tools/samt/codegen/Codegen.kt @@ -42,7 +42,7 @@ class Codegen private constructor( if (pack.hasTypes()) { val packageSource = buildString { val packageName = pack.nameComponents.joinToString(".") - appendLine("package ${packageName}") + appendLine("package $packageName") appendLine() pack.records.forEach { @@ -81,7 +81,7 @@ class Codegen private constructor( if (isOptional) { appendLine(" val ${field.name}: ${fullyQualifiedName},") } else { - appendLine(" val ${field.name}: ${fullyQualifiedName} = null,") + appendLine(" val ${field.name}: $fullyQualifiedName = null,") } } appendLine(")") @@ -97,7 +97,7 @@ class Codegen private constructor( private fun generateAlias(alias: AliasType): String = buildString { val fullyQualifiedName = generateFullyQualifiedNameForTypeReference(alias.aliasedType) - appendLine("typealias ${alias.name} = ${fullyQualifiedName}") + appendLine("typealias ${alias.name} = $fullyQualifiedName") } private fun generateService(service: ServiceType): String = buildString { @@ -125,7 +125,7 @@ class Codegen private constructor( if (operation.returnType != null) { val returnType = operation.returnType as ResolvedTypeReference val returnName = generateFullyQualifiedNameForTypeReference(returnType) - appendLine(" ): ${returnName}") + appendLine(" ): $returnName") } else { appendLine(" )") } From 94efe2dd465bc71573e2fb550fd7ca142f57e7fe Mon Sep 17 00:00:00 2001 From: Pascal Honegger Date: Sat, 13 May 2023 14:44:52 +0200 Subject: [PATCH 07/41] feat(codegen): emit rudimentary ktor provider --- .../main/kotlin/tools/samt/codegen/Codegen.kt | 182 ++++++++++++++++-- 1 file changed, 170 insertions(+), 12 deletions(-) diff --git a/codegen/src/main/kotlin/tools/samt/codegen/Codegen.kt b/codegen/src/main/kotlin/tools/samt/codegen/Codegen.kt index 791dd23c..336743f0 100644 --- a/codegen/src/main/kotlin/tools/samt/codegen/Codegen.kt +++ b/codegen/src/main/kotlin/tools/samt/codegen/Codegen.kt @@ -17,7 +17,7 @@ data class CodegenFile(val filepath: String, val source: String) * */ class Codegen private constructor( private val model: Package, - private val controller: DiagnosticController + private val controller: DiagnosticController, ) { private val emittedFiles = mutableListOf() @@ -39,7 +39,7 @@ class Codegen private constructor( check(pack.providers.isEmpty()) check(pack.consumers.isEmpty()) } else { - if (pack.hasTypes()) { + if (pack.hasModelTypes()) { val packageSource = buildString { val packageName = pack.nameComponents.joinToString(".") appendLine("package $packageName") @@ -66,6 +66,33 @@ class Codegen private constructor( val file = CodegenFile(filePath, packageSource) emittedFiles.add(file) } + + if (pack.hasProviderTypes()) { + + + pack.providers.forEach { + val packageSource = buildString { + val packageName = pack.nameComponents.joinToString(".") + appendLine("package $packageName") + appendLine() + + appendLine(generateProvider(it)) + } + + val filePath = pack.nameComponents.joinToString("/") + "_${it.name}" + ".kt" + val file = CodegenFile(filePath, packageSource) + emittedFiles.add(file) + } + val packageSource = buildString { + val packageName = pack.nameComponents.joinToString(".") + appendLine("package $packageName") + appendLine() + + pack.providers.forEach { + appendLine(generateProvider(it)) + } + } + } } pack.subPackages.forEach { generatePackage(it) } @@ -79,9 +106,9 @@ class Codegen private constructor( val isOptional = type.isOptional if (isOptional) { - appendLine(" val ${field.name}: ${fullyQualifiedName},") - } else { appendLine(" val ${field.name}: $fullyQualifiedName = null,") + } else { + appendLine(" val ${field.name}: $fullyQualifiedName,") } } appendLine(")") @@ -139,12 +166,139 @@ class Codegen private constructor( } } - private fun generateServiceOperationParameterList(parameters: List): String = buildString { - parameters.forEach { parameter -> - val type = parameter.type as ResolvedTypeReference - val fullyQualifiedName = generateFullyQualifiedNameForTypeReference(type) - appendLine(" ${parameter.name}: ${fullyQualifiedName},") + private fun generateServiceOperationParameterList(parameters: List): String = + buildString { + parameters.forEach { parameter -> + val type = parameter.type as ResolvedTypeReference + val fullyQualifiedName = generateFullyQualifiedNameForTypeReference(type) + appendLine(" ${parameter.name}: ${fullyQualifiedName},") + } + } + + data class ProviderInfo(val implements: ProviderType.Implements) { + val reference = implements.service as ResolvedTypeReference + val service = reference.type as ServiceType + val serviceArgumentName = service.name.replaceFirstChar { it.lowercase() } + } + + private fun generateProvider(provider: ProviderType): String = buildString { + check(provider.transport.name == "HTTP") { "Only HTTP transport is supported, this needs to be refactored later" } + + appendLine("import io.ktor.http.*") + appendLine("import io.ktor.serialization.kotlinx.json.*") + appendLine("import io.ktor.server.plugins.contentnegotiation.*") + appendLine("import io.ktor.server.response.*") + appendLine("import io.ktor.server.application.*") + appendLine("import io.ktor.server.request.*") + appendLine("import io.ktor.server.routing.*") + appendLine("import kotlinx.serialization.json.*") + appendLine() + + val implementedServices = provider.implements.map { ProviderInfo(it) } + val serviceArguments = implementedServices.joinToString { info -> + "${info.serviceArgumentName}: ${generateFullyQualifiedNameForTypeReference(info.reference)}" + } + appendLine("fun Routing.route${provider.name}($serviceArguments) {") + implementedServices.forEach { info -> + appendProviderOperations(info) } + appendLine("}") + } + + private fun StringBuilder.appendProviderOperations(info: ProviderInfo) { + info.implements.operations.forEach { operation -> + when (operation) { + is ServiceType.RequestResponseOperation -> { + // TODO Config: HTTP method? + // TODO Config: URL? + appendLine(" post(\"/${operation.name}\") {") + appendLine(" val bodyAsText = call.receiveText()") + appendLine(" val body = Json.parseToJsonElement(bodyAsText)\n") + appendLine() + + operation.parameters.forEach { parameter -> + // TODO Config: From Body / from Query / from Path etc. + // TODO error and null handling + // TODO complexer data types than string + + val typeRef = parameter.type.extractFullType() + if (typeRef.isOptional) { + appendLine(" val ${parameter.name} = body.jsonObject[\"${parameter.name}\"]?.jsonPrimitive?.contentOrNull") + } else { + appendLine(" val ${parameter.name} = body.jsonObject.getValue(\"${parameter.name}\").jsonPrimitive.content") + } + } + appendLine() + + operation.parameters.forEach { parameter -> + // TODO constraints within map or list + val constraints = parameter.type.extractConstraints() + constraints.forEach { constraint -> + appendLine(" // TODO validate ${parameter.name} against ${constraint.humanReadableName}") + } + } + appendLine() + + appendLine(" val response = ${info.serviceArgumentName}.${operation.name}(${operation.parameters.joinToString { it.name }})") + + // TODO Config: HTTP status code + + // TODO serialize response correctly + // TODO validate response + appendLine(" call.respond(response)") + appendLine(" }") + } + + is ServiceType.OnewayOperation -> { + // TODO Config: HTTP method? + // TODO Config: URL? + appendLine(" post(\"/${operation.name}\") {") + appendLine(" val bodyAsText = call.receiveText()") + appendLine(" val body = Json.parseToJsonElement(bodyAsText)\n") + appendLine() + + operation.parameters.forEach { parameter -> + // TODO Config: From Body / from Query / from Path etc. + // TODO error and null handling + // TODO complexer data types than string + + val typeRef = parameter.type.extractFullType() + if (typeRef.isOptional) { + appendLine(" val ${parameter.name} = body.jsonObject[\"${parameter.name}\"]?.jsonPrimitive?.contentOrNull") + } else { + appendLine(" val ${parameter.name} = body.jsonObject.getValue(\"${parameter.name}\").jsonPrimitive.content") + } + } + appendLine() + + operation.parameters.forEach { parameter -> + // TODO constraints within map or list + val constraints = parameter.type.extractConstraints() + constraints.forEach { constraint -> + appendLine(" // TODO validate ${parameter.name} against ${constraint.humanReadableName}") + } + } + appendLine() + + // TODO call asynchronously + appendLine(" ${info.serviceArgumentName}.${operation.name}(${operation.parameters.joinToString { it.name }})") + + appendLine(" call.respond(HttpStatusCode.NoContent)") + appendLine(" }") + } + } + } + } + + private fun TypeReference.extractConstraints(): List { + check(this is ResolvedTypeReference) { "Expected type reference to be resolved" } + // TODO verify workst with aliases + return constraints + } + + private fun TypeReference.extractFullType(): ResolvedTypeReference { + check(this is ResolvedTypeReference) { "Expected type reference to be resolved" } + return if (type is AliasType) (type as AliasType).fullyResolvedType!! else this } private fun generateFullyQualifiedNameForTypeReference(reference: TypeReference): String { @@ -204,8 +358,12 @@ class Codegen private constructor( return "Map<${key}, ${value}>" } - private fun Package.hasTypes(): Boolean { - return records.isNotEmpty() || enums.isNotEmpty() || services.isNotEmpty() || providers.isNotEmpty() || consumers.isNotEmpty() || aliases.isNotEmpty() + private fun Package.hasModelTypes(): Boolean { + return records.isNotEmpty() || enums.isNotEmpty() || services.isNotEmpty() || aliases.isNotEmpty() + } + + private fun Package.hasProviderTypes(): Boolean { + return providers.isNotEmpty() } companion object { @@ -214,4 +372,4 @@ class Codegen private constructor( return generator.generate() } } -} \ No newline at end of file +} From d9590115f150da7b32521fd48c844af3a2b0e64c Mon Sep 17 00:00:00 2001 From: Pascal Honegger Date: Sat, 13 May 2023 21:10:33 +0200 Subject: [PATCH 08/41] refactor(codegen): separate files into smaller chunks --- .../main/kotlin/tools/samt/codegen/Codegen.kt | 378 ++---------------- .../tools/samt/codegen/HttpTransport.kt | 38 ++ .../samt/codegen/KotlinGeneratorUtils.kt | 33 ++ .../tools/samt/codegen/KotlinKtorGenerator.kt | 183 +++++++++ .../samt/codegen/KotlinTypesGenerator.kt | 117 ++++++ .../main/kotlin/tools/samt/codegen/Mapping.kt | 193 +++++++++ .../kotlin/tools/samt/codegen/PublicApi.kt | 156 ++++++++ .../main/kotlin/tools/samt/semantic/Types.kt | 4 +- 8 files changed, 756 insertions(+), 346 deletions(-) create mode 100644 codegen/src/main/kotlin/tools/samt/codegen/HttpTransport.kt create mode 100644 codegen/src/main/kotlin/tools/samt/codegen/KotlinGeneratorUtils.kt create mode 100644 codegen/src/main/kotlin/tools/samt/codegen/KotlinKtorGenerator.kt create mode 100644 codegen/src/main/kotlin/tools/samt/codegen/KotlinTypesGenerator.kt create mode 100644 codegen/src/main/kotlin/tools/samt/codegen/Mapping.kt create mode 100644 codegen/src/main/kotlin/tools/samt/codegen/PublicApi.kt diff --git a/codegen/src/main/kotlin/tools/samt/codegen/Codegen.kt b/codegen/src/main/kotlin/tools/samt/codegen/Codegen.kt index 336743f0..4a2ce3b2 100644 --- a/codegen/src/main/kotlin/tools/samt/codegen/Codegen.kt +++ b/codegen/src/main/kotlin/tools/samt/codegen/Codegen.kt @@ -15,361 +15,51 @@ data class CodegenFile(val filepath: String, val source: String) * - Extendable * - Configurable * */ -class Codegen private constructor( - private val model: Package, - private val controller: DiagnosticController, -) { - private val emittedFiles = mutableListOf() +object Codegen { - private fun generate(): List { - generatePackage(model) - return emittedFiles - } - - private fun generatePackage(pack: Package) { - - // root package cannot have any types declared in it, only sub-packages - if (pack.isRootPackage) { - check(pack.parent == null) - check(pack.records.isEmpty()) - check(pack.enums.isEmpty()) - check(pack.aliases.isEmpty()) - - check(pack.services.isEmpty()) - check(pack.providers.isEmpty()) - check(pack.consumers.isEmpty()) - } else { - if (pack.hasModelTypes()) { - val packageSource = buildString { - val packageName = pack.nameComponents.joinToString(".") - appendLine("package $packageName") - appendLine() - - pack.records.forEach { - appendLine(generateRecord(it)) - } - - pack.enums.forEach { - appendLine(generateEnum(it)) - } - - pack.aliases.forEach { - appendLine(generateAlias(it)) - } - - pack.services.forEach { - appendLine(generateService(it)) - } - } - - val filePath = pack.nameComponents.joinToString("/") + ".kt" - val file = CodegenFile(filePath, packageSource) - emittedFiles.add(file) - } - - if (pack.hasProviderTypes()) { - - - pack.providers.forEach { - val packageSource = buildString { - val packageName = pack.nameComponents.joinToString(".") - appendLine("package $packageName") - appendLine() - - appendLine(generateProvider(it)) - } - - val filePath = pack.nameComponents.joinToString("/") + "_${it.name}" + ".kt" - val file = CodegenFile(filePath, packageSource) - emittedFiles.add(file) - } - val packageSource = buildString { - val packageName = pack.nameComponents.joinToString(".") - appendLine("package $packageName") - appendLine() - - pack.providers.forEach { - appendLine(generateProvider(it)) - } - } - } - } - - pack.subPackages.forEach { generatePackage(it) } - } - - private fun generateRecord(record: RecordType): String = buildString { - appendLine("class ${record.name}(") - record.fields.forEach { field -> - val type = field.type as ResolvedTypeReference - val fullyQualifiedName = generateFullyQualifiedNameForTypeReference(type) - val isOptional = type.isOptional - - if (isOptional) { - appendLine(" val ${field.name}: $fullyQualifiedName = null,") - } else { - appendLine(" val ${field.name}: $fullyQualifiedName,") - } - } - appendLine(")") - } - - private fun generateEnum(enum: EnumType): String = buildString { - appendLine("enum class ${enum.name} {") - enum.values.forEach { - appendLine(" ${it},") - } - appendLine("}") - } - - private fun generateAlias(alias: AliasType): String = buildString { - val fullyQualifiedName = generateFullyQualifiedNameForTypeReference(alias.aliasedType) - appendLine("typealias ${alias.name} = $fullyQualifiedName") - } - - private fun generateService(service: ServiceType): String = buildString { - appendLine("interface ${service.name} {") - service.operations.forEach { operation -> - append(generateServiceOperation(operation)) - } - appendLine("}") - } - - private fun generateServiceOperation(operation: ServiceType.Operation): String = buildString { - when (operation) { - is ServiceType.RequestResponseOperation -> { - // method head - if (operation.isAsync) { - appendLine(" suspend fun ${operation.name}(") - } else { - appendLine(" fun ${operation.name}(") - } - - // parameters - append(generateServiceOperationParameterList(operation.parameters)) - - // return type - if (operation.returnType != null) { - val returnType = operation.returnType as ResolvedTypeReference - val returnName = generateFullyQualifiedNameForTypeReference(returnType) - appendLine(" ): $returnName") - } else { - appendLine(" )") - } - } - - is ServiceType.OnewayOperation -> { - appendLine(" fun ${operation.name}(") - append(generateServiceOperationParameterList(operation.parameters)) - appendLine(" )") - } - } - } - - private fun generateServiceOperationParameterList(parameters: List): String = - buildString { - parameters.forEach { parameter -> - val type = parameter.type as ResolvedTypeReference - val fullyQualifiedName = generateFullyQualifiedNameForTypeReference(type) - appendLine(" ${parameter.name}: ${fullyQualifiedName},") - } - } - - data class ProviderInfo(val implements: ProviderType.Implements) { - val reference = implements.service as ResolvedTypeReference - val service = reference.type as ServiceType - val serviceArgumentName = service.name.replaceFirstChar { it.lowercase() } - } + private val generators: List = listOf( + KotlinTypesGenerator(), + KotlinKtorGenerator(), + ) - private fun generateProvider(provider: ProviderType): String = buildString { - check(provider.transport.name == "HTTP") { "Only HTTP transport is supported, this needs to be refactored later" } + private val transports: List = listOf( + HttpTransportConfigurationParser(), + ) - appendLine("import io.ktor.http.*") - appendLine("import io.ktor.serialization.kotlinx.json.*") - appendLine("import io.ktor.server.plugins.contentnegotiation.*") - appendLine("import io.ktor.server.response.*") - appendLine("import io.ktor.server.application.*") - appendLine("import io.ktor.server.request.*") - appendLine("import io.ktor.server.routing.*") - appendLine("import kotlinx.serialization.json.*") - appendLine() + internal class SamtGeneratorParams(rootPackage: Package, private val controller: DiagnosticController) : GeneratorParams { + private val apiMapper = PublicApiMapper(transports, controller) + override val packages: List = rootPackage.allSubPackages.map { apiMapper.toPublicApi(it) } - val implementedServices = provider.implements.map { ProviderInfo(it) } - val serviceArguments = implementedServices.joinToString { info -> - "${info.serviceArgumentName}: ${generateFullyQualifiedNameForTypeReference(info.reference)}" - } - appendLine("fun Routing.route${provider.name}($serviceArguments) {") - implementedServices.forEach { info -> - appendProviderOperations(info) + override fun reportError(message: String) { + controller.reportGlobalError(message) } - appendLine("}") - } - - private fun StringBuilder.appendProviderOperations(info: ProviderInfo) { - info.implements.operations.forEach { operation -> - when (operation) { - is ServiceType.RequestResponseOperation -> { - // TODO Config: HTTP method? - // TODO Config: URL? - appendLine(" post(\"/${operation.name}\") {") - appendLine(" val bodyAsText = call.receiveText()") - appendLine(" val body = Json.parseToJsonElement(bodyAsText)\n") - appendLine() - - operation.parameters.forEach { parameter -> - // TODO Config: From Body / from Query / from Path etc. - // TODO error and null handling - // TODO complexer data types than string - - val typeRef = parameter.type.extractFullType() - if (typeRef.isOptional) { - appendLine(" val ${parameter.name} = body.jsonObject[\"${parameter.name}\"]?.jsonPrimitive?.contentOrNull") - } else { - appendLine(" val ${parameter.name} = body.jsonObject.getValue(\"${parameter.name}\").jsonPrimitive.content") - } - } - appendLine() - - operation.parameters.forEach { parameter -> - // TODO constraints within map or list - val constraints = parameter.type.extractConstraints() - constraints.forEach { constraint -> - appendLine(" // TODO validate ${parameter.name} against ${constraint.humanReadableName}") - } - } - appendLine() - - appendLine(" val response = ${info.serviceArgumentName}.${operation.name}(${operation.parameters.joinToString { it.name }})") - - // TODO Config: HTTP status code - - // TODO serialize response correctly - // TODO validate response - appendLine(" call.respond(response)") - appendLine(" }") - } - is ServiceType.OnewayOperation -> { - // TODO Config: HTTP method? - // TODO Config: URL? - appendLine(" post(\"/${operation.name}\") {") - appendLine(" val bodyAsText = call.receiveText()") - appendLine(" val body = Json.parseToJsonElement(bodyAsText)\n") - appendLine() - - operation.parameters.forEach { parameter -> - // TODO Config: From Body / from Query / from Path etc. - // TODO error and null handling - // TODO complexer data types than string - - val typeRef = parameter.type.extractFullType() - if (typeRef.isOptional) { - appendLine(" val ${parameter.name} = body.jsonObject[\"${parameter.name}\"]?.jsonPrimitive?.contentOrNull") - } else { - appendLine(" val ${parameter.name} = body.jsonObject.getValue(\"${parameter.name}\").jsonPrimitive.content") - } - } - appendLine() - - operation.parameters.forEach { parameter -> - // TODO constraints within map or list - val constraints = parameter.type.extractConstraints() - constraints.forEach { constraint -> - appendLine(" // TODO validate ${parameter.name} against ${constraint.humanReadableName}") - } - } - appendLine() - - // TODO call asynchronously - appendLine(" ${info.serviceArgumentName}.${operation.name}(${operation.parameters.joinToString { it.name }})") - - appendLine(" call.respond(HttpStatusCode.NoContent)") - appendLine(" }") - } - } + override fun reportWarning(message: String) { + controller.reportGlobalWarning(message) } - } - - private fun TypeReference.extractConstraints(): List { - check(this is ResolvedTypeReference) { "Expected type reference to be resolved" } - // TODO verify workst with aliases - return constraints - } - - private fun TypeReference.extractFullType(): ResolvedTypeReference { - check(this is ResolvedTypeReference) { "Expected type reference to be resolved" } - return if (type is AliasType) (type as AliasType).fullyResolvedType!! else this - } - private fun generateFullyQualifiedNameForTypeReference(reference: TypeReference): String { - require(reference is ResolvedTypeReference) { "Expected type reference to be resolved" } - val type = reference.type - - return buildString { - val qualifiedName = when (type) { - is PackageType -> type.sourcePackage.nameComponents.joinToString(".") - is LiteralType -> mapSamtLiteralTypeToNativeType(type) - is ListType -> mapSamtListTypeToNativeType(type.elementType) - is MapType -> mapSamtMapTypeToNativeType(type.keyType, type.valueType) - - is UnknownType -> throw IllegalStateException("Expected type to be known") - - is UserDeclared -> { - val parentPackage = type.parentPackage - val components = parentPackage.nameComponents + type.name - components.joinToString(".") - } - } - append(qualifiedName) - - if (reference.isOptional) { - append("?") - } + override fun reportInfo(message: String) { + controller.reportGlobalInfo(message) } } - private fun mapSamtLiteralTypeToNativeType(type: LiteralType): String = when (type) { - StringType -> "String" - BytesType -> "ByteArray" - - IntType -> "Int" - LongType -> "Long" - - FloatType -> "Float" - DoubleType -> "Double" - - DecimalType -> "java.math.BigDecimal" - BooleanType -> "Boolean" - - DateType -> "java.time.LocalDate" - DateTimeType -> "java.time.LocalDateTime" - - DurationType -> "java.time.Duration" - } - - private fun mapSamtListTypeToNativeType(elementType: TypeReference): String { - val element = generateFullyQualifiedNameForTypeReference(elementType) - return "List<${element}>" - } - - private fun mapSamtMapTypeToNativeType(keyType: TypeReference, valueType: TypeReference): String { - val key = generateFullyQualifiedNameForTypeReference(keyType) - val value = generateFullyQualifiedNameForTypeReference(valueType) - return "Map<${key}, ${value}>" - } - - private fun Package.hasModelTypes(): Boolean { - return records.isNotEmpty() || enums.isNotEmpty() || services.isNotEmpty() || aliases.isNotEmpty() - } - - private fun Package.hasProviderTypes(): Boolean { - return providers.isNotEmpty() - } - - companion object { - fun generate(model: Package, controller: DiagnosticController): List = buildList { - val generator = Codegen(model, controller) - return generator.generate() + fun generate(rootPackage: Package, controller: DiagnosticController): List { + check(rootPackage.isRootPackage) + check(rootPackage.parent == null) + check(rootPackage.records.isEmpty()) + check(rootPackage.enums.isEmpty()) + check(rootPackage.aliases.isEmpty()) + check(rootPackage.services.isEmpty()) + check(rootPackage.providers.isEmpty()) + check(rootPackage.consumers.isEmpty()) + + val generatorIdentifier = "kotlin-ktor" // TODO: read from config + val matchingGenerators = generators.filter { it.identifier == generatorIdentifier } + when (matchingGenerators.size) { + 0 -> controller.reportGlobalError("No matching generator found for '$generatorIdentifier'") + 1 -> return matchingGenerators.single().generate(SamtGeneratorParams(rootPackage, controller)) + else -> controller.reportGlobalError("Multiple matching generators found for '$generatorIdentifier'") } + return emptyList() } } diff --git a/codegen/src/main/kotlin/tools/samt/codegen/HttpTransport.kt b/codegen/src/main/kotlin/tools/samt/codegen/HttpTransport.kt new file mode 100644 index 00000000..e8fb29e1 --- /dev/null +++ b/codegen/src/main/kotlin/tools/samt/codegen/HttpTransport.kt @@ -0,0 +1,38 @@ +package tools.samt.codegen + +class HttpTransportConfigurationParser : TransportConfigurationParser { + override val transportName: String + get() = "http" + + override fun default(): TransportConfiguration = HttpTransportConfiguration() + + override fun parse(configuration: Map): HttpTransportConfiguration { + return HttpTransportConfiguration() + } +} + +class HttpTransportConfiguration : TransportConfiguration { + enum class TransportMode { + Body, + Query, + Path, + Header, + Cookie, + } + enum class HttpMethod { + Get, + Post, + Put, + Delete, + Patch, + } + fun getMethod(operation: ServiceOperation): HttpMethod { + return HttpMethod.Post + } + fun getPath(operation: ServiceOperation): String { + return "/todo" + } + fun getTransportMode(parameter: ServiceOperationParameter): TransportMode { + return TransportMode.Body + } +} diff --git a/codegen/src/main/kotlin/tools/samt/codegen/KotlinGeneratorUtils.kt b/codegen/src/main/kotlin/tools/samt/codegen/KotlinGeneratorUtils.kt new file mode 100644 index 00000000..b2032c45 --- /dev/null +++ b/codegen/src/main/kotlin/tools/samt/codegen/KotlinGeneratorUtils.kt @@ -0,0 +1,33 @@ +package tools.samt.codegen + +internal val TypeReference.qualifiedName: String + get() = buildString { + val qualifiedName = when (val type = type) { + is LiteralType -> when (type) { + is StringType -> "String" + is BytesType -> "ByteArray" + is IntType -> "Int" + is LongType -> "Long" + is FloatType -> "Float" + is DoubleType -> "Double" + is DecimalType -> "java.math.BigDecimal" + is BooleanType -> "Boolean" + is DateType -> "java.time.LocalDate" + is DateTimeType -> "java.time.LocalDateTime" + is DurationType -> "java.time.Duration" + else -> error("Unsupported literal type: ${type.javaClass.simpleName}") + } + + is ListType -> "List<${type.elementType.qualifiedName}>" + is MapType -> "Map<${type.keyType.qualifiedName}, ${type.valueType.qualifiedName}>" + + is UserType -> type.qualifiedName // TODO: consider configurable package name + + else -> error("Unsupported type: ${type.javaClass.simpleName}") + } + append(qualifiedName) + + if (isOptional) { + append("?") + } + } diff --git a/codegen/src/main/kotlin/tools/samt/codegen/KotlinKtorGenerator.kt b/codegen/src/main/kotlin/tools/samt/codegen/KotlinKtorGenerator.kt new file mode 100644 index 00000000..f663e4d4 --- /dev/null +++ b/codegen/src/main/kotlin/tools/samt/codegen/KotlinKtorGenerator.kt @@ -0,0 +1,183 @@ +package tools.samt.codegen + +class KotlinKtorGenerator : Generator { + override val identifier: String = "kotlin-ktor" + override fun generate(generatorParams: GeneratorParams): List { + generatorParams.packages.forEach { + generatePackage(it) + } + return KotlinTypesGenerator().generate(generatorParams) + emittedFiles + } + + private val emittedFiles = mutableListOf() + + private fun generatePackage(pack: SamtPackage) { + if (pack.hasProviderTypes()) { + pack.providers.forEach { provider -> + val transportConfiguration = provider.transport + if (transportConfiguration !is HttpTransportConfiguration) { + // Skip providers that are not HTTP + return@forEach + } + + val packageSource = buildString { + appendLine("package ${pack.qualifiedName}") + appendLine() + + appendProvider(provider, transportConfiguration) + } + + val filePath = pack.qualifiedName.replace('.', '/') + ".kt" + val file = CodegenFile(filePath, packageSource) + emittedFiles.add(file) + } + } + } + + data class ProviderInfo(val implements: ProviderImplements) { + val reference = implements.service + val service = reference.type as ServiceType + val serviceArgumentName = service.name.replaceFirstChar { it.lowercase() } + } + + private fun StringBuilder.appendProvider(provider: ProviderType, transportConfiguration: HttpTransportConfiguration) { + appendLine("import io.ktor.http.*") + appendLine("import io.ktor.serialization.kotlinx.json.*") + appendLine("import io.ktor.server.plugins.contentnegotiation.*") + appendLine("import io.ktor.server.response.*") + appendLine("import io.ktor.server.application.*") + appendLine("import io.ktor.server.request.*") + appendLine("import io.ktor.server.routing.*") + appendLine("import kotlinx.serialization.json.*") + appendLine() + + val implementedServices = provider.implements.map { ProviderInfo(it) } + val serviceArguments = implementedServices.joinToString { info -> + "${info.serviceArgumentName}: ${info.reference.qualifiedName}" + } + appendLine("fun Routing.route${provider.name}($serviceArguments) {") + implementedServices.forEach { info -> + appendProviderOperations(info, transportConfiguration) + } + appendLine("}") + } + + private fun StringBuilder.appendProviderOperations(info: ProviderInfo, transportConfiguration: HttpTransportConfiguration) { + info.implements.operations.forEach { operation -> + when (operation) { + is RequestResponseOperation -> { + appendLine(" ${getKtorRoute(operation, transportConfiguration)} {") + + appendParsingPreamble() + + operation.parameters.forEach { parameter -> + // TODO complexer data types than string + appendParameterParsing(parameter, transportConfiguration) + } + appendLine() + + appendLine(" val response = ${getServiceCall(info, operation)}") + + // TODO Config: HTTP status code + + // TODO serialize response correctly + // TODO validate response + appendLine(" call.respond(response)") + appendLine(" }") + } + + is OnewayOperation -> { + // TODO Config: HTTP method? + // TODO Config: URL? + appendLine(" ${getKtorRoute(operation, transportConfiguration)} {") + appendParsingPreamble() + + operation.parameters.forEach { parameter -> + appendParameterParsing(parameter, transportConfiguration) + } + + appendLine(" launch {") + appendLine(" ${getServiceCall(info, operation)}") + appendLine(" }") + + appendLine(" call.respond(HttpStatusCode.NoContent)") + appendLine(" }") + } + } + } + } + + private fun StringBuilder.appendParsingPreamble() { + appendLine(" val bodyAsText = call.receiveText()") + appendLine(" val body = Json.parseToJsonElement(bodyAsText)") + appendLine() + } + + private fun getKtorRoute(operation: ServiceOperation, transportConfiguration: HttpTransportConfiguration): String { + val method = when (transportConfiguration.getMethod(operation)) { + HttpTransportConfiguration.HttpMethod.Get -> "get" + HttpTransportConfiguration.HttpMethod.Post -> "post" + HttpTransportConfiguration.HttpMethod.Put -> "put" + HttpTransportConfiguration.HttpMethod.Delete -> "delete" + HttpTransportConfiguration.HttpMethod.Patch -> "patch" + } + val path = transportConfiguration.getPath(operation) + return "${method}(\"${path}\")" + } + + private fun getServiceCall(info: ProviderInfo, operation: ServiceOperation): String { + return "${info.serviceArgumentName}.${operation.name}(${operation.parameters.joinToString { it.name }})" + } + + private fun StringBuilder.appendParameterParsing(parameter: ServiceOperationParameter, transportConfiguration: HttpTransportConfiguration) { + appendParameterDeserialization(parameter, transportConfiguration) + appendParameterConstraints(parameter) + appendLine() + } + + private fun StringBuilder.appendParameterDeserialization(parameter: ServiceOperationParameter, transportConfiguration: HttpTransportConfiguration) { + // TODO Config: From Body / from Query / from Path etc. + // TODO error and null handling + // TODO complexer data types than string + + when(transportConfiguration.getTransportMode(parameter)) { + HttpTransportConfiguration.TransportMode.Body -> { + if (parameter.type.isOptional) { + appendLine(" val ${parameter.name} = body.jsonObject[\"${parameter.name}\"]?.jsonPrimitive?.contentOrNull") + } else { + appendLine(" val ${parameter.name} = body.jsonObject.getValue(\"${parameter.name}\").jsonPrimitive.content") + } + } + HttpTransportConfiguration.TransportMode.Query -> { + if (parameter.type.isOptional) { + appendLine(" val ${parameter.name} = queryParameters[\"${parameter.name}\"]") + } else { + appendLine(" val ${parameter.name} = queryParameters.getValue(\"${parameter.name}\")") + } + } + HttpTransportConfiguration.TransportMode.Path -> TODO() + HttpTransportConfiguration.TransportMode.Header -> TODO() + HttpTransportConfiguration.TransportMode.Cookie -> TODO() + } + } + + private fun StringBuilder.appendParameterConstraints(parameter: ServiceOperationParameter) { + // TODO constraints within map or list or record field + parameter.type.rangeConstraint?.let { + appendLine(" require(${parameter.name}.length in ${it.lowerBound}..${it.upperBound}) { \"${parameter.name} must be between ${it.lowerBound} and ${it.upperBound} characters long\" }") + } + parameter.type.sizeConstraint?.let { + appendLine(" require(${parameter.name}.size in ${it.lowerBound}..${it.upperBound}) { \"${parameter.name} must be between ${it.lowerBound} and ${it.upperBound} elements long\" }") + } + parameter.type.patternConstraint?.let { + appendLine(" require(${parameter.name}.matches(\"${it.pattern}\") { \"${parameter.name} does not adhere to required pattern '${it.pattern}'\" }") + } + parameter.type.valueConstraint?.let { + appendLine(" require(${parameter.name} == ${it.value}) { \"${parameter.name} does not equal '${it.value}'\" }") + } + } + + private fun SamtPackage.hasProviderTypes(): Boolean { + return providers.isNotEmpty() + } +} diff --git a/codegen/src/main/kotlin/tools/samt/codegen/KotlinTypesGenerator.kt b/codegen/src/main/kotlin/tools/samt/codegen/KotlinTypesGenerator.kt new file mode 100644 index 00000000..ad7fe442 --- /dev/null +++ b/codegen/src/main/kotlin/tools/samt/codegen/KotlinTypesGenerator.kt @@ -0,0 +1,117 @@ +package tools.samt.codegen + +class KotlinTypesGenerator : Generator { + override val identifier: String = "kotlin-types" + override fun generate(generatorParams: GeneratorParams): List { + generatorParams.packages.forEach { + generatePackage(it) + } + return emittedFiles + } + + private val emittedFiles = mutableListOf() + + private fun generatePackage(pack: SamtPackage) { + if (pack.hasModelTypes()) { + val packageSource = buildString { + appendLine("package ${pack.qualifiedName}") + appendLine() + + pack.records.forEach { + appendRecord(it) + } + + pack.enums.forEach { + appendEnum(it) + } + + pack.aliases.forEach { + appendAlias(it) + } + + pack.services.forEach { + appendService(it) + } + } + + val filePath = pack.qualifiedName.replace('.', '/') + ".kt" + val file = CodegenFile(filePath, packageSource) + emittedFiles.add(file) + } + } + + private fun StringBuilder.appendRecord(record: RecordType) { + appendLine("class ${record.name}(") + record.fields.forEach { field -> + val fullyQualifiedName = field.type.qualifiedName + val isOptional = field.type.isOptional + + if (isOptional) { + appendLine(" val ${field.name}: $fullyQualifiedName = null,") + } else { + appendLine(" val ${field.name}: $fullyQualifiedName,") + } + } + appendLine(")") + appendLine() + } + + private fun StringBuilder.appendEnum(enum: EnumType) { + appendLine("enum class ${enum.name} {") + enum.values.forEach { + appendLine(" ${it},") + } + appendLine("}") + } + + private fun StringBuilder.appendAlias(alias: AliasType) { + appendLine("typealias ${alias.name} = ${alias.aliasedType.qualifiedName}") + } + + private fun StringBuilder.appendService(service: ServiceType) { + appendLine("interface ${service.name} {") + service.operations.forEach { operation -> + appendServiceOperation(operation) + } + appendLine("}") + } + + private fun StringBuilder.appendServiceOperation(operation: ServiceOperation) { + when (operation) { + is RequestResponseOperation -> { + // method head + if (operation.isAsync) { + appendLine(" suspend fun ${operation.name}(") + } else { + appendLine(" fun ${operation.name}(") + } + + // parameters + appendServiceOperationParameterList(operation.parameters) + + // return type + if (operation.returnType != null) { + appendLine(" ): ${operation.returnType!!.qualifiedName}") + } else { + appendLine(" )") + } + } + + is OnewayOperation -> { + appendLine(" fun ${operation.name}(") + appendServiceOperationParameterList(operation.parameters) + appendLine(" )") + } + } + } + + private fun StringBuilder.appendServiceOperationParameterList(parameters: List) { + parameters.forEach { parameter -> + appendLine(" ${parameter.name}: ${parameter.type.qualifiedName},") + } + } + + private fun SamtPackage.hasModelTypes(): Boolean { + return records.isNotEmpty() || enums.isNotEmpty() || services.isNotEmpty() || aliases.isNotEmpty() + } +} diff --git a/codegen/src/main/kotlin/tools/samt/codegen/Mapping.kt b/codegen/src/main/kotlin/tools/samt/codegen/Mapping.kt new file mode 100644 index 00000000..eb19332e --- /dev/null +++ b/codegen/src/main/kotlin/tools/samt/codegen/Mapping.kt @@ -0,0 +1,193 @@ +package tools.samt.codegen + +import tools.samt.common.DiagnosticController + +class PublicApiMapper( + private val transportParsers: List, + private val controller: DiagnosticController, +) { + fun toPublicApi(samtPackage: tools.samt.semantic.Package) = object : SamtPackage { + override val name = samtPackage.name + override val qualifiedName = samtPackage.nameComponents.joinToString(".") + override val records = samtPackage.records.map { it.toPublicApi() } + override val enums = samtPackage.enums.map { it.toPublicApi() } + override val services = samtPackage.services.map { it.toPublicApi() } + override val providers = samtPackage.providers.map { it.toPublicApi() } + override val consumers = samtPackage.consumers.map { it.toPublicApi() } + override val aliases = samtPackage.aliases.map { it.toPublicApi() } + } + + private fun tools.samt.semantic.RecordType.toPublicApi() = object : RecordType { + override val name = this@toPublicApi.name + override val qualifiedName = this@toPublicApi.getQualifiedName() + override val fields = this@toPublicApi.fields.map { it.toPublicApi() } + } + + private fun tools.samt.semantic.RecordType.Field.toPublicApi() = object : RecordField { + override val name = this@toPublicApi.name + override val type = this@toPublicApi.type.toPublicApi() + } + + private fun tools.samt.semantic.EnumType.toPublicApi() = object : EnumType { + override val name = this@toPublicApi.name + override val qualifiedName = this@toPublicApi.getQualifiedName() + override val values = this@toPublicApi.values + } + + private fun tools.samt.semantic.ServiceType.toPublicApi() = object : ServiceType { + override val name = this@toPublicApi.name + override val qualifiedName = this@toPublicApi.getQualifiedName() + override val operations = this@toPublicApi.operations.map { it.toPublicApi() } + } + + private fun tools.samt.semantic.ServiceType.Operation.toPublicApi() = when (this) { + is tools.samt.semantic.ServiceType.OnewayOperation -> toPublicApi() + is tools.samt.semantic.ServiceType.RequestResponseOperation -> toPublicApi() + } + + private fun tools.samt.semantic.ServiceType.OnewayOperation.toPublicApi() = object : OnewayOperation { + override val name = this@toPublicApi.name + override val parameters = this@toPublicApi.parameters.map { it.toPublicApi() } + } + + private fun tools.samt.semantic.ServiceType.RequestResponseOperation.toPublicApi() = + object : RequestResponseOperation { + override val name = this@toPublicApi.name + override val parameters = this@toPublicApi.parameters.map { it.toPublicApi() } + override val returnType = this@toPublicApi.returnType?.toPublicApi() + override val raisesTypes = this@toPublicApi.raisesTypes.map { it.toPublicApi() } + override val isAsync = this@toPublicApi.isAsync + } + + private fun tools.samt.semantic.ServiceType.Operation.Parameter.toPublicApi() = object : ServiceOperationParameter { + override val name = this@toPublicApi.name + override val type = this@toPublicApi.type.toPublicApi() + } + + private fun tools.samt.semantic.ProviderType.toPublicApi() = object : ProviderType { + override val name = this@toPublicApi.name + override val qualifiedName = this@toPublicApi.getQualifiedName() + override val implements = this@toPublicApi.implements.map { it.toPublicApi() } + override val transport = this@toPublicApi.transport.toPublicApi() + } + + private fun tools.samt.semantic.ProviderType.Transport.toPublicApi(): TransportConfiguration { + val transportConfigNode = configuration + val transportConfigurationParser = transportParsers.filter { it.transportName == name } + when (transportConfigurationParser.size) { + 0 -> controller.reportGlobalError("No transport configuration parser found for transport '$name'") + 1 -> { + return if (transportConfigNode != null) { + // TODO transform transportConfigNode to a Map + transportConfigurationParser.single().parse(emptyMap()) + } else { + transportConfigurationParser.single().default() + } + } + + else -> controller.reportGlobalError("Multiple transport configuration parsers found for transport '$name'") + } + return object : TransportConfiguration {} + } + + private fun tools.samt.semantic.ProviderType.Implements.toPublicApi() = object : ProviderImplements { + override val service = this@toPublicApi.service.toPublicApi() + override val operations = this@toPublicApi.operations.map { it.toPublicApi() } + } + + private fun tools.samt.semantic.ConsumerType.toPublicApi() = object : ConsumerType { + override val provider = this@toPublicApi.provider.toPublicApi().type as ProviderType + override val uses = this@toPublicApi.uses.map { it.toPublicApi() } + override val targetPackage = this@toPublicApi.parentPackage.nameComponents.joinToString(".") + } + + private fun tools.samt.semantic.ConsumerType.Uses.toPublicApi() = object : ConsumerUses { + override val service = this@toPublicApi.service.toPublicApi() + override val operations = this@toPublicApi.operations.map { it.toPublicApi() } + } + + private fun tools.samt.semantic.AliasType.toPublicApi() = object : AliasType { + override val name = this@toPublicApi.name + override val qualifiedName = this@toPublicApi.getQualifiedName() + override val aliasedType = this@toPublicApi.aliasedType.toPublicApi() + override val fullyResolvedType = this@toPublicApi.fullyResolvedType.toPublicApi() + } + + private inline fun List.findConstraint() = + firstOrNull { it is T } as T? + + private fun tools.samt.semantic.TypeReference?.toPublicApi(): TypeReference { + check(this is tools.samt.semantic.ResolvedTypeReference) + return object : TypeReference { + override val type = this@toPublicApi.type.toPublicApi() + override val isOptional = this@toPublicApi.isOptional + override val rangeConstraint = + this@toPublicApi.constraints.findConstraint() + ?.toPublicApi() + override val sizeConstraint = + this@toPublicApi.constraints.findConstraint() + ?.toPublicApi() + override val patternConstraint = + this@toPublicApi.constraints.findConstraint() + ?.toPublicApi() + override val valueConstraint = + this@toPublicApi.constraints.findConstraint() + ?.toPublicApi() + } + } + + private fun tools.samt.semantic.Type.toPublicApi() = when (this) { + tools.samt.semantic.IntType -> object : IntType {} + tools.samt.semantic.LongType -> object : LongType {} + tools.samt.semantic.FloatType -> object : FloatType {} + tools.samt.semantic.DoubleType -> object : DoubleType {} + tools.samt.semantic.DecimalType -> object : DecimalType {} + tools.samt.semantic.BooleanType -> object : BooleanType {} + tools.samt.semantic.StringType -> object : StringType {} + tools.samt.semantic.BytesType -> object : BytesType {} + tools.samt.semantic.DateType -> object : DateType {} + tools.samt.semantic.DateTimeType -> object : DateTimeType {} + tools.samt.semantic.DurationType -> object : DurationType {} + is tools.samt.semantic.ListType -> object : ListType { + override val elementType = this@toPublicApi.elementType.toPublicApi() + } + + is tools.samt.semantic.MapType -> object : MapType { + override val keyType = this@toPublicApi.keyType.toPublicApi() + override val valueType = this@toPublicApi.valueType.toPublicApi() + } + + is tools.samt.semantic.AliasType -> toPublicApi() + is tools.samt.semantic.ConsumerType -> toPublicApi() + is tools.samt.semantic.EnumType -> toPublicApi() + is tools.samt.semantic.ProviderType -> toPublicApi() + is tools.samt.semantic.RecordType -> toPublicApi() + is tools.samt.semantic.ServiceType -> toPublicApi() + is tools.samt.semantic.PackageType -> error("Package type cannot be converted to public API") + tools.samt.semantic.UnknownType -> error("Unknown type cannot be converted to public API") + } + + private fun tools.samt.semantic.ResolvedTypeReference.Constraint.Range.toPublicApi() = object : Constraint.Range { + override val lowerBound = this@toPublicApi.lowerBound + override val upperBound = this@toPublicApi.upperBound + } + + private fun tools.samt.semantic.ResolvedTypeReference.Constraint.Size.toPublicApi() = object : Constraint.Size { + override val lowerBound = this@toPublicApi.lowerBound + override val upperBound = this@toPublicApi.upperBound + } + + private fun tools.samt.semantic.ResolvedTypeReference.Constraint.Pattern.toPublicApi() = + object : Constraint.Pattern { + override val pattern = this@toPublicApi.pattern + } + + private fun tools.samt.semantic.ResolvedTypeReference.Constraint.Value.toPublicApi() = object : Constraint.Value { + override val value = this@toPublicApi.value + } + + private fun tools.samt.semantic.UserDeclaredNamedType.getQualifiedName(): String { + val components = parentPackage.nameComponents + name + return components.joinToString(".") + } +} diff --git a/codegen/src/main/kotlin/tools/samt/codegen/PublicApi.kt b/codegen/src/main/kotlin/tools/samt/codegen/PublicApi.kt new file mode 100644 index 00000000..581c93a9 --- /dev/null +++ b/codegen/src/main/kotlin/tools/samt/codegen/PublicApi.kt @@ -0,0 +1,156 @@ +package tools.samt.codegen + +interface GeneratorParams { + val packages: List + + fun reportError(message: String) + fun reportWarning(message: String) + fun reportInfo(message: String) +} + +interface SamtPackage { + val name: String + val qualifiedName: String + val records: List + val enums: List + val services: List + val providers: List + val consumers: List + val aliases: List +} + +interface Generator { + val identifier: String + fun generate(generatorParams: GeneratorParams): List +} + +interface TransportConfigurationParser { + val transportName: String + fun default(): TransportConfiguration + fun parse(configuration: Map): TransportConfiguration +} + +interface TransportConfiguration + + +interface Type + +interface LiteralType : Type + +interface IntType : LiteralType +interface LongType : LiteralType +interface FloatType : LiteralType +interface DoubleType : LiteralType +interface DecimalType : LiteralType +interface BooleanType : LiteralType +interface StringType : LiteralType +interface BytesType : LiteralType +interface DateType : LiteralType +interface DateTimeType : LiteralType +interface DurationType : LiteralType + +interface ListType : Type { + val elementType: TypeReference +} + +interface MapType : Type { + val keyType: TypeReference + val valueType: TypeReference +} + +interface UserType : Type { + val name: String + val qualifiedName: String +} + +interface AliasType : UserType { + /** The type this alias stands for, could be another alias */ + val aliasedType: TypeReference + + /** The fully resolved type, will not contain any type aliases anymore, just the underlying merged type */ + val fullyResolvedType: TypeReference +} + +interface RecordType : UserType { + val fields: List +} + +interface RecordField { + val name: String + val type: TypeReference +} + +interface EnumType : UserType { + val values: List +} + +interface ServiceType : UserType { + val operations: List +} +interface ServiceOperation { + val name: String + val parameters: List +} + +interface ServiceOperationParameter { + val name: String + val type: TypeReference +} + +interface RequestResponseOperation : ServiceOperation { + val returnType: TypeReference? + val raisesTypes: List + val isAsync: Boolean +} + +interface OnewayOperation : ServiceOperation + +interface ProviderType : UserType { + val implements: List + val transport: TransportConfiguration +} + +interface ProviderImplements { + val service: TypeReference + val operations: List +} + +interface ConsumerType : Type { + val provider: ProviderType + val uses: List + val targetPackage: String +} + +interface ConsumerUses { + val service: TypeReference + val operations: List +} + +interface TypeReference { + val type: Type + val isOptional: Boolean + val rangeConstraint: Constraint.Range? + val sizeConstraint: Constraint.Size? + val patternConstraint: Constraint.Pattern? + val valueConstraint: Constraint.Value? +} + +interface Constraint { + interface Range : Constraint { + val lowerBound: Number? + val upperBound: Number? + } + + interface Size : Constraint { + val lowerBound: Long? + val upperBound: Long? + } + + interface Pattern : Constraint { + val pattern: String + } + + interface Value : Constraint { + val value: Any + } +} diff --git a/semantic/src/main/kotlin/tools/samt/semantic/Types.kt b/semantic/src/main/kotlin/tools/samt/semantic/Types.kt index 23b5df5f..5cbae41b 100644 --- a/semantic/src/main/kotlin/tools/samt/semantic/Types.kt +++ b/semantic/src/main/kotlin/tools/samt/semantic/Types.kt @@ -203,7 +203,7 @@ class ServiceType( class ProviderType( val implements: List, - @Suppress("unused") val transport: Transport, + val transport: Transport, override val declaration: ProviderDeclarationNode, override val parentPackage: Package, ) : UserDeclaredNamedType { @@ -215,7 +215,7 @@ class ProviderType( class Transport( val name: String, - @Suppress("unused") val configuration: Any?, + val configuration: ObjectNode?, ) } From 8a7f885a5f0e91992f334553fcb72c5fa027b025 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leonard=20Schu=CC=88tz?= Date: Tue, 16 May 2023 22:41:03 +0200 Subject: [PATCH 09/41] feat(codegen): Parse http transport config Kotlin KTor Generator can use this information to emit parameter loads. --- .../main/kotlin/tools/samt/codegen/Codegen.kt | 1 - .../tools/samt/codegen/HttpTransport.kt | 267 ++++++++++++++++-- .../tools/samt/codegen/KotlinKtorGenerator.kt | 53 ++-- .../main/kotlin/tools/samt/codegen/Mapping.kt | 19 +- .../kotlin/tools/samt/codegen/PublicApi.kt | 14 +- .../semantic/SemanticModelPreProcessor.kt | 5 +- specification/examples/debug2.samt | 23 -- .../examples/todo-service/common.samt | 5 + .../todo-service/todo-provider-http.samt | 16 +- .../examples/todo-service/todo-service.samt | 2 +- 10 files changed, 331 insertions(+), 74 deletions(-) delete mode 100644 specification/examples/debug2.samt diff --git a/codegen/src/main/kotlin/tools/samt/codegen/Codegen.kt b/codegen/src/main/kotlin/tools/samt/codegen/Codegen.kt index 4a2ce3b2..c4d65869 100644 --- a/codegen/src/main/kotlin/tools/samt/codegen/Codegen.kt +++ b/codegen/src/main/kotlin/tools/samt/codegen/Codegen.kt @@ -16,7 +16,6 @@ data class CodegenFile(val filepath: String, val source: String) * - Configurable * */ object Codegen { - private val generators: List = listOf( KotlinTypesGenerator(), KotlinKtorGenerator(), diff --git a/codegen/src/main/kotlin/tools/samt/codegen/HttpTransport.kt b/codegen/src/main/kotlin/tools/samt/codegen/HttpTransport.kt index e8fb29e1..641baa12 100644 --- a/codegen/src/main/kotlin/tools/samt/codegen/HttpTransport.kt +++ b/codegen/src/main/kotlin/tools/samt/codegen/HttpTransport.kt @@ -1,24 +1,245 @@ package tools.samt.codegen -class HttpTransportConfigurationParser : TransportConfigurationParser { +import tools.samt.common.DiagnosticController +import tools.samt.parser.* +import tools.samt.semantic.Package + +// TODO: refactor diagnostic controller support + +class HttpTransportConfigurationParser: TransportConfigurationParser { override val transportName: String get() = "http" - override fun default(): TransportConfiguration = HttpTransportConfiguration() + override fun default(): TransportConfiguration { + require(false) { "Not implemented: Default Configuration" } + return HttpTransportConfiguration( + serializationMode = HttpTransportConfiguration.SerializationMode.Json, + services = emptyList(), + ) + } + + class Params( + override val configObjectNode: ObjectNode, + private val controller: DiagnosticController + ) : TransportConfigurationParserParams { + + override fun reportError(message: String) { + controller.reportGlobalError(message) + } + + override fun reportWarning(message: String) { + controller.reportGlobalWarning(message) + } + + override fun reportInfo(message: String) { + controller.reportGlobalInfo(message) + } + } + + override fun parse(params: TransportConfigurationParserParams): HttpTransportConfiguration? { + require(params is Params) { "Invalid params type" } + + val fields = parseObjectNode(params.configObjectNode) + + val serializationMode = if (fields.containsKey("serialization")) { + val serializationConfig = fields["serialization"]!! + if (serializationConfig is StringNode) { + when (serializationConfig.value) { + "json" -> HttpTransportConfiguration.SerializationMode.Json + else -> { + // unknown serialization mode + params.reportError("Unknown serialization mode '${serializationConfig.value}', defaulting to 'json'") + HttpTransportConfiguration.SerializationMode.Json + } + } + } else { + // invalid serialization mode type, expected string + params.reportError("Invalid value for 'serialization', defaulting to 'json'") + HttpTransportConfiguration.SerializationMode.Json + } + } else { + HttpTransportConfiguration.SerializationMode.Json + } + + val services = buildList { + if (!fields.containsKey("operations")) { + params.reportError("Missing 'operations' field") + return@buildList + } + + if (fields["operations"] !is ObjectNode) { + params.reportError("Invalid value for 'operations', expected object") + return@buildList + } + + val operationsConfig = parseObjectNode(fields["operations"] as ObjectNode) + for ((serviceName, operationsField) in operationsConfig) { + if (operationsField !is ObjectNode) { + params.reportError("Invalid value for '$serviceName', expected object") + continue + } + + val operationsConfig = parseObjectNode(operationsField as ObjectNode) + + val operations = buildList { + for ((operationName, operationConfig) in operationsConfig) { + if (operationConfig !is StringNode) { + params.reportError("Invalid value for operation config for '$operationName', expected string") + continue + } + + val words = operationConfig.value.split(" ") + if (words.size < 2) { + params.reportError("Invalid operation config for '$operationName', expected ' '") + continue + } + + val methodEnum = when (val methodName = words[0]) { + "GET" -> HttpTransportConfiguration.HttpMethod.Get + "POST" -> HttpTransportConfiguration.HttpMethod.Post + "PUT" -> HttpTransportConfiguration.HttpMethod.Put + "DELETE" -> HttpTransportConfiguration.HttpMethod.Delete + "PATCH" -> HttpTransportConfiguration.HttpMethod.Patch + else -> { + params.reportError("Invalid http method '$methodName'") + continue + } + } + + val path = words[1] + val parameterConfigParts = words.drop(2) + val parameters = buildList { + + // parse path and path parameters + val pathComponents = path.split("/") + for (component in pathComponents) { + if (component.startsWith("{") && component.endsWith("}")) { + val pathParameterName = component.substring(1, component.length - 1) + + if (pathParameterName.isEmpty()) { + params.reportError("Expected parameter name between curly braces in '$path'") + continue + } + + add(HttpTransportConfiguration.ParameterConfiguration( + name = pathParameterName, + transportMode = HttpTransportConfiguration.TransportMode.Path, + )) + } + } - override fun parse(configuration: Map): HttpTransportConfiguration { - return HttpTransportConfiguration() + // parse parameter declarations + for (component in parameterConfigParts) { + if (component.startsWith("{") && component.endsWith("}")) { + val parameterConfig = component.substring(1, component.length - 1) + if (parameterConfig.isEmpty()) { + params.reportError("Expected parameter name between curly braces in '$path'") + continue + } + + val parts = parameterConfig.split(":") + if (parts.size != 2) { + params.reportError("Expected parameter in format '{type:name}', got '$component'") + continue + } + + val (type, name) = parts + val transportMode = when (type) { + "query" -> HttpTransportConfiguration.TransportMode.Query + "header" -> HttpTransportConfiguration.TransportMode.Header + "body" -> HttpTransportConfiguration.TransportMode.Body + "cookie" -> HttpTransportConfiguration.TransportMode.Cookie + else -> { + params.reportError("Invalid transport mode '$type'") + continue + } + } + + add(HttpTransportConfiguration.ParameterConfiguration( + name = name, + transportMode = transportMode, + )) + } else { + params.reportError("Expected parameter in format '{type:name}', got '$component'") + } + } + } + + add(HttpTransportConfiguration.OperationConfiguration( + name = operationName, + method = methodEnum, + path = path, + parameters = parameters, + )) + } + } + + add(HttpTransportConfiguration.ServiceConfiguration( + name = serviceName, + operations = operations, + )) + } + } + + // TODO: implement faults parsing + + return HttpTransportConfiguration( + serializationMode = serializationMode, + services = services, + ) + } + + private fun parseObjectNode(node: ObjectNode): Map { + val result = mutableMapOf() + for (field in node.fields) { + result[field.name.name] = field.value + } + return result } } -class HttpTransportConfiguration : TransportConfiguration { +// TODO: store fault config +class HttpTransportConfiguration( + val serializationMode: SerializationMode, + val services: List, +) : TransportConfiguration { + class ServiceConfiguration( + val name: String, + val operations: List + ) { + fun getOperation(name: String): OperationConfiguration? { + return operations.firstOrNull { it.name == name } + } + } + + class OperationConfiguration( + val name: String, + val method: HttpMethod, + val path: String, + val parameters: List, + ) { + fun getParameter(name: String): ParameterConfiguration? { + return parameters.firstOrNull { it.name == name } + } + } + + class ParameterConfiguration( + val name: String, + val transportMode: TransportMode, + ) + + enum class SerializationMode { + Json, + } + enum class TransportMode { - Body, - Query, - Path, - Header, - Cookie, + Body, // encoded in request body via serializationMode + Query, // encoded as url query parameter + Path, // encoded as part of url path + Header, // encoded as HTTP header + Cookie, // encoded as HTTP cookie } + enum class HttpMethod { Get, Post, @@ -26,13 +247,27 @@ class HttpTransportConfiguration : TransportConfiguration { Delete, Patch, } - fun getMethod(operation: ServiceOperation): HttpMethod { - return HttpMethod.Post + + fun getService(name: String): ServiceConfiguration? { + return services.firstOrNull { it.name == name } } - fun getPath(operation: ServiceOperation): String { - return "/todo" + + fun getMethod(serviceName: String, operationName: String): HttpMethod { + val service = getService(serviceName) + val operation = service?.getOperation(operationName) + return operation?.method ?: HttpMethod.Post } - fun getTransportMode(parameter: ServiceOperationParameter): TransportMode { - return TransportMode.Body + + fun getPath(serviceName: String, operationName: String): String { + val service = getService(serviceName) + val operation = service?.getOperation(operationName) + return operation?.path ?: "/$operationName" + } + + fun getTransportMode(serviceName: String, operationName: String, parameterName: String): TransportMode { + val service = getService(serviceName) + val operation = service?.getOperation(operationName) + val parameter = operation?.getParameter(parameterName) + return parameter?.transportMode ?: TransportMode.Body } } diff --git a/codegen/src/main/kotlin/tools/samt/codegen/KotlinKtorGenerator.kt b/codegen/src/main/kotlin/tools/samt/codegen/KotlinKtorGenerator.kt index f663e4d4..1ee73396 100644 --- a/codegen/src/main/kotlin/tools/samt/codegen/KotlinKtorGenerator.kt +++ b/codegen/src/main/kotlin/tools/samt/codegen/KotlinKtorGenerator.kt @@ -1,7 +1,9 @@ package tools.samt.codegen + class KotlinKtorGenerator : Generator { override val identifier: String = "kotlin-ktor" + override fun generate(generatorParams: GeneratorParams): List { generatorParams.packages.forEach { generatePackage(it) @@ -63,16 +65,17 @@ class KotlinKtorGenerator : Generator { } private fun StringBuilder.appendProviderOperations(info: ProviderInfo, transportConfiguration: HttpTransportConfiguration) { + val service = info.service info.implements.operations.forEach { operation -> when (operation) { is RequestResponseOperation -> { - appendLine(" ${getKtorRoute(operation, transportConfiguration)} {") + appendLine(" ${getKtorRoute(service, operation, transportConfiguration)} {") appendParsingPreamble() operation.parameters.forEach { parameter -> // TODO complexer data types than string - appendParameterParsing(parameter, transportConfiguration) + appendParameterParsing(service, operation, parameter, transportConfiguration) } appendLine() @@ -89,11 +92,11 @@ class KotlinKtorGenerator : Generator { is OnewayOperation -> { // TODO Config: HTTP method? // TODO Config: URL? - appendLine(" ${getKtorRoute(operation, transportConfiguration)} {") + appendLine(" ${getKtorRoute(service, operation, transportConfiguration)} {") appendParsingPreamble() operation.parameters.forEach { parameter -> - appendParameterParsing(parameter, transportConfiguration) + appendParameterParsing(service, operation, parameter, transportConfiguration) } appendLine(" launch {") @@ -113,15 +116,15 @@ class KotlinKtorGenerator : Generator { appendLine() } - private fun getKtorRoute(operation: ServiceOperation, transportConfiguration: HttpTransportConfiguration): String { - val method = when (transportConfiguration.getMethod(operation)) { + private fun getKtorRoute(service: ServiceType, operation: ServiceOperation, transportConfiguration: HttpTransportConfiguration): String { + val method = when (transportConfiguration.getMethod(service.name, operation.name)) { HttpTransportConfiguration.HttpMethod.Get -> "get" HttpTransportConfiguration.HttpMethod.Post -> "post" HttpTransportConfiguration.HttpMethod.Put -> "put" HttpTransportConfiguration.HttpMethod.Delete -> "delete" HttpTransportConfiguration.HttpMethod.Patch -> "patch" } - val path = transportConfiguration.getPath(operation) + val path = transportConfiguration.getPath(service.name, operation.name) return "${method}(\"${path}\")" } @@ -129,18 +132,18 @@ class KotlinKtorGenerator : Generator { return "${info.serviceArgumentName}.${operation.name}(${operation.parameters.joinToString { it.name }})" } - private fun StringBuilder.appendParameterParsing(parameter: ServiceOperationParameter, transportConfiguration: HttpTransportConfiguration) { - appendParameterDeserialization(parameter, transportConfiguration) + private fun StringBuilder.appendParameterParsing(service: ServiceType, operation: ServiceOperation, parameter: ServiceOperationParameter, transportConfiguration: HttpTransportConfiguration) { + appendParameterDeserialization(service, operation, parameter, transportConfiguration) appendParameterConstraints(parameter) appendLine() } - private fun StringBuilder.appendParameterDeserialization(parameter: ServiceOperationParameter, transportConfiguration: HttpTransportConfiguration) { + private fun StringBuilder.appendParameterDeserialization(service: ServiceType, operation: ServiceOperation, parameter: ServiceOperationParameter, transportConfiguration: HttpTransportConfiguration) { // TODO Config: From Body / from Query / from Path etc. // TODO error and null handling // TODO complexer data types than string - when(transportConfiguration.getTransportMode(parameter)) { + when(transportConfiguration.getTransportMode(service.name, operation.name, parameter.name)) { HttpTransportConfiguration.TransportMode.Body -> { if (parameter.type.isOptional) { appendLine(" val ${parameter.name} = body.jsonObject[\"${parameter.name}\"]?.jsonPrimitive?.contentOrNull") @@ -150,14 +153,32 @@ class KotlinKtorGenerator : Generator { } HttpTransportConfiguration.TransportMode.Query -> { if (parameter.type.isOptional) { - appendLine(" val ${parameter.name} = queryParameters[\"${parameter.name}\"]") + appendLine(" val ${parameter.name} = call.request.queryParameters[\"${parameter.name}\"]") + } else { + appendLine(" val ${parameter.name} = call.request.queryParameters.getValue(\"${parameter.name}\")") + } + } + HttpTransportConfiguration.TransportMode.Path -> { + if (parameter.type.isOptional) { + appendLine(" val ${parameter.name} = call.parameters[\"${parameter.name}\"]") + } else { + appendLine(" val ${parameter.name} = call.parameters.getValue(\"${parameter.name}\")") + } + } + HttpTransportConfiguration.TransportMode.Header -> { + if (parameter.type.isOptional) { + appendLine(" val ${parameter.name} = call.request.headers.get(\"${parameter.name}\")") + } else { + appendLine(" val ${parameter.name} = call.request.headers.get(\"${parameter.name}\")!!") + } + } + HttpTransportConfiguration.TransportMode.Cookie -> { + if (parameter.type.isOptional) { + appendLine(" val ${parameter.name} = call.request.cookies.get(\"${parameter.name}\")") } else { - appendLine(" val ${parameter.name} = queryParameters.getValue(\"${parameter.name}\")") + appendLine(" val ${parameter.name} = call.request.cookies.get(\"${parameter.name}\")!!") } } - HttpTransportConfiguration.TransportMode.Path -> TODO() - HttpTransportConfiguration.TransportMode.Header -> TODO() - HttpTransportConfiguration.TransportMode.Cookie -> TODO() } } diff --git a/codegen/src/main/kotlin/tools/samt/codegen/Mapping.kt b/codegen/src/main/kotlin/tools/samt/codegen/Mapping.kt index eb19332e..8ae0e459 100644 --- a/codegen/src/main/kotlin/tools/samt/codegen/Mapping.kt +++ b/codegen/src/main/kotlin/tools/samt/codegen/Mapping.kt @@ -3,7 +3,9 @@ package tools.samt.codegen import tools.samt.common.DiagnosticController class PublicApiMapper( - private val transportParsers: List, + private val transportParsers: List = listOf( + HttpTransportConfigurationParser(), + ), private val controller: DiagnosticController, ) { fun toPublicApi(samtPackage: tools.samt.semantic.Package) = object : SamtPackage { @@ -77,16 +79,21 @@ class PublicApiMapper( when (transportConfigurationParser.size) { 0 -> controller.reportGlobalError("No transport configuration parser found for transport '$name'") 1 -> { - return if (transportConfigNode != null) { - // TODO transform transportConfigNode to a Map - transportConfigurationParser.single().parse(emptyMap()) + if (transportConfigNode != null) { + val config = HttpTransportConfigurationParser.Params(transportConfigNode, controller) + val transportConfig = transportConfigurationParser.single().parse(config) + if (transportConfig != null) { + return transportConfig + } else { + controller.reportGlobalError("Failed to parse transport configuration for transport '$name'") + } } else { - transportConfigurationParser.single().default() + return transportConfigurationParser.single().default() } } - else -> controller.reportGlobalError("Multiple transport configuration parsers found for transport '$name'") } + return object : TransportConfiguration {} } diff --git a/codegen/src/main/kotlin/tools/samt/codegen/PublicApi.kt b/codegen/src/main/kotlin/tools/samt/codegen/PublicApi.kt index 581c93a9..bc7ba286 100644 --- a/codegen/src/main/kotlin/tools/samt/codegen/PublicApi.kt +++ b/codegen/src/main/kotlin/tools/samt/codegen/PublicApi.kt @@ -1,5 +1,8 @@ package tools.samt.codegen +import tools.samt.common.DiagnosticController +import tools.samt.parser.ObjectNode + interface GeneratorParams { val packages: List @@ -24,15 +27,22 @@ interface Generator { fun generate(generatorParams: GeneratorParams): List } +interface TransportConfigurationParserParams { + val configObjectNode: ObjectNode + + fun reportError(message: String) + fun reportWarning(message: String) + fun reportInfo(message: String) +} + interface TransportConfigurationParser { val transportName: String fun default(): TransportConfiguration - fun parse(configuration: Map): TransportConfiguration + fun parse(params: TransportConfigurationParserParams): TransportConfiguration? } interface TransportConfiguration - interface Type interface LiteralType : Type diff --git a/semantic/src/main/kotlin/tools/samt/semantic/SemanticModelPreProcessor.kt b/semantic/src/main/kotlin/tools/samt/semantic/SemanticModelPreProcessor.kt index e8fcbf73..c2a6cb45 100644 --- a/semantic/src/main/kotlin/tools/samt/semantic/SemanticModelPreProcessor.kt +++ b/semantic/src/main/kotlin/tools/samt/semantic/SemanticModelPreProcessor.kt @@ -107,12 +107,14 @@ internal class SemanticModelPreProcessor(private val controller: DiagnosticContr } is RequestResponseOperationNode -> { +/* if (operation.isAsync) { controller.getOrCreateContext(operation.location.source).error { message("Async operations are not yet supported") highlight("unsupported async operation", operation.location) } } +*/ ServiceType.RequestResponseOperation( name = operation.name.name, parameters = parameters, @@ -136,8 +138,9 @@ internal class SemanticModelPreProcessor(private val controller: DiagnosticContr implements ) } + val transport = ProviderType.Transport( - name = statement.transport.protocolName.name, + name = statement.transport.protocolName.name.lowercase(), configuration = statement.transport.configuration ) parentPackage += ProviderType(implements, transport, statement, parentPackage) diff --git a/specification/examples/debug2.samt b/specification/examples/debug2.samt deleted file mode 100644 index f513dc11..00000000 --- a/specification/examples/debug2.samt +++ /dev/null @@ -1,23 +0,0 @@ -import debug.foo.bar.baz.* - -package debug.foo.bar.boz - -record MyTestRecord { - myPersonList: PersonList - myCarMap: CarMap - myGarageMap: GarageMap - myOtherPerson: OtherPerson - myOtherCar: OtherCar? - myOtherGarage: OtherGarage? - myShortString: ShortString? - myColor: MyColor -} - -service MyCoolService { - GetPersonByName(name: ShortString): Person? - async GetCarsOfPerson(name: ShortString): CarList - - oneway UploadCar(car: Car?) - - oneway SendPulse() -} \ No newline at end of file diff --git a/specification/examples/todo-service/common.samt b/specification/examples/todo-service/common.samt index a0a898a4..8be8dfce 100644 --- a/specification/examples/todo-service/common.samt +++ b/specification/examples/todo-service/common.samt @@ -2,5 +2,10 @@ package tools.samt.examples.common typealias UUID = String ( pattern("[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}") ) +/* record NotFoundFault extends Fault record MissingPermissionsFault extends Fault +*/ + +record NotFoundFault +record MissingPermissionsFault diff --git a/specification/examples/todo-service/todo-provider-http.samt b/specification/examples/todo-service/todo-provider-http.samt index 4a3a16fd..d971cbfa 100644 --- a/specification/examples/todo-service/todo-provider-http.samt +++ b/specification/examples/todo-service/todo-provider-http.samt @@ -4,27 +4,27 @@ provide TodoEndpointHTTP { implements TodoManager implements TodoListManager - transport HTTP { - serialization: "JSON", + transport http { + serialization: "json", operations: { TodoManager: { - createTodo: "POST /todo", - searchTodo: "GET /todo?title={title}", + createTodo: "POST /todo {cookie:session}", + searchTodo: "GET /todo {query:title}", getTodo: "GET /todo/{id}", getTodos: "GET /todo", updateTodo: "PUT /todo/{id}", deleteTodo: "DELETE /todo/{id}", - markAsCompleted: "PUT /todo/{id}/completed" + markAsCompleted: "PUT /todo/{id} {query:completed}" }, TodoListManager: { createTodoList: "POST /todo-list", - searchTodoList: "GET /todo-list?title={title}", + searchTodoList: "GET /todo-list {query:title}", getTodoList: "GET /todo-list/{id}", getTodoLists: "GET /todo-list", updateTodoList: "PUT /todo-list/{id}", deleteTodoList: "DELETE /todo-list/{id}", - addTodoToList: "PUT /todo-list/{id}/todo/{todoId}", - removeTodoFromList: "DELETE /todo-list/{id}/todo/{todoId}" + addTodoToList: "PUT /todo-list/{listId}/todo/{todoId}", + removeTodoFromList: "DELETE /todo-list/{listId}/todo/{todoId}" } }, faults: { diff --git a/specification/examples/todo-service/todo-service.samt b/specification/examples/todo-service/todo-service.samt index e37b252a..0a50cd25 100644 --- a/specification/examples/todo-service/todo-service.samt +++ b/specification/examples/todo-service/todo-service.samt @@ -19,7 +19,7 @@ record TodoList { @Description("A service for managing todo items") service TodoManager { - createTodo(title: String, description: String): TodoItem + createTodo(title: String, description: String, session: String): TodoItem searchTodo(title: String): TodoItem? getTodo(id: UUID): TodoItem raises NotFoundFault getTodos(): List From 2deb891b8d15b52761c8d18ad982bb67dffbccc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leonard=20Schu=CC=88tz?= Date: Wed, 17 May 2023 00:10:19 +0200 Subject: [PATCH 10/41] feat(codegen): Parse faults to status code mapping Currently only shown as comment in output, no actual functionality --- .../tools/samt/codegen/HttpTransport.kt | 31 ++++++++++++++----- .../tools/samt/codegen/KotlinKtorGenerator.kt | 1 + .../todo-service/todo-provider-http.samt | 10 ++---- 3 files changed, 26 insertions(+), 16 deletions(-) diff --git a/codegen/src/main/kotlin/tools/samt/codegen/HttpTransport.kt b/codegen/src/main/kotlin/tools/samt/codegen/HttpTransport.kt index 641baa12..735d7b4f 100644 --- a/codegen/src/main/kotlin/tools/samt/codegen/HttpTransport.kt +++ b/codegen/src/main/kotlin/tools/samt/codegen/HttpTransport.kt @@ -10,13 +10,11 @@ class HttpTransportConfigurationParser: TransportConfigurationParser { override val transportName: String get() = "http" - override fun default(): TransportConfiguration { - require(false) { "Not implemented: Default Configuration" } - return HttpTransportConfiguration( - serializationMode = HttpTransportConfiguration.SerializationMode.Json, - services = emptyList(), - ) - } + override fun default(): TransportConfiguration = HttpTransportConfiguration( + serializationMode = HttpTransportConfiguration.SerializationMode.Json, + services = emptyList(), + exceptionMap = emptyMap(), + ) class Params( override val configObjectNode: ObjectNode, @@ -181,11 +179,27 @@ class HttpTransportConfigurationParser: TransportConfigurationParser { } } - // TODO: implement faults parsing + val exceptions = buildMap { + if (fields.containsKey("faults")) { + if (fields["faults"] is ObjectNode) { + val faultMapping = parseObjectNode(fields["faults"] as ObjectNode) + for ((faultName, statusCode) in faultMapping) { + + if (statusCode !is IntegerNode) { + params.reportError("Expected integer value for '$faultName' fault status code") + continue + } + + set(faultName, (statusCode as IntegerNode).value) + } + } + } + } return HttpTransportConfiguration( serializationMode = serializationMode, services = services, + exceptionMap = exceptions, ) } @@ -202,6 +216,7 @@ class HttpTransportConfigurationParser: TransportConfigurationParser { class HttpTransportConfiguration( val serializationMode: SerializationMode, val services: List, + val exceptionMap: Map, ) : TransportConfiguration { class ServiceConfiguration( val name: String, diff --git a/codegen/src/main/kotlin/tools/samt/codegen/KotlinKtorGenerator.kt b/codegen/src/main/kotlin/tools/samt/codegen/KotlinKtorGenerator.kt index 1ee73396..1091f4f6 100644 --- a/codegen/src/main/kotlin/tools/samt/codegen/KotlinKtorGenerator.kt +++ b/codegen/src/main/kotlin/tools/samt/codegen/KotlinKtorGenerator.kt @@ -57,6 +57,7 @@ class KotlinKtorGenerator : Generator { val serviceArguments = implementedServices.joinToString { info -> "${info.serviceArgumentName}: ${info.reference.qualifiedName}" } + appendLine("// ${transportConfiguration.exceptionMap}") appendLine("fun Routing.route${provider.name}($serviceArguments) {") implementedServices.forEach { info -> appendProviderOperations(info, transportConfiguration) diff --git a/specification/examples/todo-service/todo-provider-http.samt b/specification/examples/todo-service/todo-provider-http.samt index d971cbfa..0f3677c9 100644 --- a/specification/examples/todo-service/todo-provider-http.samt +++ b/specification/examples/todo-service/todo-provider-http.samt @@ -28,14 +28,8 @@ provide TodoEndpointHTTP { } }, faults: { - NotFoundFault: { - code: 404, - message: "Todo not found" - }, - MissingPermissionsFault: { - code: 403, - message: "Missing permissions" - } + NotFoundFault: 404, + MissingPermissionsFault: 403 } } } From 14498bc1e313484651300bbb63e049075493d727 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leonard=20Schu=CC=88tz?= Date: Thu, 18 May 2023 02:24:14 +0200 Subject: [PATCH 11/41] feat(codegen): location aware transport errors --- .../tools/samt/codegen/HttpTransport.kt | 68 +++++++++++++++---- .../kotlin/tools/samt/codegen/PublicApi.kt | 1 - .../main/kotlin/tools/samt/parser/Nodes.kt | 19 +++++- 3 files changed, 70 insertions(+), 18 deletions(-) diff --git a/codegen/src/main/kotlin/tools/samt/codegen/HttpTransport.kt b/codegen/src/main/kotlin/tools/samt/codegen/HttpTransport.kt index 735d7b4f..964169b4 100644 --- a/codegen/src/main/kotlin/tools/samt/codegen/HttpTransport.kt +++ b/codegen/src/main/kotlin/tools/samt/codegen/HttpTransport.kt @@ -18,7 +18,7 @@ class HttpTransportConfigurationParser: TransportConfigurationParser { class Params( override val configObjectNode: ObjectNode, - private val controller: DiagnosticController + val controller: DiagnosticController ) : TransportConfigurationParserParams { override fun reportError(message: String) { @@ -46,13 +46,19 @@ class HttpTransportConfigurationParser: TransportConfigurationParser { "json" -> HttpTransportConfiguration.SerializationMode.Json else -> { // unknown serialization mode - params.reportError("Unknown serialization mode '${serializationConfig.value}', defaulting to 'json'") + serializationConfig.reportError(params.controller) { + message("Unknown serialization mode '${serializationConfig.value}', defaulting to 'json'") + highlight(serializationConfig.location, "unknown serialization mode") + } HttpTransportConfiguration.SerializationMode.Json } } } else { // invalid serialization mode type, expected string - params.reportError("Invalid value for 'serialization', defaulting to 'json'") + serializationConfig.reportError(params.controller) { + message("Expcted serialization config option to be a string, defaulting to 'json'") + highlight(serializationConfig.location, "") + } HttpTransportConfiguration.SerializationMode.Json } } else { @@ -61,19 +67,24 @@ class HttpTransportConfigurationParser: TransportConfigurationParser { val services = buildList { if (!fields.containsKey("operations")) { - params.reportError("Missing 'operations' field") return@buildList } if (fields["operations"] !is ObjectNode) { - params.reportError("Invalid value for 'operations', expected object") + fields["operations"]!!.reportError(params.controller) { + message("Invalid value for 'operations', expected object") + highlight(fields["operations"]!!.location) + } return@buildList } val operationsConfig = parseObjectNode(fields["operations"] as ObjectNode) for ((serviceName, operationsField) in operationsConfig) { if (operationsField !is ObjectNode) { - params.reportError("Invalid value for '$serviceName', expected object") + operationsField.reportError(params.controller) { + message("Invalid value for '$serviceName', expected object") + highlight(operationsField.location) + } continue } @@ -82,13 +93,19 @@ class HttpTransportConfigurationParser: TransportConfigurationParser { val operations = buildList { for ((operationName, operationConfig) in operationsConfig) { if (operationConfig !is StringNode) { - params.reportError("Invalid value for operation config for '$operationName', expected string") + operationConfig.reportError(params.controller) { + message("Invalid value for operation config for '$operationName', expected string") + highlight(operationConfig.location) + } continue } val words = operationConfig.value.split(" ") if (words.size < 2) { - params.reportError("Invalid operation config for '$operationName', expected ' '") + operationConfig.reportError(params.controller) { + message("Invalid operation config for '$operationName', expected ' '") + highlight(operationConfig.location) + } continue } @@ -99,7 +116,10 @@ class HttpTransportConfigurationParser: TransportConfigurationParser { "DELETE" -> HttpTransportConfiguration.HttpMethod.Delete "PATCH" -> HttpTransportConfiguration.HttpMethod.Patch else -> { - params.reportError("Invalid http method '$methodName'") + operationConfig.reportError(params.controller) { + message("Invalid http method '$methodName'") + highlight(operationConfig.location) + } continue } } @@ -115,7 +135,10 @@ class HttpTransportConfigurationParser: TransportConfigurationParser { val pathParameterName = component.substring(1, component.length - 1) if (pathParameterName.isEmpty()) { - params.reportError("Expected parameter name between curly braces in '$path'") + operationConfig.reportError(params.controller) { + message("Expected parameter name between curly braces in '$path'") + highlight(operationConfig.location) + } continue } @@ -131,13 +154,19 @@ class HttpTransportConfigurationParser: TransportConfigurationParser { if (component.startsWith("{") && component.endsWith("}")) { val parameterConfig = component.substring(1, component.length - 1) if (parameterConfig.isEmpty()) { - params.reportError("Expected parameter name between curly braces in '$path'") + operationConfig.reportError(params.controller) { + message("Expected parameter name between curly braces in '$path'") + highlight(operationConfig.location) + } continue } val parts = parameterConfig.split(":") if (parts.size != 2) { - params.reportError("Expected parameter in format '{type:name}', got '$component'") + operationConfig.reportError(params.controller) { + message("Expected parameter in format '{type:name}', got '$component'") + highlight(operationConfig.location) + } continue } @@ -148,7 +177,10 @@ class HttpTransportConfigurationParser: TransportConfigurationParser { "body" -> HttpTransportConfiguration.TransportMode.Body "cookie" -> HttpTransportConfiguration.TransportMode.Cookie else -> { - params.reportError("Invalid transport mode '$type'") + operationConfig.reportError(params.controller) { + message("Invalid transport mode '$type'") + highlight(operationConfig.location) + } continue } } @@ -158,7 +190,10 @@ class HttpTransportConfigurationParser: TransportConfigurationParser { transportMode = transportMode, )) } else { - params.reportError("Expected parameter in format '{type:name}', got '$component'") + operationConfig.reportError(params.controller) { + message("Expected parameter in format '{type:name}', got '$component'") + highlight(operationConfig.location) + } } } } @@ -186,7 +221,10 @@ class HttpTransportConfigurationParser: TransportConfigurationParser { for ((faultName, statusCode) in faultMapping) { if (statusCode !is IntegerNode) { - params.reportError("Expected integer value for '$faultName' fault status code") + statusCode.reportError(params.controller) { + message("Expected integer value for '$faultName' fault status code") + highlight(statusCode.location) + } continue } diff --git a/codegen/src/main/kotlin/tools/samt/codegen/PublicApi.kt b/codegen/src/main/kotlin/tools/samt/codegen/PublicApi.kt index bc7ba286..82f0fc81 100644 --- a/codegen/src/main/kotlin/tools/samt/codegen/PublicApi.kt +++ b/codegen/src/main/kotlin/tools/samt/codegen/PublicApi.kt @@ -1,6 +1,5 @@ package tools.samt.codegen -import tools.samt.common.DiagnosticController import tools.samt.parser.ObjectNode interface GeneratorParams { diff --git a/parser/src/main/kotlin/tools/samt/parser/Nodes.kt b/parser/src/main/kotlin/tools/samt/parser/Nodes.kt index 92a197d0..18bbbc9c 100644 --- a/parser/src/main/kotlin/tools/samt/parser/Nodes.kt +++ b/parser/src/main/kotlin/tools/samt/parser/Nodes.kt @@ -1,12 +1,27 @@ package tools.samt.parser -import tools.samt.common.Location -import tools.samt.common.SourceFile +import tools.samt.common.* sealed interface Node { val location: Location } +inline fun Node.report(controller: DiagnosticController, severity: DiagnosticSeverity, block: DiagnosticMessageBuilder.() -> Unit) { + controller.getOrCreateContext(location.source).report(severity, block) +} + +inline fun Node.reportError(controller: DiagnosticController, block: DiagnosticMessageBuilder.() -> Unit) { + report(controller, DiagnosticSeverity.Error, block) +} + +inline fun Node.reportWarning(controller: DiagnosticController, block: DiagnosticMessageBuilder.() -> Unit) { + report(controller, DiagnosticSeverity.Warning, block) +} + +inline fun Node.reportInfo(controller: DiagnosticController, block: DiagnosticMessageBuilder.() -> Unit) { + report(controller, DiagnosticSeverity.Info, block) +} + sealed interface AnnotatedNode : Node { val annotations: List } From cec91b62ced42c043aa9883d67561054b25eab32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leonard=20Schu=CC=88tz?= Date: Fri, 19 May 2023 13:12:02 +0200 Subject: [PATCH 12/41] feat(codegen): Emit ktor server file --- .../main/kotlin/tools/samt/cli/CliCompiler.kt | 8 ++++ .../tools/samt/codegen/HttpTransport.kt | 4 +- .../tools/samt/codegen/KotlinKtorGenerator.kt | 45 ++++++++++++++++++- .../main/kotlin/tools/samt/codegen/Mapping.kt | 2 +- 4 files changed, 55 insertions(+), 4 deletions(-) diff --git a/cli/src/main/kotlin/tools/samt/cli/CliCompiler.kt b/cli/src/main/kotlin/tools/samt/cli/CliCompiler.kt index 2bf43149..e2ca75f7 100644 --- a/cli/src/main/kotlin/tools/samt/cli/CliCompiler.kt +++ b/cli/src/main/kotlin/tools/samt/cli/CliCompiler.kt @@ -51,6 +51,14 @@ internal fun compile(command: CompileCommand, controller: DiagnosticController) // Code Generators will be called here val files = Codegen.generate(model, controller) + + // if the semantic model failed to build, exit + if (controller.hasErrors()) { + return + } + + // emit files for debug purposes + // TODO: emit into an "out" folder and build package folder structure for (file in files) { println("${yellow(file.filepath)}:\n${file.source}\n") } diff --git a/codegen/src/main/kotlin/tools/samt/codegen/HttpTransport.kt b/codegen/src/main/kotlin/tools/samt/codegen/HttpTransport.kt index 964169b4..5286500a 100644 --- a/codegen/src/main/kotlin/tools/samt/codegen/HttpTransport.kt +++ b/codegen/src/main/kotlin/tools/samt/codegen/HttpTransport.kt @@ -34,7 +34,7 @@ class HttpTransportConfigurationParser: TransportConfigurationParser { } } - override fun parse(params: TransportConfigurationParserParams): HttpTransportConfiguration? { + override fun parse(params: TransportConfigurationParserParams): HttpTransportConfiguration { require(params is Params) { "Invalid params type" } val fields = parseObjectNode(params.configObjectNode) @@ -56,7 +56,7 @@ class HttpTransportConfigurationParser: TransportConfigurationParser { } else { // invalid serialization mode type, expected string serializationConfig.reportError(params.controller) { - message("Expcted serialization config option to be a string, defaulting to 'json'") + message("Expected serialization config option to be a string, defaulting to 'json'") highlight(serializationConfig.location, "") } HttpTransportConfiguration.SerializationMode.Json diff --git a/codegen/src/main/kotlin/tools/samt/codegen/KotlinKtorGenerator.kt b/codegen/src/main/kotlin/tools/samt/codegen/KotlinKtorGenerator.kt index 1091f4f6..a198b49d 100644 --- a/codegen/src/main/kotlin/tools/samt/codegen/KotlinKtorGenerator.kt +++ b/codegen/src/main/kotlin/tools/samt/codegen/KotlinKtorGenerator.kt @@ -1,6 +1,5 @@ package tools.samt.codegen - class KotlinKtorGenerator : Generator { override val identifier: String = "kotlin-ktor" @@ -15,6 +14,11 @@ class KotlinKtorGenerator : Generator { private fun generatePackage(pack: SamtPackage) { if (pack.hasProviderTypes()) { + + // generate general ktor files + generateKtorServer(pack) + + // generate ktor providers pack.providers.forEach { provider -> val transportConfiguration = provider.transport if (transportConfiguration !is HttpTransportConfiguration) { @@ -33,7 +37,46 @@ class KotlinKtorGenerator : Generator { val file = CodegenFile(filePath, packageSource) emittedFiles.add(file) } + + // generate ktor consumers + } + } + + private fun generateKtorServer(pack: SamtPackage) { + val packageSource = buildString { + appendLine("package ${pack.qualifiedName}") + appendLine() + + appendLine("import io.ktor.http.*") + appendLine("import io.ktor.serialization.kotlinx.json.*") + appendLine("import io.ktor.server.plugins.contentnegotiation.*") + appendLine("import io.ktor.server.response.*") + appendLine("import io.ktor.server.application.*") + appendLine("import io.ktor.server.request.*") + appendLine("import io.ktor.server.routing.*") + appendLine("import kotlinx.serialization.json.*") + appendLine() + + appendLine("fun Application.configureSerialization() {") + appendLine(" install(ContentNegotiation) {") + appendLine(" json()") + appendLine(" }") + appendLine(" routing {") + + for (provider in pack.providers) { + append(" route${provider.name}(") + append("/* ") + append(provider.implements.joinToString(" */, /* ") { it.service.qualifiedName }) + appendLine("*/)") + } + + appendLine(" }") + appendLine("}") } + + val filePath = pack.qualifiedName.replace('.', '/') + "Server.kt" + val file = CodegenFile(filePath, packageSource) + emittedFiles.add(file) } data class ProviderInfo(val implements: ProviderImplements) { diff --git a/codegen/src/main/kotlin/tools/samt/codegen/Mapping.kt b/codegen/src/main/kotlin/tools/samt/codegen/Mapping.kt index 8ae0e459..11d994c3 100644 --- a/codegen/src/main/kotlin/tools/samt/codegen/Mapping.kt +++ b/codegen/src/main/kotlin/tools/samt/codegen/Mapping.kt @@ -77,7 +77,7 @@ class PublicApiMapper( val transportConfigNode = configuration val transportConfigurationParser = transportParsers.filter { it.transportName == name } when (transportConfigurationParser.size) { - 0 -> controller.reportGlobalError("No transport configuration parser found for transport '$name'") + 0 -> controller.reportGlobalWarning("No transport configuration parser found for transport '$name'") 1 -> { if (transportConfigNode != null) { val config = HttpTransportConfigurationParser.Params(transportConfigNode, controller) From 39e4fc970fab32cc1bcff2457a0b16b95b83c887 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leonard=20Schu=CC=88tz?= Date: Mon, 22 May 2023 17:44:04 +0200 Subject: [PATCH 13/41] feat(codegen): basic consumer generation --- .../tools/samt/codegen/KotlinKtorGenerator.kt | 176 +++++++++++++++++- 1 file changed, 170 insertions(+), 6 deletions(-) diff --git a/codegen/src/main/kotlin/tools/samt/codegen/KotlinKtorGenerator.kt b/codegen/src/main/kotlin/tools/samt/codegen/KotlinKtorGenerator.kt index a198b49d..42fb68a8 100644 --- a/codegen/src/main/kotlin/tools/samt/codegen/KotlinKtorGenerator.kt +++ b/codegen/src/main/kotlin/tools/samt/codegen/KotlinKtorGenerator.kt @@ -33,12 +33,31 @@ class KotlinKtorGenerator : Generator { appendProvider(provider, transportConfiguration) } - val filePath = pack.qualifiedName.replace('.', '/') + ".kt" + val filePath = pack.qualifiedName.replace('.', '/') + "Provider.kt" val file = CodegenFile(filePath, packageSource) emittedFiles.add(file) } // generate ktor consumers + pack.consumers.forEach { consumer -> + val provider = consumer.provider.type as ProviderType + val transportConfiguration = provider.transport + if (transportConfiguration !is HttpTransportConfiguration) { + // Skip consumers that are not HTTP + return@forEach + } + + val packageSource = buildString { + appendLine("package ${pack.qualifiedName}") + appendLine() + + appendConsumer(consumer, transportConfiguration) + } + + val filePath = pack.qualifiedName.replace('.', '/') + "Consumer.kt" + val file = CodegenFile(filePath, packageSource) + emittedFiles.add(file) + } } } @@ -123,14 +142,18 @@ class KotlinKtorGenerator : Generator { } appendLine() - appendLine(" val response = ${getServiceCall(info, operation)}") - // TODO Config: HTTP status code - // TODO serialize response correctly // TODO validate response - appendLine(" call.respond(response)") - appendLine(" }") + if (operation.returnType != null) { + appendLine(" val response = ${getServiceCall(info, operation)}") + appendLine(" call.respond(response)") + appendLine(" }") + } else { + appendLine(" ${getServiceCall(info, operation)}") + appendLine(" call.respond(HttpStatusCode.NoContent)") + appendLine(" }") + } } is OnewayOperation -> { @@ -160,6 +183,147 @@ class KotlinKtorGenerator : Generator { appendLine() } + data class ConsumerInfo(val uses: ConsumerUses) { + val reference = uses.service + val service = reference.type as ServiceType + val serviceArgumentName = service.name.replaceFirstChar { it.lowercase() } + } + + private fun StringBuilder.appendConsumer(consumer: ConsumerType, transportConfiguration: HttpTransportConfiguration) { + appendLine("import io.ktor.client.*") + appendLine("import io.ktor.client.engine.cio.*") + appendLine("import io.ktor.client.plugins.contentnegotiation.*") + appendLine("import io.ktor.client.request.*") + appendLine("import io.ktor.client.statement.*") + appendLine("import io.ktor.http.*") + appendLine("import io.ktor.serialization.kotlinx.json.*") + appendLine("import kotlinx.coroutines.runBlocking") + appendLine("import kotlinx.serialization.json.*") + + val implementedServices = consumer.uses.map { ConsumerInfo(it) } + val serviceArguments = implementedServices.joinToString { info -> + "${info.serviceArgumentName}: ${info.reference.qualifiedName}" + } + appendLine("// ${transportConfiguration.exceptionMap}") + appendLine("class ${consumer.name}() {") + implementedServices.forEach { info -> + appendConsumerOperations(info, transportConfiguration) + } + appendLine("}") + } + + private fun StringBuilder.appendConsumerOperations(info: ConsumerInfo, transportConfiguration: HttpTransportConfiguration) { + appendLine(" private val client = HttpClient(CIO) {") + appendLine(" install(ContentNegotiation) {") + appendLine(" json()") + appendLine(" }") + appendLine(" }") + appendLine() + + val service = info.service + info.uses.operations.forEach { operation -> + val operationParameters = operation.parameters.joinToString { "${it.name}: ${it.type.qualifiedName}" } + + when (operation) { + is RequestResponseOperation -> { + if (operation.returnType != null) { + appendLine(" override fun ${operation.name}($operationParameters): ${operation.returnType!!.qualifiedName} {") + } else { + appendLine(" override fun ${operation.name}($operationParameters): Unit {") + } + + // TODO Config: HTTP status code + // TODO serialize response correctly + // TODO validate response + appendLine("return runBlocking {") + + appendConsumerServiceCall(info, operation, transportConfiguration) + appendConsumerResponseParsing(operation, transportConfiguration) + + appendLine("}") + } + + is OnewayOperation -> { + // TODO + } + } + } + } + + private fun StringBuilder.appendConsumerServiceCall(info: ConsumerInfo, operation: ServiceOperation, transport: HttpTransportConfiguration) { + /* + val response = client.request("$baseUrl/todos/$title") { + method = HttpMethod.Post + headers["title"] = title + cookie("description", description) + setBody( + buildJsonObject { + put("title", title) + put("description", description) + } + ) + contentType(ContentType.Application.Json) + } + */ + + // collect parameters for each transport type + val headerParameters = mutableListOf() + val cookieParameters = mutableListOf() + val bodyParameters = mutableListOf() + val pathParameters = mutableListOf() + val queryParameters = mutableListOf() + operation.parameters.forEach { + val name = it.name + val transportMode = transport.getTransportMode(info.service.name, operation.name, name) + when (transportMode) { + HttpTransportConfiguration.TransportMode.Header -> { + headerParameters.add(name) + } + HttpTransportConfiguration.TransportMode.Cookie -> { + cookieParameters.add(name) + } + HttpTransportConfiguration.TransportMode.Body -> { + bodyParameters.add(name) + } + HttpTransportConfiguration.TransportMode.Path -> { + pathParameters.add(name) + } + HttpTransportConfiguration.TransportMode.Query -> { + queryParameters.add(name) + } + } + } + + // build request path + // need to split transport path into path segments and query parameter slots + val pathSegments = mutableListOf() + val queryParameterSlots = mutableListOf() + val transportPath = transport.getPath(info.service.name, operation.name) + val pathParts = transportPath.split("/") + + // build request headers and body + + // oneway vs request-response + } + + private fun StringBuilder.appendConsumerResponseParsing(operation: ServiceOperation, transport: HttpTransportConfiguration) { + /* + val bodyAsText = response.bodyAsText() + val body = Json.parseToJsonElement(bodyAsText) + + val respTitle = body.jsonObject["title"]!!.jsonPrimitive.content + val respDescription = response.headers["description"]!! + check(respTitle.length in 1..100) + + Todo( + title = respTitle, + description = respDescription, + ) + */ + + + } + private fun getKtorRoute(service: ServiceType, operation: ServiceOperation, transportConfiguration: HttpTransportConfiguration): String { val method = when (transportConfiguration.getMethod(service.name, operation.name)) { HttpTransportConfiguration.HttpMethod.Get -> "get" From 28b317290c82b095029ca133b4e56f623b3b9b3e Mon Sep 17 00:00:00 2001 From: Pascal Honegger Date: Fri, 19 May 2023 15:28:53 +0200 Subject: [PATCH 14/41] feat(cli): parse samt config and resolve paths relative to project root --- cli/build.gradle.kts | 1 + cli/src/main/kotlin/tools/samt/cli/CliArgs.kt | 8 ++++---- .../main/kotlin/tools/samt/cli/CliCompiler.kt | 14 ++++++++++++-- cli/src/main/kotlin/tools/samt/cli/CliDumper.kt | 17 ++++++++++++++++- .../kotlin/tools/samt/cli/CliFileResolution.kt | 12 ------------ .../tools/samt/cli/DiagnosticFormatter.kt | 3 --- .../tools/samt/common/SamtConfiguration.kt | 8 +++++--- .../samt/config/SamtConfigurationParser.kt | 9 ++++++--- .../samt/config/SamtConfigurationParserTest.kt | 10 +++++----- specification/examples/.samtrc.yaml | 1 + specification/examples/samt.yaml | 5 +++++ 11 files changed, 55 insertions(+), 33 deletions(-) delete mode 100644 cli/src/main/kotlin/tools/samt/cli/CliFileResolution.kt create mode 100644 specification/examples/.samtrc.yaml create mode 100644 specification/examples/samt.yaml diff --git a/cli/build.gradle.kts b/cli/build.gradle.kts index a18e1688..240b94b4 100644 --- a/cli/build.gradle.kts +++ b/cli/build.gradle.kts @@ -14,6 +14,7 @@ dependencies { implementation(project(":lexer")) implementation(project(":parser")) implementation(project(":semantic")) + implementation(project(":samt-config")) implementation(project(":codegen")) } diff --git a/cli/src/main/kotlin/tools/samt/cli/CliArgs.kt b/cli/src/main/kotlin/tools/samt/cli/CliArgs.kt index 75d11099..586b66ae 100644 --- a/cli/src/main/kotlin/tools/samt/cli/CliArgs.kt +++ b/cli/src/main/kotlin/tools/samt/cli/CliArgs.kt @@ -10,8 +10,8 @@ class CliArgs { @Parameters(commandDescription = "Compile SAMT files") class CompileCommand { - @Parameter(description = "Files to compile, defaults to all .samt files in the current directory") - var files: List = mutableListOf() + @Parameter(description = "SAMT project to compile, defaults to the 'samt.yaml' file in the current directory") + var file: String = "./samt.yaml" } @Parameters(commandDescription = "Dump SAMT files in various formats for debugging purposes") @@ -25,8 +25,8 @@ class DumpCommand { @Parameter(names = ["--types"], description = "Dump a visual representation of the resolved types") var dumpTypes: Boolean = false - @Parameter(description = "Files to dump, defaults to all .samt files in the current directory") - var files: List = mutableListOf() + @Parameter(description = "SAMT project to dump, defaults to the 'samt.yaml' file in the current directory") + var file: String = "./samt.yaml" } @Parameters(commandDescription = "Initialize or update the SAMT wrapper") diff --git a/cli/src/main/kotlin/tools/samt/cli/CliCompiler.kt b/cli/src/main/kotlin/tools/samt/cli/CliCompiler.kt index e2ca75f7..ff070351 100644 --- a/cli/src/main/kotlin/tools/samt/cli/CliCompiler.kt +++ b/cli/src/main/kotlin/tools/samt/cli/CliCompiler.kt @@ -4,12 +4,23 @@ import com.github.ajalt.mordant.rendering.TextColors.* import tools.samt.codegen.Codegen import tools.samt.common.DiagnosticController import tools.samt.common.DiagnosticException +import tools.samt.common.collectSamtFiles +import tools.samt.common.readSamtSource import tools.samt.lexer.Lexer import tools.samt.parser.Parser import tools.samt.semantic.SemanticModel +import kotlin.io.path.isDirectory +import kotlin.io.path.notExists internal fun compile(command: CompileCommand, controller: DiagnosticController) { - val sourceFiles = command.files.readSamtSourceFiles(controller) + val (configuration ,_) = CliConfigParser.readConfig(command.file, controller) ?: return + + if (configuration.source.notExists() || !configuration.source.isDirectory()) { + controller.reportGlobalError("Source path '${configuration.source.toUri()}' does not point to valid directory") + return + } + + val sourceFiles = collectSamtFiles(configuration.source.toUri()).readSamtSource(controller) if (controller.hasErrors()) { return @@ -49,7 +60,6 @@ internal fun compile(command: CompileCommand, controller: DiagnosticController) return } - // Code Generators will be called here val files = Codegen.generate(model, controller) // if the semantic model failed to build, exit diff --git a/cli/src/main/kotlin/tools/samt/cli/CliDumper.kt b/cli/src/main/kotlin/tools/samt/cli/CliDumper.kt index cb4f95c1..39ed2428 100644 --- a/cli/src/main/kotlin/tools/samt/cli/CliDumper.kt +++ b/cli/src/main/kotlin/tools/samt/cli/CliDumper.kt @@ -3,12 +3,27 @@ package tools.samt.cli import com.github.ajalt.mordant.terminal.Terminal import tools.samt.common.DiagnosticController import tools.samt.common.DiagnosticException +import tools.samt.common.collectSamtFiles +import tools.samt.common.readSamtSource import tools.samt.lexer.Lexer import tools.samt.parser.Parser import tools.samt.semantic.SemanticModel +import kotlin.io.path.isDirectory +import kotlin.io.path.notExists internal fun dump(command: DumpCommand, terminal: Terminal, controller: DiagnosticController) { - val sourceFiles = command.files.readSamtSourceFiles(controller) + val (configuration ,_) = CliConfigParser.readConfig(command.file, controller) ?: return + + if (configuration.source.notExists() || !configuration.source.isDirectory()) { + controller.reportGlobalError("Source path '${configuration.source.toUri()}' does not point to valid directory") + return + } + + val sourceFiles = collectSamtFiles(configuration.source.toUri()).readSamtSource(controller) + + if (controller.hasErrors()) { + return + } if (controller.hasErrors()) { return diff --git a/cli/src/main/kotlin/tools/samt/cli/CliFileResolution.kt b/cli/src/main/kotlin/tools/samt/cli/CliFileResolution.kt deleted file mode 100644 index 4a959abe..00000000 --- a/cli/src/main/kotlin/tools/samt/cli/CliFileResolution.kt +++ /dev/null @@ -1,12 +0,0 @@ -package tools.samt.cli - -import tools.samt.common.DiagnosticController -import tools.samt.common.SourceFile -import tools.samt.common.collectSamtFiles -import tools.samt.common.readSamtSource -import java.io.File - -internal fun List.readSamtSourceFiles(controller: DiagnosticController): List = - map { File(it) }.ifEmpty { collectSamtFiles(controller.workingDirectory) } - .readSamtSource(controller) - diff --git a/cli/src/main/kotlin/tools/samt/cli/DiagnosticFormatter.kt b/cli/src/main/kotlin/tools/samt/cli/DiagnosticFormatter.kt index 167e11b2..e40b2781 100644 --- a/cli/src/main/kotlin/tools/samt/cli/DiagnosticFormatter.kt +++ b/cli/src/main/kotlin/tools/samt/cli/DiagnosticFormatter.kt @@ -15,9 +15,6 @@ internal class DiagnosticFormatter( companion object { private const val CONTEXT_ROW_COUNT = 3 - // FIXME: this is a bit of a hack to get the terminal width - // it also means we're assuming this output will only ever be printed in a terminal - // i don't actually know what happens if it doesn't run in a tty setting fun format(controller: DiagnosticController, startTimestamp: Long, currentTimestamp: Long, terminalWidth: Int = Terminal().info.width): String { val formatter = DiagnosticFormatter(controller, startTimestamp, currentTimestamp, terminalWidth) return formatter.format() diff --git a/common/src/main/kotlin/tools/samt/common/SamtConfiguration.kt b/common/src/main/kotlin/tools/samt/common/SamtConfiguration.kt index 6ec74f5d..ba957b0e 100644 --- a/common/src/main/kotlin/tools/samt/common/SamtConfiguration.kt +++ b/common/src/main/kotlin/tools/samt/common/SamtConfiguration.kt @@ -1,7 +1,9 @@ package tools.samt.common +import java.nio.file.Path + data class SamtConfiguration( - val source: String, + val source: Path, val plugins: List, val generators: List, ) @@ -9,7 +11,7 @@ data class SamtConfiguration( sealed interface SamtPluginConfiguration data class SamtLocalPluginConfiguration( - val path: String, + val path: Path, ) : SamtPluginConfiguration data class SamtMavenPluginConfiguration( @@ -21,6 +23,6 @@ data class SamtMavenPluginConfiguration( data class SamtGeneratorConfiguration( val name: String, - val output: String, + val output: Path, val options: Map, ) diff --git a/samt-config/src/main/kotlin/tools/samt/config/SamtConfigurationParser.kt b/samt-config/src/main/kotlin/tools/samt/config/SamtConfigurationParser.kt index 7f3ec9c9..49e4b21c 100644 --- a/samt-config/src/main/kotlin/tools/samt/config/SamtConfigurationParser.kt +++ b/samt-config/src/main/kotlin/tools/samt/config/SamtConfigurationParser.kt @@ -3,6 +3,7 @@ package tools.samt.config import com.charleskorn.kaml.* import kotlinx.serialization.SerializationException import java.nio.file.Path +import kotlin.io.path.Path import kotlin.io.path.exists import kotlin.io.path.inputStream import tools.samt.common.DiagnosticSeverity as CommonDiagnosticSeverity @@ -37,12 +38,14 @@ object SamtConfigurationParser { SamtConfiguration() } + val projectDirectory = path.parent + return CommonSamtConfiguration( - source = parsedConfiguration.source, + source = projectDirectory.resolve(parsedConfiguration.source).normalize(), plugins = parsedConfiguration.plugins.map { plugin -> when (plugin) { is SamtLocalPluginConfiguration -> CommonLocalPluginConfiguration( - path = plugin.path + path = projectDirectory.resolve(plugin.path).normalize() ) is SamtMavenPluginConfiguration -> CommonMavenPluginConfiguration( @@ -63,7 +66,7 @@ object SamtConfigurationParser { generators = parsedConfiguration.generators.map { generator -> CommonGeneratorConfiguration( name = generator.name, - output = generator.output, + output = projectDirectory.resolve(generator.output).normalize(), options = generator.options ) } diff --git a/samt-config/src/test/kotlin/tools/samt/config/SamtConfigurationParserTest.kt b/samt-config/src/test/kotlin/tools/samt/config/SamtConfigurationParserTest.kt index 517d2b4c..5abfbf77 100644 --- a/samt-config/src/test/kotlin/tools/samt/config/SamtConfigurationParserTest.kt +++ b/samt-config/src/test/kotlin/tools/samt/config/SamtConfigurationParserTest.kt @@ -29,10 +29,10 @@ class SamtConfigurationParserTest { assertEquals( tools.samt.common.SamtConfiguration( - source = "./some/other/src", + source = testDirectory.resolve("some/other/src"), plugins = listOf( tools.samt.common.SamtLocalPluginConfiguration( - path = "./path/to/plugin.jar" + path = testDirectory.resolve("path/to/plugin.jar") ), tools.samt.common.SamtMavenPluginConfiguration( groupId = "com.example", @@ -50,7 +50,7 @@ class SamtConfigurationParserTest { generators = listOf( tools.samt.common.SamtGeneratorConfiguration( name = "samt-kotlin-ktor", - output = "./some/other/out", + output = testDirectory.resolve("some/other/out"), options = mapOf( "removePrefixFromSamtPackage" to "tools.samt", "addPrefixToKotlinPackage" to "tools.samt.example.generated", @@ -67,12 +67,12 @@ class SamtConfigurationParserTest { assertEquals( tools.samt.common.SamtConfiguration( - source = "./src", + source = testDirectory.resolve("src"), plugins = emptyList(), generators = listOf( tools.samt.common.SamtGeneratorConfiguration( name = "samt-kotlin-ktor", - output = "./out", + output = testDirectory.resolve("out"), options = mapOf( "addPrefixToKotlinPackage" to "com.company.samt.generated", ) diff --git a/specification/examples/.samtrc.yaml b/specification/examples/.samtrc.yaml new file mode 100644 index 00000000..c35faf33 --- /dev/null +++ b/specification/examples/.samtrc.yaml @@ -0,0 +1 @@ +extends: strict diff --git a/specification/examples/samt.yaml b/specification/examples/samt.yaml new file mode 100644 index 00000000..314bb2a8 --- /dev/null +++ b/specification/examples/samt.yaml @@ -0,0 +1,5 @@ +source: ./todo-service + +generators: + - name: kotlin-ktor + output: ./out From e17db6ac7ed0c71a18ad6f9571f869585cb70572 Mon Sep 17 00:00:00 2001 From: Pascal Honegger Date: Sat, 20 May 2023 09:53:07 +0200 Subject: [PATCH 15/41] feat(codegen): output configured generator to file system --- .../main/kotlin/tools/samt/cli/CliCompiler.kt | 19 +++++---- .../kotlin/tools/samt/cli/CliConfigParser.kt | 41 +++++++++++++++++++ .../kotlin/tools/samt/cli/OutputWriter.kt | 39 ++++++++++++++++++ .../main/kotlin/tools/samt/codegen/Codegen.kt | 10 ++--- .../tools/samt/codegen/KotlinKtorGenerator.kt | 2 +- .../samt/codegen/KotlinTypesGenerator.kt | 2 +- .../kotlin/tools/samt/codegen/PublicApi.kt | 2 +- 7 files changed, 98 insertions(+), 17 deletions(-) create mode 100644 cli/src/main/kotlin/tools/samt/cli/CliConfigParser.kt create mode 100644 cli/src/main/kotlin/tools/samt/cli/OutputWriter.kt diff --git a/cli/src/main/kotlin/tools/samt/cli/CliCompiler.kt b/cli/src/main/kotlin/tools/samt/cli/CliCompiler.kt index ff070351..b6d10e99 100644 --- a/cli/src/main/kotlin/tools/samt/cli/CliCompiler.kt +++ b/cli/src/main/kotlin/tools/samt/cli/CliCompiler.kt @@ -1,6 +1,5 @@ package tools.samt.cli -import com.github.ajalt.mordant.rendering.TextColors.* import tools.samt.codegen.Codegen import tools.samt.common.DiagnosticController import tools.samt.common.DiagnosticException @@ -9,6 +8,7 @@ import tools.samt.common.readSamtSource import tools.samt.lexer.Lexer import tools.samt.parser.Parser import tools.samt.semantic.SemanticModel +import java.io.IOException import kotlin.io.path.isDirectory import kotlin.io.path.notExists @@ -60,16 +60,17 @@ internal fun compile(command: CompileCommand, controller: DiagnosticController) return } - val files = Codegen.generate(model, controller) - - // if the semantic model failed to build, exit - if (controller.hasErrors()) { + if (configuration.generators.isEmpty()) { + controller.reportGlobalInfo("No generators configured, did you forget to add a 'generators' section to the 'samt.yaml' configuration?") return } - // emit files for debug purposes - // TODO: emit into an "out" folder and build package folder structure - for (file in files) { - println("${yellow(file.filepath)}:\n${file.source}\n") + for (generator in configuration.generators) { + val files = Codegen.generate(model, generator, controller) + try { + OutputWriter.write(generator.output, files) + } catch (e: IOException) { + controller.reportGlobalError("Failed to write output for generator '${generator.name}': ${e.message}") + } } } diff --git a/cli/src/main/kotlin/tools/samt/cli/CliConfigParser.kt b/cli/src/main/kotlin/tools/samt/cli/CliConfigParser.kt new file mode 100644 index 00000000..db39d7a1 --- /dev/null +++ b/cli/src/main/kotlin/tools/samt/cli/CliConfigParser.kt @@ -0,0 +1,41 @@ +package tools.samt.cli + +import tools.samt.common.DiagnosticController +import tools.samt.common.SamtConfiguration +import tools.samt.common.SamtLinterConfiguration +import tools.samt.config.SamtConfigurationParser +import java.nio.file.InvalidPathException +import kotlin.io.path.Path +import kotlin.io.path.notExists + +internal object CliConfigParser { + fun readConfig(file: String, controller: DiagnosticController): Pair? { + val configFile = try { + Path(file) + } catch (e: InvalidPathException) { + controller.reportGlobalError("Invalid path '${file}': ${e.message}") + return null + } + if (configFile.notExists()) { + controller.reportGlobalInfo("Configuration file '${configFile.toUri()}' does not exist, using default configuration") + } + val configuration = try { + SamtConfigurationParser.parseConfiguration(configFile) + } catch (e: Exception) { + controller.reportGlobalError("Failed to parse configuration file '${configFile.toUri()}': ${e.message}") + return null + } + val samtLintConfigFile = configFile.resolveSibling(".samtrc.yaml") + if (samtLintConfigFile.notExists()) { + controller.reportGlobalInfo("Lint configuration file '${samtLintConfigFile.toUri()}' does not exist, using default lint configuration") + } + val linterConfiguration = try { + SamtConfigurationParser.parseLinterConfiguration(samtLintConfigFile) + } catch (e: Exception) { + controller.reportGlobalError("Failed to parse lint configuration file '${samtLintConfigFile.toUri()}': ${e.message}") + return null + } + + return Pair(configuration, linterConfiguration) + } +} diff --git a/cli/src/main/kotlin/tools/samt/cli/OutputWriter.kt b/cli/src/main/kotlin/tools/samt/cli/OutputWriter.kt new file mode 100644 index 00000000..a1f3e0a7 --- /dev/null +++ b/cli/src/main/kotlin/tools/samt/cli/OutputWriter.kt @@ -0,0 +1,39 @@ +package tools.samt.cli + +import tools.samt.codegen.CodegenFile +import java.io.IOException +import java.nio.file.InvalidPathException +import java.nio.file.Path +import kotlin.io.path.* + +internal object OutputWriter { + @Throws(IOException::class) + fun write(outputDirectory: Path, files: List) { + if (!outputDirectory.exists()) { + try { + outputDirectory.createDirectory() + } catch (e: IOException) { + throw IOException("Failed to create output directory '${outputDirectory}'", e) + } + } + if (!outputDirectory.isDirectory()) { + throw IOException("Path '${outputDirectory}' does not point to a directory") + } + for (file in files) { + val outputFile = try { + outputDirectory.resolve(file.filepath) + } catch (e: InvalidPathException) { + throw IOException("Invalid path '${file.filepath}'", e) + } + try { + outputFile.parent.createDirectories() + if (outputFile.notExists()) { + outputFile.createFile() + } + outputFile.writeText(file.source) + } catch (e: IOException) { + throw IOException("Failed to write file '${outputFile.toUri()}'", e) + } + } + } +} diff --git a/codegen/src/main/kotlin/tools/samt/codegen/Codegen.kt b/codegen/src/main/kotlin/tools/samt/codegen/Codegen.kt index c4d65869..f6035f5d 100644 --- a/codegen/src/main/kotlin/tools/samt/codegen/Codegen.kt +++ b/codegen/src/main/kotlin/tools/samt/codegen/Codegen.kt @@ -1,6 +1,7 @@ package tools.samt.codegen import tools.samt.common.DiagnosticController +import tools.samt.common.SamtGeneratorConfiguration import tools.samt.semantic.* data class CodegenFile(val filepath: String, val source: String) @@ -42,7 +43,7 @@ object Codegen { } } - fun generate(rootPackage: Package, controller: DiagnosticController): List { + fun generate(rootPackage: Package, configuration: SamtGeneratorConfiguration, controller: DiagnosticController): List { check(rootPackage.isRootPackage) check(rootPackage.parent == null) check(rootPackage.records.isEmpty()) @@ -52,12 +53,11 @@ object Codegen { check(rootPackage.providers.isEmpty()) check(rootPackage.consumers.isEmpty()) - val generatorIdentifier = "kotlin-ktor" // TODO: read from config - val matchingGenerators = generators.filter { it.identifier == generatorIdentifier } + val matchingGenerators = generators.filter { it.name == configuration.name } when (matchingGenerators.size) { - 0 -> controller.reportGlobalError("No matching generator found for '$generatorIdentifier'") + 0 -> controller.reportGlobalError("No matching generator found for '${configuration.name}'") 1 -> return matchingGenerators.single().generate(SamtGeneratorParams(rootPackage, controller)) - else -> controller.reportGlobalError("Multiple matching generators found for '$generatorIdentifier'") + else -> controller.reportGlobalError("Multiple matching generators found for '${configuration.name}'") } return emptyList() } diff --git a/codegen/src/main/kotlin/tools/samt/codegen/KotlinKtorGenerator.kt b/codegen/src/main/kotlin/tools/samt/codegen/KotlinKtorGenerator.kt index 42fb68a8..9f7fb312 100644 --- a/codegen/src/main/kotlin/tools/samt/codegen/KotlinKtorGenerator.kt +++ b/codegen/src/main/kotlin/tools/samt/codegen/KotlinKtorGenerator.kt @@ -1,7 +1,7 @@ package tools.samt.codegen class KotlinKtorGenerator : Generator { - override val identifier: String = "kotlin-ktor" + override val name: String = "kotlin-ktor" override fun generate(generatorParams: GeneratorParams): List { generatorParams.packages.forEach { diff --git a/codegen/src/main/kotlin/tools/samt/codegen/KotlinTypesGenerator.kt b/codegen/src/main/kotlin/tools/samt/codegen/KotlinTypesGenerator.kt index ad7fe442..2c26d4c4 100644 --- a/codegen/src/main/kotlin/tools/samt/codegen/KotlinTypesGenerator.kt +++ b/codegen/src/main/kotlin/tools/samt/codegen/KotlinTypesGenerator.kt @@ -1,7 +1,7 @@ package tools.samt.codegen class KotlinTypesGenerator : Generator { - override val identifier: String = "kotlin-types" + override val name: String = "kotlin-types" override fun generate(generatorParams: GeneratorParams): List { generatorParams.packages.forEach { generatePackage(it) diff --git a/codegen/src/main/kotlin/tools/samt/codegen/PublicApi.kt b/codegen/src/main/kotlin/tools/samt/codegen/PublicApi.kt index 82f0fc81..f91ed186 100644 --- a/codegen/src/main/kotlin/tools/samt/codegen/PublicApi.kt +++ b/codegen/src/main/kotlin/tools/samt/codegen/PublicApi.kt @@ -22,7 +22,7 @@ interface SamtPackage { } interface Generator { - val identifier: String + val name: String fun generate(generatorParams: GeneratorParams): List } From f214cd11fd22438c9c833b1117e66852f38b333d Mon Sep 17 00:00:00 2001 From: Pascal Honegger Date: Sat, 20 May 2023 13:42:07 +0200 Subject: [PATCH 16/41] feat(codegen): basic mapping code generation --- .../main/kotlin/tools/samt/codegen/Codegen.kt | 14 +- .../tools/samt/codegen/HttpTransport.kt | 7 + .../samt/codegen/KotlinGeneratorUtils.kt | 87 ++++--- .../tools/samt/codegen/KotlinKtorGenerator.kt | 243 ++++++++++++------ .../samt/codegen/KotlinTypesGenerator.kt | 53 ++-- .../kotlin/tools/samt/codegen/PublicApi.kt | 1 + 6 files changed, 275 insertions(+), 130 deletions(-) diff --git a/codegen/src/main/kotlin/tools/samt/codegen/Codegen.kt b/codegen/src/main/kotlin/tools/samt/codegen/Codegen.kt index f6035f5d..30ffbbec 100644 --- a/codegen/src/main/kotlin/tools/samt/codegen/Codegen.kt +++ b/codegen/src/main/kotlin/tools/samt/codegen/Codegen.kt @@ -26,7 +26,11 @@ object Codegen { HttpTransportConfigurationParser(), ) - internal class SamtGeneratorParams(rootPackage: Package, private val controller: DiagnosticController) : GeneratorParams { + internal class SamtGeneratorParams( + rootPackage: Package, + private val controller: DiagnosticController, + override val options: Map, + ) : GeneratorParams { private val apiMapper = PublicApiMapper(transports, controller) override val packages: List = rootPackage.allSubPackages.map { apiMapper.toPublicApi(it) } @@ -43,7 +47,11 @@ object Codegen { } } - fun generate(rootPackage: Package, configuration: SamtGeneratorConfiguration, controller: DiagnosticController): List { + fun generate( + rootPackage: Package, + configuration: SamtGeneratorConfiguration, + controller: DiagnosticController, + ): List { check(rootPackage.isRootPackage) check(rootPackage.parent == null) check(rootPackage.records.isEmpty()) @@ -56,7 +64,7 @@ object Codegen { val matchingGenerators = generators.filter { it.name == configuration.name } when (matchingGenerators.size) { 0 -> controller.reportGlobalError("No matching generator found for '${configuration.name}'") - 1 -> return matchingGenerators.single().generate(SamtGeneratorParams(rootPackage, controller)) + 1 -> return matchingGenerators.single().generate(SamtGeneratorParams(rootPackage, controller, configuration.options)) else -> controller.reportGlobalError("Multiple matching generators found for '${configuration.name}'") } return emptyList() diff --git a/codegen/src/main/kotlin/tools/samt/codegen/HttpTransport.kt b/codegen/src/main/kotlin/tools/samt/codegen/HttpTransport.kt index 5286500a..c9c2a47d 100644 --- a/codegen/src/main/kotlin/tools/samt/codegen/HttpTransport.kt +++ b/codegen/src/main/kotlin/tools/samt/codegen/HttpTransport.kt @@ -210,6 +210,7 @@ class HttpTransportConfigurationParser: TransportConfigurationParser { add(HttpTransportConfiguration.ServiceConfiguration( name = serviceName, operations = operations, + path = "" // TODO )) } } @@ -258,6 +259,7 @@ class HttpTransportConfiguration( ) : TransportConfiguration { class ServiceConfiguration( val name: String, + val path: String, val operations: List ) { fun getOperation(name: String): OperationConfiguration? { @@ -317,6 +319,11 @@ class HttpTransportConfiguration( return operation?.path ?: "/$operationName" } + fun getPath(serviceName: String): String { + val service = getService(serviceName) + return service?.path ?: "" + } + fun getTransportMode(serviceName: String, operationName: String, parameterName: String): TransportMode { val service = getService(serviceName) val operation = service?.getOperation(operationName) diff --git a/codegen/src/main/kotlin/tools/samt/codegen/KotlinGeneratorUtils.kt b/codegen/src/main/kotlin/tools/samt/codegen/KotlinGeneratorUtils.kt index b2032c45..24334e25 100644 --- a/codegen/src/main/kotlin/tools/samt/codegen/KotlinGeneratorUtils.kt +++ b/codegen/src/main/kotlin/tools/samt/codegen/KotlinGeneratorUtils.kt @@ -1,33 +1,60 @@ package tools.samt.codegen -internal val TypeReference.qualifiedName: String - get() = buildString { - val qualifiedName = when (val type = type) { - is LiteralType -> when (type) { - is StringType -> "String" - is BytesType -> "ByteArray" - is IntType -> "Int" - is LongType -> "Long" - is FloatType -> "Float" - is DoubleType -> "Double" - is DecimalType -> "java.math.BigDecimal" - is BooleanType -> "Boolean" - is DateType -> "java.time.LocalDate" - is DateTimeType -> "java.time.LocalDateTime" - is DurationType -> "java.time.Duration" - else -> error("Unsupported literal type: ${type.javaClass.simpleName}") - } - - is ListType -> "List<${type.elementType.qualifiedName}>" - is MapType -> "Map<${type.keyType.qualifiedName}, ${type.valueType.qualifiedName}>" - - is UserType -> type.qualifiedName // TODO: consider configurable package name - - else -> error("Unsupported type: ${type.javaClass.simpleName}") - } - append(qualifiedName) - - if (isOptional) { - append("?") - } +object KotlinGeneratorConfig { + const val removePrefixFromSamtPackage = "removePrefixFromSamtPackage" + const val addPrefixToKotlinPackage = "addPrefixToKotlinPackage" +} + +const val GeneratedFilePreamble = """@file:Suppress("RemoveRedundantQualifierName", "unused", "UnusedImport")""" + +internal fun String.replacePackage(options: Map): String { + val removePrefix = options[KotlinGeneratorConfig.removePrefixFromSamtPackage] + val addPrefix = options[KotlinGeneratorConfig.addPrefixToKotlinPackage] + + var result = this + + if (removePrefix != null) { + result = result.removePrefix(removePrefix).removePrefix(".") } + + if (addPrefix != null) { + result = "$addPrefix.$result" + } + + return result +} + +internal fun SamtPackage.getQualifiedName(options: Map): String = qualifiedName.replacePackage(options) + +internal fun TypeReference.getQualifiedName(options: Map): String { + val qualifiedName = type.getQualifiedName(options) + return if (isOptional) { + "$qualifiedName?" + } else { + qualifiedName + } +} + +internal fun Type.getQualifiedName(options: Map): String = when (this) { + is LiteralType -> when (this) { + is StringType -> "String" + is BytesType -> "ByteArray" + is IntType -> "Int" + is LongType -> "Long" + is FloatType -> "Float" + is DoubleType -> "Double" + is DecimalType -> "java.math.BigDecimal" + is BooleanType -> "Boolean" + is DateType -> "java.time.LocalDate" + is DateTimeType -> "java.time.LocalDateTime" + is DurationType -> "java.time.Duration" + else -> error("Unsupported literal type: ${this.javaClass.simpleName}") + } + + is ListType -> "List<${elementType.getQualifiedName(options)}>" + is MapType -> "Map<${keyType.getQualifiedName(options)}, ${valueType.getQualifiedName(options)}>" + + is UserType -> qualifiedName.replacePackage(options) + + else -> error("Unsupported type: ${javaClass.simpleName}") +} diff --git a/codegen/src/main/kotlin/tools/samt/codegen/KotlinKtorGenerator.kt b/codegen/src/main/kotlin/tools/samt/codegen/KotlinKtorGenerator.kt index 9f7fb312..9f2d6bfd 100644 --- a/codegen/src/main/kotlin/tools/samt/codegen/KotlinKtorGenerator.kt +++ b/codegen/src/main/kotlin/tools/samt/codegen/KotlinKtorGenerator.kt @@ -5,18 +5,54 @@ class KotlinKtorGenerator : Generator { override fun generate(generatorParams: GeneratorParams): List { generatorParams.packages.forEach { - generatePackage(it) + generateMappings(it, generatorParams.options) + generatePackage(it, generatorParams.options) } - return KotlinTypesGenerator().generate(generatorParams) + emittedFiles + val result = KotlinTypesGenerator().generate(generatorParams) + emittedFiles + emittedFiles.clear() + return result } private val emittedFiles = mutableListOf() - private fun generatePackage(pack: SamtPackage) { + private fun generateMappings(pack: SamtPackage, options: Map) { + if (pack.hasDataTypes()) { + val packageSource = buildString { + appendLine(GeneratedFilePreamble) + appendLine() + appendLine("package ${pack.getQualifiedName(options)}") + appendLine() + appendLine("import kotlinx.serialization.json.*") + appendLine() + + pack.records.forEach { + appendLine("fun parse${it.name}(json: JsonObject): ${it.getQualifiedName(options)} {") + appendLine(" TODO()") + appendLine("}") + } + + pack.enums.forEach { + val enumName = it.getQualifiedName(options) + appendLine("fun parse${it.name}(name: String) = when(name) {") + it.values.forEach { value -> + appendLine(" \"${value}\" -> ${enumName}.${value}") + } + appendLine(" else -> ${enumName}.UNKNOWN") + appendLine("}") + } + } + + val filePath = "${pack.getQualifiedName(options).replace('.', '/')}/KtorMappings.kt" + val file = CodegenFile(filePath, packageSource) + emittedFiles.add(file) + } + } + + private fun generatePackage(pack: SamtPackage, options: Map) { if (pack.hasProviderTypes()) { // generate general ktor files - generateKtorServer(pack) + generateKtorServer(pack, options) // generate ktor providers pack.providers.forEach { provider -> @@ -27,13 +63,15 @@ class KotlinKtorGenerator : Generator { } val packageSource = buildString { - appendLine("package ${pack.qualifiedName}") + appendLine(GeneratedFilePreamble) + appendLine() + appendLine("package ${pack.getQualifiedName(options)}") appendLine() - appendProvider(provider, transportConfiguration) + appendProvider(provider, transportConfiguration, options) } - val filePath = pack.qualifiedName.replace('.', '/') + "Provider.kt" + val filePath = "${pack.getQualifiedName(options).replace('.', '/')}/${provider.name}.kt" val file = CodegenFile(filePath, packageSource) emittedFiles.add(file) } @@ -51,7 +89,7 @@ class KotlinKtorGenerator : Generator { appendLine("package ${pack.qualifiedName}") appendLine() - appendConsumer(consumer, transportConfiguration) + appendConsumer(consumer, transportConfiguration, options) } val filePath = pack.qualifiedName.replace('.', '/') + "Consumer.kt" @@ -61,9 +99,11 @@ class KotlinKtorGenerator : Generator { } } - private fun generateKtorServer(pack: SamtPackage) { + private fun generateKtorServer(pack: SamtPackage, options: Map) { val packageSource = buildString { - appendLine("package ${pack.qualifiedName}") + appendLine(GeneratedFilePreamble) + appendLine() + appendLine("package ${pack.getQualifiedName(options)}") appendLine() appendLine("import io.ktor.http.*") @@ -83,17 +123,20 @@ class KotlinKtorGenerator : Generator { appendLine(" routing {") for (provider in pack.providers) { - append(" route${provider.name}(") - append("/* ") - append(provider.implements.joinToString(" */, /* ") { it.service.qualifiedName }) - appendLine("*/)") + val implementedServices = provider.implements.map { ProviderInfo(it) } + appendLine(" route${provider.name}(") + for (info in implementedServices) { + provider.implements.joinToString(" */, /* ") { it.service.getQualifiedName(options) } + appendLine(" ${info.serviceArgumentName} = TODO(\"Implement ${info.service.getQualifiedName(options)}\"),") + } + appendLine(" )") } appendLine(" }") appendLine("}") } - val filePath = pack.qualifiedName.replace('.', '/') + "Server.kt" + val filePath = "${pack.getQualifiedName(options).replace('.', '/')}/KtorServer.kt" val file = CodegenFile(filePath, packageSource) emittedFiles.add(file) } @@ -104,7 +147,11 @@ class KotlinKtorGenerator : Generator { val serviceArgumentName = service.name.replaceFirstChar { it.lowercase() } } - private fun StringBuilder.appendProvider(provider: ProviderType, transportConfiguration: HttpTransportConfiguration) { + private fun StringBuilder.appendProvider( + provider: ProviderType, + transportConfiguration: HttpTransportConfiguration, + options: Map, + ) { appendLine("import io.ktor.http.*") appendLine("import io.ktor.serialization.kotlinx.json.*") appendLine("import io.ktor.server.plugins.contentnegotiation.*") @@ -116,70 +163,90 @@ class KotlinKtorGenerator : Generator { appendLine() val implementedServices = provider.implements.map { ProviderInfo(it) } - val serviceArguments = implementedServices.joinToString { info -> - "${info.serviceArgumentName}: ${info.reference.qualifiedName}" - } appendLine("// ${transportConfiguration.exceptionMap}") - appendLine("fun Routing.route${provider.name}($serviceArguments) {") + appendLine("/** Connector for SAMT provider ${provider.name} */") + appendLine("fun Routing.route${provider.name}(") + for (info in implementedServices) { + appendLine(" ${info.serviceArgumentName}: ${info.reference.getQualifiedName(options)},") + } + appendLine(") {") implementedServices.forEach { info -> appendProviderOperations(info, transportConfiguration) } appendLine("}") } - private fun StringBuilder.appendProviderOperations(info: ProviderInfo, transportConfiguration: HttpTransportConfiguration) { + private fun StringBuilder.appendProviderOperations( + info: ProviderInfo, + transportConfiguration: HttpTransportConfiguration, + ) { val service = info.service info.implements.operations.forEach { operation -> - when (operation) { - is RequestResponseOperation -> { - appendLine(" ${getKtorRoute(service, operation, transportConfiguration)} {") + appendLine(" // Handler for SAMT Service ${info.service.name}") + appendLine(" route(\"${transportConfiguration.getPath(service.name)}\") {") + appendProviderOperation(operation, info, service, transportConfiguration) + appendLine(" }") + } + } - appendParsingPreamble() + private fun StringBuilder.appendProviderOperation( + operation: ServiceOperation, + info: ProviderInfo, + service: ServiceType, + transportConfiguration: HttpTransportConfiguration, + ) { + when (operation) { + is RequestResponseOperation -> { + appendLine(" // Handler for SAMT operation ${operation.name}") + appendLine(" ${getKtorRoute(service, operation, transportConfiguration)} {") + + appendParsingPreamble() + + operation.parameters.forEach { parameter -> + // TODO complexer data types than string + appendParameterParsing(service, operation, parameter, transportConfiguration) + } + appendLine() - operation.parameters.forEach { parameter -> - // TODO complexer data types than string - appendParameterParsing(service, operation, parameter, transportConfiguration) - } - appendLine() - // TODO Config: HTTP status code - // TODO serialize response correctly - // TODO validate response - if (operation.returnType != null) { + + // TODO Config: HTTP status code + + // TODO serialize response correctly + // TODO validate response + if (operation.returnType != null) { appendLine(" val response = ${getServiceCall(info, operation)}") - appendLine(" call.respond(response)") - appendLine(" }") - } else { + appendLine(" call.respond(response)") + appendLine(" }") + } else { appendLine(" ${getServiceCall(info, operation)}") appendLine(" call.respond(HttpStatusCode.NoContent)") appendLine(" }") } } - is OnewayOperation -> { - // TODO Config: HTTP method? - // TODO Config: URL? - appendLine(" ${getKtorRoute(service, operation, transportConfiguration)} {") - appendParsingPreamble() - - operation.parameters.forEach { parameter -> - appendParameterParsing(service, operation, parameter, transportConfiguration) - } + is OnewayOperation -> { + appendLine(" ${getKtorRoute(service, operation, transportConfiguration)} {") - appendLine(" launch {") - appendLine(" ${getServiceCall(info, operation)}") - appendLine(" }") + appendParsingPreamble() - appendLine(" call.respond(HttpStatusCode.NoContent)") - appendLine(" }") + operation.parameters.forEach { parameter -> + appendParameterParsing(service, operation, parameter, transportConfiguration) } + + appendLine(" launch {") + appendLine(" ${getServiceCall(info, operation)}") + appendLine(" }") + + appendLine(" call.respond(HttpStatusCode.NoContent)") + appendLine(" }") } } } private fun StringBuilder.appendParsingPreamble() { - appendLine(" val bodyAsText = call.receiveText()") - appendLine(" val body = Json.parseToJsonElement(bodyAsText)") + appendLine(" val bodyAsText = call.receiveText()") + appendLine(" val body = Json.parseToJsonElement(bodyAsText)") appendLine() } @@ -189,7 +256,7 @@ class KotlinKtorGenerator : Generator { val serviceArgumentName = service.name.replaceFirstChar { it.lowercase() } } - private fun StringBuilder.appendConsumer(consumer: ConsumerType, transportConfiguration: HttpTransportConfiguration) { + private fun StringBuilder.appendConsumer(consumer: ConsumerType, transportConfiguration: HttpTransportConfiguration, options: Map) { appendLine("import io.ktor.client.*") appendLine("import io.ktor.client.engine.cio.*") appendLine("import io.ktor.client.plugins.contentnegotiation.*") @@ -202,17 +269,17 @@ class KotlinKtorGenerator : Generator { val implementedServices = consumer.uses.map { ConsumerInfo(it) } val serviceArguments = implementedServices.joinToString { info -> - "${info.serviceArgumentName}: ${info.reference.qualifiedName}" + "${info.serviceArgumentName}: ${info.reference.getQualifiedName(options)}" } appendLine("// ${transportConfiguration.exceptionMap}") appendLine("class ${consumer.name}() {") implementedServices.forEach { info -> - appendConsumerOperations(info, transportConfiguration) + appendConsumerOperations(info, transportConfiguration, options) } appendLine("}") } - private fun StringBuilder.appendConsumerOperations(info: ConsumerInfo, transportConfiguration: HttpTransportConfiguration) { + private fun StringBuilder.appendConsumerOperations(info: ConsumerInfo, transportConfiguration: HttpTransportConfiguration, options: Map) { appendLine(" private val client = HttpClient(CIO) {") appendLine(" install(ContentNegotiation) {") appendLine(" json()") @@ -222,12 +289,12 @@ class KotlinKtorGenerator : Generator { val service = info.service info.uses.operations.forEach { operation -> - val operationParameters = operation.parameters.joinToString { "${it.name}: ${it.type.qualifiedName}" } + val operationParameters = operation.parameters.joinToString { "${it.name}: ${it.type.getQualifiedName(options)}" } when (operation) { is RequestResponseOperation -> { if (operation.returnType != null) { - appendLine(" override fun ${operation.name}($operationParameters): ${operation.returnType!!.qualifiedName} {") + appendLine(" override fun ${operation.name}($operationParameters): ${operation.returnType!!.getQualifiedName(options)} {") } else { appendLine(" override fun ${operation.name}($operationParameters): Unit {") } @@ -324,7 +391,11 @@ class KotlinKtorGenerator : Generator { } - private fun getKtorRoute(service: ServiceType, operation: ServiceOperation, transportConfiguration: HttpTransportConfiguration): String { + private fun getKtorRoute( + service: ServiceType, + operation: ServiceOperation, + transportConfiguration: HttpTransportConfiguration, + ): String { val method = when (transportConfiguration.getMethod(service.name, operation.name)) { HttpTransportConfiguration.HttpMethod.Get -> "get" HttpTransportConfiguration.HttpMethod.Post -> "post" @@ -340,51 +411,65 @@ class KotlinKtorGenerator : Generator { return "${info.serviceArgumentName}.${operation.name}(${operation.parameters.joinToString { it.name }})" } - private fun StringBuilder.appendParameterParsing(service: ServiceType, operation: ServiceOperation, parameter: ServiceOperationParameter, transportConfiguration: HttpTransportConfiguration) { + private fun StringBuilder.appendParameterParsing( + service: ServiceType, + operation: ServiceOperation, + parameter: ServiceOperationParameter, + transportConfiguration: HttpTransportConfiguration, + ) { appendParameterDeserialization(service, operation, parameter, transportConfiguration) appendParameterConstraints(parameter) appendLine() } - private fun StringBuilder.appendParameterDeserialization(service: ServiceType, operation: ServiceOperation, parameter: ServiceOperationParameter, transportConfiguration: HttpTransportConfiguration) { + private fun StringBuilder.appendParameterDeserialization( + service: ServiceType, + operation: ServiceOperation, + parameter: ServiceOperationParameter, + transportConfiguration: HttpTransportConfiguration, + ) { // TODO Config: From Body / from Query / from Path etc. // TODO error and null handling // TODO complexer data types than string - when(transportConfiguration.getTransportMode(service.name, operation.name, parameter.name)) { + when (transportConfiguration.getTransportMode(service.name, operation.name, parameter.name)) { HttpTransportConfiguration.TransportMode.Body -> { if (parameter.type.isOptional) { - appendLine(" val ${parameter.name} = body.jsonObject[\"${parameter.name}\"]?.jsonPrimitive?.contentOrNull") + appendLine(" val ${parameter.name} = body.jsonObject[\"${parameter.name}\"]?.jsonPrimitive?.contentOrNull") } else { - appendLine(" val ${parameter.name} = body.jsonObject.getValue(\"${parameter.name}\").jsonPrimitive.content") + appendLine(" val ${parameter.name} = body.jsonObject.getValue(\"${parameter.name}\").jsonPrimitive.content") } } + HttpTransportConfiguration.TransportMode.Query -> { if (parameter.type.isOptional) { - appendLine(" val ${parameter.name} = call.request.queryParameters[\"${parameter.name}\"]") + appendLine(" val ${parameter.name} = call.request.queryParameters[\"${parameter.name}\"]") } else { - appendLine(" val ${parameter.name} = call.request.queryParameters.getValue(\"${parameter.name}\")") + appendLine(" val ${parameter.name} = call.request.queryParameters.getValue(\"${parameter.name}\")") } } + HttpTransportConfiguration.TransportMode.Path -> { if (parameter.type.isOptional) { - appendLine(" val ${parameter.name} = call.parameters[\"${parameter.name}\"]") + appendLine(" val ${parameter.name} = call.parameters[\"${parameter.name}\"]") } else { - appendLine(" val ${parameter.name} = call.parameters.getValue(\"${parameter.name}\")") + appendLine(" val ${parameter.name} = call.parameters.getValue(\"${parameter.name}\")") } } + HttpTransportConfiguration.TransportMode.Header -> { if (parameter.type.isOptional) { - appendLine(" val ${parameter.name} = call.request.headers.get(\"${parameter.name}\")") + appendLine(" val ${parameter.name} = call.request.headers.get(\"${parameter.name}\")") } else { - appendLine(" val ${parameter.name} = call.request.headers.get(\"${parameter.name}\")!!") + appendLine(" val ${parameter.name} = call.request.headers.get(\"${parameter.name}\")!!") } } + HttpTransportConfiguration.TransportMode.Cookie -> { if (parameter.type.isOptional) { - appendLine(" val ${parameter.name} = call.request.cookies.get(\"${parameter.name}\")") + appendLine(" val ${parameter.name} = call.request.cookies.get(\"${parameter.name}\")") } else { - appendLine(" val ${parameter.name} = call.request.cookies.get(\"${parameter.name}\")!!") + appendLine(" val ${parameter.name} = call.request.cookies.get(\"${parameter.name}\")!!") } } } @@ -393,19 +478,23 @@ class KotlinKtorGenerator : Generator { private fun StringBuilder.appendParameterConstraints(parameter: ServiceOperationParameter) { // TODO constraints within map or list or record field parameter.type.rangeConstraint?.let { - appendLine(" require(${parameter.name}.length in ${it.lowerBound}..${it.upperBound}) { \"${parameter.name} must be between ${it.lowerBound} and ${it.upperBound} characters long\" }") + appendLine(" require(${parameter.name}.length in ${it.lowerBound}..${it.upperBound}) { \"${parameter.name} must be between ${it.lowerBound} and ${it.upperBound} characters long\" }") } parameter.type.sizeConstraint?.let { - appendLine(" require(${parameter.name}.size in ${it.lowerBound}..${it.upperBound}) { \"${parameter.name} must be between ${it.lowerBound} and ${it.upperBound} elements long\" }") + appendLine(" require(${parameter.name}.size in ${it.lowerBound}..${it.upperBound}) { \"${parameter.name} must be between ${it.lowerBound} and ${it.upperBound} elements long\" }") } parameter.type.patternConstraint?.let { - appendLine(" require(${parameter.name}.matches(\"${it.pattern}\") { \"${parameter.name} does not adhere to required pattern '${it.pattern}'\" }") + appendLine(" require(${parameter.name}.matches(\"${it.pattern}\") { \"${parameter.name} does not adhere to required pattern '${it.pattern}'\" }") } parameter.type.valueConstraint?.let { - appendLine(" require(${parameter.name} == ${it.value}) { \"${parameter.name} does not equal '${it.value}'\" }") + appendLine(" require(${parameter.name} == ${it.value}) { \"${parameter.name} does not equal '${it.value}'\" }") } } + private fun SamtPackage.hasDataTypes(): Boolean { + return records.isNotEmpty() || enums.isNotEmpty() + } + private fun SamtPackage.hasProviderTypes(): Boolean { return providers.isNotEmpty() } diff --git a/codegen/src/main/kotlin/tools/samt/codegen/KotlinTypesGenerator.kt b/codegen/src/main/kotlin/tools/samt/codegen/KotlinTypesGenerator.kt index 2c26d4c4..86627f99 100644 --- a/codegen/src/main/kotlin/tools/samt/codegen/KotlinTypesGenerator.kt +++ b/codegen/src/main/kotlin/tools/samt/codegen/KotlinTypesGenerator.kt @@ -4,21 +4,25 @@ class KotlinTypesGenerator : Generator { override val name: String = "kotlin-types" override fun generate(generatorParams: GeneratorParams): List { generatorParams.packages.forEach { - generatePackage(it) + generatePackage(it, generatorParams.options) } - return emittedFiles + val result = emittedFiles.toList() + emittedFiles.clear() + return result } private val emittedFiles = mutableListOf() - private fun generatePackage(pack: SamtPackage) { + private fun generatePackage(pack: SamtPackage, options: Map) { if (pack.hasModelTypes()) { val packageSource = buildString { - appendLine("package ${pack.qualifiedName}") + appendLine(GeneratedFilePreamble) + appendLine() + appendLine("package ${pack.getQualifiedName(options)}") appendLine() pack.records.forEach { - appendRecord(it) + appendRecord(it, options) } pack.enums.forEach { @@ -26,24 +30,24 @@ class KotlinTypesGenerator : Generator { } pack.aliases.forEach { - appendAlias(it) + appendAlias(it, options) } pack.services.forEach { - appendService(it) + appendService(it, options) } } - val filePath = pack.qualifiedName.replace('.', '/') + ".kt" + val filePath = "${pack.getQualifiedName(options).replace('.', '/')}/Types.kt" val file = CodegenFile(filePath, packageSource) emittedFiles.add(file) } } - private fun StringBuilder.appendRecord(record: RecordType) { + private fun StringBuilder.appendRecord(record: RecordType, options: Map) { appendLine("class ${record.name}(") record.fields.forEach { field -> - val fullyQualifiedName = field.type.qualifiedName + val fullyQualifiedName = field.type.getQualifiedName(options) val isOptional = field.type.isOptional if (isOptional) { @@ -58,25 +62,27 @@ class KotlinTypesGenerator : Generator { private fun StringBuilder.appendEnum(enum: EnumType) { appendLine("enum class ${enum.name} {") + appendLine(" /** Default value used when the enum could not be parsed */") + appendLine(" UNKNOWN,") enum.values.forEach { appendLine(" ${it},") } appendLine("}") } - private fun StringBuilder.appendAlias(alias: AliasType) { - appendLine("typealias ${alias.name} = ${alias.aliasedType.qualifiedName}") + private fun StringBuilder.appendAlias(alias: AliasType, options: Map) { + appendLine("typealias ${alias.name} = ${alias.aliasedType.getQualifiedName(options)}") } - private fun StringBuilder.appendService(service: ServiceType) { + private fun StringBuilder.appendService(service: ServiceType, options: Map) { appendLine("interface ${service.name} {") service.operations.forEach { operation -> - appendServiceOperation(operation) + appendServiceOperation(operation, options) } appendLine("}") } - private fun StringBuilder.appendServiceOperation(operation: ServiceOperation) { + private fun StringBuilder.appendServiceOperation(operation: ServiceOperation, options: Map) { when (operation) { is RequestResponseOperation -> { // method head @@ -87,11 +93,11 @@ class KotlinTypesGenerator : Generator { } // parameters - appendServiceOperationParameterList(operation.parameters) + appendServiceOperationParameterList(operation.parameters, options) // return type if (operation.returnType != null) { - appendLine(" ): ${operation.returnType!!.qualifiedName}") + appendLine(" ): ${operation.returnType!!.getQualifiedName(options)}") } else { appendLine(" )") } @@ -99,15 +105,22 @@ class KotlinTypesGenerator : Generator { is OnewayOperation -> { appendLine(" fun ${operation.name}(") - appendServiceOperationParameterList(operation.parameters) + appendServiceOperationParameterList(operation.parameters, options) appendLine(" )") } } } - private fun StringBuilder.appendServiceOperationParameterList(parameters: List) { + private fun StringBuilder.appendServiceOperationParameterList(parameters: List, options: Map) { parameters.forEach { parameter -> - appendLine(" ${parameter.name}: ${parameter.type.qualifiedName},") + val fullyQualifiedName = parameter.type.getQualifiedName(options) + val isOptional = parameter.type.isOptional + + if (isOptional) { + appendLine(" ${parameter.name}: $fullyQualifiedName = null,") + } else { + appendLine(" ${parameter.name}: $fullyQualifiedName,") + } } } diff --git a/codegen/src/main/kotlin/tools/samt/codegen/PublicApi.kt b/codegen/src/main/kotlin/tools/samt/codegen/PublicApi.kt index f91ed186..7b3ae475 100644 --- a/codegen/src/main/kotlin/tools/samt/codegen/PublicApi.kt +++ b/codegen/src/main/kotlin/tools/samt/codegen/PublicApi.kt @@ -4,6 +4,7 @@ import tools.samt.parser.ObjectNode interface GeneratorParams { val packages: List + val options: Map fun reportError(message: String) fun reportWarning(message: String) From 0513a8f713fd2c9569ca03d21c453d272b042c7d Mon Sep 17 00:00:00 2001 From: Pascal Honegger Date: Sun, 21 May 2023 19:19:56 +0200 Subject: [PATCH 17/41] feat(codegen): advanced provider code generation --- .../samt/codegen/KotlinGeneratorUtils.kt | 2 +- .../tools/samt/codegen/KotlinKtorGenerator.kt | 240 +++++++++++------- 2 files changed, 153 insertions(+), 89 deletions(-) diff --git a/codegen/src/main/kotlin/tools/samt/codegen/KotlinGeneratorUtils.kt b/codegen/src/main/kotlin/tools/samt/codegen/KotlinGeneratorUtils.kt index 24334e25..13248325 100644 --- a/codegen/src/main/kotlin/tools/samt/codegen/KotlinGeneratorUtils.kt +++ b/codegen/src/main/kotlin/tools/samt/codegen/KotlinGeneratorUtils.kt @@ -5,7 +5,7 @@ object KotlinGeneratorConfig { const val addPrefixToKotlinPackage = "addPrefixToKotlinPackage" } -const val GeneratedFilePreamble = """@file:Suppress("RemoveRedundantQualifierName", "unused", "UnusedImport")""" +const val GeneratedFilePreamble = """@file:Suppress("RemoveRedundantQualifierName", "unused", "UnusedImport", "LocalVariableName", "FunctionName", "ConvertTwoComparisonsToRangeCheck", "ReplaceSizeCheckWithIsNotEmpty")""" internal fun String.replacePackage(options: Map): String { val removePrefix = options[KotlinGeneratorConfig.removePrefixFromSamtPackage] diff --git a/codegen/src/main/kotlin/tools/samt/codegen/KotlinKtorGenerator.kt b/codegen/src/main/kotlin/tools/samt/codegen/KotlinKtorGenerator.kt index 9f2d6bfd..25a6b674 100644 --- a/codegen/src/main/kotlin/tools/samt/codegen/KotlinKtorGenerator.kt +++ b/codegen/src/main/kotlin/tools/samt/codegen/KotlinKtorGenerator.kt @@ -22,21 +22,41 @@ class KotlinKtorGenerator : Generator { appendLine() appendLine("package ${pack.getQualifiedName(options)}") appendLine() + appendLine("import io.ktor.util.*") appendLine("import kotlinx.serialization.json.*") appendLine() - pack.records.forEach { - appendLine("fun parse${it.name}(json: JsonObject): ${it.getQualifiedName(options)} {") - appendLine(" TODO()") + pack.records.forEach { record -> + appendLine("/** Parse and validate record ${record.qualifiedName} */") + appendLine("fun `parse ${record.name}`(json: JsonElement): ${record.getQualifiedName(options)} {") + for (field in record.fields) { + appendLine(" // Parse field ${field.name}") + appendLine(" val `field ${field.name}` = run {") + if (field.type.isOptional) { + appendLine(" val jsonElement = json.jsonObject[\"${field.name}\"] ?: return@run null") + } else { + appendLine(" val jsonElement = json.jsonObject[\"${field.name}\"]!!") + } + appendLine(" ${deserializeJsonElement(field.type, options)}") + appendLine(" }") + } + appendLine(" return ${record.getQualifiedName(options)}(") + for (field in record.fields) { + appendLine(" ${field.name} = `field ${field.name}`,") + } + appendLine(" )") appendLine("}") + appendLine() } - pack.enums.forEach { - val enumName = it.getQualifiedName(options) - appendLine("fun parse${it.name}(name: String) = when(name) {") - it.values.forEach { value -> + pack.enums.forEach { enum -> + val enumName = enum.getQualifiedName(options) + appendLine("/** Parse enum ${enum.qualifiedName} */") + appendLine("fun `parse ${enum.name}`(json: JsonElement) = when(json.jsonPrimitive.content) {") + enum.values.forEach { value -> appendLine(" \"${value}\" -> ${enumName}.${value}") } + appendLine(" // Value not found in enum ${enum.qualifiedName}, returning UNKNOWN") appendLine(" else -> ${enumName}.UNKNOWN") appendLine("}") } @@ -127,7 +147,13 @@ class KotlinKtorGenerator : Generator { appendLine(" route${provider.name}(") for (info in implementedServices) { provider.implements.joinToString(" */, /* ") { it.service.getQualifiedName(options) } - appendLine(" ${info.serviceArgumentName} = TODO(\"Implement ${info.service.getQualifiedName(options)}\"),") + appendLine( + " ${info.serviceArgumentName} = TODO(\"Implement ${ + info.service.getQualifiedName( + options + ) + }\")," + ) } appendLine(" )") } @@ -170,22 +196,33 @@ class KotlinKtorGenerator : Generator { appendLine(" ${info.serviceArgumentName}: ${info.reference.getQualifiedName(options)},") } appendLine(") {") + appendUtilities() implementedServices.forEach { info -> - appendProviderOperations(info, transportConfiguration) + appendProviderOperations(info, transportConfiguration, options) } appendLine("}") } + private fun StringBuilder.appendUtilities() { + appendLine(" /** Utility used to convert string to JSON element */") + appendLine(" fun String.toJson() = Json.parseToJsonElement(this)") + appendLine(" /** Utility used to convert string to JSON element or null */") + appendLine(" fun String.toJsonOrNull() = Json.parseToJsonElement(this).takeUnless { it is JsonNull }") + appendLine() + } + private fun StringBuilder.appendProviderOperations( info: ProviderInfo, transportConfiguration: HttpTransportConfiguration, + options: Map, ) { val service = info.service info.implements.operations.forEach { operation -> appendLine(" // Handler for SAMT Service ${info.service.name}") appendLine(" route(\"${transportConfiguration.getPath(service.name)}\") {") - appendProviderOperation(operation, info, service, transportConfiguration) + appendProviderOperation(operation, info, service, transportConfiguration, options) appendLine(" }") + appendLine() } } @@ -194,6 +231,7 @@ class KotlinKtorGenerator : Generator { info: ProviderInfo, service: ServiceType, transportConfiguration: HttpTransportConfiguration, + options: Map, ) { when (operation) { is RequestResponseOperation -> { @@ -203,41 +241,35 @@ class KotlinKtorGenerator : Generator { appendParsingPreamble() operation.parameters.forEach { parameter -> - // TODO complexer data types than string - appendParameterParsing(service, operation, parameter, transportConfiguration) + appendParameterParsing(service, operation, parameter, transportConfiguration, options) } - appendLine() - - - - // TODO Config: HTTP status code - // TODO serialize response correctly - // TODO validate response - if (operation.returnType != null) { - appendLine(" val response = ${getServiceCall(info, operation)}") - appendLine(" call.respond(response)") - appendLine(" }") - } else { - appendLine(" ${getServiceCall(info, operation)}") - appendLine(" call.respond(HttpStatusCode.NoContent)") - appendLine(" }") - } - } + appendLine(" // Call user provided implementation") + appendLine(" val response = ${getServiceCall(info, operation)}") + appendLine() + appendLine(" call.respond(response)") + appendLine(" }") + appendLine() + } is OnewayOperation -> { - appendLine(" ${getKtorRoute(service, operation, transportConfiguration)} {") + appendLine(" // Handler for SAMT oneway operation ${operation.name}") + appendLine(" ${getKtorRoute(service, operation, transportConfiguration)} {") appendParsingPreamble() operation.parameters.forEach { parameter -> - appendParameterParsing(service, operation, parameter, transportConfiguration) + appendParameterParsing(service, operation, parameter, transportConfiguration, options) } + appendLine(" // Use launch to handle the request asynchronously, not waiting for the response") appendLine(" launch {") + appendLine(" // Call user provided implementation") appendLine(" ${getServiceCall(info, operation)}") appendLine(" }") + appendLine() + appendLine(" // Oneway operation always returns 204 No Content") appendLine(" call.respond(HttpStatusCode.NoContent)") appendLine(" }") } @@ -245,8 +277,9 @@ class KotlinKtorGenerator : Generator { } private fun StringBuilder.appendParsingPreamble() { + appendLine(" // Parse body lazily in case no parameter is transported in the body") appendLine(" val bodyAsText = call.receiveText()") - appendLine(" val body = Json.parseToJsonElement(bodyAsText)") + appendLine(" val body by lazy { bodyAsText.toJson() }") appendLine() } @@ -408,7 +441,7 @@ class KotlinKtorGenerator : Generator { } private fun getServiceCall(info: ProviderInfo, operation: ServiceOperation): String { - return "${info.serviceArgumentName}.${operation.name}(${operation.parameters.joinToString { it.name }})" + return "${info.serviceArgumentName}.${operation.name}(${operation.parameters.joinToString { "`parameter ${it.name}`" }})" } private fun StringBuilder.appendParameterParsing( @@ -416,79 +449,110 @@ class KotlinKtorGenerator : Generator { operation: ServiceOperation, parameter: ServiceOperationParameter, transportConfiguration: HttpTransportConfiguration, + options: Map, ) { - appendParameterDeserialization(service, operation, parameter, transportConfiguration) - appendParameterConstraints(parameter) + appendLine(" // Parse parameter ${parameter.name}") + appendLine(" val `parameter ${parameter.name}` = run {") + val transportMode = transportConfiguration.getTransportMode(service.name, operation.name, parameter.name) + appendParameterDeserialization(parameter, transportMode, options) + appendLine(" }") appendLine() } private fun StringBuilder.appendParameterDeserialization( - service: ServiceType, - operation: ServiceOperation, parameter: ServiceOperationParameter, - transportConfiguration: HttpTransportConfiguration, + transportMode: HttpTransportConfiguration.TransportMode, + options: Map, ) { - // TODO Config: From Body / from Query / from Path etc. - // TODO error and null handling - // TODO complexer data types than string - - when (transportConfiguration.getTransportMode(service.name, operation.name, parameter.name)) { - HttpTransportConfiguration.TransportMode.Body -> { - if (parameter.type.isOptional) { - appendLine(" val ${parameter.name} = body.jsonObject[\"${parameter.name}\"]?.jsonPrimitive?.contentOrNull") - } else { - appendLine(" val ${parameter.name} = body.jsonObject.getValue(\"${parameter.name}\").jsonPrimitive.content") - } - } + appendReadJsonElement(parameter, transportMode) + appendLine(" ${deserializeJsonElement(parameter.type, options)}") + } - HttpTransportConfiguration.TransportMode.Query -> { - if (parameter.type.isOptional) { - appendLine(" val ${parameter.name} = call.request.queryParameters[\"${parameter.name}\"]") - } else { - appendLine(" val ${parameter.name} = call.request.queryParameters.getValue(\"${parameter.name}\")") - } + private fun StringBuilder.appendReadJsonElement( + parameter: ServiceOperationParameter, + transportMode: HttpTransportConfiguration.TransportMode, + ) { + appendLine(" // Read from ${transportMode.name.lowercase()}") + append(" val jsonElement = ") + if (parameter.type.isOptional) { + when (transportMode) { + HttpTransportConfiguration.TransportMode.Body -> append("body.jsonObject[\"${parameter.name}\"]?.takeUnless { it is JsonNull }") + HttpTransportConfiguration.TransportMode.Query -> append("call.request.queryParameters[\"${parameter.name}\"]?.toJsonOrNull()") + HttpTransportConfiguration.TransportMode.Path -> append("call.parameters[\"${parameter.name}\"]?.toJsonOrNull()") + HttpTransportConfiguration.TransportMode.Header -> append("call.request.headers[\"${parameter.name}\"]?.toJsonOrNull()") + HttpTransportConfiguration.TransportMode.Cookie -> append("call.request.cookies[\"${parameter.name}\"]?.toJsonOrNull()") } - - HttpTransportConfiguration.TransportMode.Path -> { - if (parameter.type.isOptional) { - appendLine(" val ${parameter.name} = call.parameters[\"${parameter.name}\"]") - } else { - appendLine(" val ${parameter.name} = call.parameters.getValue(\"${parameter.name}\")") - } + append(" ?: return@run null") + } else { + when (transportMode) { + HttpTransportConfiguration.TransportMode.Body -> append("body.jsonObject[\"${parameter.name}\"]!!") + HttpTransportConfiguration.TransportMode.Query -> append("call.request.queryParameters[\"${parameter.name}\"]!!.toJson()") + HttpTransportConfiguration.TransportMode.Path -> append("call.parameters[\"${parameter.name}\"]!!.toJson()") + HttpTransportConfiguration.TransportMode.Header -> append("call.request.headers[\"${parameter.name}\"]!!.toJson()") + HttpTransportConfiguration.TransportMode.Cookie -> append("call.request.cookies[\"${parameter.name}\"]!!.toJson()") } + } + appendLine() + } - HttpTransportConfiguration.TransportMode.Header -> { - if (parameter.type.isOptional) { - appendLine(" val ${parameter.name} = call.request.headers.get(\"${parameter.name}\")") - } else { - appendLine(" val ${parameter.name} = call.request.headers.get(\"${parameter.name}\")!!") + private fun deserializeJsonElement(typeReference: TypeReference, options: Map): String { + return when (val type = typeReference.type) { + is LiteralType -> when (type) { + is StringType -> "jsonElement.jsonPrimitive.content" + is BytesType -> "jsonElement.jsonPrimitive.content.decodeBase64Bytes()" + is IntType -> "jsonElement.jsonPrimitive.int" + is LongType -> "jsonElement.jsonPrimitive.long" + is FloatType -> "jsonElement.jsonPrimitive.float" + is DoubleType -> "jsonElement.jsonPrimitive.double" + is DecimalType -> "jsonElement.jsonPrimitive.content.let { java.math.BigDecimal(it) }" + is BooleanType -> "jsonElement.jsonPrimitive.boolean" + is DateType -> "jsonElement.jsonPrimitive.content?.let { java.time.LocalDate.parse(it) }" + is DateTimeType -> "jsonElement.jsonPrimitive.content?.let { java.time.LocalDateTime.parse(it) }" + is DurationType -> "jsonElement.jsonPrimitive.content?.let { java.time.Duration.parse(it) }" + else -> error("Unsupported literal type: ${this.javaClass.simpleName}") + } + literalConstraintSuffix(typeReference) + + is ListType -> "jsonElement.jsonArray.map { ${deserializeJsonElement(type.elementType, options)} }" + is MapType -> "jsonElement.jsonObject.mapValues { ${deserializeJsonElement(type.valueType, options)} }" + + is UserType -> "`parse ${type.name}`(jsonElement)" + + else -> error("Unsupported type: ${javaClass.simpleName}") + } + } + + private fun literalConstraintSuffix(typeReference: TypeReference): String { + val conditions = buildList { + typeReference.rangeConstraint?.let { constraint -> + constraint.lowerBound?.let { + add("it >= ${constraint.lowerBound}") + } + constraint.upperBound?.let { + add("it <= ${constraint.upperBound}") } } - - HttpTransportConfiguration.TransportMode.Cookie -> { - if (parameter.type.isOptional) { - appendLine(" val ${parameter.name} = call.request.cookies.get(\"${parameter.name}\")") - } else { - appendLine(" val ${parameter.name} = call.request.cookies.get(\"${parameter.name}\")!!") + typeReference.sizeConstraint?.let { constraint -> + val property = if (typeReference.type is StringType) "length" else "size" + constraint.lowerBound?.let { + add("it.${property} >= ${constraint.lowerBound}") + } + constraint.upperBound?.let { + add("it.${property} <= ${constraint.upperBound}") } } + typeReference.patternConstraint?.let { constraint -> + add("it.matches(\"${constraint.pattern}\")") + } + typeReference.valueConstraint?.let { constraint -> + add("it == ${constraint.value})") + } } - } - private fun StringBuilder.appendParameterConstraints(parameter: ServiceOperationParameter) { - // TODO constraints within map or list or record field - parameter.type.rangeConstraint?.let { - appendLine(" require(${parameter.name}.length in ${it.lowerBound}..${it.upperBound}) { \"${parameter.name} must be between ${it.lowerBound} and ${it.upperBound} characters long\" }") - } - parameter.type.sizeConstraint?.let { - appendLine(" require(${parameter.name}.size in ${it.lowerBound}..${it.upperBound}) { \"${parameter.name} must be between ${it.lowerBound} and ${it.upperBound} elements long\" }") - } - parameter.type.patternConstraint?.let { - appendLine(" require(${parameter.name}.matches(\"${it.pattern}\") { \"${parameter.name} does not adhere to required pattern '${it.pattern}'\" }") - } - parameter.type.valueConstraint?.let { - appendLine(" require(${parameter.name} == ${it.value}) { \"${parameter.name} does not equal '${it.value}'\" }") + if (conditions.isEmpty()) { + return "" } + + return ".also { require(${conditions.joinToString(" && ")}) }" } private fun SamtPackage.hasDataTypes(): Boolean { From a71fd520f7b23a200b9e8fe7315d2c2d8057e4d6 Mon Sep 17 00:00:00 2001 From: Pascal Honegger Date: Sun, 21 May 2023 19:21:06 +0200 Subject: [PATCH 18/41] feat(codegen): move files around --- .../main/kotlin/tools/samt/codegen/Codegen.kt | 3 ++ .../main/kotlin/tools/samt/codegen/Mapping.kt | 1 + .../samt/codegen/{ => http}/HttpTransport.kt | 52 +++++++++++-------- .../{ => kotlin}/KotlinGeneratorUtils.kt | 4 +- .../{ => kotlin}/KotlinTypesGenerator.kt | 4 +- .../{ => kotlin/ktor}/KotlinKtorGenerator.kt | 8 ++- 6 files changed, 48 insertions(+), 24 deletions(-) rename codegen/src/main/kotlin/tools/samt/codegen/{ => http}/HttpTransport.kt (89%) rename codegen/src/main/kotlin/tools/samt/codegen/{ => kotlin}/KotlinGeneratorUtils.kt (97%) rename codegen/src/main/kotlin/tools/samt/codegen/{ => kotlin}/KotlinTypesGenerator.kt (98%) rename codegen/src/main/kotlin/tools/samt/codegen/{ => kotlin/ktor}/KotlinKtorGenerator.kt (98%) diff --git a/codegen/src/main/kotlin/tools/samt/codegen/Codegen.kt b/codegen/src/main/kotlin/tools/samt/codegen/Codegen.kt index 30ffbbec..afbceb43 100644 --- a/codegen/src/main/kotlin/tools/samt/codegen/Codegen.kt +++ b/codegen/src/main/kotlin/tools/samt/codegen/Codegen.kt @@ -1,5 +1,8 @@ package tools.samt.codegen +import tools.samt.codegen.http.HttpTransportConfigurationParser +import tools.samt.codegen.kotlin.KotlinTypesGenerator +import tools.samt.codegen.kotlin.ktor.KotlinKtorGenerator import tools.samt.common.DiagnosticController import tools.samt.common.SamtGeneratorConfiguration import tools.samt.semantic.* diff --git a/codegen/src/main/kotlin/tools/samt/codegen/Mapping.kt b/codegen/src/main/kotlin/tools/samt/codegen/Mapping.kt index 11d994c3..f43cbcb6 100644 --- a/codegen/src/main/kotlin/tools/samt/codegen/Mapping.kt +++ b/codegen/src/main/kotlin/tools/samt/codegen/Mapping.kt @@ -1,5 +1,6 @@ package tools.samt.codegen +import tools.samt.codegen.http.HttpTransportConfigurationParser import tools.samt.common.DiagnosticController class PublicApiMapper( diff --git a/codegen/src/main/kotlin/tools/samt/codegen/HttpTransport.kt b/codegen/src/main/kotlin/tools/samt/codegen/http/HttpTransport.kt similarity index 89% rename from codegen/src/main/kotlin/tools/samt/codegen/HttpTransport.kt rename to codegen/src/main/kotlin/tools/samt/codegen/http/HttpTransport.kt index c9c2a47d..93352ef0 100644 --- a/codegen/src/main/kotlin/tools/samt/codegen/HttpTransport.kt +++ b/codegen/src/main/kotlin/tools/samt/codegen/http/HttpTransport.kt @@ -1,8 +1,10 @@ -package tools.samt.codegen +package tools.samt.codegen.http +import tools.samt.codegen.TransportConfiguration +import tools.samt.codegen.TransportConfigurationParser +import tools.samt.codegen.TransportConfigurationParserParams import tools.samt.common.DiagnosticController import tools.samt.parser.* -import tools.samt.semantic.Package // TODO: refactor diagnostic controller support @@ -142,10 +144,12 @@ class HttpTransportConfigurationParser: TransportConfigurationParser { continue } - add(HttpTransportConfiguration.ParameterConfiguration( - name = pathParameterName, - transportMode = HttpTransportConfiguration.TransportMode.Path, - )) + add( + HttpTransportConfiguration.ParameterConfiguration( + name = pathParameterName, + transportMode = HttpTransportConfiguration.TransportMode.Path, + ) + ) } } @@ -185,10 +189,12 @@ class HttpTransportConfigurationParser: TransportConfigurationParser { } } - add(HttpTransportConfiguration.ParameterConfiguration( - name = name, - transportMode = transportMode, - )) + add( + HttpTransportConfiguration.ParameterConfiguration( + name = name, + transportMode = transportMode, + ) + ) } else { operationConfig.reportError(params.controller) { message("Expected parameter in format '{type:name}', got '$component'") @@ -198,20 +204,24 @@ class HttpTransportConfigurationParser: TransportConfigurationParser { } } - add(HttpTransportConfiguration.OperationConfiguration( - name = operationName, - method = methodEnum, - path = path, - parameters = parameters, - )) + add( + HttpTransportConfiguration.OperationConfiguration( + name = operationName, + method = methodEnum, + path = path, + parameters = parameters, + ) + ) } } - add(HttpTransportConfiguration.ServiceConfiguration( - name = serviceName, - operations = operations, - path = "" // TODO - )) + add( + HttpTransportConfiguration.ServiceConfiguration( + name = serviceName, + operations = operations, + path = "" // TODO + ) + ) } } diff --git a/codegen/src/main/kotlin/tools/samt/codegen/KotlinGeneratorUtils.kt b/codegen/src/main/kotlin/tools/samt/codegen/kotlin/KotlinGeneratorUtils.kt similarity index 97% rename from codegen/src/main/kotlin/tools/samt/codegen/KotlinGeneratorUtils.kt rename to codegen/src/main/kotlin/tools/samt/codegen/kotlin/KotlinGeneratorUtils.kt index 13248325..4845aaf8 100644 --- a/codegen/src/main/kotlin/tools/samt/codegen/KotlinGeneratorUtils.kt +++ b/codegen/src/main/kotlin/tools/samt/codegen/kotlin/KotlinGeneratorUtils.kt @@ -1,4 +1,6 @@ -package tools.samt.codegen +package tools.samt.codegen.kotlin + +import tools.samt.codegen.* object KotlinGeneratorConfig { const val removePrefixFromSamtPackage = "removePrefixFromSamtPackage" diff --git a/codegen/src/main/kotlin/tools/samt/codegen/KotlinTypesGenerator.kt b/codegen/src/main/kotlin/tools/samt/codegen/kotlin/KotlinTypesGenerator.kt similarity index 98% rename from codegen/src/main/kotlin/tools/samt/codegen/KotlinTypesGenerator.kt rename to codegen/src/main/kotlin/tools/samt/codegen/kotlin/KotlinTypesGenerator.kt index 86627f99..7a1fffaa 100644 --- a/codegen/src/main/kotlin/tools/samt/codegen/KotlinTypesGenerator.kt +++ b/codegen/src/main/kotlin/tools/samt/codegen/kotlin/KotlinTypesGenerator.kt @@ -1,4 +1,6 @@ -package tools.samt.codegen +package tools.samt.codegen.kotlin + +import tools.samt.codegen.* class KotlinTypesGenerator : Generator { override val name: String = "kotlin-types" diff --git a/codegen/src/main/kotlin/tools/samt/codegen/KotlinKtorGenerator.kt b/codegen/src/main/kotlin/tools/samt/codegen/kotlin/ktor/KotlinKtorGenerator.kt similarity index 98% rename from codegen/src/main/kotlin/tools/samt/codegen/KotlinKtorGenerator.kt rename to codegen/src/main/kotlin/tools/samt/codegen/kotlin/ktor/KotlinKtorGenerator.kt index 25a6b674..88333ac9 100644 --- a/codegen/src/main/kotlin/tools/samt/codegen/KotlinKtorGenerator.kt +++ b/codegen/src/main/kotlin/tools/samt/codegen/kotlin/ktor/KotlinKtorGenerator.kt @@ -1,4 +1,10 @@ -package tools.samt.codegen +package tools.samt.codegen.kotlin.ktor + +import tools.samt.codegen.* +import tools.samt.codegen.http.HttpTransportConfiguration +import tools.samt.codegen.kotlin.GeneratedFilePreamble +import tools.samt.codegen.kotlin.KotlinTypesGenerator +import tools.samt.codegen.kotlin.getQualifiedName class KotlinKtorGenerator : Generator { override val name: String = "kotlin-ktor" From 3d81850bd1cc4d9357f2c7237e42a854574674ed Mon Sep 17 00:00:00 2001 From: Pascal Honegger Date: Sun, 21 May 2023 19:22:53 +0200 Subject: [PATCH 19/41] feat(codegen): use objects for singletons --- .../main/kotlin/tools/samt/codegen/Codegen.kt | 16 +++------------- .../main/kotlin/tools/samt/codegen/Mapping.kt | 4 +--- .../tools/samt/codegen/http/HttpTransport.kt | 2 +- .../samt/codegen/kotlin/KotlinTypesGenerator.kt | 2 +- .../codegen/kotlin/ktor/KotlinKtorGenerator.kt | 4 ++-- 5 files changed, 8 insertions(+), 20 deletions(-) diff --git a/codegen/src/main/kotlin/tools/samt/codegen/Codegen.kt b/codegen/src/main/kotlin/tools/samt/codegen/Codegen.kt index afbceb43..f54e653d 100644 --- a/codegen/src/main/kotlin/tools/samt/codegen/Codegen.kt +++ b/codegen/src/main/kotlin/tools/samt/codegen/Codegen.kt @@ -9,24 +9,14 @@ import tools.samt.semantic.* data class CodegenFile(val filepath: String, val source: String) -/* - * Proof of concept codegen for Kotlin code - * - * Todos: - * - Emit providers - * - Emit consumers - * - Modular - * - Extendable - * - Configurable - * */ object Codegen { private val generators: List = listOf( - KotlinTypesGenerator(), - KotlinKtorGenerator(), + KotlinTypesGenerator, + KotlinKtorGenerator, ) private val transports: List = listOf( - HttpTransportConfigurationParser(), + HttpTransportConfigurationParser, ) internal class SamtGeneratorParams( diff --git a/codegen/src/main/kotlin/tools/samt/codegen/Mapping.kt b/codegen/src/main/kotlin/tools/samt/codegen/Mapping.kt index f43cbcb6..afcf2d22 100644 --- a/codegen/src/main/kotlin/tools/samt/codegen/Mapping.kt +++ b/codegen/src/main/kotlin/tools/samt/codegen/Mapping.kt @@ -4,9 +4,7 @@ import tools.samt.codegen.http.HttpTransportConfigurationParser import tools.samt.common.DiagnosticController class PublicApiMapper( - private val transportParsers: List = listOf( - HttpTransportConfigurationParser(), - ), + private val transportParsers: List, private val controller: DiagnosticController, ) { fun toPublicApi(samtPackage: tools.samt.semantic.Package) = object : SamtPackage { diff --git a/codegen/src/main/kotlin/tools/samt/codegen/http/HttpTransport.kt b/codegen/src/main/kotlin/tools/samt/codegen/http/HttpTransport.kt index 93352ef0..b3bfb095 100644 --- a/codegen/src/main/kotlin/tools/samt/codegen/http/HttpTransport.kt +++ b/codegen/src/main/kotlin/tools/samt/codegen/http/HttpTransport.kt @@ -8,7 +8,7 @@ import tools.samt.parser.* // TODO: refactor diagnostic controller support -class HttpTransportConfigurationParser: TransportConfigurationParser { +object HttpTransportConfigurationParser: TransportConfigurationParser { override val transportName: String get() = "http" diff --git a/codegen/src/main/kotlin/tools/samt/codegen/kotlin/KotlinTypesGenerator.kt b/codegen/src/main/kotlin/tools/samt/codegen/kotlin/KotlinTypesGenerator.kt index 7a1fffaa..dfcf2757 100644 --- a/codegen/src/main/kotlin/tools/samt/codegen/kotlin/KotlinTypesGenerator.kt +++ b/codegen/src/main/kotlin/tools/samt/codegen/kotlin/KotlinTypesGenerator.kt @@ -2,7 +2,7 @@ package tools.samt.codegen.kotlin import tools.samt.codegen.* -class KotlinTypesGenerator : Generator { +object KotlinTypesGenerator : Generator { override val name: String = "kotlin-types" override fun generate(generatorParams: GeneratorParams): List { generatorParams.packages.forEach { diff --git a/codegen/src/main/kotlin/tools/samt/codegen/kotlin/ktor/KotlinKtorGenerator.kt b/codegen/src/main/kotlin/tools/samt/codegen/kotlin/ktor/KotlinKtorGenerator.kt index 88333ac9..a26b0f1b 100644 --- a/codegen/src/main/kotlin/tools/samt/codegen/kotlin/ktor/KotlinKtorGenerator.kt +++ b/codegen/src/main/kotlin/tools/samt/codegen/kotlin/ktor/KotlinKtorGenerator.kt @@ -6,7 +6,7 @@ import tools.samt.codegen.kotlin.GeneratedFilePreamble import tools.samt.codegen.kotlin.KotlinTypesGenerator import tools.samt.codegen.kotlin.getQualifiedName -class KotlinKtorGenerator : Generator { +object KotlinKtorGenerator : Generator { override val name: String = "kotlin-ktor" override fun generate(generatorParams: GeneratorParams): List { @@ -14,7 +14,7 @@ class KotlinKtorGenerator : Generator { generateMappings(it, generatorParams.options) generatePackage(it, generatorParams.options) } - val result = KotlinTypesGenerator().generate(generatorParams) + emittedFiles + val result = KotlinTypesGenerator.generate(generatorParams) + emittedFiles emittedFiles.clear() return result } From c3fba3a3f17b9600d94139fddb775f7ed414dad5 Mon Sep 17 00:00:00 2001 From: Pascal Honegger Date: Sun, 21 May 2023 20:45:21 +0200 Subject: [PATCH 20/41] feat(codegen): fully functional provider generation --- .../main/kotlin/tools/samt/codegen/Codegen.kt | 4 +- .../codegen/kotlin/KotlinGeneratorUtils.kt | 2 +- .../codegen/kotlin/KotlinTypesGenerator.kt | 2 +- .../ktor/KotlinKtorGeneratorUtilities.kt | 203 ++++++++++++++++++ ...ator.kt => KotlinKtorProviderGenerator.kt} | 178 ++++----------- 5 files changed, 246 insertions(+), 143 deletions(-) create mode 100644 codegen/src/main/kotlin/tools/samt/codegen/kotlin/ktor/KotlinKtorGeneratorUtilities.kt rename codegen/src/main/kotlin/tools/samt/codegen/kotlin/ktor/{KotlinKtorGenerator.kt => KotlinKtorProviderGenerator.kt} (73%) diff --git a/codegen/src/main/kotlin/tools/samt/codegen/Codegen.kt b/codegen/src/main/kotlin/tools/samt/codegen/Codegen.kt index f54e653d..28747816 100644 --- a/codegen/src/main/kotlin/tools/samt/codegen/Codegen.kt +++ b/codegen/src/main/kotlin/tools/samt/codegen/Codegen.kt @@ -2,7 +2,7 @@ package tools.samt.codegen import tools.samt.codegen.http.HttpTransportConfigurationParser import tools.samt.codegen.kotlin.KotlinTypesGenerator -import tools.samt.codegen.kotlin.ktor.KotlinKtorGenerator +import tools.samt.codegen.kotlin.ktor.KotlinKtorProviderGenerator import tools.samt.common.DiagnosticController import tools.samt.common.SamtGeneratorConfiguration import tools.samt.semantic.* @@ -12,7 +12,7 @@ data class CodegenFile(val filepath: String, val source: String) object Codegen { private val generators: List = listOf( KotlinTypesGenerator, - KotlinKtorGenerator, + KotlinKtorProviderGenerator, ) private val transports: List = listOf( diff --git a/codegen/src/main/kotlin/tools/samt/codegen/kotlin/KotlinGeneratorUtils.kt b/codegen/src/main/kotlin/tools/samt/codegen/kotlin/KotlinGeneratorUtils.kt index 4845aaf8..953c2458 100644 --- a/codegen/src/main/kotlin/tools/samt/codegen/kotlin/KotlinGeneratorUtils.kt +++ b/codegen/src/main/kotlin/tools/samt/codegen/kotlin/KotlinGeneratorUtils.kt @@ -7,7 +7,7 @@ object KotlinGeneratorConfig { const val addPrefixToKotlinPackage = "addPrefixToKotlinPackage" } -const val GeneratedFilePreamble = """@file:Suppress("RemoveRedundantQualifierName", "unused", "UnusedImport", "LocalVariableName", "FunctionName", "ConvertTwoComparisonsToRangeCheck", "ReplaceSizeCheckWithIsNotEmpty")""" +const val GeneratedFilePreamble = """@file:Suppress("RemoveRedundantQualifierName", "unused", "UnusedImport", "LocalVariableName", "FunctionName", "ConvertTwoComparisonsToRangeCheck", "ReplaceSizeCheckWithIsNotEmpty", "NAME_SHADOWING")""" internal fun String.replacePackage(options: Map): String { val removePrefix = options[KotlinGeneratorConfig.removePrefixFromSamtPackage] diff --git a/codegen/src/main/kotlin/tools/samt/codegen/kotlin/KotlinTypesGenerator.kt b/codegen/src/main/kotlin/tools/samt/codegen/kotlin/KotlinTypesGenerator.kt index dfcf2757..2e1e8bf5 100644 --- a/codegen/src/main/kotlin/tools/samt/codegen/kotlin/KotlinTypesGenerator.kt +++ b/codegen/src/main/kotlin/tools/samt/codegen/kotlin/KotlinTypesGenerator.kt @@ -65,7 +65,7 @@ object KotlinTypesGenerator : Generator { private fun StringBuilder.appendEnum(enum: EnumType) { appendLine("enum class ${enum.name} {") appendLine(" /** Default value used when the enum could not be parsed */") - appendLine(" UNKNOWN,") + appendLine(" FAILED_TO_PARSE,") enum.values.forEach { appendLine(" ${it},") } diff --git a/codegen/src/main/kotlin/tools/samt/codegen/kotlin/ktor/KotlinKtorGeneratorUtilities.kt b/codegen/src/main/kotlin/tools/samt/codegen/kotlin/ktor/KotlinKtorGeneratorUtilities.kt new file mode 100644 index 00000000..e2dea067 --- /dev/null +++ b/codegen/src/main/kotlin/tools/samt/codegen/kotlin/ktor/KotlinKtorGeneratorUtilities.kt @@ -0,0 +1,203 @@ +package tools.samt.codegen.kotlin.ktor + +import tools.samt.codegen.* +import tools.samt.codegen.kotlin.GeneratedFilePreamble +import tools.samt.codegen.kotlin.getQualifiedName + +fun mappingFileContent(pack: SamtPackage, options: Map) = buildString { + if (pack.records.isNotEmpty() || pack.enums.isNotEmpty()) { + appendLine(GeneratedFilePreamble) + appendLine() + appendLine("package ${pack.getQualifiedName(options)}") + appendLine() + appendLine("import io.ktor.util.*") + appendLine("import kotlinx.serialization.json.*") + appendLine() + + pack.records.forEach { record -> + appendEncodeRecord(record, options) + appendDecodeRecord(record, options) + appendLine() + } + + pack.enums.forEach { enum -> + appendEncodeEnum(enum, options) + appendDecodeEnum(enum, options) + appendLine() + } + } +} + +private fun StringBuilder.appendEncodeRecord( + record: RecordType, + options: Map, +) { + appendLine("/** Encode and validate record ${record.qualifiedName} to JSON */") + appendLine("fun `encode ${record.name}`(record: ${record.getQualifiedName(options)}): JsonObject {") + for (field in record.fields) { + appendEncodeRecordField(field, options) + } + appendLine(" // Create JSON for ${record.qualifiedName}") + appendLine(" return buildJsonObject {") + for (field in record.fields) { + appendLine(" put(\"${field.name}\", `field ${field.name}`)") + } + appendLine(" }") + appendLine("}") +} + +private fun StringBuilder.appendDecodeRecord( + record: RecordType, + options: Map, +) { + appendLine("/** Decode and validate record ${record.qualifiedName} from JSON */") + appendLine("fun `decode ${record.name}`(json: JsonElement): ${record.getQualifiedName(options)} {") + for (field in record.fields) { + appendDecodeRecordField(field, options) + } + appendLine(" // Create record ${record.qualifiedName}") + appendLine(" return ${record.getQualifiedName(options)}(") + for (field in record.fields) { + appendLine(" ${field.name} = `field ${field.name}`,") + } + appendLine(" )") + appendLine("}") +} + +private fun StringBuilder.appendEncodeEnum(enum: EnumType, options: Map) { + val enumName = enum.getQualifiedName(options) + appendLine("/** Encode enum ${enum.qualifiedName} to JSON */") + appendLine("fun `encode ${enum.name}`(value: ${enumName}) = when(value) {") + enum.values.forEach { value -> + appendLine(" ${enumName}.${value} -> \"${value}\"") + } + appendLine(" ${enumName}.FAILED_TO_PARSE -> error(\"Cannot encode FAILED_TO_PARSE value\")") + appendLine("}") +} + +private fun StringBuilder.appendDecodeEnum(enum: EnumType, options: Map) { + val enumName = enum.getQualifiedName(options) + appendLine("/** Decode enum ${enum.qualifiedName} from JSON */") + appendLine("fun `decode ${enum.name}`(json: JsonElement) = when(json.jsonPrimitive.content) {") + enum.values.forEach { value -> + appendLine(" \"${value}\" -> ${enumName}.${value}") + } + appendLine(" // Value not found in enum ${enum.qualifiedName}") + appendLine(" else -> ${enumName}.FAILED_TO_PARSE") + appendLine("}") +} + +private fun StringBuilder.appendEncodeRecordField(field: RecordField, options: Map) { + appendLine(" // Encode field ${field.name}") + appendLine(" val `field ${field.name}` = run {") + append(" val value = ") + if (field.type.isOptional) { + append("record.${field.name} ?: return@run JsonNull") + } else { + append("record.${field.name}") + } + appendLine() + appendLine(" ${encodeJsonElement(field.type, options)}") + appendLine(" }") +} + +private fun StringBuilder.appendDecodeRecordField(field: RecordField, options: Map) { + appendLine(" // Decode field ${field.name}") + appendLine(" val `field ${field.name}` = run {") + append(" val jsonElement = ") + if (field.type.isOptional) { + append("json.jsonObject[\"${field.name}\"] ?: return@run null") + } else { + append("json.jsonObject[\"${field.name}\"]!!") + } + appendLine() + appendLine(" ${decodeJsonElement(field.type, options)}") + appendLine(" }") +} + +fun encodeJsonElement(typeReference: TypeReference, options: Map): String = + when (val type = typeReference.type) { + is LiteralType -> { + val getContent = when (type) { + is StringType, + is IntType, + is LongType, + is FloatType, + is DoubleType, + is BooleanType -> "value" + is BytesType -> "value.encodeBase64Bytes()" + is DecimalType -> "value.toPlainString()" + is DateType, + is DateTimeType, + is DurationType -> "value.toString()" + else -> error("Unsupported literal type: ${type.javaClass.simpleName}") + } + "Json.encodeToJsonElement($getContent${validateLiteralConstraintsSuffix(typeReference)})" + } + + is ListType -> "value.map { value -> ${encodeJsonElement(type.elementType, options)} }" + is MapType -> "value.mapValues { value -> ${decodeJsonElement(type.valueType, options)} }" + + is UserType -> "`encode ${type.name}`(value)" + + else -> error("Unsupported type: ${type.javaClass.simpleName}") + } + +fun decodeJsonElement(typeReference: TypeReference, options: Map): String = + when (val type = typeReference.type) { + is LiteralType -> when (type) { + is StringType -> "jsonElement.jsonPrimitive.content" + is BytesType -> "jsonElement.jsonPrimitive.content.decodeBase64Bytes()" + is IntType -> "jsonElement.jsonPrimitive.int" + is LongType -> "jsonElement.jsonPrimitive.long" + is FloatType -> "jsonElement.jsonPrimitive.float" + is DoubleType -> "jsonElement.jsonPrimitive.double" + is DecimalType -> "jsonElement.jsonPrimitive.content.let { java.math.BigDecimal(it) }" + is BooleanType -> "jsonElement.jsonPrimitive.boolean" + is DateType -> "jsonElement.jsonPrimitive.content?.let { java.time.LocalDate.parse(it) }" + is DateTimeType -> "jsonElement.jsonPrimitive.content?.let { java.time.LocalDateTime.parse(it) }" + is DurationType -> "jsonElement.jsonPrimitive.content?.let { java.time.Duration.parse(it) }" + else -> error("Unsupported literal type: ${type.javaClass.simpleName}") + } + validateLiteralConstraintsSuffix(typeReference) + + is ListType -> "jsonElement.jsonArray.map { jsonElement -> ${decodeJsonElement(type.elementType, options)} }" + is MapType -> "jsonElement.jsonObject.mapValues { jsonElement -> ${decodeJsonElement(type.valueType, options)} }" + + is UserType -> "`decode ${type.name}`(jsonElement)" + + else -> error("Unsupported type: ${type.javaClass.simpleName}") + } + +private fun validateLiteralConstraintsSuffix(typeReference: TypeReference): String { + val conditions = buildList { + typeReference.rangeConstraint?.let { constraint -> + constraint.lowerBound?.let { + add("it >= ${constraint.lowerBound}") + } + constraint.upperBound?.let { + add("it <= ${constraint.upperBound}") + } + } + typeReference.sizeConstraint?.let { constraint -> + val property = if (typeReference.type is StringType) "length" else "size" + constraint.lowerBound?.let { + add("it.${property} >= ${constraint.lowerBound}") + } + constraint.upperBound?.let { + add("it.${property} <= ${constraint.upperBound}") + } + } + typeReference.patternConstraint?.let { constraint -> + add("it.matches(\"${constraint.pattern}\")") + } + typeReference.valueConstraint?.let { constraint -> + add("it == ${constraint.value})") + } + } + + if (conditions.isEmpty()) { + return "" + } + + return ".also { require(${conditions.joinToString(" && ")}) }" +} diff --git a/codegen/src/main/kotlin/tools/samt/codegen/kotlin/ktor/KotlinKtorGenerator.kt b/codegen/src/main/kotlin/tools/samt/codegen/kotlin/ktor/KotlinKtorProviderGenerator.kt similarity index 73% rename from codegen/src/main/kotlin/tools/samt/codegen/kotlin/ktor/KotlinKtorGenerator.kt rename to codegen/src/main/kotlin/tools/samt/codegen/kotlin/ktor/KotlinKtorProviderGenerator.kt index a26b0f1b..ff6c105a 100644 --- a/codegen/src/main/kotlin/tools/samt/codegen/kotlin/ktor/KotlinKtorGenerator.kt +++ b/codegen/src/main/kotlin/tools/samt/codegen/kotlin/ktor/KotlinKtorProviderGenerator.kt @@ -6,8 +6,9 @@ import tools.samt.codegen.kotlin.GeneratedFilePreamble import tools.samt.codegen.kotlin.KotlinTypesGenerator import tools.samt.codegen.kotlin.getQualifiedName -object KotlinKtorGenerator : Generator { - override val name: String = "kotlin-ktor" +object KotlinKtorProviderGenerator : Generator { + override val name: String = "kotlin-ktor-provider" + private const val skipKtorServer = "skipKtorServer" override fun generate(generatorParams: GeneratorParams): List { generatorParams.packages.forEach { @@ -22,52 +23,8 @@ object KotlinKtorGenerator : Generator { private val emittedFiles = mutableListOf() private fun generateMappings(pack: SamtPackage, options: Map) { - if (pack.hasDataTypes()) { - val packageSource = buildString { - appendLine(GeneratedFilePreamble) - appendLine() - appendLine("package ${pack.getQualifiedName(options)}") - appendLine() - appendLine("import io.ktor.util.*") - appendLine("import kotlinx.serialization.json.*") - appendLine() - - pack.records.forEach { record -> - appendLine("/** Parse and validate record ${record.qualifiedName} */") - appendLine("fun `parse ${record.name}`(json: JsonElement): ${record.getQualifiedName(options)} {") - for (field in record.fields) { - appendLine(" // Parse field ${field.name}") - appendLine(" val `field ${field.name}` = run {") - if (field.type.isOptional) { - appendLine(" val jsonElement = json.jsonObject[\"${field.name}\"] ?: return@run null") - } else { - appendLine(" val jsonElement = json.jsonObject[\"${field.name}\"]!!") - } - appendLine(" ${deserializeJsonElement(field.type, options)}") - appendLine(" }") - } - appendLine(" return ${record.getQualifiedName(options)}(") - for (field in record.fields) { - appendLine(" ${field.name} = `field ${field.name}`,") - } - appendLine(" )") - appendLine("}") - appendLine() - } - - pack.enums.forEach { enum -> - val enumName = enum.getQualifiedName(options) - appendLine("/** Parse enum ${enum.qualifiedName} */") - appendLine("fun `parse ${enum.name}`(json: JsonElement) = when(json.jsonPrimitive.content) {") - enum.values.forEach { value -> - appendLine(" \"${value}\" -> ${enumName}.${value}") - } - appendLine(" // Value not found in enum ${enum.qualifiedName}, returning UNKNOWN") - appendLine(" else -> ${enumName}.UNKNOWN") - appendLine("}") - } - } - + val packageSource = mappingFileContent(pack, options) + if (packageSource.isNotEmpty()) { val filePath = "${pack.getQualifiedName(options).replace('.', '/')}/KtorMappings.kt" val file = CodegenFile(filePath, packageSource) emittedFiles.add(file) @@ -75,18 +32,17 @@ object KotlinKtorGenerator : Generator { } private fun generatePackage(pack: SamtPackage, options: Map) { - if (pack.hasProviderTypes()) { - - // generate general ktor files - generateKtorServer(pack, options) + val relevantProviders = pack.providers.filter { it.transport is HttpTransportConfiguration } + if (relevantProviders.isNotEmpty()) { + if (options[skipKtorServer] != "true") { + // generate general ktor files + generateKtorServer(pack, options) + } // generate ktor providers - pack.providers.forEach { provider -> + relevantProviders.forEach { provider -> val transportConfiguration = provider.transport - if (transportConfiguration !is HttpTransportConfiguration) { - // Skip providers that are not HTTP - return@forEach - } + check (transportConfiguration is HttpTransportConfiguration) val packageSource = buildString { appendLine(GeneratedFilePreamble) @@ -223,13 +179,13 @@ object KotlinKtorGenerator : Generator { options: Map, ) { val service = info.service + appendLine(" // Handler for SAMT Service ${info.service.name}") + appendLine(" route(\"${transportConfiguration.getPath(service.name)}\") {") info.implements.operations.forEach { operation -> - appendLine(" // Handler for SAMT Service ${info.service.name}") - appendLine(" route(\"${transportConfiguration.getPath(service.name)}\") {") appendProviderOperation(operation, info, service, transportConfiguration, options) - appendLine(" }") - appendLine() } + appendLine(" }") + appendLine() } private fun StringBuilder.appendProviderOperation( @@ -247,14 +203,26 @@ object KotlinKtorGenerator : Generator { appendParsingPreamble() operation.parameters.forEach { parameter -> - appendParameterParsing(service, operation, parameter, transportConfiguration, options) + appendParameterDecoding(service, operation, parameter, transportConfiguration, options) } appendLine(" // Call user provided implementation") - appendLine(" val response = ${getServiceCall(info, operation)}") - appendLine() + val returnType = operation.returnType + if (returnType != null) { + appendLine(" val value = ${getServiceCall(info, operation)}") + appendLine() + appendLine(" // Encode response") + appendLine(" val response = ${encodeJsonElement(returnType, options)}") + appendLine() + appendLine(" // Return response with 200 OK") + appendLine(" call.respondText(response.toString(), ContentType.Application.Json, HttpStatusCode.OK)") + } else { + appendLine(" ${getServiceCall(info, operation)}") + appendLine() + appendLine(" // Return 204 No Content") + appendLine(" call.respond(HttpStatusCode.NoContent)") + } - appendLine(" call.respond(response)") appendLine(" }") appendLine() } @@ -265,7 +233,7 @@ object KotlinKtorGenerator : Generator { appendParsingPreamble() operation.parameters.forEach { parameter -> - appendParameterParsing(service, operation, parameter, transportConfiguration, options) + appendParameterDecoding(service, operation, parameter, transportConfiguration, options) } appendLine(" // Use launch to handle the request asynchronously, not waiting for the response") @@ -450,14 +418,14 @@ object KotlinKtorGenerator : Generator { return "${info.serviceArgumentName}.${operation.name}(${operation.parameters.joinToString { "`parameter ${it.name}`" }})" } - private fun StringBuilder.appendParameterParsing( + private fun StringBuilder.appendParameterDecoding( service: ServiceType, operation: ServiceOperation, parameter: ServiceOperationParameter, transportConfiguration: HttpTransportConfiguration, options: Map, ) { - appendLine(" // Parse parameter ${parameter.name}") + appendLine(" // Decode parameter ${parameter.name}") appendLine(" val `parameter ${parameter.name}` = run {") val transportMode = transportConfiguration.getTransportMode(service.name, operation.name, parameter.name) appendParameterDeserialization(parameter, transportMode, options) @@ -470,11 +438,11 @@ object KotlinKtorGenerator : Generator { transportMode: HttpTransportConfiguration.TransportMode, options: Map, ) { - appendReadJsonElement(parameter, transportMode) - appendLine(" ${deserializeJsonElement(parameter.type, options)}") + appendReadParameterJsonElement(parameter, transportMode) + appendLine(" ${decodeJsonElement(parameter.type, options)}") } - private fun StringBuilder.appendReadJsonElement( + private fun StringBuilder.appendReadParameterJsonElement( parameter: ServiceOperationParameter, transportMode: HttpTransportConfiguration.TransportMode, ) { @@ -500,72 +468,4 @@ object KotlinKtorGenerator : Generator { } appendLine() } - - private fun deserializeJsonElement(typeReference: TypeReference, options: Map): String { - return when (val type = typeReference.type) { - is LiteralType -> when (type) { - is StringType -> "jsonElement.jsonPrimitive.content" - is BytesType -> "jsonElement.jsonPrimitive.content.decodeBase64Bytes()" - is IntType -> "jsonElement.jsonPrimitive.int" - is LongType -> "jsonElement.jsonPrimitive.long" - is FloatType -> "jsonElement.jsonPrimitive.float" - is DoubleType -> "jsonElement.jsonPrimitive.double" - is DecimalType -> "jsonElement.jsonPrimitive.content.let { java.math.BigDecimal(it) }" - is BooleanType -> "jsonElement.jsonPrimitive.boolean" - is DateType -> "jsonElement.jsonPrimitive.content?.let { java.time.LocalDate.parse(it) }" - is DateTimeType -> "jsonElement.jsonPrimitive.content?.let { java.time.LocalDateTime.parse(it) }" - is DurationType -> "jsonElement.jsonPrimitive.content?.let { java.time.Duration.parse(it) }" - else -> error("Unsupported literal type: ${this.javaClass.simpleName}") - } + literalConstraintSuffix(typeReference) - - is ListType -> "jsonElement.jsonArray.map { ${deserializeJsonElement(type.elementType, options)} }" - is MapType -> "jsonElement.jsonObject.mapValues { ${deserializeJsonElement(type.valueType, options)} }" - - is UserType -> "`parse ${type.name}`(jsonElement)" - - else -> error("Unsupported type: ${javaClass.simpleName}") - } - } - - private fun literalConstraintSuffix(typeReference: TypeReference): String { - val conditions = buildList { - typeReference.rangeConstraint?.let { constraint -> - constraint.lowerBound?.let { - add("it >= ${constraint.lowerBound}") - } - constraint.upperBound?.let { - add("it <= ${constraint.upperBound}") - } - } - typeReference.sizeConstraint?.let { constraint -> - val property = if (typeReference.type is StringType) "length" else "size" - constraint.lowerBound?.let { - add("it.${property} >= ${constraint.lowerBound}") - } - constraint.upperBound?.let { - add("it.${property} <= ${constraint.upperBound}") - } - } - typeReference.patternConstraint?.let { constraint -> - add("it.matches(\"${constraint.pattern}\")") - } - typeReference.valueConstraint?.let { constraint -> - add("it == ${constraint.value})") - } - } - - if (conditions.isEmpty()) { - return "" - } - - return ".also { require(${conditions.joinToString(" && ")}) }" - } - - private fun SamtPackage.hasDataTypes(): Boolean { - return records.isNotEmpty() || enums.isNotEmpty() - } - - private fun SamtPackage.hasProviderTypes(): Boolean { - return providers.isNotEmpty() - } } From d078bf47ae3a7bcdff2b2f5bc24e8684ed062dc2 Mon Sep 17 00:00:00 2001 From: Pascal Honegger Date: Mon, 22 May 2023 18:42:33 +0200 Subject: [PATCH 21/41] feat(codegen): dedicated consumer generator --- .../main/kotlin/tools/samt/codegen/Codegen.kt | 19 +- .../ktor/KotlinKtorConsumerGenerator.kt | 194 ++++++++++++++++++ .../ktor/KotlinKtorProviderGenerator.kt | 169 +-------------- 3 files changed, 204 insertions(+), 178 deletions(-) create mode 100644 codegen/src/main/kotlin/tools/samt/codegen/kotlin/ktor/KotlinKtorConsumerGenerator.kt diff --git a/codegen/src/main/kotlin/tools/samt/codegen/Codegen.kt b/codegen/src/main/kotlin/tools/samt/codegen/Codegen.kt index 28747816..d66dcfb5 100644 --- a/codegen/src/main/kotlin/tools/samt/codegen/Codegen.kt +++ b/codegen/src/main/kotlin/tools/samt/codegen/Codegen.kt @@ -2,6 +2,7 @@ package tools.samt.codegen import tools.samt.codegen.http.HttpTransportConfigurationParser import tools.samt.codegen.kotlin.KotlinTypesGenerator +import tools.samt.codegen.kotlin.ktor.KotlinKtorConsumerGenerator import tools.samt.codegen.kotlin.ktor.KotlinKtorProviderGenerator import tools.samt.common.DiagnosticController import tools.samt.common.SamtGeneratorConfiguration @@ -13,6 +14,7 @@ object Codegen { private val generators: List = listOf( KotlinTypesGenerator, KotlinKtorProviderGenerator, + KotlinKtorConsumerGenerator, ) private val transports: List = listOf( @@ -20,12 +22,12 @@ object Codegen { ) internal class SamtGeneratorParams( - rootPackage: Package, + semanticModel: SemanticModel, private val controller: DiagnosticController, override val options: Map, ) : GeneratorParams { private val apiMapper = PublicApiMapper(transports, controller) - override val packages: List = rootPackage.allSubPackages.map { apiMapper.toPublicApi(it) } + override val packages: List = semanticModel.global.allSubPackages.map { apiMapper.toPublicApi(it) } override fun reportError(message: String) { controller.reportGlobalError(message) @@ -41,23 +43,14 @@ object Codegen { } fun generate( - rootPackage: Package, + semanticModel: SemanticModel, configuration: SamtGeneratorConfiguration, controller: DiagnosticController, ): List { - check(rootPackage.isRootPackage) - check(rootPackage.parent == null) - check(rootPackage.records.isEmpty()) - check(rootPackage.enums.isEmpty()) - check(rootPackage.aliases.isEmpty()) - check(rootPackage.services.isEmpty()) - check(rootPackage.providers.isEmpty()) - check(rootPackage.consumers.isEmpty()) - val matchingGenerators = generators.filter { it.name == configuration.name } when (matchingGenerators.size) { 0 -> controller.reportGlobalError("No matching generator found for '${configuration.name}'") - 1 -> return matchingGenerators.single().generate(SamtGeneratorParams(rootPackage, controller, configuration.options)) + 1 -> return matchingGenerators.single().generate(SamtGeneratorParams(semanticModel, controller, configuration.options)) else -> controller.reportGlobalError("Multiple matching generators found for '${configuration.name}'") } return emptyList() diff --git a/codegen/src/main/kotlin/tools/samt/codegen/kotlin/ktor/KotlinKtorConsumerGenerator.kt b/codegen/src/main/kotlin/tools/samt/codegen/kotlin/ktor/KotlinKtorConsumerGenerator.kt new file mode 100644 index 00000000..9b149845 --- /dev/null +++ b/codegen/src/main/kotlin/tools/samt/codegen/kotlin/ktor/KotlinKtorConsumerGenerator.kt @@ -0,0 +1,194 @@ +package tools.samt.codegen.kotlin.ktor + +import tools.samt.codegen.* +import tools.samt.codegen.http.HttpTransportConfiguration +import tools.samt.codegen.kotlin.GeneratedFilePreamble +import tools.samt.codegen.kotlin.KotlinTypesGenerator +import tools.samt.codegen.kotlin.getQualifiedName + +object KotlinKtorConsumerGenerator : Generator { + override val name: String = "kotlin-ktor-consumer" + + override fun generate(generatorParams: GeneratorParams): List { + generatorParams.packages.forEach { + generateMappings(it, generatorParams.options) + generatePackage(it, generatorParams.options) + } + val result = KotlinTypesGenerator.generate(generatorParams) + emittedFiles + emittedFiles.clear() + return result + } + + private val emittedFiles = mutableListOf() + + private fun generateMappings(pack: SamtPackage, options: Map) { + val packageSource = mappingFileContent(pack, options) + if (packageSource.isNotEmpty()) { + val filePath = "${pack.getQualifiedName(options).replace('.', '/')}/KtorMappings.kt" + val file = CodegenFile(filePath, packageSource) + emittedFiles.add(file) + } + } + + private fun generatePackage(pack: SamtPackage, options: Map) { + val relevantConsumers = pack.consumers.filter { it.provider.transport is HttpTransportConfiguration } + if (relevantConsumers.isNotEmpty()) { + // generate ktor consumers + relevantConsumers.forEach { consumer -> + val transportConfiguration = consumer.provider.transport as HttpTransportConfiguration + + val packageSource = buildString { + appendLine(GeneratedFilePreamble) + appendLine() + appendLine("package ${pack.getQualifiedName(options)}") + appendLine() + + appendConsumer(consumer, transportConfiguration, options) + } + + val filePath = "${pack.getQualifiedName(options).replace('.', '/')}/Consumer.kt" + val file = CodegenFile(filePath, packageSource) + emittedFiles.add(file) + } + } + } + + data class ConsumerInfo(val uses: ConsumerUses) { + val reference = uses.service + val service = reference.type as ServiceType + val serviceArgumentName = service.name.replaceFirstChar { it.lowercase() } + } + + private fun StringBuilder.appendConsumer(consumer: ConsumerType, transportConfiguration: HttpTransportConfiguration, options: Map) { + appendLine("import io.ktor.client.*") + appendLine("import io.ktor.client.engine.cio.*") + appendLine("import io.ktor.client.plugins.contentnegotiation.*") + appendLine("import io.ktor.client.request.*") + appendLine("import io.ktor.client.statement.*") + appendLine("import io.ktor.http.*") + appendLine("import io.ktor.serialization.kotlinx.json.*") + appendLine("import io.ktor.util.*") + appendLine("import kotlinx.coroutines.runBlocking") + appendLine("import kotlinx.serialization.json.*") + + val implementedServices = consumer.uses.map { ConsumerInfo(it) } + appendLine("// ${transportConfiguration.exceptionMap}") + appendLine("class ${consumer.provider.name}Impl(private val `consumer baseUrl`: String) : ${implementedServices.joinToString { it.service.getQualifiedName(options) }} {") + implementedServices.forEach { info -> + appendConsumerOperations(info, transportConfiguration, options) + } + appendLine("}") + } + + private fun StringBuilder.appendConsumerOperations(info: ConsumerInfo, transportConfiguration: HttpTransportConfiguration, options: Map) { + appendLine(" private val client = HttpClient(CIO) {") + appendLine(" install(ContentNegotiation) {") + appendLine(" json()") + appendLine(" }") + appendLine(" }") + appendLine() + + val service = info.service + info.uses.operations.forEach { operation -> + val operationParameters = operation.parameters.joinToString { "${it.name}: ${it.type.getQualifiedName(options)}" } + + when (operation) { + is RequestResponseOperation -> { + if (operation.returnType != null) { + appendLine(" override fun ${operation.name}($operationParameters): ${operation.returnType!!.getQualifiedName(options)} {") + } else { + appendLine(" override fun ${operation.name}($operationParameters): Unit {") + } + + // TODO Config: HTTP status code + // TODO serialize response correctly + // TODO validate response + appendLine("return runBlocking {") + + appendConsumerServiceCall(info, operation, transportConfiguration) + appendConsumerResponseParsing(operation, transportConfiguration) + + appendLine("}") + } + + is OnewayOperation -> { + // TODO + } + } + } + } + + private fun StringBuilder.appendConsumerServiceCall(info: ConsumerInfo, operation: ServiceOperation, transport: HttpTransportConfiguration) { + /* + val response = client.request("$baseUrl/todos/$title") { + method = HttpMethod.Post + headers["title"] = title + cookie("description", description) + setBody( + buildJsonObject { + put("title", title) + put("description", description) + } + ) + contentType(ContentType.Application.Json) + } + */ + + // collect parameters for each transport type + val headerParameters = mutableListOf() + val cookieParameters = mutableListOf() + val bodyParameters = mutableListOf() + val pathParameters = mutableListOf() + val queryParameters = mutableListOf() + operation.parameters.forEach { + val name = it.name + val transportMode = transport.getTransportMode(info.service.name, operation.name, name) + when (transportMode) { + HttpTransportConfiguration.TransportMode.Header -> { + headerParameters.add(name) + } + HttpTransportConfiguration.TransportMode.Cookie -> { + cookieParameters.add(name) + } + HttpTransportConfiguration.TransportMode.Body -> { + bodyParameters.add(name) + } + HttpTransportConfiguration.TransportMode.Path -> { + pathParameters.add(name) + } + HttpTransportConfiguration.TransportMode.Query -> { + queryParameters.add(name) + } + } + } + + // build request path + // need to split transport path into path segments and query parameter slots + val pathSegments = mutableListOf() + val queryParameterSlots = mutableListOf() + val transportPath = transport.getPath(info.service.name, operation.name) + val pathParts = transportPath.split("/") + + // build request headers and body + + // oneway vs request-response + } + + private fun StringBuilder.appendConsumerResponseParsing(operation: ServiceOperation, transport: HttpTransportConfiguration) { + /* + val bodyAsText = response.bodyAsText() + val body = Json.parseToJsonElement(bodyAsText) + + val respTitle = body.jsonObject["title"]!!.jsonPrimitive.content + val respDescription = response.headers["description"]!! + check(respTitle.length in 1..100) + + Todo( + title = respTitle, + description = respDescription, + ) + */ + + + } +} diff --git a/codegen/src/main/kotlin/tools/samt/codegen/kotlin/ktor/KotlinKtorProviderGenerator.kt b/codegen/src/main/kotlin/tools/samt/codegen/kotlin/ktor/KotlinKtorProviderGenerator.kt index ff6c105a..46354206 100644 --- a/codegen/src/main/kotlin/tools/samt/codegen/kotlin/ktor/KotlinKtorProviderGenerator.kt +++ b/codegen/src/main/kotlin/tools/samt/codegen/kotlin/ktor/KotlinKtorProviderGenerator.kt @@ -42,7 +42,7 @@ object KotlinKtorProviderGenerator : Generator { // generate ktor providers relevantProviders.forEach { provider -> val transportConfiguration = provider.transport - check (transportConfiguration is HttpTransportConfiguration) + check(transportConfiguration is HttpTransportConfiguration) val packageSource = buildString { appendLine(GeneratedFilePreamble) @@ -57,27 +57,6 @@ object KotlinKtorProviderGenerator : Generator { val file = CodegenFile(filePath, packageSource) emittedFiles.add(file) } - - // generate ktor consumers - pack.consumers.forEach { consumer -> - val provider = consumer.provider.type as ProviderType - val transportConfiguration = provider.transport - if (transportConfiguration !is HttpTransportConfiguration) { - // Skip consumers that are not HTTP - return@forEach - } - - val packageSource = buildString { - appendLine("package ${pack.qualifiedName}") - appendLine() - - appendConsumer(consumer, transportConfiguration, options) - } - - val filePath = pack.qualifiedName.replace('.', '/') + "Consumer.kt" - val file = CodegenFile(filePath, packageSource) - emittedFiles.add(file) - } } } @@ -142,11 +121,12 @@ object KotlinKtorProviderGenerator : Generator { ) { appendLine("import io.ktor.http.*") appendLine("import io.ktor.serialization.kotlinx.json.*") - appendLine("import io.ktor.server.plugins.contentnegotiation.*") - appendLine("import io.ktor.server.response.*") appendLine("import io.ktor.server.application.*") + appendLine("import io.ktor.server.plugins.contentnegotiation.*") appendLine("import io.ktor.server.request.*") + appendLine("import io.ktor.server.response.*") appendLine("import io.ktor.server.routing.*") + appendLine("import io.ktor.util.*") appendLine("import kotlinx.serialization.json.*") appendLine() @@ -257,147 +237,6 @@ object KotlinKtorProviderGenerator : Generator { appendLine() } - data class ConsumerInfo(val uses: ConsumerUses) { - val reference = uses.service - val service = reference.type as ServiceType - val serviceArgumentName = service.name.replaceFirstChar { it.lowercase() } - } - - private fun StringBuilder.appendConsumer(consumer: ConsumerType, transportConfiguration: HttpTransportConfiguration, options: Map) { - appendLine("import io.ktor.client.*") - appendLine("import io.ktor.client.engine.cio.*") - appendLine("import io.ktor.client.plugins.contentnegotiation.*") - appendLine("import io.ktor.client.request.*") - appendLine("import io.ktor.client.statement.*") - appendLine("import io.ktor.http.*") - appendLine("import io.ktor.serialization.kotlinx.json.*") - appendLine("import kotlinx.coroutines.runBlocking") - appendLine("import kotlinx.serialization.json.*") - - val implementedServices = consumer.uses.map { ConsumerInfo(it) } - val serviceArguments = implementedServices.joinToString { info -> - "${info.serviceArgumentName}: ${info.reference.getQualifiedName(options)}" - } - appendLine("// ${transportConfiguration.exceptionMap}") - appendLine("class ${consumer.name}() {") - implementedServices.forEach { info -> - appendConsumerOperations(info, transportConfiguration, options) - } - appendLine("}") - } - - private fun StringBuilder.appendConsumerOperations(info: ConsumerInfo, transportConfiguration: HttpTransportConfiguration, options: Map) { - appendLine(" private val client = HttpClient(CIO) {") - appendLine(" install(ContentNegotiation) {") - appendLine(" json()") - appendLine(" }") - appendLine(" }") - appendLine() - - val service = info.service - info.uses.operations.forEach { operation -> - val operationParameters = operation.parameters.joinToString { "${it.name}: ${it.type.getQualifiedName(options)}" } - - when (operation) { - is RequestResponseOperation -> { - if (operation.returnType != null) { - appendLine(" override fun ${operation.name}($operationParameters): ${operation.returnType!!.getQualifiedName(options)} {") - } else { - appendLine(" override fun ${operation.name}($operationParameters): Unit {") - } - - // TODO Config: HTTP status code - // TODO serialize response correctly - // TODO validate response - appendLine("return runBlocking {") - - appendConsumerServiceCall(info, operation, transportConfiguration) - appendConsumerResponseParsing(operation, transportConfiguration) - - appendLine("}") - } - - is OnewayOperation -> { - // TODO - } - } - } - } - - private fun StringBuilder.appendConsumerServiceCall(info: ConsumerInfo, operation: ServiceOperation, transport: HttpTransportConfiguration) { - /* - val response = client.request("$baseUrl/todos/$title") { - method = HttpMethod.Post - headers["title"] = title - cookie("description", description) - setBody( - buildJsonObject { - put("title", title) - put("description", description) - } - ) - contentType(ContentType.Application.Json) - } - */ - - // collect parameters for each transport type - val headerParameters = mutableListOf() - val cookieParameters = mutableListOf() - val bodyParameters = mutableListOf() - val pathParameters = mutableListOf() - val queryParameters = mutableListOf() - operation.parameters.forEach { - val name = it.name - val transportMode = transport.getTransportMode(info.service.name, operation.name, name) - when (transportMode) { - HttpTransportConfiguration.TransportMode.Header -> { - headerParameters.add(name) - } - HttpTransportConfiguration.TransportMode.Cookie -> { - cookieParameters.add(name) - } - HttpTransportConfiguration.TransportMode.Body -> { - bodyParameters.add(name) - } - HttpTransportConfiguration.TransportMode.Path -> { - pathParameters.add(name) - } - HttpTransportConfiguration.TransportMode.Query -> { - queryParameters.add(name) - } - } - } - - // build request path - // need to split transport path into path segments and query parameter slots - val pathSegments = mutableListOf() - val queryParameterSlots = mutableListOf() - val transportPath = transport.getPath(info.service.name, operation.name) - val pathParts = transportPath.split("/") - - // build request headers and body - - // oneway vs request-response - } - - private fun StringBuilder.appendConsumerResponseParsing(operation: ServiceOperation, transport: HttpTransportConfiguration) { - /* - val bodyAsText = response.bodyAsText() - val body = Json.parseToJsonElement(bodyAsText) - - val respTitle = body.jsonObject["title"]!!.jsonPrimitive.content - val respDescription = response.headers["description"]!! - check(respTitle.length in 1..100) - - Todo( - title = respTitle, - description = respDescription, - ) - */ - - - } - private fun getKtorRoute( service: ServiceType, operation: ServiceOperation, From a7b0ae8a71e819f8a66ad9f5f681e61787c89695 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leonard=20Schu=CC=88tz?= Date: Wed, 24 May 2023 01:14:10 +0200 Subject: [PATCH 22/41] feat(codegen): Consumer request encoding --- .../ktor/KotlinKtorConsumerGenerator.kt | 131 ++++++++++++------ specification/examples/samt.yaml | 4 +- 2 files changed, 95 insertions(+), 40 deletions(-) diff --git a/codegen/src/main/kotlin/tools/samt/codegen/kotlin/ktor/KotlinKtorConsumerGenerator.kt b/codegen/src/main/kotlin/tools/samt/codegen/kotlin/ktor/KotlinKtorConsumerGenerator.kt index 9b149845..07e2bbef 100644 --- a/codegen/src/main/kotlin/tools/samt/codegen/kotlin/ktor/KotlinKtorConsumerGenerator.kt +++ b/codegen/src/main/kotlin/tools/samt/codegen/kotlin/ktor/KotlinKtorConsumerGenerator.kt @@ -94,21 +94,35 @@ object KotlinKtorConsumerGenerator : Generator { when (operation) { is RequestResponseOperation -> { - if (operation.returnType != null) { - appendLine(" override fun ${operation.name}($operationParameters): ${operation.returnType!!.getQualifiedName(options)} {") + if (operation.isAsync) { + if (operation.returnType != null) { + appendLine(" override suspend fun ${operation.name}($operationParameters): ${operation.returnType!!.getQualifiedName(options)} {") + } else { + appendLine(" override suspend fun ${operation.name}($operationParameters): Unit {") + } } else { - appendLine(" override fun ${operation.name}($operationParameters): Unit {") + if (operation.returnType != null) { + appendLine(" override fun ${operation.name}($operationParameters): ${operation.returnType!!.getQualifiedName(options)} {") + } else { + appendLine(" override fun ${operation.name}($operationParameters): Unit {") + } } // TODO Config: HTTP status code - // TODO serialize response correctly + // TODO validate call parameters // TODO validate response - appendLine("return runBlocking {") + // TODO serialize response correctly + if (operation.isAsync) { + appendLine(" return runBlocking {") + } else { + appendLine(" return run {") + } appendConsumerServiceCall(info, operation, transportConfiguration) appendConsumerResponseParsing(operation, transportConfiguration) - appendLine("}") + appendLine(" }") + appendLine(" }") } is OnewayOperation -> { @@ -119,57 +133,98 @@ object KotlinKtorConsumerGenerator : Generator { } private fun StringBuilder.appendConsumerServiceCall(info: ConsumerInfo, operation: ServiceOperation, transport: HttpTransportConfiguration) { - /* - val response = client.request("$baseUrl/todos/$title") { - method = HttpMethod.Post - headers["title"] = title - cookie("description", description) - setBody( - buildJsonObject { - put("title", title) - put("description", description) - } - ) - contentType(ContentType.Application.Json) - } - */ - // collect parameters for each transport type - val headerParameters = mutableListOf() - val cookieParameters = mutableListOf() - val bodyParameters = mutableListOf() - val pathParameters = mutableListOf() - val queryParameters = mutableListOf() + val headerParameters = mutableMapOf() + val cookieParameters = mutableMapOf() + val bodyParameters = mutableMapOf() + val pathParameters = mutableMapOf() + val queryParameters = mutableMapOf() operation.parameters.forEach { val name = it.name - val transportMode = transport.getTransportMode(info.service.name, operation.name, name) - when (transportMode) { + when (transport.getTransportMode(info.service.name, operation.name, name)) { HttpTransportConfiguration.TransportMode.Header -> { - headerParameters.add(name) + headerParameters[name] = it } HttpTransportConfiguration.TransportMode.Cookie -> { - cookieParameters.add(name) + cookieParameters[name] = it } HttpTransportConfiguration.TransportMode.Body -> { - bodyParameters.add(name) + bodyParameters[name] = it } HttpTransportConfiguration.TransportMode.Path -> { - pathParameters.add(name) + pathParameters[name] = it } HttpTransportConfiguration.TransportMode.Query -> { - queryParameters.add(name) + queryParameters[name] = it } } } + /* + val response = client.request("$baseUrl/todos/$title") { + method = HttpMethod.Post + headers["title"] = title + cookie("description", description) + setBody( + buildJsonObject { + put("title", title) + put("description", description) + } + ) + contentType(ContentType.Application.Json) + } + */ + + // build request headers and body + appendLine(" val `consumer response` = client.request(`consumer baseUrl`) {") + // build request path // need to split transport path into path segments and query parameter slots - val pathSegments = mutableListOf() - val queryParameterSlots = mutableListOf() + // remove first empty component (paths start with a / so the first component is always empty) val transportPath = transport.getPath(info.service.name, operation.name) - val pathParts = transportPath.split("/") + val transportPathComponents = transportPath.split("/") + appendLine(" url {") + transportPathComponents.drop(1).map { + if (it.startsWith("{") && it.endsWith("}")) { + val parameterName = it.substring(1, it.length - 1) + require(pathParameters.contains(parameterName)) { "${operation.name}: path parameter $parameterName is not a known path parameter" } + appendLine(" appendPathSegments($parameterName)") + } else { + appendLine(" appendPathSegments(\"$it\")") + } + } + appendLine(" }") - // build request headers and body + // serialization mode + when (val serializationMode = transport.serializationMode) { + HttpTransportConfiguration.SerializationMode.Json -> appendLine(" contentType(ContentType.Application.Json)") + else -> error("unsupported serialization mode: $serializationMode") + } + + // transport method + val transportMethod = transport.getMethod(info.service.name, operation.name) + appendLine(" method = HttpMethod.$transportMethod") + + // header parameters + headerParameters.forEach { + appendLine(" headers[\"$it\"] = $it") + } + + // cookie parameters + cookieParameters.forEach { + appendLine(" cookie(\"$it\", $it)") + } + + // body parameters + appendLine(" setBody(") + appendLine(" buildJsonObject {") + bodyParameters.forEach { (name, parameter) -> + appendLine(" put(\"$name\", \"placeholder hello world\"") + } + appendLine(" }") + appendLine(" )") + + appendLine(" }") // oneway vs request-response } @@ -188,7 +243,5 @@ object KotlinKtorConsumerGenerator : Generator { description = respDescription, ) */ - - } } diff --git a/specification/examples/samt.yaml b/specification/examples/samt.yaml index 314bb2a8..109ac346 100644 --- a/specification/examples/samt.yaml +++ b/specification/examples/samt.yaml @@ -1,5 +1,7 @@ source: ./todo-service generators: - - name: kotlin-ktor + - name: kotlin-types + output: ./out + - name: kotlin-ktor-consumer output: ./out From 8947dabbec52131fb3b35447f64e09657c7e4b09 Mon Sep 17 00:00:00 2001 From: Pascal Honegger Date: Sat, 27 May 2023 13:29:42 +0200 Subject: [PATCH 23/41] feat(semantic): support async operations --- .../samt/semantic/SemanticModelPreProcessor.kt | 8 -------- .../tools/samt/semantic/SemanticModelTest.kt | 18 ++---------------- 2 files changed, 2 insertions(+), 24 deletions(-) diff --git a/semantic/src/main/kotlin/tools/samt/semantic/SemanticModelPreProcessor.kt b/semantic/src/main/kotlin/tools/samt/semantic/SemanticModelPreProcessor.kt index c2a6cb45..320b4ccf 100644 --- a/semantic/src/main/kotlin/tools/samt/semantic/SemanticModelPreProcessor.kt +++ b/semantic/src/main/kotlin/tools/samt/semantic/SemanticModelPreProcessor.kt @@ -107,14 +107,6 @@ internal class SemanticModelPreProcessor(private val controller: DiagnosticContr } is RequestResponseOperationNode -> { -/* - if (operation.isAsync) { - controller.getOrCreateContext(operation.location.source).error { - message("Async operations are not yet supported") - highlight("unsupported async operation", operation.location) - } - } -*/ ServiceType.RequestResponseOperation( name = operation.name.name, parameters = parameters, diff --git a/semantic/src/test/kotlin/tools/samt/semantic/SemanticModelTest.kt b/semantic/src/test/kotlin/tools/samt/semantic/SemanticModelTest.kt index c0a4e012..dced9482 100644 --- a/semantic/src/test/kotlin/tools/samt/semantic/SemanticModelTest.kt +++ b/semantic/src/test/kotlin/tools/samt/semantic/SemanticModelTest.kt @@ -644,20 +644,6 @@ class SemanticModelTest { source to listOf("Error: Record extends are not yet supported") ) } - - @Test - fun `cannot use async operations`() { - val source = """ - package color - - service ColorService { - async get(): Int - } - """.trimIndent() - parseAndCheck( - source to listOf("Error: Async operations are not yet supported") - ) - } } @Nested @@ -821,7 +807,7 @@ class SemanticModelTest { package services service FooService { - foo() + oneway foo() } """.trimIndent() parseAndCheck( @@ -901,7 +887,7 @@ class SemanticModelTest { @Deprecated("service deprecation") service UserService { @Deprecated("operation deprecation") - get(@Deprecated("parameter deprecation") id: Id): User + async get(@Deprecated("parameter deprecation") id: Id): User } """.trimIndent() val model = parseAndCheck( From a3b3d21925008b87f4af367f037339ee8cedba1e Mon Sep 17 00:00:00 2001 From: Pascal Honegger Date: Sat, 27 May 2023 15:17:43 +0200 Subject: [PATCH 24/41] refactor(codegen): abstraction layer around config parsing --- .../main/kotlin/tools/samt/codegen/Mapping.kt | 199 ----------- .../kotlin/tools/samt/codegen/PublicApi.kt | 63 +++- .../tools/samt/codegen/PublicApiMapper.kt | 217 ++++++++++++ .../codegen/TransportConfigurationMapper.kt | 177 ++++++++++ .../tools/samt/codegen/http/HttpTransport.kt | 322 ++++++------------ .../ktor/KotlinKtorConsumerGenerator.kt | 5 +- .../ktor/KotlinKtorProviderGenerator.kt | 8 +- 7 files changed, 552 insertions(+), 439 deletions(-) delete mode 100644 codegen/src/main/kotlin/tools/samt/codegen/Mapping.kt create mode 100644 codegen/src/main/kotlin/tools/samt/codegen/PublicApiMapper.kt create mode 100644 codegen/src/main/kotlin/tools/samt/codegen/TransportConfigurationMapper.kt diff --git a/codegen/src/main/kotlin/tools/samt/codegen/Mapping.kt b/codegen/src/main/kotlin/tools/samt/codegen/Mapping.kt deleted file mode 100644 index afcf2d22..00000000 --- a/codegen/src/main/kotlin/tools/samt/codegen/Mapping.kt +++ /dev/null @@ -1,199 +0,0 @@ -package tools.samt.codegen - -import tools.samt.codegen.http.HttpTransportConfigurationParser -import tools.samt.common.DiagnosticController - -class PublicApiMapper( - private val transportParsers: List, - private val controller: DiagnosticController, -) { - fun toPublicApi(samtPackage: tools.samt.semantic.Package) = object : SamtPackage { - override val name = samtPackage.name - override val qualifiedName = samtPackage.nameComponents.joinToString(".") - override val records = samtPackage.records.map { it.toPublicApi() } - override val enums = samtPackage.enums.map { it.toPublicApi() } - override val services = samtPackage.services.map { it.toPublicApi() } - override val providers = samtPackage.providers.map { it.toPublicApi() } - override val consumers = samtPackage.consumers.map { it.toPublicApi() } - override val aliases = samtPackage.aliases.map { it.toPublicApi() } - } - - private fun tools.samt.semantic.RecordType.toPublicApi() = object : RecordType { - override val name = this@toPublicApi.name - override val qualifiedName = this@toPublicApi.getQualifiedName() - override val fields = this@toPublicApi.fields.map { it.toPublicApi() } - } - - private fun tools.samt.semantic.RecordType.Field.toPublicApi() = object : RecordField { - override val name = this@toPublicApi.name - override val type = this@toPublicApi.type.toPublicApi() - } - - private fun tools.samt.semantic.EnumType.toPublicApi() = object : EnumType { - override val name = this@toPublicApi.name - override val qualifiedName = this@toPublicApi.getQualifiedName() - override val values = this@toPublicApi.values - } - - private fun tools.samt.semantic.ServiceType.toPublicApi() = object : ServiceType { - override val name = this@toPublicApi.name - override val qualifiedName = this@toPublicApi.getQualifiedName() - override val operations = this@toPublicApi.operations.map { it.toPublicApi() } - } - - private fun tools.samt.semantic.ServiceType.Operation.toPublicApi() = when (this) { - is tools.samt.semantic.ServiceType.OnewayOperation -> toPublicApi() - is tools.samt.semantic.ServiceType.RequestResponseOperation -> toPublicApi() - } - - private fun tools.samt.semantic.ServiceType.OnewayOperation.toPublicApi() = object : OnewayOperation { - override val name = this@toPublicApi.name - override val parameters = this@toPublicApi.parameters.map { it.toPublicApi() } - } - - private fun tools.samt.semantic.ServiceType.RequestResponseOperation.toPublicApi() = - object : RequestResponseOperation { - override val name = this@toPublicApi.name - override val parameters = this@toPublicApi.parameters.map { it.toPublicApi() } - override val returnType = this@toPublicApi.returnType?.toPublicApi() - override val raisesTypes = this@toPublicApi.raisesTypes.map { it.toPublicApi() } - override val isAsync = this@toPublicApi.isAsync - } - - private fun tools.samt.semantic.ServiceType.Operation.Parameter.toPublicApi() = object : ServiceOperationParameter { - override val name = this@toPublicApi.name - override val type = this@toPublicApi.type.toPublicApi() - } - - private fun tools.samt.semantic.ProviderType.toPublicApi() = object : ProviderType { - override val name = this@toPublicApi.name - override val qualifiedName = this@toPublicApi.getQualifiedName() - override val implements = this@toPublicApi.implements.map { it.toPublicApi() } - override val transport = this@toPublicApi.transport.toPublicApi() - } - - private fun tools.samt.semantic.ProviderType.Transport.toPublicApi(): TransportConfiguration { - val transportConfigNode = configuration - val transportConfigurationParser = transportParsers.filter { it.transportName == name } - when (transportConfigurationParser.size) { - 0 -> controller.reportGlobalWarning("No transport configuration parser found for transport '$name'") - 1 -> { - if (transportConfigNode != null) { - val config = HttpTransportConfigurationParser.Params(transportConfigNode, controller) - val transportConfig = transportConfigurationParser.single().parse(config) - if (transportConfig != null) { - return transportConfig - } else { - controller.reportGlobalError("Failed to parse transport configuration for transport '$name'") - } - } else { - return transportConfigurationParser.single().default() - } - } - else -> controller.reportGlobalError("Multiple transport configuration parsers found for transport '$name'") - } - - return object : TransportConfiguration {} - } - - private fun tools.samt.semantic.ProviderType.Implements.toPublicApi() = object : ProviderImplements { - override val service = this@toPublicApi.service.toPublicApi() - override val operations = this@toPublicApi.operations.map { it.toPublicApi() } - } - - private fun tools.samt.semantic.ConsumerType.toPublicApi() = object : ConsumerType { - override val provider = this@toPublicApi.provider.toPublicApi().type as ProviderType - override val uses = this@toPublicApi.uses.map { it.toPublicApi() } - override val targetPackage = this@toPublicApi.parentPackage.nameComponents.joinToString(".") - } - - private fun tools.samt.semantic.ConsumerType.Uses.toPublicApi() = object : ConsumerUses { - override val service = this@toPublicApi.service.toPublicApi() - override val operations = this@toPublicApi.operations.map { it.toPublicApi() } - } - - private fun tools.samt.semantic.AliasType.toPublicApi() = object : AliasType { - override val name = this@toPublicApi.name - override val qualifiedName = this@toPublicApi.getQualifiedName() - override val aliasedType = this@toPublicApi.aliasedType.toPublicApi() - override val fullyResolvedType = this@toPublicApi.fullyResolvedType.toPublicApi() - } - - private inline fun List.findConstraint() = - firstOrNull { it is T } as T? - - private fun tools.samt.semantic.TypeReference?.toPublicApi(): TypeReference { - check(this is tools.samt.semantic.ResolvedTypeReference) - return object : TypeReference { - override val type = this@toPublicApi.type.toPublicApi() - override val isOptional = this@toPublicApi.isOptional - override val rangeConstraint = - this@toPublicApi.constraints.findConstraint() - ?.toPublicApi() - override val sizeConstraint = - this@toPublicApi.constraints.findConstraint() - ?.toPublicApi() - override val patternConstraint = - this@toPublicApi.constraints.findConstraint() - ?.toPublicApi() - override val valueConstraint = - this@toPublicApi.constraints.findConstraint() - ?.toPublicApi() - } - } - - private fun tools.samt.semantic.Type.toPublicApi() = when (this) { - tools.samt.semantic.IntType -> object : IntType {} - tools.samt.semantic.LongType -> object : LongType {} - tools.samt.semantic.FloatType -> object : FloatType {} - tools.samt.semantic.DoubleType -> object : DoubleType {} - tools.samt.semantic.DecimalType -> object : DecimalType {} - tools.samt.semantic.BooleanType -> object : BooleanType {} - tools.samt.semantic.StringType -> object : StringType {} - tools.samt.semantic.BytesType -> object : BytesType {} - tools.samt.semantic.DateType -> object : DateType {} - tools.samt.semantic.DateTimeType -> object : DateTimeType {} - tools.samt.semantic.DurationType -> object : DurationType {} - is tools.samt.semantic.ListType -> object : ListType { - override val elementType = this@toPublicApi.elementType.toPublicApi() - } - - is tools.samt.semantic.MapType -> object : MapType { - override val keyType = this@toPublicApi.keyType.toPublicApi() - override val valueType = this@toPublicApi.valueType.toPublicApi() - } - - is tools.samt.semantic.AliasType -> toPublicApi() - is tools.samt.semantic.ConsumerType -> toPublicApi() - is tools.samt.semantic.EnumType -> toPublicApi() - is tools.samt.semantic.ProviderType -> toPublicApi() - is tools.samt.semantic.RecordType -> toPublicApi() - is tools.samt.semantic.ServiceType -> toPublicApi() - is tools.samt.semantic.PackageType -> error("Package type cannot be converted to public API") - tools.samt.semantic.UnknownType -> error("Unknown type cannot be converted to public API") - } - - private fun tools.samt.semantic.ResolvedTypeReference.Constraint.Range.toPublicApi() = object : Constraint.Range { - override val lowerBound = this@toPublicApi.lowerBound - override val upperBound = this@toPublicApi.upperBound - } - - private fun tools.samt.semantic.ResolvedTypeReference.Constraint.Size.toPublicApi() = object : Constraint.Size { - override val lowerBound = this@toPublicApi.lowerBound - override val upperBound = this@toPublicApi.upperBound - } - - private fun tools.samt.semantic.ResolvedTypeReference.Constraint.Pattern.toPublicApi() = - object : Constraint.Pattern { - override val pattern = this@toPublicApi.pattern - } - - private fun tools.samt.semantic.ResolvedTypeReference.Constraint.Value.toPublicApi() = object : Constraint.Value { - override val value = this@toPublicApi.value - } - - private fun tools.samt.semantic.UserDeclaredNamedType.getQualifiedName(): String { - val components = parentPackage.nameComponents + name - return components.joinToString(".") - } -} diff --git a/codegen/src/main/kotlin/tools/samt/codegen/PublicApi.kt b/codegen/src/main/kotlin/tools/samt/codegen/PublicApi.kt index 7b3ae475..63a7ec85 100644 --- a/codegen/src/main/kotlin/tools/samt/codegen/PublicApi.kt +++ b/codegen/src/main/kotlin/tools/samt/codegen/PublicApi.kt @@ -1,7 +1,5 @@ package tools.samt.codegen -import tools.samt.parser.ObjectNode - interface GeneratorParams { val packages: List val options: Map @@ -28,17 +26,56 @@ interface Generator { } interface TransportConfigurationParserParams { - val configObjectNode: ObjectNode + val config: ConfigurationObject - fun reportError(message: String) - fun reportWarning(message: String) - fun reportInfo(message: String) + fun reportError(message: String, context: ConfigurationElement? = null) + fun reportWarning(message: String, context: ConfigurationElement? = null) + fun reportInfo(message: String, context: ConfigurationElement? = null) +} + +interface ConfigurationElement { + val asObject: ConfigurationObject + val asValue: ConfigurationValue + val asList: ConfigurationList +} + +interface ConfigurationObject : ConfigurationElement { + val fields: Map + fun getField(name: String): ConfigurationElement + fun getFieldOrNull(name: String): ConfigurationElement? } +interface ConfigurationList : ConfigurationElement { + val entries: List +} + +interface ConfigurationValue : ConfigurationElement { + val asString: String + fun > asEnum(enum: Class): T + val asLong: Long + val asDouble: Double + val asBoolean: Boolean + val asServiceName: ServiceType + fun asOperationName(service: ServiceType): ServiceOperation +} + +inline fun > ConfigurationValue.asEnum() = asEnum(T::class.java) + interface TransportConfigurationParser { val transportName: String + + /** + * Create the default configuration for this transport, used when no configuration body is specified + * @return Default configuration + */ fun default(): TransportConfiguration - fun parse(params: TransportConfigurationParserParams): TransportConfiguration? + + /** + * Parses the configuration body and returns the configuration object + * @throws RuntimeException if the configuration is invalid and graceful error handling is not possible + * @return Parsed configuration + */ + fun parse(params: TransportConfigurationParserParams): TransportConfiguration } interface TransportConfiguration @@ -74,10 +111,14 @@ interface UserType : Type { } interface AliasType : UserType { - /** The type this alias stands for, could be another alias */ + /** + * The type this alias stands for, could be another alias + */ val aliasedType: TypeReference - /** The fully resolved type, will not contain any type aliases anymore, just the underlying merged type */ + /** + * The fully resolved type, will not contain any type aliases anymore, just the underlying merged type + */ val fullyResolvedType: TypeReference } @@ -121,7 +162,7 @@ interface ProviderType : UserType { } interface ProviderImplements { - val service: TypeReference + val service: ServiceType val operations: List } @@ -132,7 +173,7 @@ interface ConsumerType : Type { } interface ConsumerUses { - val service: TypeReference + val service: ServiceType val operations: List } diff --git a/codegen/src/main/kotlin/tools/samt/codegen/PublicApiMapper.kt b/codegen/src/main/kotlin/tools/samt/codegen/PublicApiMapper.kt new file mode 100644 index 00000000..f865808c --- /dev/null +++ b/codegen/src/main/kotlin/tools/samt/codegen/PublicApiMapper.kt @@ -0,0 +1,217 @@ +package tools.samt.codegen + +import tools.samt.common.DiagnosticController + +class PublicApiMapper( + private val transportParsers: List, + private val controller: DiagnosticController, +) { + fun toPublicApi(samtPackage: tools.samt.semantic.Package) = object : SamtPackage { + override val name = samtPackage.name + override val qualifiedName = samtPackage.nameComponents.joinToString(".") + override val records = samtPackage.records.map { it.toPublicRecord() } + override val enums = samtPackage.enums.map { it.toPublicEnum() } + override val services = samtPackage.services.map { it.toPublicService() } + override val providers = samtPackage.providers.map { it.toPublicProvider() } + override val consumers = samtPackage.consumers.map { it.toPublicConsumer() } + override val aliases = samtPackage.aliases.map { it.toPublicAlias() } + } + + private fun tools.samt.semantic.RecordType.toPublicRecord() = object : RecordType { + override val name = this@toPublicRecord.name + override val qualifiedName = this@toPublicRecord.getQualifiedName() + override val fields = this@toPublicRecord.fields.map { it.toPublicField() } + } + + private fun tools.samt.semantic.RecordType.Field.toPublicField() = object : RecordField { + override val name = this@toPublicField.name + override val type = this@toPublicField.type.toPublicTypeReference() + } + + private fun tools.samt.semantic.EnumType.toPublicEnum() = object : EnumType { + override val name = this@toPublicEnum.name + override val qualifiedName = this@toPublicEnum.getQualifiedName() + override val values = this@toPublicEnum.values + } + + private fun tools.samt.semantic.ServiceType.toPublicService() = object : ServiceType { + override val name = this@toPublicService.name + override val qualifiedName = this@toPublicService.getQualifiedName() + override val operations = this@toPublicService.operations.map { it.toPublicOperation() } + } + + private fun tools.samt.semantic.ServiceType.Operation.toPublicOperation() = when (this) { + is tools.samt.semantic.ServiceType.OnewayOperation -> toPublicOnewayOperation() + is tools.samt.semantic.ServiceType.RequestResponseOperation -> toPublicRequestResponseOperation() + } + + private fun tools.samt.semantic.ServiceType.OnewayOperation.toPublicOnewayOperation() = object : OnewayOperation { + override val name = this@toPublicOnewayOperation.name + override val parameters = this@toPublicOnewayOperation.parameters.map { it.toPublicParameter() } + } + + private fun tools.samt.semantic.ServiceType.RequestResponseOperation.toPublicRequestResponseOperation() = + object : RequestResponseOperation { + override val name = this@toPublicRequestResponseOperation.name + override val parameters = this@toPublicRequestResponseOperation.parameters.map { it.toPublicParameter() } + override val returnType = this@toPublicRequestResponseOperation.returnType?.toPublicTypeReference() + override val raisesTypes = this@toPublicRequestResponseOperation.raisesTypes.map { it.toPublicTypeReference() } + override val isAsync = this@toPublicRequestResponseOperation.isAsync + } + + private fun tools.samt.semantic.ServiceType.Operation.Parameter.toPublicParameter() = object : ServiceOperationParameter { + override val name = this@toPublicParameter.name + override val type = this@toPublicParameter.type.toPublicTypeReference() + } + + private fun tools.samt.semantic.ProviderType.toPublicProvider() = object : ProviderType { + override val name = this@toPublicProvider.name + override val qualifiedName = this@toPublicProvider.getQualifiedName() + override val implements = this@toPublicProvider.implements.map { it.toPublicImplements() } + override val transport = this@toPublicProvider.transport.toPublicTransport(this) + } + + private class Params( + override val config: ConfigurationObject, + val controller: DiagnosticController + ) : TransportConfigurationParserParams { + + // TODO use context if provided + override fun reportError(message: String, context: ConfigurationElement?) { + controller.reportGlobalError(message) + } + + override fun reportWarning(message: String, context: ConfigurationElement?) { + controller.reportGlobalWarning(message) + } + + override fun reportInfo(message: String, context: ConfigurationElement?) { + controller.reportGlobalInfo(message) + } + } + + private fun tools.samt.semantic.ProviderType.Transport.toPublicTransport(provider: ProviderType): TransportConfiguration { + val transportConfigurationParsers = transportParsers.filter { it.transportName == name } + when (transportConfigurationParsers.size) { + 0 -> controller.reportGlobalWarning("No transport configuration parser found for transport '$name'") + 1 -> { + val transportConfigurationParser = transportConfigurationParsers.single() + if (configuration != null) { + val transportConfigNode = TransportConfigurationMapper(provider, controller).parse(configuration!!) + val config = Params(transportConfigNode, controller) + try { + return transportConfigurationParser.parse(config) + } catch (e: Exception) { + controller.reportGlobalError("Failed to parse transport configuration for transport '$name': ${e.message}") + } + } else { + return transportConfigurationParser.default() + } + } + else -> controller.reportGlobalError("Multiple transport configuration parsers found for transport '$name'") + } + + return object : TransportConfiguration {} + } + + private fun tools.samt.semantic.ProviderType.Implements.toPublicImplements() = object : ProviderImplements { + override val service = this@toPublicImplements.service.toPublicTypeReference().type as ServiceType + override val operations = this@toPublicImplements.operations.map { it.toPublicOperation() } + } + + private fun tools.samt.semantic.ConsumerType.toPublicConsumer() = object : ConsumerType { + override val provider = this@toPublicConsumer.provider.toPublicTypeReference().type as ProviderType + override val uses = this@toPublicConsumer.uses.map { it.toPublicUses() } + override val targetPackage = this@toPublicConsumer.parentPackage.nameComponents.joinToString(".") + } + + private fun tools.samt.semantic.ConsumerType.Uses.toPublicUses() = object : ConsumerUses { + override val service = this@toPublicUses.service.toPublicTypeReference().type as ServiceType + override val operations = this@toPublicUses.operations.map { it.toPublicOperation() } + } + + private fun tools.samt.semantic.AliasType.toPublicAlias() = object : AliasType { + override val name = this@toPublicAlias.name + override val qualifiedName = this@toPublicAlias.getQualifiedName() + override val aliasedType = this@toPublicAlias.aliasedType.toPublicTypeReference() + override val fullyResolvedType = this@toPublicAlias.fullyResolvedType.toPublicTypeReference() + } + + private inline fun List.findConstraint() = + firstOrNull { it is T } as T? + + private fun tools.samt.semantic.TypeReference?.toPublicTypeReference(): TypeReference { + check(this is tools.samt.semantic.ResolvedTypeReference) + return object : TypeReference { + override val type = this@toPublicTypeReference.type.toPublicType() + override val isOptional = this@toPublicTypeReference.isOptional + override val rangeConstraint = + this@toPublicTypeReference.constraints.findConstraint() + ?.toPublicRangeConstraint() + override val sizeConstraint = + this@toPublicTypeReference.constraints.findConstraint() + ?.toPublicSizeConstraint() + override val patternConstraint = + this@toPublicTypeReference.constraints.findConstraint() + ?.toPublicPatternConstraint() + override val valueConstraint = + this@toPublicTypeReference.constraints.findConstraint() + ?.toPublicValueConstraint() + } + } + + private fun tools.samt.semantic.Type.toPublicType() = when (this) { + tools.samt.semantic.IntType -> object : IntType {} + tools.samt.semantic.LongType -> object : LongType {} + tools.samt.semantic.FloatType -> object : FloatType {} + tools.samt.semantic.DoubleType -> object : DoubleType {} + tools.samt.semantic.DecimalType -> object : DecimalType {} + tools.samt.semantic.BooleanType -> object : BooleanType {} + tools.samt.semantic.StringType -> object : StringType {} + tools.samt.semantic.BytesType -> object : BytesType {} + tools.samt.semantic.DateType -> object : DateType {} + tools.samt.semantic.DateTimeType -> object : DateTimeType {} + tools.samt.semantic.DurationType -> object : DurationType {} + is tools.samt.semantic.ListType -> object : ListType { + override val elementType = this@toPublicType.elementType.toPublicTypeReference() + } + + is tools.samt.semantic.MapType -> object : MapType { + override val keyType = this@toPublicType.keyType.toPublicTypeReference() + override val valueType = this@toPublicType.valueType.toPublicTypeReference() + } + + is tools.samt.semantic.AliasType -> toPublicAlias() + is tools.samt.semantic.ConsumerType -> toPublicConsumer() + is tools.samt.semantic.EnumType -> toPublicEnum() + is tools.samt.semantic.ProviderType -> toPublicProvider() + is tools.samt.semantic.RecordType -> toPublicRecord() + is tools.samt.semantic.ServiceType -> toPublicService() + is tools.samt.semantic.PackageType -> error("Package type cannot be converted to public API") + tools.samt.semantic.UnknownType -> error("Unknown type cannot be converted to public API") + } + + private fun tools.samt.semantic.ResolvedTypeReference.Constraint.Range.toPublicRangeConstraint() = object : Constraint.Range { + override val lowerBound = this@toPublicRangeConstraint.lowerBound + override val upperBound = this@toPublicRangeConstraint.upperBound + } + + private fun tools.samt.semantic.ResolvedTypeReference.Constraint.Size.toPublicSizeConstraint() = object : Constraint.Size { + override val lowerBound = this@toPublicSizeConstraint.lowerBound + override val upperBound = this@toPublicSizeConstraint.upperBound + } + + private fun tools.samt.semantic.ResolvedTypeReference.Constraint.Pattern.toPublicPatternConstraint() = + object : Constraint.Pattern { + override val pattern = this@toPublicPatternConstraint.pattern + } + + private fun tools.samt.semantic.ResolvedTypeReference.Constraint.Value.toPublicValueConstraint() = object : Constraint.Value { + override val value = this@toPublicValueConstraint.value + } + + private fun tools.samt.semantic.UserDeclaredNamedType.getQualifiedName(): String { + val components = parentPackage.nameComponents + name + return components.joinToString(".") + } +} diff --git a/codegen/src/main/kotlin/tools/samt/codegen/TransportConfigurationMapper.kt b/codegen/src/main/kotlin/tools/samt/codegen/TransportConfigurationMapper.kt new file mode 100644 index 00000000..d36ac088 --- /dev/null +++ b/codegen/src/main/kotlin/tools/samt/codegen/TransportConfigurationMapper.kt @@ -0,0 +1,177 @@ +package tools.samt.codegen + +import tools.samt.common.DiagnosticController +import tools.samt.parser.reportError + +class TransportConfigurationMapper( + private val provider: ProviderType, + private val controller: DiagnosticController, +) { + fun parse(configuration: tools.samt.parser.ObjectNode): ConfigurationObject { + return configuration.toConfigurationObject() + } + + private fun tools.samt.parser.Node.reportAndThrow(message: String): Nothing { + reportError(controller) { + message(message) + highlight("related configuration", location) + } + error(message) + } + + private fun tools.samt.parser.ExpressionNode.toConfigurationElement(): ConfigurationElement = when (this) { + is tools.samt.parser.ArrayNode -> toConfigurationList() + is tools.samt.parser.BooleanNode -> toConfigurationValue() + is tools.samt.parser.BundleIdentifierNode -> components.last().toConfigurationValue() + is tools.samt.parser.IdentifierNode -> toConfigurationValue() + is tools.samt.parser.FloatNode -> toConfigurationValue() + is tools.samt.parser.IntegerNode -> toConfigurationValue() + is tools.samt.parser.ObjectNode -> toConfigurationObject() + is tools.samt.parser.StringNode -> toConfigurationValue() + else -> reportAndThrow("Unexpected expression") + } + + private fun tools.samt.parser.IntegerNode.toConfigurationValue() = object : ConfigurationValue { + override val asString: String get() = reportAndThrow("Unexpected integer, expected a string") + + override fun > asEnum(enum: Class): T = + reportAndThrow("Unexpected integer, expected an enum (${enum.simpleName})") + + override val asLong: Long get() = value + override val asDouble: Double = value.toDouble() + override val asBoolean: Boolean get() = reportAndThrow("Unexpected integer, expected a boolean") + override val asServiceName: ServiceType get() = reportAndThrow("Unexpected integer, expected a service name") + override fun asOperationName(service: ServiceType): ServiceOperation = + reportAndThrow("Unexpected integer, expected an operation name") + + override val asObject: ConfigurationObject get() = reportAndThrow("Unexpected integer, expected an object") + override val asValue: ConfigurationValue get() = this + override val asList: ConfigurationList get() = reportAndThrow("Unexpected integer, expected a list") + } + + private fun tools.samt.parser.FloatNode.toConfigurationValue() = object : ConfigurationValue { + override val asString: String get() = reportAndThrow("Unexpected float, expected a string") + + override fun > asEnum(enum: Class): T = + reportAndThrow("Unexpected float, expected an enum (${enum.simpleName})") + + override val asLong: Long get() = reportAndThrow("Unexpected float, expected an integer") + override val asDouble: Double = value + override val asBoolean: Boolean get() = reportAndThrow("Unexpected float, expected a boolean") + override val asServiceName: ServiceType get() = reportAndThrow("Unexpected float, expected a service name") + override fun asOperationName(service: ServiceType): ServiceOperation = + reportAndThrow("Unexpected float, expected an operation name") + + override val asObject: ConfigurationObject get() = reportAndThrow("Unexpected float, expected an object") + override val asValue: ConfigurationValue get() = this + override val asList: ConfigurationList get() = reportAndThrow("Unexpected float, expected a list") + } + + private fun tools.samt.parser.StringNode.toConfigurationValue() = object : ConfigurationValue { + override val asString: String get() = value + + override fun > asEnum(enum: Class): T { + check(enum.isEnum) + return enum.enumConstants.find { it.name.equals(value, ignoreCase = true) } + ?: reportAndThrow("Illegal enum value, expected one of ${enum.enumConstants.joinToString { it.name }}") + } + + override val asLong: Long get() = reportAndThrow("Unexpected string, expected an integer") + override val asDouble: Double get() = reportAndThrow("Unexpected string, expected a float") + override val asBoolean: Boolean get() = reportAndThrow("Unexpected string, expected a boolean") + override val asServiceName: ServiceType get() = reportAndThrow("Unexpected string, expected a service name") + override fun asOperationName(service: ServiceType): ServiceOperation = + reportAndThrow("Unexpected string, expected an operation name") + + override val asObject: ConfigurationObject get() = reportAndThrow("Unexpected string, expected an object") + override val asValue: ConfigurationValue get() = this + override val asList: ConfigurationList get() = reportAndThrow("Unexpected string, expected a list") + } + + private fun tools.samt.parser.BooleanNode.toConfigurationValue() = object : ConfigurationValue { + override val asString: String get() = reportAndThrow("Unexpected boolean, expected a string") + + override fun > asEnum(enum: Class): T = + reportAndThrow("Unexpected boolean, expected an enum (${enum.simpleName})") + + override val asLong: Long get() = reportAndThrow("Unexpected boolean, expected an integer") + override val asDouble: Double get() = reportAndThrow("Unexpected boolean, expected a float") + override val asBoolean: Boolean get() = value + override val asServiceName: ServiceType get() = reportAndThrow("Unexpected boolean, expected a service name") + override fun asOperationName(service: ServiceType): ServiceOperation = + reportAndThrow("Unexpected boolean, expected an operation name") + + override val asObject: ConfigurationObject get() = reportAndThrow("Unexpected boolean, expected an object") + override val asValue: ConfigurationValue get() = this + override val asList: ConfigurationList get() = reportAndThrow("Unexpected boolean, expected a list") + } + + private fun tools.samt.parser.IdentifierNode.toConfigurationValue() = object : ConfigurationValue { + override val asString: String get() = reportAndThrow("Unexpected identifier, expected a string") + + override fun > asEnum(enum: Class): T = + reportAndThrow("Unexpected identifier, expected an enum (${enum.simpleName})") + + override val asLong: Long get() = reportAndThrow("Unexpected identifier, expected an integer") + override val asDouble: Double get() = reportAndThrow("Unexpected identifier, expected a float") + override val asBoolean: Boolean get() = reportAndThrow("Unexpected identifier, expected a boolean") + override val asServiceName: ServiceType + get() = provider.implements.find { it.service.name == name }?.service + ?: reportAndThrow("No service with name '$name' found in provider '${provider.name}'") + + override fun asOperationName(service: ServiceType): ServiceOperation = + provider.implements.find { it.service.qualifiedName == service.qualifiedName }?.operations?.find { it.name == name } + ?: reportAndThrow("No operation with name '$name' found in service '${service.name}' of provider '${provider.name}'") + + override val asObject: ConfigurationObject get() = reportAndThrow("Unexpected identifier, expected an object") + override val asValue: ConfigurationValue get() = this + override val asList: ConfigurationList get() = reportAndThrow("Unexpected identifier, expected a list") + } + + private fun tools.samt.parser.ArrayNode.toConfigurationList() = object : ConfigurationList { + override val entries: List + get() = this@toConfigurationList.values.map { it.toConfigurationElement() } + override val asObject: ConfigurationObject + get() = reportAndThrow("Unexpected array, expected an object") + override val asValue: ConfigurationValue + get() = reportAndThrow("Unexpected array, expected a value") + override val asList: ConfigurationList + get() = this + } + + private fun tools.samt.parser.ObjectNode.toConfigurationObject() = object : ConfigurationObject { + override val fields: Map + get() = this@toConfigurationObject.fields.associate { it.name.toConfigurationValue() to it.value.toConfigurationElement() } + + override fun getField(name: String): ConfigurationElement = + getFieldOrNull(name) ?: run { + this@toConfigurationObject.reportError(controller) { + message("No field with name '$name' found") + highlight("related object", this@toConfigurationObject.location) + } + throw NoSuchElementException("No field with name '$name' found") + } + + override fun getFieldOrNull(name: String): ConfigurationElement? = + this@toConfigurationObject.fields.find { it.name.name == name }?.value?.toConfigurationElement() + + override val asObject: ConfigurationObject + get() = this + override val asValue: ConfigurationValue + get() { + this@toConfigurationObject.reportError(controller) { + message("Object is not a value") + highlight("unexpected object, expected value", this@toConfigurationObject.location) + } + error("Object is not a value") + } + override val asList: ConfigurationList + get() { + this@toConfigurationObject.reportError(controller) { + message("Object is not a list") + highlight("unexpected object, expected list", this@toConfigurationObject.location) + } + error("Object is not a list") + } + } +} diff --git a/codegen/src/main/kotlin/tools/samt/codegen/http/HttpTransport.kt b/codegen/src/main/kotlin/tools/samt/codegen/http/HttpTransport.kt index b3bfb095..03e8d399 100644 --- a/codegen/src/main/kotlin/tools/samt/codegen/http/HttpTransport.kt +++ b/codegen/src/main/kotlin/tools/samt/codegen/http/HttpTransport.kt @@ -1,271 +1,153 @@ package tools.samt.codegen.http -import tools.samt.codegen.TransportConfiguration -import tools.samt.codegen.TransportConfigurationParser -import tools.samt.codegen.TransportConfigurationParserParams -import tools.samt.common.DiagnosticController -import tools.samt.parser.* +import tools.samt.codegen.* -// TODO: refactor diagnostic controller support - -object HttpTransportConfigurationParser: TransportConfigurationParser { +object HttpTransportConfigurationParser : TransportConfigurationParser { override val transportName: String get() = "http" override fun default(): TransportConfiguration = HttpTransportConfiguration( serializationMode = HttpTransportConfiguration.SerializationMode.Json, services = emptyList(), - exceptionMap = emptyMap(), ) - class Params( - override val configObjectNode: ObjectNode, - val controller: DiagnosticController - ) : TransportConfigurationParserParams { - - override fun reportError(message: String) { - controller.reportGlobalError(message) - } - - override fun reportWarning(message: String) { - controller.reportGlobalWarning(message) - } - - override fun reportInfo(message: String) { - controller.reportGlobalInfo(message) - } - } - override fun parse(params: TransportConfigurationParserParams): HttpTransportConfiguration { - require(params is Params) { "Invalid params type" } - - val fields = parseObjectNode(params.configObjectNode) - - val serializationMode = if (fields.containsKey("serialization")) { - val serializationConfig = fields["serialization"]!! - if (serializationConfig is StringNode) { - when (serializationConfig.value) { - "json" -> HttpTransportConfiguration.SerializationMode.Json - else -> { - // unknown serialization mode - serializationConfig.reportError(params.controller) { - message("Unknown serialization mode '${serializationConfig.value}', defaulting to 'json'") - highlight(serializationConfig.location, "unknown serialization mode") + val config = params.config + val serializationMode = + config.getFieldOrNull("serialization")?.asValue?.asEnum() + ?: HttpTransportConfiguration.SerializationMode.Json + + val services = config.getFieldOrNull("operations")?.asObject?.let { operations -> + // TODO This currently fails horribly if an operation is called basePath + val servicePath = operations.getFieldOrNull("basePath")?.asValue?.asString ?: "" + + operations.asObject.fields.map { (operationsKey, operationsField) -> + val service = operationsKey.asServiceName + val serviceName = service.name + val operationConfiguration = operationsField.asObject + + val parsedOperations = operationConfiguration.fields.map { (key, value) -> + val operationConfig = value.asValue + val operation = key.asOperationName(service) + val operationName = operation.name + val words = operationConfig.asString.split(" ") + if (words.size < 2) { + params.reportError( + "Invalid operation config for '$operationName', expected ' '", + operationConfig + ) + error("Invalid operation config for '$operationName', expected ' '") + } + + val methodEnum = when (val methodName = words[0]) { + "GET" -> HttpTransportConfiguration.HttpMethod.Get + "POST" -> HttpTransportConfiguration.HttpMethod.Post + "PUT" -> HttpTransportConfiguration.HttpMethod.Put + "DELETE" -> HttpTransportConfiguration.HttpMethod.Delete + "PATCH" -> HttpTransportConfiguration.HttpMethod.Patch + else -> { + params.reportError("Invalid http method '$methodName'", operationConfig) + error("Invalid http method '$methodName'") } - HttpTransportConfiguration.SerializationMode.Json } - } - } else { - // invalid serialization mode type, expected string - serializationConfig.reportError(params.controller) { - message("Expected serialization config option to be a string, defaulting to 'json'") - highlight(serializationConfig.location, "") - } - HttpTransportConfiguration.SerializationMode.Json - } - } else { - HttpTransportConfiguration.SerializationMode.Json - } - val services = buildList { - if (!fields.containsKey("operations")) { - return@buildList - } + val path = words[1] + val parameterConfigParts = words.drop(2) + val parameters = mutableListOf() - if (fields["operations"] !is ObjectNode) { - fields["operations"]!!.reportError(params.controller) { - message("Invalid value for 'operations', expected object") - highlight(fields["operations"]!!.location) - } - return@buildList - } + // parse path and path parameters + val pathComponents = path.split("/") + for (component in pathComponents) { + if (!component.startsWith("{") || !component.endsWith("}")) continue + + val pathParameterName = component.substring(1, component.length - 1) + + if (pathParameterName.isEmpty()) { + params.reportError( + "Expected parameter name between curly braces in '$path'", + operationConfig + ) + continue + } - val operationsConfig = parseObjectNode(fields["operations"] as ObjectNode) - for ((serviceName, operationsField) in operationsConfig) { - if (operationsField !is ObjectNode) { - operationsField.reportError(params.controller) { - message("Invalid value for '$serviceName', expected object") - highlight(operationsField.location) + parameters += HttpTransportConfiguration.ParameterConfiguration( + name = pathParameterName, + transportMode = HttpTransportConfiguration.TransportMode.Path, + ) } - continue - } - val operationsConfig = parseObjectNode(operationsField as ObjectNode) + // parse parameter declarations + for (component in parameterConfigParts) { + if (!component.startsWith("{") || !component.endsWith("}")) { + params.reportError( + "Expected parameter in format '{type:name}', got '$component'", + operationConfig + ) + continue + } - val operations = buildList { - for ((operationName, operationConfig) in operationsConfig) { - if (operationConfig !is StringNode) { - operationConfig.reportError(params.controller) { - message("Invalid value for operation config for '$operationName', expected string") - highlight(operationConfig.location) - } + val parameterConfig = component.substring(1, component.length - 1) + if (parameterConfig.isEmpty()) { + params.reportError( + "Expected parameter name between curly braces in '$path'", + operationConfig + ) continue } - val words = operationConfig.value.split(" ") - if (words.size < 2) { - operationConfig.reportError(params.controller) { - message("Invalid operation config for '$operationName', expected ' '") - highlight(operationConfig.location) - } + val parts = parameterConfig.split(":") + if (parts.size != 2) { + params.reportError( + "Expected parameter in format '{type:name}', got '$component'", + operationConfig + ) continue } - val methodEnum = when (val methodName = words[0]) { - "GET" -> HttpTransportConfiguration.HttpMethod.Get - "POST" -> HttpTransportConfiguration.HttpMethod.Post - "PUT" -> HttpTransportConfiguration.HttpMethod.Put - "DELETE" -> HttpTransportConfiguration.HttpMethod.Delete - "PATCH" -> HttpTransportConfiguration.HttpMethod.Patch + val (type, name) = parts + val transportMode = when (type) { + "query" -> HttpTransportConfiguration.TransportMode.Query + "header" -> HttpTransportConfiguration.TransportMode.Header + "body" -> HttpTransportConfiguration.TransportMode.Body + "cookie" -> HttpTransportConfiguration.TransportMode.Cookie else -> { - operationConfig.reportError(params.controller) { - message("Invalid http method '$methodName'") - highlight(operationConfig.location) - } + params.reportError("Invalid transport mode '$type'", operationConfig) continue } } - val path = words[1] - val parameterConfigParts = words.drop(2) - val parameters = buildList { - - // parse path and path parameters - val pathComponents = path.split("/") - for (component in pathComponents) { - if (component.startsWith("{") && component.endsWith("}")) { - val pathParameterName = component.substring(1, component.length - 1) - - if (pathParameterName.isEmpty()) { - operationConfig.reportError(params.controller) { - message("Expected parameter name between curly braces in '$path'") - highlight(operationConfig.location) - } - continue - } - - add( - HttpTransportConfiguration.ParameterConfiguration( - name = pathParameterName, - transportMode = HttpTransportConfiguration.TransportMode.Path, - ) - ) - } - } - - // parse parameter declarations - for (component in parameterConfigParts) { - if (component.startsWith("{") && component.endsWith("}")) { - val parameterConfig = component.substring(1, component.length - 1) - if (parameterConfig.isEmpty()) { - operationConfig.reportError(params.controller) { - message("Expected parameter name between curly braces in '$path'") - highlight(operationConfig.location) - } - continue - } - - val parts = parameterConfig.split(":") - if (parts.size != 2) { - operationConfig.reportError(params.controller) { - message("Expected parameter in format '{type:name}', got '$component'") - highlight(operationConfig.location) - } - continue - } - - val (type, name) = parts - val transportMode = when (type) { - "query" -> HttpTransportConfiguration.TransportMode.Query - "header" -> HttpTransportConfiguration.TransportMode.Header - "body" -> HttpTransportConfiguration.TransportMode.Body - "cookie" -> HttpTransportConfiguration.TransportMode.Cookie - else -> { - operationConfig.reportError(params.controller) { - message("Invalid transport mode '$type'") - highlight(operationConfig.location) - } - continue - } - } - - add( - HttpTransportConfiguration.ParameterConfiguration( - name = name, - transportMode = transportMode, - ) - ) - } else { - operationConfig.reportError(params.controller) { - message("Expected parameter in format '{type:name}', got '$component'") - highlight(operationConfig.location) - } - } - } - } - - add( - HttpTransportConfiguration.OperationConfiguration( - name = operationName, - method = methodEnum, - path = path, - parameters = parameters, - ) + parameters += HttpTransportConfiguration.ParameterConfiguration( + name = name, + transportMode = transportMode, ) } - } - add( - HttpTransportConfiguration.ServiceConfiguration( - name = serviceName, - operations = operations, - path = "" // TODO + HttpTransportConfiguration.OperationConfiguration( + name = operationName, + method = methodEnum, + path = path, + parameters = parameters, ) - ) - } - } - - val exceptions = buildMap { - if (fields.containsKey("faults")) { - if (fields["faults"] is ObjectNode) { - val faultMapping = parseObjectNode(fields["faults"] as ObjectNode) - for ((faultName, statusCode) in faultMapping) { - - if (statusCode !is IntegerNode) { - statusCode.reportError(params.controller) { - message("Expected integer value for '$faultName' fault status code") - highlight(statusCode.location) - } - continue - } - - set(faultName, (statusCode as IntegerNode).value) - } } + + HttpTransportConfiguration.ServiceConfiguration( + name = serviceName, + operations = parsedOperations, + path = servicePath + ) } - } + } ?: emptyList() return HttpTransportConfiguration( serializationMode = serializationMode, services = services, - exceptionMap = exceptions, ) } - - private fun parseObjectNode(node: ObjectNode): Map { - val result = mutableMapOf() - for (field in node.fields) { - result[field.name.name] = field.value - } - return result - } } -// TODO: store fault config class HttpTransportConfiguration( val serializationMode: SerializationMode, val services: List, - val exceptionMap: Map, ) : TransportConfiguration { class ServiceConfiguration( val name: String, diff --git a/codegen/src/main/kotlin/tools/samt/codegen/kotlin/ktor/KotlinKtorConsumerGenerator.kt b/codegen/src/main/kotlin/tools/samt/codegen/kotlin/ktor/KotlinKtorConsumerGenerator.kt index 07e2bbef..41c9bc45 100644 --- a/codegen/src/main/kotlin/tools/samt/codegen/kotlin/ktor/KotlinKtorConsumerGenerator.kt +++ b/codegen/src/main/kotlin/tools/samt/codegen/kotlin/ktor/KotlinKtorConsumerGenerator.kt @@ -54,9 +54,7 @@ object KotlinKtorConsumerGenerator : Generator { } data class ConsumerInfo(val uses: ConsumerUses) { - val reference = uses.service - val service = reference.type as ServiceType - val serviceArgumentName = service.name.replaceFirstChar { it.lowercase() } + val service = uses.service } private fun StringBuilder.appendConsumer(consumer: ConsumerType, transportConfiguration: HttpTransportConfiguration, options: Map) { @@ -72,7 +70,6 @@ object KotlinKtorConsumerGenerator : Generator { appendLine("import kotlinx.serialization.json.*") val implementedServices = consumer.uses.map { ConsumerInfo(it) } - appendLine("// ${transportConfiguration.exceptionMap}") appendLine("class ${consumer.provider.name}Impl(private val `consumer baseUrl`: String) : ${implementedServices.joinToString { it.service.getQualifiedName(options) }} {") implementedServices.forEach { info -> appendConsumerOperations(info, transportConfiguration, options) diff --git a/codegen/src/main/kotlin/tools/samt/codegen/kotlin/ktor/KotlinKtorProviderGenerator.kt b/codegen/src/main/kotlin/tools/samt/codegen/kotlin/ktor/KotlinKtorProviderGenerator.kt index 46354206..9d5bb339 100644 --- a/codegen/src/main/kotlin/tools/samt/codegen/kotlin/ktor/KotlinKtorProviderGenerator.kt +++ b/codegen/src/main/kotlin/tools/samt/codegen/kotlin/ktor/KotlinKtorProviderGenerator.kt @@ -109,9 +109,8 @@ object KotlinKtorProviderGenerator : Generator { } data class ProviderInfo(val implements: ProviderImplements) { - val reference = implements.service - val service = reference.type as ServiceType - val serviceArgumentName = service.name.replaceFirstChar { it.lowercase() } + val service = implements.service + val serviceArgumentName = implements.service.name.replaceFirstChar { it.lowercase() } } private fun StringBuilder.appendProvider( @@ -131,11 +130,10 @@ object KotlinKtorProviderGenerator : Generator { appendLine() val implementedServices = provider.implements.map { ProviderInfo(it) } - appendLine("// ${transportConfiguration.exceptionMap}") appendLine("/** Connector for SAMT provider ${provider.name} */") appendLine("fun Routing.route${provider.name}(") for (info in implementedServices) { - appendLine(" ${info.serviceArgumentName}: ${info.reference.getQualifiedName(options)},") + appendLine(" ${info.serviceArgumentName}: ${info.service.getQualifiedName(options)},") } appendLine(") {") appendUtilities() From 110dcc4cbdbf20d95d7e4872d0216807b8a9d2d5 Mon Sep 17 00:00:00 2001 From: Pascal Honegger Date: Sat, 27 May 2023 17:12:23 +0200 Subject: [PATCH 25/41] feat(codegen): implement more consumer features --- .../ktor/KotlinKtorConsumerGenerator.kt | 115 ++++++++---------- 1 file changed, 53 insertions(+), 62 deletions(-) diff --git a/codegen/src/main/kotlin/tools/samt/codegen/kotlin/ktor/KotlinKtorConsumerGenerator.kt b/codegen/src/main/kotlin/tools/samt/codegen/kotlin/ktor/KotlinKtorConsumerGenerator.kt index 41c9bc45..c6b6a574 100644 --- a/codegen/src/main/kotlin/tools/samt/codegen/kotlin/ktor/KotlinKtorConsumerGenerator.kt +++ b/codegen/src/main/kotlin/tools/samt/codegen/kotlin/ktor/KotlinKtorConsumerGenerator.kt @@ -55,6 +55,8 @@ object KotlinKtorConsumerGenerator : Generator { data class ConsumerInfo(val uses: ConsumerUses) { val service = uses.service + val implementedOperations = uses.operations + val notImplementedOperations = service.operations.filter { serviceOp -> implementedOperations.none { it.name == serviceOp.name } } } private fun StringBuilder.appendConsumer(consumer: ConsumerType, transportConfiguration: HttpTransportConfiguration, options: Map) { @@ -68,6 +70,7 @@ object KotlinKtorConsumerGenerator : Generator { appendLine("import io.ktor.util.*") appendLine("import kotlinx.coroutines.runBlocking") appendLine("import kotlinx.serialization.json.*") + appendLine("import kotlinx.coroutines.*") val implementedServices = consumer.uses.map { ConsumerInfo(it) } appendLine("class ${consumer.provider.name}Impl(private val `consumer baseUrl`: String) : ${implementedServices.joinToString { it.service.getQualifiedName(options) }} {") @@ -84,52 +87,62 @@ object KotlinKtorConsumerGenerator : Generator { appendLine(" }") appendLine(" }") appendLine() + appendLine(" /** Used to launch oneway operations asynchronously */") + appendLine(" private val onewayScope = CoroutineScope(Dispatchers.IO)") + appendLine() - val service = info.service - info.uses.operations.forEach { operation -> + info.implementedOperations.forEach { operation -> val operationParameters = operation.parameters.joinToString { "${it.name}: ${it.type.getQualifiedName(options)}" } when (operation) { is RequestResponseOperation -> { if (operation.isAsync) { - if (operation.returnType != null) { - appendLine(" override suspend fun ${operation.name}($operationParameters): ${operation.returnType!!.getQualifiedName(options)} {") - } else { - appendLine(" override suspend fun ${operation.name}($operationParameters): Unit {") - } + appendLine(" override suspend fun ${operation.name}($operationParameters): ${operation.returnType?.getQualifiedName(options) ?: "Unit"} {") } else { - if (operation.returnType != null) { - appendLine(" override fun ${operation.name}($operationParameters): ${operation.returnType!!.getQualifiedName(options)} {") - } else { - appendLine(" override fun ${operation.name}($operationParameters): Unit {") - } + appendLine(" override fun ${operation.name}($operationParameters): ${operation.returnType?.getQualifiedName(options) ?: "Unit"} = runBlocking {") } - // TODO Config: HTTP status code - // TODO validate call parameters - // TODO validate response - // TODO serialize response correctly - if (operation.isAsync) { - appendLine(" return runBlocking {") - } else { - appendLine(" return run {") - } + appendConsumerServiceCall(info, operation, transportConfiguration, options) + appendCheckResponseStatus(operation) + appendConsumerResponseParsing(operation, transportConfiguration, options) + + appendLine(" }") + } - appendConsumerServiceCall(info, operation, transportConfiguration) - appendConsumerResponseParsing(operation, transportConfiguration) + is OnewayOperation -> { + appendLine(" override fun ${operation.name}($operationParameters): Unit {") + appendLine(" onewayScope.launch {") + + appendConsumerServiceCall(info, operation, transportConfiguration, options) + appendCheckResponseStatus(operation) appendLine(" }") appendLine(" }") } + } + } + + info.notImplementedOperations.forEach { operation -> + val operationParameters = operation.parameters.joinToString { "${it.name}: ${it.type.getQualifiedName(options)}" } + + when (operation) { + is RequestResponseOperation -> { + if (operation.isAsync) { + appendLine(" override suspend fun ${operation.name}($operationParameters): ${operation.returnType?.getQualifiedName(options) ?: "Unit"} {") + } else { + appendLine(" override fun ${operation.name}($operationParameters): ${operation.returnType?.getQualifiedName(options) ?: "Unit"}") + } + } is OnewayOperation -> { - // TODO + appendLine(" override fun ${operation.name}($operationParameters): Unit") } } + appendLine(" = error(\"Not used in model and therefore not generated\")") } } - private fun StringBuilder.appendConsumerServiceCall(info: ConsumerInfo, operation: ServiceOperation, transport: HttpTransportConfiguration) { + private fun StringBuilder.appendConsumerServiceCall(info: ConsumerInfo, operation: ServiceOperation, transport: HttpTransportConfiguration, options: Map) { // collect parameters for each transport type val headerParameters = mutableMapOf() val cookieParameters = mutableMapOf() @@ -157,30 +170,15 @@ object KotlinKtorConsumerGenerator : Generator { } } - /* - val response = client.request("$baseUrl/todos/$title") { - method = HttpMethod.Post - headers["title"] = title - cookie("description", description) - setBody( - buildJsonObject { - put("title", title) - put("description", description) - } - ) - contentType(ContentType.Application.Json) - } - */ - // build request headers and body - appendLine(" val `consumer response` = client.request(`consumer baseUrl`) {") + appendLine(" val response = client.request(`consumer baseUrl`) {") // build request path // need to split transport path into path segments and query parameter slots // remove first empty component (paths start with a / so the first component is always empty) val transportPath = transport.getPath(info.service.name, operation.name) val transportPathComponents = transportPath.split("/") - appendLine(" url {") + appendLine(" url(`consumer baseUrl`) {") transportPathComponents.drop(1).map { if (it.startsWith("{") && it.endsWith("}")) { val parameterName = it.substring(1, it.length - 1) @@ -193,9 +191,8 @@ object KotlinKtorConsumerGenerator : Generator { appendLine(" }") // serialization mode - when (val serializationMode = transport.serializationMode) { + when (transport.serializationMode) { HttpTransportConfiguration.SerializationMode.Json -> appendLine(" contentType(ContentType.Application.Json)") - else -> error("unsupported serialization mode: $serializationMode") } // transport method @@ -204,41 +201,35 @@ object KotlinKtorConsumerGenerator : Generator { // header parameters headerParameters.forEach { - appendLine(" headers[\"$it\"] = $it") + appendLine(" headers[\"${it.key}\"] = ${encodeJsonElement(it.value.type, options)}") } // cookie parameters cookieParameters.forEach { - appendLine(" cookie(\"$it\", $it)") + appendLine(" cookie(\"${it.key}\", ${encodeJsonElement(it.value.type, options)})") } // body parameters appendLine(" setBody(") appendLine(" buildJsonObject {") bodyParameters.forEach { (name, parameter) -> - appendLine(" put(\"$name\", \"placeholder hello world\"") + appendLine(" put(\"$name\", ${encodeJsonElement(parameter.type, options)})") } appendLine(" }") appendLine(" )") appendLine(" }") - - // oneway vs request-response } - private fun StringBuilder.appendConsumerResponseParsing(operation: ServiceOperation, transport: HttpTransportConfiguration) { - /* - val bodyAsText = response.bodyAsText() - val body = Json.parseToJsonElement(bodyAsText) - - val respTitle = body.jsonObject["title"]!!.jsonPrimitive.content - val respDescription = response.headers["description"]!! - check(respTitle.length in 1..100) + private fun StringBuilder.appendCheckResponseStatus(operation: ServiceOperation) { + appendLine(" check(!response.status.isSuccess()) { \"${operation.name} failed with status \${response.status}\" }") + } - Todo( - title = respTitle, - description = respDescription, - ) - */ + private fun StringBuilder.appendConsumerResponseParsing(operation: RequestResponseOperation, transport: HttpTransportConfiguration, options: Map) { + operation.returnType?.let { returnType -> + appendLine(" val bodyAsText = response.bodyAsText()") + appendLine(" val jsonElement = Json.parseToJsonElement(bodyAsText)") + appendLine(" return ${decodeJsonElement(returnType, options)}") + } } } From dbf8de4ffe8974e30466948c735b6e3a97430931 Mon Sep 17 00:00:00 2001 From: Pascal Honegger Date: Sun, 28 May 2023 21:09:46 +0200 Subject: [PATCH 26/41] feat(codegen): correctly generate a variety of use-cases --- .../codegen/kotlin/KotlinGeneratorUtils.kt | 11 ++- .../codegen/kotlin/KotlinTypesGenerator.kt | 2 +- .../ktor/KotlinKtorConsumerGenerator.kt | 72 +++++++++++++------ .../ktor/KotlinKtorGeneratorUtilities.kt | 55 +++++++------- 4 files changed, 91 insertions(+), 49 deletions(-) diff --git a/codegen/src/main/kotlin/tools/samt/codegen/kotlin/KotlinGeneratorUtils.kt b/codegen/src/main/kotlin/tools/samt/codegen/kotlin/KotlinGeneratorUtils.kt index 953c2458..37a25a85 100644 --- a/codegen/src/main/kotlin/tools/samt/codegen/kotlin/KotlinGeneratorUtils.kt +++ b/codegen/src/main/kotlin/tools/samt/codegen/kotlin/KotlinGeneratorUtils.kt @@ -7,7 +7,14 @@ object KotlinGeneratorConfig { const val addPrefixToKotlinPackage = "addPrefixToKotlinPackage" } -const val GeneratedFilePreamble = """@file:Suppress("RemoveRedundantQualifierName", "unused", "UnusedImport", "LocalVariableName", "FunctionName", "ConvertTwoComparisonsToRangeCheck", "ReplaceSizeCheckWithIsNotEmpty", "NAME_SHADOWING")""" +val GeneratedFilePreamble = """ + @file:Suppress("RemoveRedundantQualifierName", "unused", "UnusedImport", "LocalVariableName", "FunctionName", "ConvertTwoComparisonsToRangeCheck", "ReplaceSizeCheckWithIsNotEmpty", "NAME_SHADOWING", "UNUSED_VARIABLE") + + /* + * This file is generated by SAMT, manual changes will be overwritten. + * Visit the SAMT GitHub for more details: https://github.com/samtkit/core + */ +""".trimIndent() internal fun String.replacePackage(options: Map): String { val removePrefix = options[KotlinGeneratorConfig.removePrefixFromSamtPackage] @@ -60,3 +67,5 @@ internal fun Type.getQualifiedName(options: Map): String = when else -> error("Unsupported type: ${javaClass.simpleName}") } + +internal fun UserType.getTargetPackage(options: Map): String = qualifiedName.replacePackage(options).dropLastWhile { it != '.' } diff --git a/codegen/src/main/kotlin/tools/samt/codegen/kotlin/KotlinTypesGenerator.kt b/codegen/src/main/kotlin/tools/samt/codegen/kotlin/KotlinTypesGenerator.kt index 2e1e8bf5..98efa2e3 100644 --- a/codegen/src/main/kotlin/tools/samt/codegen/kotlin/KotlinTypesGenerator.kt +++ b/codegen/src/main/kotlin/tools/samt/codegen/kotlin/KotlinTypesGenerator.kt @@ -47,7 +47,7 @@ object KotlinTypesGenerator : Generator { } private fun StringBuilder.appendRecord(record: RecordType, options: Map) { - appendLine("class ${record.name}(") + appendLine("data class ${record.name}(") record.fields.forEach { field -> val fullyQualifiedName = field.type.getQualifiedName(options) val isOptional = field.type.isOptional diff --git a/codegen/src/main/kotlin/tools/samt/codegen/kotlin/ktor/KotlinKtorConsumerGenerator.kt b/codegen/src/main/kotlin/tools/samt/codegen/kotlin/ktor/KotlinKtorConsumerGenerator.kt index c6b6a574..14fd1314 100644 --- a/codegen/src/main/kotlin/tools/samt/codegen/kotlin/ktor/KotlinKtorConsumerGenerator.kt +++ b/codegen/src/main/kotlin/tools/samt/codegen/kotlin/ktor/KotlinKtorConsumerGenerator.kt @@ -53,7 +53,7 @@ object KotlinKtorConsumerGenerator : Generator { } } - data class ConsumerInfo(val uses: ConsumerUses) { + data class ConsumerInfo(val consumer: ConsumerType, val uses: ConsumerUses) { val service = uses.service val implementedOperations = uses.operations val notImplementedOperations = service.operations.filter { serviceOp -> implementedOperations.none { it.name == serviceOp.name } } @@ -71,9 +71,10 @@ object KotlinKtorConsumerGenerator : Generator { appendLine("import kotlinx.coroutines.runBlocking") appendLine("import kotlinx.serialization.json.*") appendLine("import kotlinx.coroutines.*") + appendLine() - val implementedServices = consumer.uses.map { ConsumerInfo(it) } - appendLine("class ${consumer.provider.name}Impl(private val `consumer baseUrl`: String) : ${implementedServices.joinToString { it.service.getQualifiedName(options) }} {") + val implementedServices = consumer.uses.map { ConsumerInfo(consumer, it) } + appendLine("class ${consumer.className}(private val baseUrl: String) : ${implementedServices.joinToString { it.service.getQualifiedName(options) }} {") implementedServices.forEach { info -> appendConsumerOperations(info, transportConfiguration, options) } @@ -97,7 +98,7 @@ object KotlinKtorConsumerGenerator : Generator { when (operation) { is RequestResponseOperation -> { if (operation.isAsync) { - appendLine(" override suspend fun ${operation.name}($operationParameters): ${operation.returnType?.getQualifiedName(options) ?: "Unit"} {") + appendLine(" override suspend fun ${operation.name}($operationParameters): ${operation.returnType?.getQualifiedName(options) ?: "Unit"} = run {") } else { appendLine(" override fun ${operation.name}($operationParameters): ${operation.returnType?.getQualifiedName(options) ?: "Unit"} = runBlocking {") } @@ -138,7 +139,7 @@ object KotlinKtorConsumerGenerator : Generator { appendLine(" override fun ${operation.name}($operationParameters): Unit") } } - appendLine(" = error(\"Not used in model and therefore not generated\")") + appendLine(" = error(\"Not used in SAMT consumer and therefore not generated\")") } } @@ -171,21 +172,35 @@ object KotlinKtorConsumerGenerator : Generator { } // build request headers and body - appendLine(" val response = client.request(`consumer baseUrl`) {") + appendLine(" // Make actual network call") + appendLine(" val `client response` = client.request(this@${info.consumer.className}.baseUrl) {") // build request path // need to split transport path into path segments and query parameter slots // remove first empty component (paths start with a / so the first component is always empty) val transportPath = transport.getPath(info.service.name, operation.name) val transportPathComponents = transportPath.split("/") - appendLine(" url(`consumer baseUrl`) {") - transportPathComponents.drop(1).map { + appendLine(" url {") + appendLine(" // Construct path and encode path parameters") + transportPathComponents.drop(1).forEach { if (it.startsWith("{") && it.endsWith("}")) { val parameterName = it.substring(1, it.length - 1) - require(pathParameters.contains(parameterName)) { "${operation.name}: path parameter $parameterName is not a known path parameter" } - appendLine(" appendPathSegments($parameterName)") + require(parameterName in pathParameters) { "${operation.name}: path parameter $parameterName is not a known path parameter" } + appendLine(" appendPathSegments($parameterName, encodeSlash = true)") + } else { + appendLine(" appendPathSegments(\"$it\", encodeSlash = true)") + } + } + appendLine() + + appendLine(" // Encode query parameters") + queryParameters.forEach { (name, queryParameter) -> + if (queryParameter.type.isOptional) { + appendLine(" if ($name != null) {") + appendLine(" this.parameters.append(\"$name\", ${encodeJsonElement(queryParameter.type, options, valueName = name)}.toString())") + appendLine(" }") } else { - appendLine(" appendPathSegments(\"$it\")") + appendLine(" this.parameters.append(\"$name\", ${encodeJsonElement(queryParameter.type, options, valueName = name)}.toString())") } } appendLine(" }") @@ -197,23 +212,35 @@ object KotlinKtorConsumerGenerator : Generator { // transport method val transportMethod = transport.getMethod(info.service.name, operation.name) - appendLine(" method = HttpMethod.$transportMethod") + appendLine(" this.method = HttpMethod.$transportMethod") // header parameters - headerParameters.forEach { - appendLine(" headers[\"${it.key}\"] = ${encodeJsonElement(it.value.type, options)}") + headerParameters.forEach { (name, headerParameter) -> + if (headerParameter.type.isOptional) { + appendLine(" if ($name != null) {") + appendLine(" header(\"${name}\", ${encodeJsonElement(headerParameter.type, options, valueName = name)})") + appendLine(" }") + } else { + appendLine(" header(\"${name}\", ${encodeJsonElement(headerParameter.type, options, valueName = name)})") + } } // cookie parameters - cookieParameters.forEach { - appendLine(" cookie(\"${it.key}\", ${encodeJsonElement(it.value.type, options)})") + cookieParameters.forEach { (name, cookieParameter) -> + if (cookieParameter.type.isOptional) { + appendLine(" if ($name != null) {") + appendLine(" cookie(\"${name}\", ${encodeJsonElement(cookieParameter.type, options, valueName = name)}.toString())") + appendLine(" }") + } else { + appendLine(" cookie(\"${name}\", ${encodeJsonElement(cookieParameter.type, options, valueName = name)}.toString())") + } } // body parameters appendLine(" setBody(") appendLine(" buildJsonObject {") - bodyParameters.forEach { (name, parameter) -> - appendLine(" put(\"$name\", ${encodeJsonElement(parameter.type, options)})") + bodyParameters.forEach { (name, bodyParameter) -> + appendLine(" put(\"$name\", ${encodeJsonElement(bodyParameter.type, options, valueName = name)})") } appendLine(" }") appendLine(" )") @@ -222,14 +249,17 @@ object KotlinKtorConsumerGenerator : Generator { } private fun StringBuilder.appendCheckResponseStatus(operation: ServiceOperation) { - appendLine(" check(!response.status.isSuccess()) { \"${operation.name} failed with status \${response.status}\" }") + appendLine(" check(`client response`.status.isSuccess()) { \"${operation.name} failed with status \${`client response`.status}\" }") } private fun StringBuilder.appendConsumerResponseParsing(operation: RequestResponseOperation, transport: HttpTransportConfiguration, options: Map) { operation.returnType?.let { returnType -> - appendLine(" val bodyAsText = response.bodyAsText()") + appendLine(" val bodyAsText = `client response`.bodyAsText()") appendLine(" val jsonElement = Json.parseToJsonElement(bodyAsText)") - appendLine(" return ${decodeJsonElement(returnType, options)}") + appendLine() + appendLine(" ${decodeJsonElement(returnType, options)}") } } + + private val ConsumerType.className get() = "${provider.name}Impl" } diff --git a/codegen/src/main/kotlin/tools/samt/codegen/kotlin/ktor/KotlinKtorGeneratorUtilities.kt b/codegen/src/main/kotlin/tools/samt/codegen/kotlin/ktor/KotlinKtorGeneratorUtilities.kt index e2dea067..c8de1d6b 100644 --- a/codegen/src/main/kotlin/tools/samt/codegen/kotlin/ktor/KotlinKtorGeneratorUtilities.kt +++ b/codegen/src/main/kotlin/tools/samt/codegen/kotlin/ktor/KotlinKtorGeneratorUtilities.kt @@ -3,6 +3,7 @@ package tools.samt.codegen.kotlin.ktor import tools.samt.codegen.* import tools.samt.codegen.kotlin.GeneratedFilePreamble import tools.samt.codegen.kotlin.getQualifiedName +import tools.samt.codegen.kotlin.getTargetPackage fun mappingFileContent(pack: SamtPackage, options: Map) = buildString { if (pack.records.isNotEmpty() || pack.enums.isNotEmpty()) { @@ -33,7 +34,8 @@ private fun StringBuilder.appendEncodeRecord( options: Map, ) { appendLine("/** Encode and validate record ${record.qualifiedName} to JSON */") - appendLine("fun `encode ${record.name}`(record: ${record.getQualifiedName(options)}): JsonObject {") + appendLine("fun `encode ${record.name}`(record: ${record.getQualifiedName(options)}?): JsonElement {") + appendLine(" if (record == null) return JsonNull") for (field in record.fields) { appendEncodeRecordField(field, options) } @@ -67,7 +69,8 @@ private fun StringBuilder.appendDecodeRecord( private fun StringBuilder.appendEncodeEnum(enum: EnumType, options: Map) { val enumName = enum.getQualifiedName(options) appendLine("/** Encode enum ${enum.qualifiedName} to JSON */") - appendLine("fun `encode ${enum.name}`(value: ${enumName}) = when(value) {") + appendLine("fun `encode ${enum.name}`(value: ${enumName}?) = when(value) {") + appendLine(" null -> null") enum.values.forEach { value -> appendLine(" ${enumName}.${value} -> \"${value}\"") } @@ -115,7 +118,7 @@ private fun StringBuilder.appendDecodeRecordField(field: RecordField, options: M appendLine(" }") } -fun encodeJsonElement(typeReference: TypeReference, options: Map): String = +fun encodeJsonElement(typeReference: TypeReference, options: Map, valueName: String = "value"): String = when (val type = typeReference.type) { is LiteralType -> { val getContent = when (type) { @@ -124,46 +127,46 @@ fun encodeJsonElement(typeReference: TypeReference, options: Map is LongType, is FloatType, is DoubleType, - is BooleanType -> "value" - is BytesType -> "value.encodeBase64Bytes()" - is DecimalType -> "value.toPlainString()" + is BooleanType -> valueName + is BytesType -> "${valueName}.encodeBase64()" + is DecimalType -> "${valueName}.toPlainString()" is DateType, is DateTimeType, - is DurationType -> "value.toString()" + is DurationType -> "${valueName}.toString()" else -> error("Unsupported literal type: ${type.javaClass.simpleName}") } "Json.encodeToJsonElement($getContent${validateLiteralConstraintsSuffix(typeReference)})" } - is ListType -> "value.map { value -> ${encodeJsonElement(type.elementType, options)} }" - is MapType -> "value.mapValues { value -> ${decodeJsonElement(type.valueType, options)} }" + is ListType -> "${valueName}.map { ${encodeJsonElement(type.elementType, options, valueName = "it")} }" + is MapType -> "${valueName}.mapValues { ${encodeJsonElement(type.valueType, options, valueName = "it")} }" - is UserType -> "`encode ${type.name}`(value)" + is UserType -> "${type.getTargetPackage(options)}`encode ${type.name}`(${valueName})" else -> error("Unsupported type: ${type.javaClass.simpleName}") } -fun decodeJsonElement(typeReference: TypeReference, options: Map): String = +fun decodeJsonElement(typeReference: TypeReference, options: Map, valueName: String = "jsonElement"): String = when (val type = typeReference.type) { is LiteralType -> when (type) { - is StringType -> "jsonElement.jsonPrimitive.content" - is BytesType -> "jsonElement.jsonPrimitive.content.decodeBase64Bytes()" - is IntType -> "jsonElement.jsonPrimitive.int" - is LongType -> "jsonElement.jsonPrimitive.long" - is FloatType -> "jsonElement.jsonPrimitive.float" - is DoubleType -> "jsonElement.jsonPrimitive.double" - is DecimalType -> "jsonElement.jsonPrimitive.content.let { java.math.BigDecimal(it) }" - is BooleanType -> "jsonElement.jsonPrimitive.boolean" - is DateType -> "jsonElement.jsonPrimitive.content?.let { java.time.LocalDate.parse(it) }" - is DateTimeType -> "jsonElement.jsonPrimitive.content?.let { java.time.LocalDateTime.parse(it) }" - is DurationType -> "jsonElement.jsonPrimitive.content?.let { java.time.Duration.parse(it) }" + is StringType -> "${valueName}.jsonPrimitive.content" + is BytesType -> "${valueName}.jsonPrimitive.content.decodeBase64Bytes()" + is IntType -> "${valueName}.jsonPrimitive.int" + is LongType -> "${valueName}.jsonPrimitive.long" + is FloatType -> "${valueName}.jsonPrimitive.float" + is DoubleType -> "${valueName}.jsonPrimitive.double" + is DecimalType -> "${valueName}.jsonPrimitive.content.let { java.math.BigDecimal(it) }" + is BooleanType -> "${valueName}.jsonPrimitive.boolean" + is DateType -> "${valueName}.jsonPrimitive.content?.let { java.time.LocalDate.parse(it) }" + is DateTimeType -> "${valueName}.jsonPrimitive.content?.let { java.time.LocalDateTime.parse(it) }" + is DurationType -> "${valueName}.jsonPrimitive.content?.let { java.time.Duration.parse(it) }" else -> error("Unsupported literal type: ${type.javaClass.simpleName}") } + validateLiteralConstraintsSuffix(typeReference) - is ListType -> "jsonElement.jsonArray.map { jsonElement -> ${decodeJsonElement(type.elementType, options)} }" - is MapType -> "jsonElement.jsonObject.mapValues { jsonElement -> ${decodeJsonElement(type.valueType, options)} }" + is ListType -> "${valueName}.jsonArray.map { ${decodeJsonElement(type.elementType, options, valueName = "it")} }" + is MapType -> "${valueName}.jsonObject.mapValues { ${decodeJsonElement(type.valueType, options, valueName = "it")} }" - is UserType -> "`decode ${type.name}`(jsonElement)" + is UserType -> "${type.getTargetPackage(options)}`decode ${type.name}`(${valueName})" else -> error("Unsupported type: ${type.javaClass.simpleName}") } @@ -199,5 +202,5 @@ private fun validateLiteralConstraintsSuffix(typeReference: TypeReference): Stri return "" } - return ".also { require(${conditions.joinToString(" && ")}) }" + return "${if (typeReference.isOptional) "?.also" else ".also"} { require(${conditions.joinToString(" && ")}) }" } From aad7a1614c8e99caf672f8f85d2edb321b87eb3f Mon Sep 17 00:00:00 2001 From: Pascal Honegger Date: Sun, 28 May 2023 23:05:17 +0200 Subject: [PATCH 27/41] feat(codegen): change syntax of http configuration --- .../kotlin/tools/samt/codegen/PublicApi.kt | 3 +- .../codegen/TransportConfigurationMapper.kt | 11 +++- .../tools/samt/codegen/http/HttpTransport.kt | 64 +++++++------------ 3 files changed, 32 insertions(+), 46 deletions(-) diff --git a/codegen/src/main/kotlin/tools/samt/codegen/PublicApi.kt b/codegen/src/main/kotlin/tools/samt/codegen/PublicApi.kt index 63a7ec85..2a1bc949 100644 --- a/codegen/src/main/kotlin/tools/samt/codegen/PublicApi.kt +++ b/codegen/src/main/kotlin/tools/samt/codegen/PublicApi.kt @@ -33,7 +33,7 @@ interface TransportConfigurationParserParams { fun reportInfo(message: String, context: ConfigurationElement? = null) } -interface ConfigurationElement { +interface ConfigurationElement { val asObject: ConfigurationObject val asValue: ConfigurationValue val asList: ConfigurationList @@ -51,6 +51,7 @@ interface ConfigurationList : ConfigurationElement { interface ConfigurationValue : ConfigurationElement { val asString: String + val asIdentifier: String fun > asEnum(enum: Class): T val asLong: Long val asDouble: Double diff --git a/codegen/src/main/kotlin/tools/samt/codegen/TransportConfigurationMapper.kt b/codegen/src/main/kotlin/tools/samt/codegen/TransportConfigurationMapper.kt index d36ac088..0e4bb018 100644 --- a/codegen/src/main/kotlin/tools/samt/codegen/TransportConfigurationMapper.kt +++ b/codegen/src/main/kotlin/tools/samt/codegen/TransportConfigurationMapper.kt @@ -14,7 +14,7 @@ class TransportConfigurationMapper( private fun tools.samt.parser.Node.reportAndThrow(message: String): Nothing { reportError(controller) { message(message) - highlight("related configuration", location) + highlight("offending configuration", location) } error(message) } @@ -33,6 +33,7 @@ class TransportConfigurationMapper( private fun tools.samt.parser.IntegerNode.toConfigurationValue() = object : ConfigurationValue { override val asString: String get() = reportAndThrow("Unexpected integer, expected a string") + override val asIdentifier: String get() = reportAndThrow("Unexpected integer, expected an identifier") override fun > asEnum(enum: Class): T = reportAndThrow("Unexpected integer, expected an enum (${enum.simpleName})") @@ -51,8 +52,9 @@ class TransportConfigurationMapper( private fun tools.samt.parser.FloatNode.toConfigurationValue() = object : ConfigurationValue { override val asString: String get() = reportAndThrow("Unexpected float, expected a string") + override val asIdentifier: String get() = reportAndThrow("Unexpected float, expected an identifier") - override fun > asEnum(enum: Class): T = + override fun > asEnum(enum: Class): T = reportAndThrow("Unexpected float, expected an enum (${enum.simpleName})") override val asLong: Long get() = reportAndThrow("Unexpected float, expected an integer") @@ -69,8 +71,9 @@ class TransportConfigurationMapper( private fun tools.samt.parser.StringNode.toConfigurationValue() = object : ConfigurationValue { override val asString: String get() = value + override val asIdentifier: String get() = reportAndThrow("Unexpected string, expected an identifier") - override fun > asEnum(enum: Class): T { + override fun > asEnum(enum: Class): T { check(enum.isEnum) return enum.enumConstants.find { it.name.equals(value, ignoreCase = true) } ?: reportAndThrow("Illegal enum value, expected one of ${enum.enumConstants.joinToString { it.name }}") @@ -90,6 +93,7 @@ class TransportConfigurationMapper( private fun tools.samt.parser.BooleanNode.toConfigurationValue() = object : ConfigurationValue { override val asString: String get() = reportAndThrow("Unexpected boolean, expected a string") + override val asIdentifier: String get() = reportAndThrow("Unexpected boolean, expected an identifier") override fun > asEnum(enum: Class): T = reportAndThrow("Unexpected boolean, expected an enum (${enum.simpleName})") @@ -108,6 +112,7 @@ class TransportConfigurationMapper( private fun tools.samt.parser.IdentifierNode.toConfigurationValue() = object : ConfigurationValue { override val asString: String get() = reportAndThrow("Unexpected identifier, expected a string") + override val asIdentifier: String get() = name override fun > asEnum(enum: Class): T = reportAndThrow("Unexpected identifier, expected an enum (${enum.simpleName})") diff --git a/codegen/src/main/kotlin/tools/samt/codegen/http/HttpTransport.kt b/codegen/src/main/kotlin/tools/samt/codegen/http/HttpTransport.kt index 03e8d399..52ccce0c 100644 --- a/codegen/src/main/kotlin/tools/samt/codegen/http/HttpTransport.kt +++ b/codegen/src/main/kotlin/tools/samt/codegen/http/HttpTransport.kt @@ -18,20 +18,23 @@ object HttpTransportConfigurationParser : TransportConfigurationParser { ?: HttpTransportConfiguration.SerializationMode.Json val services = config.getFieldOrNull("operations")?.asObject?.let { operations -> - // TODO This currently fails horribly if an operation is called basePath - val servicePath = operations.getFieldOrNull("basePath")?.asValue?.asString ?: "" operations.asObject.fields.map { (operationsKey, operationsField) -> + // TODO This currently fails horribly if an operation is called basePath + val servicePath = operations.getFieldOrNull("basePath")?.asValue?.asString ?: "" val service = operationsKey.asServiceName val serviceName = service.name val operationConfiguration = operationsField.asObject - val parsedOperations = operationConfiguration.fields.map { (key, value) -> + val parsedOperations = operationConfiguration.fields.filterKeys { it.asIdentifier != "basePath" }.map { (key, value) -> val operationConfig = value.asValue val operation = key.asOperationName(service) val operationName = operation.name - val words = operationConfig.asString.split(" ") - if (words.size < 2) { + val methodEndpointRegex = Regex("""(\w+)\s+(\S+)(.*)""") + val parameterRegex = Regex("""\{(.*?) in (.*?)}""") + + val methodEndpointResult = methodEndpointRegex.matchEntire(operationConfig.asString) + if (methodEndpointResult == null) { params.reportError( "Invalid operation config for '$operationName', expected ' '", operationConfig @@ -39,20 +42,20 @@ object HttpTransportConfigurationParser : TransportConfigurationParser { error("Invalid operation config for '$operationName', expected ' '") } - val methodEnum = when (val methodName = words[0]) { + val (method, path, parameterPart) = methodEndpointResult.destructured + + val methodEnum = when (method) { "GET" -> HttpTransportConfiguration.HttpMethod.Get "POST" -> HttpTransportConfiguration.HttpMethod.Post "PUT" -> HttpTransportConfiguration.HttpMethod.Put "DELETE" -> HttpTransportConfiguration.HttpMethod.Delete "PATCH" -> HttpTransportConfiguration.HttpMethod.Patch else -> { - params.reportError("Invalid http method '$methodName'", operationConfig) - error("Invalid http method '$methodName'") + params.reportError("Invalid http method '$method'", operationConfig) + error("Invalid http method '$method'") } } - val path = words[1] - val parameterConfigParts = words.drop(2) val parameters = mutableListOf() // parse path and path parameters @@ -76,35 +79,10 @@ object HttpTransportConfigurationParser : TransportConfigurationParser { ) } + val parameterResults = parameterRegex.findAll(parameterPart) // parse parameter declarations - for (component in parameterConfigParts) { - if (!component.startsWith("{") || !component.endsWith("}")) { - params.reportError( - "Expected parameter in format '{type:name}', got '$component'", - operationConfig - ) - continue - } - - val parameterConfig = component.substring(1, component.length - 1) - if (parameterConfig.isEmpty()) { - params.reportError( - "Expected parameter name between curly braces in '$path'", - operationConfig - ) - continue - } - - val parts = parameterConfig.split(":") - if (parts.size != 2) { - params.reportError( - "Expected parameter in format '{type:name}', got '$component'", - operationConfig - ) - continue - } - - val (type, name) = parts + for (parameterResult in parameterResults) { + val (names, type) = parameterResult.destructured val transportMode = when (type) { "query" -> HttpTransportConfiguration.TransportMode.Query "header" -> HttpTransportConfiguration.TransportMode.Header @@ -116,10 +94,12 @@ object HttpTransportConfigurationParser : TransportConfigurationParser { } } - parameters += HttpTransportConfiguration.ParameterConfiguration( - name = name, - transportMode = transportMode, - ) + names.split(",").forEach { name -> + parameters += HttpTransportConfiguration.ParameterConfiguration( + name = name.trim(), + transportMode = transportMode, + ) + } } HttpTransportConfiguration.OperationConfiguration( From bdd53f86ec82a827ea7775ce65e318731e9b3f49 Mon Sep 17 00:00:00 2001 From: Pascal Honegger Date: Sun, 28 May 2023 23:05:37 +0200 Subject: [PATCH 28/41] feat(examples): model OpenAPI petstore in SAMT --- specification/examples/.gitignore | 1 + specification/examples/greeter.samt | 2 +- specification/examples/petstore/.samtrc.yaml | 1 + specification/examples/petstore/samt.yaml | 2 + .../examples/petstore/src/common.samt | 7 +++ .../examples/petstore/src/pet-provider.samt | 21 +++++++++ specification/examples/petstore/src/pet.samt | 43 +++++++++++++++++++ .../examples/petstore/src/store-provider.samt | 18 ++++++++ .../examples/petstore/src/store.samt | 23 ++++++++++ .../examples/petstore/src/user-provider.samt | 21 +++++++++ specification/examples/petstore/src/user.samt | 22 ++++++++++ specification/examples/samt.yaml | 2 - 12 files changed, 160 insertions(+), 3 deletions(-) create mode 100644 specification/examples/.gitignore create mode 100644 specification/examples/petstore/.samtrc.yaml create mode 100644 specification/examples/petstore/samt.yaml create mode 100644 specification/examples/petstore/src/common.samt create mode 100644 specification/examples/petstore/src/pet-provider.samt create mode 100644 specification/examples/petstore/src/pet.samt create mode 100644 specification/examples/petstore/src/store-provider.samt create mode 100644 specification/examples/petstore/src/store.samt create mode 100644 specification/examples/petstore/src/user-provider.samt create mode 100644 specification/examples/petstore/src/user.samt diff --git a/specification/examples/.gitignore b/specification/examples/.gitignore new file mode 100644 index 00000000..78d42947 --- /dev/null +++ b/specification/examples/.gitignore @@ -0,0 +1 @@ +*.kt diff --git a/specification/examples/greeter.samt b/specification/examples/greeter.samt index 6f01a3f8..3c57a764 100644 --- a/specification/examples/greeter.samt +++ b/specification/examples/greeter.samt @@ -18,7 +18,7 @@ record GreetResponse { @Description("foo bar This is some very long comment which describes the service. - baz") + ") timestamp: DateTime } diff --git a/specification/examples/petstore/.samtrc.yaml b/specification/examples/petstore/.samtrc.yaml new file mode 100644 index 00000000..c35faf33 --- /dev/null +++ b/specification/examples/petstore/.samtrc.yaml @@ -0,0 +1 @@ +extends: strict diff --git a/specification/examples/petstore/samt.yaml b/specification/examples/petstore/samt.yaml new file mode 100644 index 00000000..68b29443 --- /dev/null +++ b/specification/examples/petstore/samt.yaml @@ -0,0 +1,2 @@ +generators: + - name: kotlin-ktor-provider diff --git a/specification/examples/petstore/src/common.samt b/specification/examples/petstore/src/common.samt new file mode 100644 index 00000000..0bc70507 --- /dev/null +++ b/specification/examples/petstore/src/common.samt @@ -0,0 +1,7 @@ +package org.openapitools.examples.petstore + +typealias ID = Long + +typealias UUID = String ( pattern("[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}") ) + +record NotFoundFault diff --git a/specification/examples/petstore/src/pet-provider.samt b/specification/examples/petstore/src/pet-provider.samt new file mode 100644 index 00000000..06904843 --- /dev/null +++ b/specification/examples/petstore/src/pet-provider.samt @@ -0,0 +1,21 @@ +package org.openapitools.examples.petstore + +provide PetEndpointHTTP { + implements PetService + + transport http { + serialization: "json", + operations: { + PetService: { + basePath: "/pet", + addPet: "POST /", + updatePet: "PUT /", + findPetsByStatus: "GET /findByStatus {status in query}", + findPetsByTags: "GET /findByTags {tags in query}", + getPetById: "GET /{petId}", + updatePetWithForm: "POST /{petId} {name, status in query}", + deletePet: "DELETE /petId}" + } + } + } +} diff --git a/specification/examples/petstore/src/pet.samt b/specification/examples/petstore/src/pet.samt new file mode 100644 index 00000000..b15a1bc6 --- /dev/null +++ b/specification/examples/petstore/src/pet.samt @@ -0,0 +1,43 @@ +package org.openapitools.examples.petstore + +record Pet { + id: ID? + name: String + category: Category? + photoUrls: List + tags: List? + status: PetStatus +} + +record Category { + id: ID? + name: String? +} + +record Tag { + id: ID? + name: String? +} + +record ApiResponse { + code: Int? + type: String? + message: String? +} + +enum PetStatus { + available, + pending, + sold +} + +service PetService { + addPet(newPet: Pet): Pet + updatePet(updatedPet: Pet): Pet + findPetsByStatus(status: PetStatus): List + findPetsByTags(tags: List): List + getPetById(petId: ID): Pet + updatePetWithForm(petId: ID, name: String?, status: PetStatus?): Pet + deletePet(petId: ID): Pet + uploadImage(petId: ID, additionalMetadata: String?, file: Bytes): ApiResponse +} diff --git a/specification/examples/petstore/src/store-provider.samt b/specification/examples/petstore/src/store-provider.samt new file mode 100644 index 00000000..6204bd3c --- /dev/null +++ b/specification/examples/petstore/src/store-provider.samt @@ -0,0 +1,18 @@ +package org.openapitools.examples.petstore + +provide StoreEndpointHTTP { + implements StoreService + + transport http { + serialization: "json", + operations: { + StoreService: { + basePath: "/store", + getInventory: "GET /inventory", + placeOrder: "POST /order", + getOrderById: "GET /order/{orderId}", + deleteOrder: "DELETE /order/{orderId}" + } + } + } +} diff --git a/specification/examples/petstore/src/store.samt b/specification/examples/petstore/src/store.samt new file mode 100644 index 00000000..90b70298 --- /dev/null +++ b/specification/examples/petstore/src/store.samt @@ -0,0 +1,23 @@ +package org.openapitools.examples.petstore + +record Order { + id: ID? + petId: ID? + quantity: Int? + shipDate: DateTime? + status: OrderStatus? + complete: Boolean? +} + +enum OrderStatus { + placed, + approved, + delivered +} + +service StoreService { + getInventory(): Map + placeOrder(order: Order): Order + getOrderById(orderId: ID): Order + deleteOrder(orderId: ID) +} diff --git a/specification/examples/petstore/src/user-provider.samt b/specification/examples/petstore/src/user-provider.samt new file mode 100644 index 00000000..dad6ace6 --- /dev/null +++ b/specification/examples/petstore/src/user-provider.samt @@ -0,0 +1,21 @@ +package org.openapitools.examples.petstore + +provide UserEndpointHTTP { + implements UserService + + transport http { + serialization: "json", + operations: { + UserService: { + basePath: "/user", + createUser: "POST /", + createUsers: "POST /createWithList", + login: "GET /login {username, password in query}", + logout: "GET /logout", + getUserByUsername: "GET /{username}", + updateUser: "PUT /{username}", + deleteUser: "DELETE /{username}" + } + } + } +} diff --git a/specification/examples/petstore/src/user.samt b/specification/examples/petstore/src/user.samt new file mode 100644 index 00000000..ce343784 --- /dev/null +++ b/specification/examples/petstore/src/user.samt @@ -0,0 +1,22 @@ +package org.openapitools.examples.petstore + +record User { + id: ID? + username: String? + firstName: String? + lastName: String? + email: String? + password: String? + phone: String? + userStatus: Int? +} + +service UserService { + createUser(user: User): User + createUsers(users: List): User + login(username: String, password: String): String + logout() + getUserByUsername(username: String): User + updateUser(username: String, user: User): User + deleteUser(username: String) +} diff --git a/specification/examples/samt.yaml b/specification/examples/samt.yaml index 109ac346..67a45950 100644 --- a/specification/examples/samt.yaml +++ b/specification/examples/samt.yaml @@ -1,7 +1,5 @@ source: ./todo-service generators: - - name: kotlin-types - output: ./out - name: kotlin-ktor-consumer output: ./out From 0baae45e4b2dfe067b1617e11b696d6614cef661 Mon Sep 17 00:00:00 2001 From: Pascal Honegger Date: Mon, 29 May 2023 17:26:39 +0200 Subject: [PATCH 29/41] feat(codegen): correctly generate petstore --- .../kotlin/tools/samt/codegen/PublicApi.kt | 58 +++++++++++ .../tools/samt/codegen/PublicApiMapper.kt | 70 +++++++++----- .../codegen/kotlin/KotlinGeneratorUtils.kt | 2 +- .../codegen/kotlin/KotlinTypesGenerator.kt | 12 ++- .../ktor/KotlinKtorConsumerGenerator.kt | 14 ++- .../ktor/KotlinKtorGeneratorUtilities.kt | 95 ++++++++++++++----- .../ktor/KotlinKtorProviderGenerator.kt | 4 +- 7 files changed, 195 insertions(+), 60 deletions(-) diff --git a/codegen/src/main/kotlin/tools/samt/codegen/PublicApi.kt b/codegen/src/main/kotlin/tools/samt/codegen/PublicApi.kt index 2a1bc949..54ee9c4b 100644 --- a/codegen/src/main/kotlin/tools/samt/codegen/PublicApi.kt +++ b/codegen/src/main/kotlin/tools/samt/codegen/PublicApi.kt @@ -179,12 +179,70 @@ interface ConsumerUses { } interface TypeReference { + /** + * The type this reference points to + */ val type: Type + + /** + * Is true if this type reference is optional, meaning it can be null + */ val isOptional: Boolean + + /** + * The range constraints placed on this type, if any + */ val rangeConstraint: Constraint.Range? + + /** + * The size constraints placed on this type, if any + */ val sizeConstraint: Constraint.Size? + + /** + * The pattern constraints placed on this type, if any + */ val patternConstraint: Constraint.Pattern? + + /** + * The value constraints placed on this type, if any + */ val valueConstraint: Constraint.Value? + + /** + * The runtime type this reference points to, could be different from [type] if this is an alias + */ + val runtimeType: Type + + /** + * Is true if this type reference or underlying type is optional, meaning it can be null at runtime + * This is different from [isOptional] in that it will return true for an alias that points to an optional type + */ + val isRuntimeOptional: Boolean + + /** + * The runtime range constraints placed on this type, if any. + * Will differ from [rangeConstraint] if this is an alias + */ + val runtimeRangeConstraint: Constraint.Range? + + /** + * The runtime size constraints placed on this type, if any. + * Will differ from [sizeConstraint] if this is an alias + */ + val runtimeSizeConstraint: Constraint.Size? + + /** + * The runtime pattern constraints placed on this type, if any. + * Will differ from [patternConstraint] if this is an alias + */ + val runtimePatternConstraint: Constraint.Pattern? + + /** + * The runtime value constraints placed on this type, if any. + * Will differ from [valueConstraint] if this is an alias + */ + val runtimeValueConstraint: Constraint.Value? } interface Constraint { diff --git a/codegen/src/main/kotlin/tools/samt/codegen/PublicApiMapper.kt b/codegen/src/main/kotlin/tools/samt/codegen/PublicApiMapper.kt index f865808c..e4181cea 100644 --- a/codegen/src/main/kotlin/tools/samt/codegen/PublicApiMapper.kt +++ b/codegen/src/main/kotlin/tools/samt/codegen/PublicApiMapper.kt @@ -55,14 +55,16 @@ class PublicApiMapper( override val name = this@toPublicRequestResponseOperation.name override val parameters = this@toPublicRequestResponseOperation.parameters.map { it.toPublicParameter() } override val returnType = this@toPublicRequestResponseOperation.returnType?.toPublicTypeReference() - override val raisesTypes = this@toPublicRequestResponseOperation.raisesTypes.map { it.toPublicTypeReference() } + override val raisesTypes = + this@toPublicRequestResponseOperation.raisesTypes.map { it.toPublicTypeReference() } override val isAsync = this@toPublicRequestResponseOperation.isAsync } - private fun tools.samt.semantic.ServiceType.Operation.Parameter.toPublicParameter() = object : ServiceOperationParameter { - override val name = this@toPublicParameter.name - override val type = this@toPublicParameter.type.toPublicTypeReference() - } + private fun tools.samt.semantic.ServiceType.Operation.Parameter.toPublicParameter() = + object : ServiceOperationParameter { + override val name = this@toPublicParameter.name + override val type = this@toPublicParameter.type.toPublicTypeReference() + } private fun tools.samt.semantic.ProviderType.toPublicProvider() = object : ProviderType { override val name = this@toPublicProvider.name @@ -108,6 +110,7 @@ class PublicApiMapper( return transportConfigurationParser.default() } } + else -> controller.reportGlobalError("Multiple transport configuration parsers found for transport '$name'") } @@ -142,20 +145,40 @@ class PublicApiMapper( private fun tools.samt.semantic.TypeReference?.toPublicTypeReference(): TypeReference { check(this is tools.samt.semantic.ResolvedTypeReference) + val typeReference = this@toPublicTypeReference + val runtimeTypeReference = when (val type = typeReference.type) { + is tools.samt.semantic.AliasType -> checkNotNull(type.fullyResolvedType) { "Found unresolved alias when generating code" } + else -> typeReference + } return object : TypeReference { - override val type = this@toPublicTypeReference.type.toPublicType() - override val isOptional = this@toPublicTypeReference.isOptional + override val type = typeReference.type.toPublicType() + override val isOptional = typeReference.isOptional override val rangeConstraint = - this@toPublicTypeReference.constraints.findConstraint() + typeReference.constraints.findConstraint() ?.toPublicRangeConstraint() override val sizeConstraint = - this@toPublicTypeReference.constraints.findConstraint() + typeReference.constraints.findConstraint() ?.toPublicSizeConstraint() override val patternConstraint = - this@toPublicTypeReference.constraints.findConstraint() + typeReference.constraints.findConstraint() ?.toPublicPatternConstraint() override val valueConstraint = - this@toPublicTypeReference.constraints.findConstraint() + typeReference.constraints.findConstraint() + ?.toPublicValueConstraint() + + override val runtimeType = runtimeTypeReference.type.toPublicType() + override val isRuntimeOptional = isOptional || runtimeTypeReference.isOptional + override val runtimeRangeConstraint = rangeConstraint + ?: runtimeTypeReference.constraints.findConstraint() + ?.toPublicRangeConstraint() + override val runtimeSizeConstraint = sizeConstraint + ?: runtimeTypeReference.constraints.findConstraint() + ?.toPublicSizeConstraint() + override val runtimePatternConstraint = patternConstraint + ?: runtimeTypeReference.constraints.findConstraint() + ?.toPublicPatternConstraint() + override val runtimeValueConstraint = valueConstraint + ?: runtimeTypeReference.constraints.findConstraint() ?.toPublicValueConstraint() } } @@ -191,24 +214,27 @@ class PublicApiMapper( tools.samt.semantic.UnknownType -> error("Unknown type cannot be converted to public API") } - private fun tools.samt.semantic.ResolvedTypeReference.Constraint.Range.toPublicRangeConstraint() = object : Constraint.Range { - override val lowerBound = this@toPublicRangeConstraint.lowerBound - override val upperBound = this@toPublicRangeConstraint.upperBound - } + private fun tools.samt.semantic.ResolvedTypeReference.Constraint.Range.toPublicRangeConstraint() = + object : Constraint.Range { + override val lowerBound = this@toPublicRangeConstraint.lowerBound + override val upperBound = this@toPublicRangeConstraint.upperBound + } - private fun tools.samt.semantic.ResolvedTypeReference.Constraint.Size.toPublicSizeConstraint() = object : Constraint.Size { - override val lowerBound = this@toPublicSizeConstraint.lowerBound - override val upperBound = this@toPublicSizeConstraint.upperBound - } + private fun tools.samt.semantic.ResolvedTypeReference.Constraint.Size.toPublicSizeConstraint() = + object : Constraint.Size { + override val lowerBound = this@toPublicSizeConstraint.lowerBound + override val upperBound = this@toPublicSizeConstraint.upperBound + } private fun tools.samt.semantic.ResolvedTypeReference.Constraint.Pattern.toPublicPatternConstraint() = object : Constraint.Pattern { override val pattern = this@toPublicPatternConstraint.pattern } - private fun tools.samt.semantic.ResolvedTypeReference.Constraint.Value.toPublicValueConstraint() = object : Constraint.Value { - override val value = this@toPublicValueConstraint.value - } + private fun tools.samt.semantic.ResolvedTypeReference.Constraint.Value.toPublicValueConstraint() = + object : Constraint.Value { + override val value = this@toPublicValueConstraint.value + } private fun tools.samt.semantic.UserDeclaredNamedType.getQualifiedName(): String { val components = parentPackage.nameComponents + name diff --git a/codegen/src/main/kotlin/tools/samt/codegen/kotlin/KotlinGeneratorUtils.kt b/codegen/src/main/kotlin/tools/samt/codegen/kotlin/KotlinGeneratorUtils.kt index 37a25a85..ef0c6a96 100644 --- a/codegen/src/main/kotlin/tools/samt/codegen/kotlin/KotlinGeneratorUtils.kt +++ b/codegen/src/main/kotlin/tools/samt/codegen/kotlin/KotlinGeneratorUtils.kt @@ -8,7 +8,7 @@ object KotlinGeneratorConfig { } val GeneratedFilePreamble = """ - @file:Suppress("RemoveRedundantQualifierName", "unused", "UnusedImport", "LocalVariableName", "FunctionName", "ConvertTwoComparisonsToRangeCheck", "ReplaceSizeCheckWithIsNotEmpty", "NAME_SHADOWING", "UNUSED_VARIABLE") + @file:Suppress("RemoveRedundantQualifierName", "unused", "UnusedImport", "LocalVariableName", "FunctionName", "ConvertTwoComparisonsToRangeCheck", "ReplaceSizeCheckWithIsNotEmpty", "NAME_SHADOWING", "UNUSED_VARIABLE", "NestedLambdaShadowedImplicitParameter", "KotlinRedundantDiagnosticSuppress") /* * This file is generated by SAMT, manual changes will be overwritten. diff --git a/codegen/src/main/kotlin/tools/samt/codegen/kotlin/KotlinTypesGenerator.kt b/codegen/src/main/kotlin/tools/samt/codegen/kotlin/KotlinTypesGenerator.kt index 98efa2e3..7bbfe4b5 100644 --- a/codegen/src/main/kotlin/tools/samt/codegen/kotlin/KotlinTypesGenerator.kt +++ b/codegen/src/main/kotlin/tools/samt/codegen/kotlin/KotlinTypesGenerator.kt @@ -47,12 +47,17 @@ object KotlinTypesGenerator : Generator { } private fun StringBuilder.appendRecord(record: RecordType, options: Map) { + if (record.fields.isEmpty()) { + appendLine("class ${record.name}") + appendLine() + return + } + appendLine("data class ${record.name}(") record.fields.forEach { field -> val fullyQualifiedName = field.type.getQualifiedName(options) - val isOptional = field.type.isOptional - if (isOptional) { + if (field.type.isRuntimeOptional) { appendLine(" val ${field.name}: $fullyQualifiedName = null,") } else { appendLine(" val ${field.name}: $fullyQualifiedName,") @@ -116,9 +121,8 @@ object KotlinTypesGenerator : Generator { private fun StringBuilder.appendServiceOperationParameterList(parameters: List, options: Map) { parameters.forEach { parameter -> val fullyQualifiedName = parameter.type.getQualifiedName(options) - val isOptional = parameter.type.isOptional - if (isOptional) { + if (parameter.type.isRuntimeOptional) { appendLine(" ${parameter.name}: $fullyQualifiedName = null,") } else { appendLine(" ${parameter.name}: $fullyQualifiedName,") diff --git a/codegen/src/main/kotlin/tools/samt/codegen/kotlin/ktor/KotlinKtorConsumerGenerator.kt b/codegen/src/main/kotlin/tools/samt/codegen/kotlin/ktor/KotlinKtorConsumerGenerator.kt index 14fd1314..a5c2b804 100644 --- a/codegen/src/main/kotlin/tools/samt/codegen/kotlin/ktor/KotlinKtorConsumerGenerator.kt +++ b/codegen/src/main/kotlin/tools/samt/codegen/kotlin/ktor/KotlinKtorConsumerGenerator.kt @@ -105,7 +105,7 @@ object KotlinKtorConsumerGenerator : Generator { appendConsumerServiceCall(info, operation, transportConfiguration, options) appendCheckResponseStatus(operation) - appendConsumerResponseParsing(operation, transportConfiguration, options) + appendConsumerResponseParsing(operation, options) appendLine(" }") } @@ -121,6 +121,7 @@ object KotlinKtorConsumerGenerator : Generator { appendLine(" }") } } + appendLine() } info.notImplementedOperations.forEach { operation -> @@ -195,7 +196,7 @@ object KotlinKtorConsumerGenerator : Generator { appendLine(" // Encode query parameters") queryParameters.forEach { (name, queryParameter) -> - if (queryParameter.type.isOptional) { + if (queryParameter.type.isRuntimeOptional) { appendLine(" if ($name != null) {") appendLine(" this.parameters.append(\"$name\", ${encodeJsonElement(queryParameter.type, options, valueName = name)}.toString())") appendLine(" }") @@ -216,7 +217,7 @@ object KotlinKtorConsumerGenerator : Generator { // header parameters headerParameters.forEach { (name, headerParameter) -> - if (headerParameter.type.isOptional) { + if (headerParameter.type.isRuntimeOptional) { appendLine(" if ($name != null) {") appendLine(" header(\"${name}\", ${encodeJsonElement(headerParameter.type, options, valueName = name)})") appendLine(" }") @@ -227,7 +228,7 @@ object KotlinKtorConsumerGenerator : Generator { // cookie parameters cookieParameters.forEach { (name, cookieParameter) -> - if (cookieParameter.type.isOptional) { + if (cookieParameter.type.isRuntimeOptional) { appendLine(" if ($name != null) {") appendLine(" cookie(\"${name}\", ${encodeJsonElement(cookieParameter.type, options, valueName = name)}.toString())") appendLine(" }") @@ -252,7 +253,10 @@ object KotlinKtorConsumerGenerator : Generator { appendLine(" check(`client response`.status.isSuccess()) { \"${operation.name} failed with status \${`client response`.status}\" }") } - private fun StringBuilder.appendConsumerResponseParsing(operation: RequestResponseOperation, transport: HttpTransportConfiguration, options: Map) { + private fun StringBuilder.appendConsumerResponseParsing( + operation: RequestResponseOperation, + options: Map + ) { operation.returnType?.let { returnType -> appendLine(" val bodyAsText = `client response`.bodyAsText()") appendLine(" val jsonElement = Json.parseToJsonElement(bodyAsText)") diff --git a/codegen/src/main/kotlin/tools/samt/codegen/kotlin/ktor/KotlinKtorGeneratorUtilities.kt b/codegen/src/main/kotlin/tools/samt/codegen/kotlin/ktor/KotlinKtorGeneratorUtilities.kt index c8de1d6b..c5321d62 100644 --- a/codegen/src/main/kotlin/tools/samt/codegen/kotlin/ktor/KotlinKtorGeneratorUtilities.kt +++ b/codegen/src/main/kotlin/tools/samt/codegen/kotlin/ktor/KotlinKtorGeneratorUtilities.kt @@ -6,7 +6,7 @@ import tools.samt.codegen.kotlin.getQualifiedName import tools.samt.codegen.kotlin.getTargetPackage fun mappingFileContent(pack: SamtPackage, options: Map) = buildString { - if (pack.records.isNotEmpty() || pack.enums.isNotEmpty()) { + if (pack.records.isNotEmpty() || pack.enums.isNotEmpty() || pack.aliases.isNotEmpty()) { appendLine(GeneratedFilePreamble) appendLine() appendLine("package ${pack.getQualifiedName(options)}") @@ -26,6 +26,12 @@ fun mappingFileContent(pack: SamtPackage, options: Map) = buildS appendDecodeEnum(enum, options) appendLine() } + + pack.aliases.forEach { alias -> + appendEncodeAlias(alias, options) + appendDecodeAlias(alias, options) + appendLine() + } } } @@ -34,8 +40,7 @@ private fun StringBuilder.appendEncodeRecord( options: Map, ) { appendLine("/** Encode and validate record ${record.qualifiedName} to JSON */") - appendLine("fun `encode ${record.name}`(record: ${record.getQualifiedName(options)}?): JsonElement {") - appendLine(" if (record == null) return JsonNull") + appendLine("fun `encode ${record.name}`(record: ${record.getQualifiedName(options)}): JsonElement {") for (field in record.fields) { appendEncodeRecordField(field, options) } @@ -69,10 +74,10 @@ private fun StringBuilder.appendDecodeRecord( private fun StringBuilder.appendEncodeEnum(enum: EnumType, options: Map) { val enumName = enum.getQualifiedName(options) appendLine("/** Encode enum ${enum.qualifiedName} to JSON */") - appendLine("fun `encode ${enum.name}`(value: ${enumName}?) = when(value) {") - appendLine(" null -> null") + appendLine("fun `encode ${enum.name}`(value: ${enumName}?): JsonElement = when(value) {") + appendLine(" null -> JsonNull") enum.values.forEach { value -> - appendLine(" ${enumName}.${value} -> \"${value}\"") + appendLine(" ${enumName}.${value} -> JsonPrimitive(\"${value}\")") } appendLine(" ${enumName}.FAILED_TO_PARSE -> error(\"Cannot encode FAILED_TO_PARSE value\")") appendLine("}") @@ -81,7 +86,7 @@ private fun StringBuilder.appendEncodeEnum(enum: EnumType, options: Map) { val enumName = enum.getQualifiedName(options) appendLine("/** Decode enum ${enum.qualifiedName} from JSON */") - appendLine("fun `decode ${enum.name}`(json: JsonElement) = when(json.jsonPrimitive.content) {") + appendLine("fun `decode ${enum.name}`(json: JsonElement): $enumName = when(json.jsonPrimitive.content) {") enum.values.forEach { value -> appendLine(" \"${value}\" -> ${enumName}.${value}") } @@ -93,12 +98,7 @@ private fun StringBuilder.appendDecodeEnum(enum: EnumType, options: Map) { appendLine(" // Encode field ${field.name}") appendLine(" val `field ${field.name}` = run {") - append(" val value = ") - if (field.type.isOptional) { - append("record.${field.name} ?: return@run JsonNull") - } else { - append("record.${field.name}") - } + append(" val value = record.${field.name}") appendLine() appendLine(" ${encodeJsonElement(field.type, options)}") appendLine(" }") @@ -108,8 +108,8 @@ private fun StringBuilder.appendDecodeRecordField(field: RecordField, options: M appendLine(" // Decode field ${field.name}") appendLine(" val `field ${field.name}` = run {") append(" val jsonElement = ") - if (field.type.isOptional) { - append("json.jsonObject[\"${field.name}\"] ?: return@run null") + if (field.type.isRuntimeOptional) { + append("json.jsonObject[\"${field.name}\"] ?: JsonNull") } else { append("json.jsonObject[\"${field.name}\"]!!") } @@ -118,8 +118,28 @@ private fun StringBuilder.appendDecodeRecordField(field: RecordField, options: M appendLine(" }") } -fun encodeJsonElement(typeReference: TypeReference, options: Map, valueName: String = "value"): String = - when (val type = typeReference.type) { +private fun StringBuilder.appendEncodeAlias(alias: AliasType, options: Map) { + appendLine("/** Encode alias ${alias.qualifiedName} to JSON */") + appendLine("fun `encode ${alias.name}`(value: ${alias.getQualifiedName(options)}): JsonElement =") + appendLine(" ${encodeJsonElement(alias.fullyResolvedType, options, valueName = "value")}") +} + +private fun StringBuilder.appendDecodeAlias(alias: AliasType, options: Map) { + appendLine("/** Decode alias ${alias.qualifiedName} from JSON */") + appendLine("fun `decode ${alias.name}`(json: JsonElement): ${alias.fullyResolvedType.getQualifiedName(options)} {") + if (alias.fullyResolvedType.isRuntimeOptional) { + appendLine(" if (json is JsonNull) return null") + } + appendLine(" return ${decodeJsonElement(alias.fullyResolvedType, options, valueName = "json")}") + appendLine("}") +} + +/** + * Encode a [typeReference] to a JSON element. + * The resulting expression will always be a JsonElement. + */ +fun encodeJsonElement(typeReference: TypeReference, options: Map, valueName: String = "value"): String { + val convertExpression = when (val type = typeReference.type) { is LiteralType -> { val getContent = when (type) { is StringType, @@ -135,17 +155,28 @@ fun encodeJsonElement(typeReference: TypeReference, options: Map is DurationType -> "${valueName}.toString()" else -> error("Unsupported literal type: ${type.javaClass.simpleName}") } - "Json.encodeToJsonElement($getContent${validateLiteralConstraintsSuffix(typeReference)})" + "JsonPrimitive($getContent${validateLiteralConstraintsSuffix(typeReference)})" } - is ListType -> "${valueName}.map { ${encodeJsonElement(type.elementType, options, valueName = "it")} }" - is MapType -> "${valueName}.mapValues { ${encodeJsonElement(type.valueType, options, valueName = "it")} }" + is ListType -> "JsonArray(${valueName}.map { ${encodeJsonElement(type.elementType, options, valueName = "it")} })" + is MapType -> "JsonObject(${valueName}.mapValues { (_, value) -> ${encodeJsonElement(type.valueType, options, valueName = "value")} })" is UserType -> "${type.getTargetPackage(options)}`encode ${type.name}`(${valueName})" else -> error("Unsupported type: ${type.javaClass.simpleName}") } + return if (typeReference.isRuntimeOptional) { + "$valueName?.let { $valueName -> $convertExpression } ?: JsonNull" + } else { + convertExpression + } +} + +/** + * Decode a [typeReference] from a JSON element. + * The resulting expression will always be a value of the type. + */ fun decodeJsonElement(typeReference: TypeReference, options: Map, valueName: String = "jsonElement"): String = when (val type = typeReference.type) { is LiteralType -> when (type) { @@ -157,14 +188,26 @@ fun decodeJsonElement(typeReference: TypeReference, options: Map is DoubleType -> "${valueName}.jsonPrimitive.double" is DecimalType -> "${valueName}.jsonPrimitive.content.let { java.math.BigDecimal(it) }" is BooleanType -> "${valueName}.jsonPrimitive.boolean" - is DateType -> "${valueName}.jsonPrimitive.content?.let { java.time.LocalDate.parse(it) }" - is DateTimeType -> "${valueName}.jsonPrimitive.content?.let { java.time.LocalDateTime.parse(it) }" - is DurationType -> "${valueName}.jsonPrimitive.content?.let { java.time.Duration.parse(it) }" + is DateType -> "${valueName}.jsonPrimitive.content.let { java.time.LocalDate.parse(it) }" + is DateTimeType -> "${valueName}.jsonPrimitive.content.let { java.time.LocalDateTime.parse(it) }" + is DurationType -> "${valueName}.jsonPrimitive.content.let { java.time.Duration.parse(it) }" else -> error("Unsupported literal type: ${type.javaClass.simpleName}") } + validateLiteralConstraintsSuffix(typeReference) - is ListType -> "${valueName}.jsonArray.map { ${decodeJsonElement(type.elementType, options, valueName = "it")} }" - is MapType -> "${valueName}.jsonObject.mapValues { ${decodeJsonElement(type.valueType, options, valueName = "it")} }" + is ListType -> { + val elementDecodeStatement = decodeJsonElement(type.elementType, options, valueName = "it") + if (type.elementType.isRuntimeOptional) + "${valueName}.jsonArray.map { it.takeUnless { it is JsonNull }?.let { $elementDecodeStatement } }" + else + "${valueName}.jsonArray.map { $elementDecodeStatement }" + } + is MapType -> { + val valueDecodeStatement = decodeJsonElement(type.valueType, options, valueName = "value") + if (type.valueType.isRuntimeOptional) + "${valueName}.jsonObject.mapValues { (_, value) -> value?.let { value -> $valueDecodeStatement } }" + else + "${valueName}.jsonObject.mapValues { (_, value) -> $valueDecodeStatement }" + } is UserType -> "${type.getTargetPackage(options)}`decode ${type.name}`(${valueName})" @@ -202,5 +245,5 @@ private fun validateLiteralConstraintsSuffix(typeReference: TypeReference): Stri return "" } - return "${if (typeReference.isOptional) "?.also" else ".also"} { require(${conditions.joinToString(" && ")}) }" + return ".also { require(${conditions.joinToString(" && ")}) }" } diff --git a/codegen/src/main/kotlin/tools/samt/codegen/kotlin/ktor/KotlinKtorProviderGenerator.kt b/codegen/src/main/kotlin/tools/samt/codegen/kotlin/ktor/KotlinKtorProviderGenerator.kt index 9d5bb339..a3a06968 100644 --- a/codegen/src/main/kotlin/tools/samt/codegen/kotlin/ktor/KotlinKtorProviderGenerator.kt +++ b/codegen/src/main/kotlin/tools/samt/codegen/kotlin/ktor/KotlinKtorProviderGenerator.kt @@ -193,7 +193,7 @@ object KotlinKtorProviderGenerator : Generator { appendLine(" val response = ${encodeJsonElement(returnType, options)}") appendLine() appendLine(" // Return response with 200 OK") - appendLine(" call.respondText(response.toString(), ContentType.Application.Json, HttpStatusCode.OK)") + appendLine(" call.respond(HttpStatusCode.OK, response)") } else { appendLine(" ${getServiceCall(info, operation)}") appendLine() @@ -285,7 +285,7 @@ object KotlinKtorProviderGenerator : Generator { ) { appendLine(" // Read from ${transportMode.name.lowercase()}") append(" val jsonElement = ") - if (parameter.type.isOptional) { + if (parameter.type.isRuntimeOptional) { when (transportMode) { HttpTransportConfiguration.TransportMode.Body -> append("body.jsonObject[\"${parameter.name}\"]?.takeUnless { it is JsonNull }") HttpTransportConfiguration.TransportMode.Query -> append("call.request.queryParameters[\"${parameter.name}\"]?.toJsonOrNull()") From 01391d8ee3aeb9cc684d12eab4067cf507484d62 Mon Sep 17 00:00:00 2001 From: Pascal Honegger Date: Mon, 29 May 2023 17:50:58 +0200 Subject: [PATCH 30/41] feat(codegen): fix style issues --- .../samt/codegen/kotlin/ktor/KotlinKtorGeneratorUtilities.kt | 2 +- .../main/kotlin/tools/samt/config/SamtConfigurationParser.kt | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/codegen/src/main/kotlin/tools/samt/codegen/kotlin/ktor/KotlinKtorGeneratorUtilities.kt b/codegen/src/main/kotlin/tools/samt/codegen/kotlin/ktor/KotlinKtorGeneratorUtilities.kt index c5321d62..3453e173 100644 --- a/codegen/src/main/kotlin/tools/samt/codegen/kotlin/ktor/KotlinKtorGeneratorUtilities.kt +++ b/codegen/src/main/kotlin/tools/samt/codegen/kotlin/ktor/KotlinKtorGeneratorUtilities.kt @@ -204,7 +204,7 @@ fun decodeJsonElement(typeReference: TypeReference, options: Map is MapType -> { val valueDecodeStatement = decodeJsonElement(type.valueType, options, valueName = "value") if (type.valueType.isRuntimeOptional) - "${valueName}.jsonObject.mapValues { (_, value) -> value?.let { value -> $valueDecodeStatement } }" + "${valueName}.jsonObject.mapValues { (_, value) -> value.takeUnless { it is JsonNull }?.let { value -> $valueDecodeStatement } }" else "${valueName}.jsonObject.mapValues { (_, value) -> $valueDecodeStatement }" } diff --git a/samt-config/src/main/kotlin/tools/samt/config/SamtConfigurationParser.kt b/samt-config/src/main/kotlin/tools/samt/config/SamtConfigurationParser.kt index 49e4b21c..871877bb 100644 --- a/samt-config/src/main/kotlin/tools/samt/config/SamtConfigurationParser.kt +++ b/samt-config/src/main/kotlin/tools/samt/config/SamtConfigurationParser.kt @@ -3,7 +3,6 @@ package tools.samt.config import com.charleskorn.kaml.* import kotlinx.serialization.SerializationException import java.nio.file.Path -import kotlin.io.path.Path import kotlin.io.path.exists import kotlin.io.path.inputStream import tools.samt.common.DiagnosticSeverity as CommonDiagnosticSeverity From 7a82c8f28cd3caf8885ba7ad6d31f122eae229a1 Mon Sep 17 00:00:00 2001 From: Pascal Honegger Date: Mon, 29 May 2023 17:51:03 +0200 Subject: [PATCH 31/41] feat(semantic): fix style issues --- .../tools/samt/semantic/ConstraintBuilder.kt | 35 +++++++++--------- .../kotlin/tools/samt/semantic/Package.kt | 2 +- .../tools/samt/semantic/SemanticModel.kt | 29 +++++++-------- .../SemanticModelAnnotationProcessor.kt | 20 +++++------ .../semantic/SemanticModelPostProcessor.kt | 36 ++++++++++--------- .../semantic/SemanticModelPreProcessor.kt | 6 ++-- .../SemanticModelReferenceResolver.kt | 22 ++++++------ 7 files changed, 75 insertions(+), 75 deletions(-) diff --git a/semantic/src/main/kotlin/tools/samt/semantic/ConstraintBuilder.kt b/semantic/src/main/kotlin/tools/samt/semantic/ConstraintBuilder.kt index 92877ecd..e750bef9 100644 --- a/semantic/src/main/kotlin/tools/samt/semantic/ConstraintBuilder.kt +++ b/semantic/src/main/kotlin/tools/samt/semantic/ConstraintBuilder.kt @@ -12,7 +12,7 @@ internal class ConstraintBuilder(private val controller: DiagnosticController) { is NumberNode -> expressionNode.value is WildcardNode -> null else -> { - controller.getOrCreateContext(expressionNode.location.source).error { + expressionNode.reportError(controller) { message("Range constraint argument must be a valid number range") highlight("neither a number nor '*'", expressionNode.location) help("A valid constraint would be range(1..10.5) or range(1..*)") @@ -25,7 +25,7 @@ internal class ConstraintBuilder(private val controller: DiagnosticController) { val higher = resolveSide(argument.right) if (lower == null && higher == null) { - controller.getOrCreateContext(argument.location.source).error { + argument.reportError(controller) { message("Range constraint must have at least one valid number") highlight("invalid constraint", argument.location) help("A valid constraint would be range(1..10.5) or range(1..*)") @@ -36,11 +36,10 @@ internal class ConstraintBuilder(private val controller: DiagnosticController) { if (lower is Double && higher is Double && lower > higher || lower is Long && higher is Long && lower > higher ) { - controller.getOrCreateContext(argument.location.source) - .error { - message("Range constraint must have a lower bound lower than the upper bound") - highlight("invalid constraint", argument.location) - } + argument.reportError(controller) { + message("Range constraint must have a lower bound lower than the upper bound") + highlight("invalid constraint", argument.location) + } return null } @@ -60,7 +59,7 @@ internal class ConstraintBuilder(private val controller: DiagnosticController) { is WildcardNode -> null else -> { - controller.getOrCreateContext(expressionNode.location.source).error { + expressionNode.reportError(controller) { message("Expected size constraint argument to be a whole number or wildcard") highlight("expected whole number or wildcard '*'", expressionNode.location) help("A valid constraint would be size(1..10), size(1..*) or size(*..10)") @@ -73,7 +72,7 @@ internal class ConstraintBuilder(private val controller: DiagnosticController) { val higher = resolveSide(argument.right) if (lower == null && higher == null) { - controller.getOrCreateContext(argument.location.source).error { + argument.reportError(controller) { message("Constraint parameters cannot both be wildcards") highlight("invalid constraint", argument.location) help("A valid constraint would be range(1..10.5) or range(1..*)") @@ -82,7 +81,7 @@ internal class ConstraintBuilder(private val controller: DiagnosticController) { } if (lower != null && higher != null && lower > higher) { - controller.getOrCreateContext(argument.location.source).error { + argument.reportError(controller) { message("Size constraint lower bound must be lower than or equal to the upper bound") highlight("invalid constraint", argument.location) } @@ -113,7 +112,7 @@ internal class ConstraintBuilder(private val controller: DiagnosticController) { is NumberNode -> ResolvedTypeReference.Constraint.Value(expression, argument.value) is BooleanNode -> ResolvedTypeReference.Constraint.Value(expression, argument.value) else -> { - controller.getOrCreateContext(argument.location.source).error { + argument.reportError(controller) { message("Value constraint must be a string, integer, float or boolean") highlight("invalid constraint", argument.location) help("A valid constraint would be value(\"foo\"), value(42) or value(false)") @@ -136,7 +135,7 @@ internal class ConstraintBuilder(private val controller: DiagnosticController) { when (name) { "range" -> { if (expression.arguments.size != 1 || expression.arguments.firstOrNull() !is RangeExpressionNode) { - controller.getOrCreateContext(expression.location.source).error { + expression.reportError(controller) { message("Range constraint must have exactly one range argument") highlight("invalid constraint", expression.location) help("A valid constraint would be range(1..10.5)") @@ -148,7 +147,7 @@ internal class ConstraintBuilder(private val controller: DiagnosticController) { "size" -> { if (expression.arguments.size != 1 || expression.arguments.firstOrNull() !is RangeExpressionNode) { - controller.getOrCreateContext(expression.location.source).error { + expression.reportError(controller) { message("Size constraint must have exactly one size argument") highlight("invalid constraint", expression.location) help("A valid constraint would be size(1..10)") @@ -163,7 +162,7 @@ internal class ConstraintBuilder(private val controller: DiagnosticController) { "pattern" -> { if (expression.arguments.size != 1 || expression.arguments.firstOrNull() !is StringNode) { - controller.getOrCreateContext(expression.location.source).error { + expression.reportError(controller) { message("Pattern constraint must have exactly one string argument") highlight("invalid constraint", expression.location) help("A valid constraint would be pattern(\"a-z\")") @@ -175,7 +174,7 @@ internal class ConstraintBuilder(private val controller: DiagnosticController) { "value" -> { if (expression.arguments.size != 1) { - controller.getOrCreateContext(expression.location.source).error { + expression.reportError(controller) { message("value constraint must have exactly one argument") highlight("invalid constraint", expression.location) } @@ -185,7 +184,7 @@ internal class ConstraintBuilder(private val controller: DiagnosticController) { } is String -> { - controller.getOrCreateContext(expression.location.source).error { + expression.reportError(controller) { message("Constraint with name '${name}' does not exist") highlight("unknown constraint", expression.base.location) help("A valid constraint would be range(1..10.5), size(1..10), pattern(\"a-z\") or value(\"foo\")") @@ -220,7 +219,7 @@ internal class ConstraintBuilder(private val controller: DiagnosticController) { is StringNode -> return createPattern(expression = expression, argument = expression) else -> Unit } - controller.getOrCreateContext(expression.location.source).error { + expression.reportError(controller) { message("Invalid constraint") highlight("invalid constraint", expression.location) } @@ -247,7 +246,7 @@ internal class ConstraintBuilder(private val controller: DiagnosticController) { return if (validateConstraintMatches(constraint, baseType)) { constraint } else { - controller.getOrCreateContext(expression.location.source).error { + expression.reportError(controller) { message("Constraint '${constraint.humanReadableName}' is not allowed for type '${baseType.humanReadableName}'") highlight("illegal constraint", expression.location) diff --git a/semantic/src/main/kotlin/tools/samt/semantic/Package.kt b/semantic/src/main/kotlin/tools/samt/semantic/Package.kt index 65073cce..bb497a4c 100644 --- a/semantic/src/main/kotlin/tools/samt/semantic/Package.kt +++ b/semantic/src/main/kotlin/tools/samt/semantic/Package.kt @@ -2,7 +2,7 @@ package tools.samt.semantic import tools.samt.parser.* -class Package(val name: String, val parent: Package?) { +class Package(val name: String, private val parent: Package?) { val subPackages: MutableList = mutableListOf() val records: MutableList = mutableListOf() diff --git a/semantic/src/main/kotlin/tools/samt/semantic/SemanticModel.kt b/semantic/src/main/kotlin/tools/samt/semantic/SemanticModel.kt index 11fdd99d..10be0c11 100644 --- a/semantic/src/main/kotlin/tools/samt/semantic/SemanticModel.kt +++ b/semantic/src/main/kotlin/tools/samt/semantic/SemanticModel.kt @@ -2,14 +2,11 @@ package tools.samt.semantic import tools.samt.common.DiagnosticController import tools.samt.common.SourceFile -import tools.samt.parser.FileNode -import tools.samt.parser.NamedDeclarationNode -import tools.samt.parser.TypeImportNode -import tools.samt.parser.WildcardImportNode +import tools.samt.parser.* class SemanticModel( - val global: Package, - val userMetadata: UserMetadata, + val global: Package, + val userMetadata: UserMetadata, ) { companion object { fun build(files: List, controller: DiagnosticController): SemanticModel { @@ -26,7 +23,7 @@ class SemanticModel( * - Resolve all references to types * - Resolve all references to their declarations in the AST * */ -internal class SemanticModelBuilder ( +internal class SemanticModelBuilder( private val files: List, private val controller: DiagnosticController, ) { @@ -77,15 +74,16 @@ internal class SemanticModelBuilder ( check(typeReference is ResolvedTypeReference) fun merge(base: ResolvedTypeReference, inner: ResolvedTypeReference): ResolvedTypeReference { if (base.isOptional && inner.isOptional) { - controller.getOrCreateContext(base.fullNode.location.source).warn { + base.fullNode.reportWarning(controller) { message("Type is already optional, ignoring '?'") highlight("duplicate optional", base.fullNode.location) highlight("declared optional here", inner.fullNode.location) } } - val overlappingConstraints = base.constraints.filter { baseConstraint -> inner.constraints.any { innerConstraint -> baseConstraint::class == innerConstraint::class } } + val overlappingConstraints = + base.constraints.filter { baseConstraint -> inner.constraints.any { innerConstraint -> baseConstraint::class == innerConstraint::class } } for (overlappingConstraint in overlappingConstraints) { - controller.getOrCreateContext(base.fullNode.location.source).error { + base.fullNode.reportError(controller) { message("Cannot have multiple constraints of the same type") val baseConstraint = base.constraints.first { it::class == overlappingConstraint::class } highlight("duplicate constraint", baseConstraint.node.location) @@ -117,6 +115,7 @@ internal class SemanticModelBuilder ( null } } + is MapType -> { val keyType = getFullyResolvedType(type.keyType) val valueType = getFullyResolvedType(type.valueType) @@ -126,13 +125,15 @@ internal class SemanticModelBuilder ( null } } + is PackageType -> { - controller.getOrCreateContext(typeReference.typeNode.location.source).error { + typeReference.typeNode.reportError(controller) { message("Type alias cannot reference package") highlight("illegal package", typeReference.typeNode.location) } typeReference } + is ConsumerType -> error("Consumer type cannot be referenced by name, this should never happen") } } @@ -196,7 +197,7 @@ internal class SemanticModelBuilder ( file.imports.forEach { import -> fun addImportedType(name: String, type: Type) { putIfAbsent(name, type)?.let { existingType -> - controller.getOrCreateContext(file.sourceFile).error { + file.reportError(controller) { message("Import '$name' conflicts with locally defined type with same name") highlight("conflicting import", import.location) if (existingType is UserDeclared) { @@ -232,7 +233,7 @@ internal class SemanticModelBuilder ( addImportedType(name, type) } } else { - controller.getOrCreateContext(file.sourceFile).error { + file.reportError(controller) { message("Import '${import.name.name}.*' must point to a package and not a type") highlight( "illegal wildcard import", import.location, suggestChange = "import ${ @@ -252,7 +253,7 @@ internal class SemanticModelBuilder ( // Add built-in types fun addBuiltIn(name: String, type: Type) { putIfAbsent(name, type)?.let { existingType -> - controller.getOrCreateContext(file.sourceFile).error { + file.reportError(controller) { message("Type '$name' shadows built-in type with same name") if (existingType is UserDeclared) { val definition = existingType.declaration diff --git a/semantic/src/main/kotlin/tools/samt/semantic/SemanticModelAnnotationProcessor.kt b/semantic/src/main/kotlin/tools/samt/semantic/SemanticModelAnnotationProcessor.kt index 7a25fca5..cec0dc0a 100644 --- a/semantic/src/main/kotlin/tools/samt/semantic/SemanticModelAnnotationProcessor.kt +++ b/semantic/src/main/kotlin/tools/samt/semantic/SemanticModelAnnotationProcessor.kt @@ -3,6 +3,7 @@ package tools.samt.semantic import tools.samt.common.DiagnosticController import tools.samt.parser.AnnotationNode import tools.samt.parser.StringNode +import tools.samt.parser.reportError internal class SemanticModelAnnotationProcessor( private val controller: DiagnosticController @@ -12,11 +13,10 @@ internal class SemanticModelAnnotationProcessor( val deprecations = mutableMapOf() for (element in global.getAnnotatedElements()) { for (annotation in element.annotations) { - val context = controller.getOrCreateContext(annotation.location.source) when (val name = annotation.name.name) { "Description" -> { if (element in descriptions) { - context.error { + annotation.reportError(controller) { message("Duplicate @Description annotation") highlight("duplicate annotation", annotation.location) highlight("previous annotation", element.annotations.first { it.name.name == "Description" }.location) @@ -26,7 +26,7 @@ internal class SemanticModelAnnotationProcessor( } "Deprecated" -> { if (element in deprecations) { - context.error { + annotation.reportError(controller) { message("Duplicate @Deprecated annotation") highlight("duplicate annotation", annotation.location) highlight("previous annotation", element.annotations.first { it.name.name == "Deprecated" }.location) @@ -35,7 +35,7 @@ internal class SemanticModelAnnotationProcessor( deprecations[element] = getDeprecation(annotation) } else -> { - context.error { + annotation.reportError(controller) { message("Unknown annotation @${name}, allowed annotations are @Description and @Deprecated") highlight("invalid annotation", annotation.location) } @@ -62,9 +62,8 @@ internal class SemanticModelAnnotationProcessor( private fun getDescription(annotation: AnnotationNode): String { check(annotation.name.name == "Description") val arguments = annotation.arguments - val context = controller.getOrCreateContext(annotation.location.source) if (arguments.isEmpty()) { - context.error { + annotation.reportError(controller) { message("Missing argument for @Description") highlight("invalid annotation", annotation.location) } @@ -72,7 +71,7 @@ internal class SemanticModelAnnotationProcessor( } if (arguments.size > 1) { val errorLocation = arguments[1].location.copy(end = arguments.last().location.end) - context.error { + annotation.reportError(controller) { message("@Description expects exactly one string argument") highlight("extraneous arguments", errorLocation) } @@ -80,7 +79,7 @@ internal class SemanticModelAnnotationProcessor( return when (val description = arguments.first()) { is StringNode -> description.value else -> { - context.error { + annotation.reportError(controller) { message("Argument for @Description must be a string") highlight("invalid argument type", description.location) } @@ -91,17 +90,16 @@ internal class SemanticModelAnnotationProcessor( private fun getDeprecation(annotation: AnnotationNode): UserMetadata.Deprecation { check(annotation.name.name == "Deprecated") - val context = controller.getOrCreateContext(annotation.location.source) val description = annotation.arguments.firstOrNull() if (description != null && description !is StringNode) { - context.error { + annotation.reportError(controller) { message("Argument for @Deprecated must be a string") highlight("invalid argument type", description.location) } } if (annotation.arguments.size > 1) { val errorLocation = annotation.arguments[1].location.copy(end = annotation.arguments.last().location.end) - context.error { + annotation.reportError(controller) { message("@Deprecated expects at most one string argument") highlight("extraneous arguments", errorLocation) } diff --git a/semantic/src/main/kotlin/tools/samt/semantic/SemanticModelPostProcessor.kt b/semantic/src/main/kotlin/tools/samt/semantic/SemanticModelPostProcessor.kt index 451b881d..0422a777 100644 --- a/semantic/src/main/kotlin/tools/samt/semantic/SemanticModelPostProcessor.kt +++ b/semantic/src/main/kotlin/tools/samt/semantic/SemanticModelPostProcessor.kt @@ -2,6 +2,8 @@ package tools.samt.semantic import tools.samt.common.DiagnosticController import tools.samt.common.Location +import tools.samt.parser.reportError +import tools.samt.parser.reportWarning internal class SemanticModelPostProcessor(private val controller: DiagnosticController) { /** @@ -27,7 +29,7 @@ internal class SemanticModelPostProcessor(private val controller: DiagnosticCont check(typeReference is ResolvedTypeReference) when (val type = typeReference.type) { is ServiceType -> { - controller.getOrCreateContext(typeReference.typeNode.location.source).error { + typeReference.typeNode.reportError(controller) { // error message applies to both record fields and return types message("Cannot use service '${type.name}' as type") highlight("service type not allowed here", typeReference.typeNode.location) @@ -35,14 +37,14 @@ internal class SemanticModelPostProcessor(private val controller: DiagnosticCont } is ProviderType -> { - controller.getOrCreateContext(typeReference.typeNode.location.source).error { + typeReference.typeNode.reportError(controller) { message("Cannot use provider '${type.name}' as type") highlight("provider type not allowed here", typeReference.typeNode.location) } } is PackageType -> { - controller.getOrCreateContext(typeReference.typeNode.location.source).error { + typeReference.typeNode.reportError(controller) { message("Cannot use package '${type.packageName}' as type") highlight("package type not allowed here", typeReference.typeNode.location) } @@ -61,7 +63,7 @@ internal class SemanticModelPostProcessor(private val controller: DiagnosticCont val underlyingTypeReference = type.fullyResolvedType ?: return val underlyingType = underlyingTypeReference.type if (underlyingType is ServiceType || underlyingType is ProviderType || underlyingType is PackageType) { - controller.getOrCreateContext(typeReference.typeNode.location.source).error { + typeReference.typeNode.reportError(controller) { message("Type alias refers to '${underlyingType.humanReadableName}', which cannot be used in this context") highlight("type alias", typeReference.typeNode.location) highlight("underlying type", underlyingTypeReference.typeNode.location) @@ -69,7 +71,7 @@ internal class SemanticModelPostProcessor(private val controller: DiagnosticCont } if (typeReference.isOptional && underlyingTypeReference.isOptional) { - controller.getOrCreateContext(typeReference.typeNode.location.source).warn { + typeReference.typeNode.reportWarning(controller) { message("Type alias refers to type which is already optional, ignoring '?'") highlight("duplicate optional", typeReference.fullNode.location) highlight("declared optional here", underlyingTypeReference.fullNode.location) @@ -96,7 +98,7 @@ internal class SemanticModelPostProcessor(private val controller: DiagnosticCont if (aliasedType is ServiceType) { block(aliasedType) } else { - controller.getOrCreateContext(typeReference.typeNode.location.source).error { + typeReference.typeNode.reportError(controller) { message("Expected a service but type alias '${type.name}' points to '${aliasedType.humanReadableName}'") highlight("type alias", typeReference.typeNode.location) highlight("underlying type", aliasedTypeReference.typeNode.location) @@ -106,7 +108,7 @@ internal class SemanticModelPostProcessor(private val controller: DiagnosticCont is UnknownType -> Unit else -> { - controller.getOrCreateContext(typeReference.typeNode.location.source).error { + typeReference.typeNode.reportError(controller) { message("Expected a service but got '${type.humanReadableName}'") highlight("illegal type", typeReference.typeNode.location) } @@ -129,7 +131,7 @@ internal class SemanticModelPostProcessor(private val controller: DiagnosticCont if (aliasedType is ProviderType) { block(aliasedType) } else { - controller.getOrCreateContext(typeReference.typeNode.location.source).error { + typeReference.typeNode.reportError(controller) { message("Expected a provider but type alias '${type.name}' points to '${aliasedType.humanReadableName}'") highlight("type alias", typeReference.typeNode.location) highlight("underlying type", aliasedTypeReference.typeNode.location) @@ -139,7 +141,7 @@ internal class SemanticModelPostProcessor(private val controller: DiagnosticCont is UnknownType -> Unit else -> { - controller.getOrCreateContext(typeReference.typeNode.location.source).error { + typeReference.typeNode.reportError(controller) { message("Expected a provider but got '${type.humanReadableName}'") highlight("illegal type", typeReference.typeNode.location) } @@ -152,7 +154,7 @@ internal class SemanticModelPostProcessor(private val controller: DiagnosticCont var isBlank = true if (typeReference.constraints.isNotEmpty()) { isBlank = false - controller.getOrCreateContext(typeReference.fullNode.location.source).error { + typeReference.fullNode.reportError(controller) { message("Cannot have constraints on $what") for (constraint in typeReference.constraints) { highlight("illegal constraint", constraint.node.location) @@ -161,7 +163,7 @@ internal class SemanticModelPostProcessor(private val controller: DiagnosticCont } if (typeReference.isOptional) { isBlank = false - controller.getOrCreateContext(typeReference.fullNode.location.source).error { + typeReference.fullNode.reportError(controller) { message("Cannot have optional $what") highlight("illegal optional", typeReference.fullNode.location) } @@ -190,7 +192,7 @@ internal class SemanticModelPostProcessor(private val controller: DiagnosticCont provider.implements.forEach { implements -> checkServiceType(implements.service) { type -> implementsTypes.putIfAbsent(type, implements.node.location)?.let { existingLocation -> - controller.getOrCreateContext(implements.node.location.source).error { + implements.node.reportError(controller) { message("Service '${type.name}' already implemented") highlight("duplicate implements", implements.node.location) highlight("previous implements", existingLocation) @@ -206,7 +208,7 @@ internal class SemanticModelPostProcessor(private val controller: DiagnosticCont if (matchingOperation != null) { matchingOperation } else { - controller.getOrCreateContext(provider.declaration.location.source).error { + provider.declaration.reportError(controller) { message("Operation '${serviceOperationName.name}' not found in service '${type.name}'") highlight("unknown operation", serviceOperationName.location) } @@ -224,7 +226,7 @@ internal class SemanticModelPostProcessor(private val controller: DiagnosticCont consumer.uses.forEach { uses -> checkServiceType(uses.service) { type -> usesTypes.putIfAbsent(type, uses.node.location)?.let { existingLocation -> - controller.getOrCreateContext(uses.node.location.source).error { + uses.node.reportError(controller) { message("Service '${type.name}' already used") highlight("duplicate uses", uses.node.location) highlight("previous uses", existingLocation) @@ -235,7 +237,7 @@ internal class SemanticModelPostProcessor(private val controller: DiagnosticCont val matchingImplements = providerType.implements.find { (it.service as ResolvedTypeReference).type == type } if (matchingImplements == null) { - controller.getOrCreateContext(uses.node.location.source).error { + uses.node.reportError(controller) { message("Service '${type.name}' is not implemented by provider '${providerType.name}'") highlight("unavailable service", uses.node.serviceName.location) } @@ -251,12 +253,12 @@ internal class SemanticModelPostProcessor(private val controller: DiagnosticCont matchingOperation } else { if (type.operations.any { it.name == serviceOperationName.name }) { - controller.getOrCreateContext(uses.node.location.source).error { + uses.node.reportError(controller) { message("Operation '${serviceOperationName.name}' in service '${type.name}' is not implemented by provider '${providerType.name}'") highlight("unavailable operation", serviceOperationName.location) } } else { - controller.getOrCreateContext(uses.node.location.source).error { + uses.node.reportError(controller) { message("Operation '${serviceOperationName.name}' not found in service '${type.name}'") highlight("unknown operation", serviceOperationName.location) } diff --git a/semantic/src/main/kotlin/tools/samt/semantic/SemanticModelPreProcessor.kt b/semantic/src/main/kotlin/tools/samt/semantic/SemanticModelPreProcessor.kt index 320b4ccf..2c9db0a7 100644 --- a/semantic/src/main/kotlin/tools/samt/semantic/SemanticModelPreProcessor.kt +++ b/semantic/src/main/kotlin/tools/samt/semantic/SemanticModelPreProcessor.kt @@ -12,7 +12,7 @@ internal class SemanticModelPreProcessor(private val controller: DiagnosticContr ) { if (statement.name in parentPackage) { val existingType = parentPackage.types.getValue(statement.name.name) - controller.getOrCreateContext(statement.location.source).error { + statement.reportError(controller) { message("'${statement.name.name}' is already declared") highlight("duplicate declaration", statement.name.location) if (existingType is UserDeclared) { @@ -32,7 +32,7 @@ internal class SemanticModelPreProcessor(private val controller: DiagnosticContr val name = identifierGetter(item).name val existingLocation = existingItems.putIfAbsent(name, item.location) if (existingLocation != null) { - controller.getOrCreateContext(item.location.source).error { + item.reportError(controller) { message("$what '$name' is defined more than once") highlight("duplicate declaration", identifierGetter(item).location) highlight("previous declaration", existingLocation) @@ -59,7 +59,7 @@ internal class SemanticModelPreProcessor(private val controller: DiagnosticContr reportDuplicateDeclaration(parentPackage, statement) reportDuplicates(statement.fields, "Record field") { it.name } if (statement.extends.isNotEmpty()) { - controller.getOrCreateContext(statement.location.source).error { + statement.reportError(controller) { message("Record extends are not yet supported") highlight("cannot extend other records", statement.extends.first().location) } diff --git a/semantic/src/main/kotlin/tools/samt/semantic/SemanticModelReferenceResolver.kt b/semantic/src/main/kotlin/tools/samt/semantic/SemanticModelReferenceResolver.kt index a57e0363..9412ddf9 100644 --- a/semantic/src/main/kotlin/tools/samt/semantic/SemanticModelReferenceResolver.kt +++ b/semantic/src/main/kotlin/tools/samt/semantic/SemanticModelReferenceResolver.kt @@ -28,7 +28,7 @@ internal class SemanticModelReferenceResolver( return ResolvedTypeReference(type, expression) } - controller.getOrCreateContext(expression.location.source).error { + expression.reportError(controller) { message("Type '${expression.name}' could not be resolved") highlight("unresolved type", expression.location) } @@ -54,14 +54,14 @@ internal class SemanticModelReferenceResolver( } null -> { - controller.getOrCreateContext(expression.location.source).error { + expression.reportError(controller) { message("Type '${expression.name}' could not be resolved") highlight("unresolved type", expression.location) } } else -> { - controller.getOrCreateContext(expression.location.source).error { + expression.reportError(controller) { message("Type '${expression.components.first().name}' is not a package, cannot access sub-types") highlight("not a package", expression.components.first().location) } @@ -73,14 +73,14 @@ internal class SemanticModelReferenceResolver( val baseType = resolveAndLinkExpression(scope, expression.base) val constraints = expression.arguments.mapNotNull { constraintBuilder.build(baseType.type, it) } if (baseType.constraints.isNotEmpty()) { - controller.getOrCreateContext(expression.location.source).error { + expression.reportError(controller) { message("Cannot have nested constraints") highlight("illegal nested constraint", expression.location) } } for (constraintInstances in constraints.groupBy { it::class }.values) { if (constraintInstances.size > 1) { - controller.getOrCreateContext(expression.location.source).error { + expression.reportError(controller) { message("Cannot have multiple constraints of the same type") highlight("first constraint", constraintInstances.first().node.location) for (duplicateConstraints in constraintInstances.drop(1)) { @@ -128,7 +128,7 @@ internal class SemanticModelReferenceResolver( } } } - controller.getOrCreateContext(expression.location.source).error { + expression.reportError(controller) { message("Unsupported generic type") highlight(expression.location) help("Valid generic types are List and Map") @@ -138,7 +138,7 @@ internal class SemanticModelReferenceResolver( is OptionalDeclarationNode -> { val baseType = resolveAndLinkExpression(scope, expression.base) if (baseType.isOptional) { - controller.getOrCreateContext(expression.location.source).warn { + expression.reportWarning(controller) { message("Type is already optional, ignoring '?'") highlight("already optional", expression.base.location) } @@ -149,7 +149,7 @@ internal class SemanticModelReferenceResolver( is BooleanNode, is NumberNode, is StringNode, - -> controller.getOrCreateContext(expression.location.source).error { + -> expression.reportError(controller) { message("Cannot use literal value as type") highlight("not a type expression", expression.location) } @@ -158,7 +158,7 @@ internal class SemanticModelReferenceResolver( is ArrayNode, is RangeExpressionNode, is WildcardNode, - -> controller.getOrCreateContext(expression.location.source).error { + -> expression.reportError(controller) { message("Invalid type expression") highlight("not a type expression", expression.location) } @@ -180,7 +180,7 @@ internal class SemanticModelReferenceResolver( } null -> { - controller.getOrCreateContext(component.location.source).error { + component.reportError(controller) { message("Could not resolve reference '${component.name}'") highlight("unresolved reference", component.location) } @@ -191,7 +191,7 @@ internal class SemanticModelReferenceResolver( if (iterator.hasNext()) { // We resolved a non-package type but there are still components left - controller.getOrCreateContext(component.location.source).error { + component.reportError(controller) { message("Type '${component.name}' is not a package, cannot access sub-types") highlight("must be a package", component.location) } From 788e77891ca899cdca4b6941149a9f745f714eb1 Mon Sep 17 00:00:00 2001 From: Marcel Joss Date: Mon, 29 May 2023 18:39:26 +0200 Subject: [PATCH 32/41] fix(codegen): fix regex constraint check --- .../samt/codegen/kotlin/ktor/KotlinKtorGeneratorUtilities.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codegen/src/main/kotlin/tools/samt/codegen/kotlin/ktor/KotlinKtorGeneratorUtilities.kt b/codegen/src/main/kotlin/tools/samt/codegen/kotlin/ktor/KotlinKtorGeneratorUtilities.kt index 3453e173..4d3fe243 100644 --- a/codegen/src/main/kotlin/tools/samt/codegen/kotlin/ktor/KotlinKtorGeneratorUtilities.kt +++ b/codegen/src/main/kotlin/tools/samt/codegen/kotlin/ktor/KotlinKtorGeneratorUtilities.kt @@ -234,7 +234,7 @@ private fun validateLiteralConstraintsSuffix(typeReference: TypeReference): Stri } } typeReference.patternConstraint?.let { constraint -> - add("it.matches(\"${constraint.pattern}\")") + add("it.matches(Regex(\"${constraint.pattern}\"))") } typeReference.valueConstraint?.let { constraint -> add("it == ${constraint.value})") From acdf47a5149a68ded76c8c76c542264db2a058fa Mon Sep 17 00:00:00 2001 From: Pascal Honegger Date: Mon, 29 May 2023 21:51:21 +0200 Subject: [PATCH 33/41] feat(codegen): only parse provider once per generation cycle --- .../kotlin/tools/samt/codegen/PublicApi.kt | 127 +++++++- .../tools/samt/codegen/PublicApiMapper.kt | 237 +++++++++------ .../codegen/TransportConfigurationMapper.kt | 276 ++++++++++-------- .../tools/samt/codegen/http/HttpTransport.kt | 148 +++++----- .../ktor/KotlinKtorConsumerGenerator.kt | 28 +- .../ktor/KotlinKtorProviderGenerator.kt | 4 +- 6 files changed, 495 insertions(+), 325 deletions(-) diff --git a/codegen/src/main/kotlin/tools/samt/codegen/PublicApi.kt b/codegen/src/main/kotlin/tools/samt/codegen/PublicApi.kt index 54ee9c4b..7a3a86e4 100644 --- a/codegen/src/main/kotlin/tools/samt/codegen/PublicApi.kt +++ b/codegen/src/main/kotlin/tools/samt/codegen/PublicApi.kt @@ -62,6 +62,11 @@ interface ConfigurationValue : ConfigurationElement { inline fun > ConfigurationValue.asEnum() = asEnum(T::class.java) +/** + * A transport configuration parser. + * This interface is intended to be implemented by a transport configuration parser, for example HTTP. + * It is used to parse the configuration body into a specific [TransportConfiguration]. + */ interface TransportConfigurationParser { val transportName: String @@ -79,8 +84,15 @@ interface TransportConfigurationParser { fun parse(params: TransportConfigurationParserParams): TransportConfiguration } +/** + * A base interface for transport configurations. + * This interface is intended to be sub-typed and extended by transport configuration implementations. + */ interface TransportConfiguration +/** + * A SAMT type + */ interface Type interface LiteralType : Type @@ -97,12 +109,28 @@ interface DateType : LiteralType interface DateTimeType : LiteralType interface DurationType : LiteralType +/** + * A ordered list of elements + */ interface ListType : Type { + /** + * The type of the elements in the list + */ val elementType: TypeReference } +/** + * A map of key-value pairs + */ interface MapType : Type { + /** + * The type of the keys in the map + */ val keyType: TypeReference + + /** + * The type of the values in the map + */ val valueType: TypeReference } @@ -123,61 +151,144 @@ interface AliasType : UserType { val fullyResolvedType: TypeReference } +/** + * A SAMT record + */ interface RecordType : UserType { val fields: List } +/** + * A field in a record + */ interface RecordField { val name: String val type: TypeReference } +/** + * A SAMT enum + */ interface EnumType : UserType { val values: List } +/** + * A SAMT service + */ interface ServiceType : UserType { val operations: List } + +/** + * An operation in a service + */ interface ServiceOperation { val name: String val parameters: List } +/** + * A parameter in a service operation + */ interface ServiceOperationParameter { val name: String val type: TypeReference } +/** + * A service operation that returns a response + */ interface RequestResponseOperation : ServiceOperation { + /** + * The return type of this operation. + * If null, this operation returns nothing. + */ val returnType: TypeReference? - val raisesTypes: List + + /** + * Is true if this operation is asynchronous. + * This could mean that the operation returns a future in Java, or a Promise in JavaScript. + */ val isAsync: Boolean } +/** + * A service operation that is fire-and-forget, never returning a response + */ interface OnewayOperation : ServiceOperation +/** + * A SAMT provider + */ interface ProviderType : UserType { - val implements: List + val implements: List val transport: TransportConfiguration } -interface ProviderImplements { +/** + * Connects a provider to a service + */ +interface ProvidedService { + /** + * The underlying service this provider implements + */ val service: ServiceType - val operations: List + + /** + * The operations that are implemented by this provider + */ + val implementedOperations: List + + /** + * The operations that are not implemented by this provider + */ + val unimplementedOperations: List } +/** + * A SAMT consumer + */ interface ConsumerType : Type { + /** + * The provider this consumer is connected to + */ val provider: ProviderType - val uses: List - val targetPackage: String + + /** + * The services this consumer uses + */ + val uses: List + + /** + * The package this consumer is located in + */ + val samtPackage: String } -interface ConsumerUses { +/** + * Connects a consumer to a service + */ +interface ConsumedService { + /** + * The underlying service this consumer uses + */ val service: ServiceType - val operations: List + + /** + * The operations that are consumed by this consumer + */ + val consumedOperations: List + + /** + * The operations that are not consumed by this consumer + */ + val unconsumedOperations: List } +/** + * A type reference + */ interface TypeReference { /** * The type this reference points to diff --git a/codegen/src/main/kotlin/tools/samt/codegen/PublicApiMapper.kt b/codegen/src/main/kotlin/tools/samt/codegen/PublicApiMapper.kt index e4181cea..709daa32 100644 --- a/codegen/src/main/kotlin/tools/samt/codegen/PublicApiMapper.kt +++ b/codegen/src/main/kotlin/tools/samt/codegen/PublicApiMapper.kt @@ -1,11 +1,22 @@ package tools.samt.codegen import tools.samt.common.DiagnosticController +import tools.samt.parser.reportError +import tools.samt.parser.reportInfo +import tools.samt.parser.reportWarning class PublicApiMapper( private val transportParsers: List, private val controller: DiagnosticController, ) { + private val typeCache = mutableMapOf() + + /** + * Returns a lazy delegate that will initialize its value only once, without synchronization. + * Because we are in a single-threaded environment, this is safe and significantly faster. + */ + fun unsafeLazy(initializer: () -> T) = lazy(LazyThreadSafetyMode.NONE, initializer) + fun toPublicApi(samtPackage: tools.samt.semantic.Package) = object : SamtPackage { override val name = samtPackage.name override val qualifiedName = samtPackage.nameComponents.joinToString(".") @@ -18,26 +29,26 @@ class PublicApiMapper( } private fun tools.samt.semantic.RecordType.toPublicRecord() = object : RecordType { - override val name = this@toPublicRecord.name - override val qualifiedName = this@toPublicRecord.getQualifiedName() - override val fields = this@toPublicRecord.fields.map { it.toPublicField() } + override val name get() = this@toPublicRecord.name + override val qualifiedName by unsafeLazy { this@toPublicRecord.getQualifiedName() } + override val fields by unsafeLazy { this@toPublicRecord.fields.map { it.toPublicField() } } } private fun tools.samt.semantic.RecordType.Field.toPublicField() = object : RecordField { - override val name = this@toPublicField.name - override val type = this@toPublicField.type.toPublicTypeReference() + override val name get() = this@toPublicField.name + override val type by unsafeLazy { this@toPublicField.type.toPublicTypeReference() } } private fun tools.samt.semantic.EnumType.toPublicEnum() = object : EnumType { - override val name = this@toPublicEnum.name - override val qualifiedName = this@toPublicEnum.getQualifiedName() - override val values = this@toPublicEnum.values + override val name get() = this@toPublicEnum.name + override val qualifiedName by unsafeLazy { this@toPublicEnum.getQualifiedName() } + override val values get() = this@toPublicEnum.values } private fun tools.samt.semantic.ServiceType.toPublicService() = object : ServiceType { - override val name = this@toPublicService.name - override val qualifiedName = this@toPublicService.getQualifiedName() - override val operations = this@toPublicService.operations.map { it.toPublicOperation() } + override val name get() = this@toPublicService.name + override val qualifiedName by unsafeLazy { this@toPublicService.getQualifiedName() } + override val operations by unsafeLazy { this@toPublicService.operations.map { it.toPublicOperation() } } } private fun tools.samt.semantic.ServiceType.Operation.toPublicOperation() = when (this) { @@ -46,31 +57,29 @@ class PublicApiMapper( } private fun tools.samt.semantic.ServiceType.OnewayOperation.toPublicOnewayOperation() = object : OnewayOperation { - override val name = this@toPublicOnewayOperation.name - override val parameters = this@toPublicOnewayOperation.parameters.map { it.toPublicParameter() } + override val name get() = this@toPublicOnewayOperation.name + override val parameters by unsafeLazy { this@toPublicOnewayOperation.parameters.map { it.toPublicParameter() } } } private fun tools.samt.semantic.ServiceType.RequestResponseOperation.toPublicRequestResponseOperation() = object : RequestResponseOperation { - override val name = this@toPublicRequestResponseOperation.name - override val parameters = this@toPublicRequestResponseOperation.parameters.map { it.toPublicParameter() } - override val returnType = this@toPublicRequestResponseOperation.returnType?.toPublicTypeReference() - override val raisesTypes = - this@toPublicRequestResponseOperation.raisesTypes.map { it.toPublicTypeReference() } - override val isAsync = this@toPublicRequestResponseOperation.isAsync + override val name get() = this@toPublicRequestResponseOperation.name + override val parameters by unsafeLazy { this@toPublicRequestResponseOperation.parameters.map { it.toPublicParameter() } } + override val returnType by unsafeLazy { this@toPublicRequestResponseOperation.returnType?.toPublicTypeReference() } + override val isAsync get() = this@toPublicRequestResponseOperation.isAsync } private fun tools.samt.semantic.ServiceType.Operation.Parameter.toPublicParameter() = object : ServiceOperationParameter { - override val name = this@toPublicParameter.name - override val type = this@toPublicParameter.type.toPublicTypeReference() + override val name get() = this@toPublicParameter.name + override val type by unsafeLazy { this@toPublicParameter.type.toPublicTypeReference() } } private fun tools.samt.semantic.ProviderType.toPublicProvider() = object : ProviderType { - override val name = this@toPublicProvider.name - override val qualifiedName = this@toPublicProvider.getQualifiedName() - override val implements = this@toPublicProvider.implements.map { it.toPublicImplements() } - override val transport = this@toPublicProvider.transport.toPublicTransport(this) + override val name get() = this@toPublicProvider.name + override val qualifiedName by unsafeLazy { this@toPublicProvider.getQualifiedName() } + override val implements by unsafeLazy { this@toPublicProvider.implements.map { it.toPublicImplements() } } + override val transport by unsafeLazy { this@toPublicProvider.transport.toPublicTransport(this) } } private class Params( @@ -78,17 +87,37 @@ class PublicApiMapper( val controller: DiagnosticController ) : TransportConfigurationParserParams { - // TODO use context if provided override fun reportError(message: String, context: ConfigurationElement?) { - controller.reportGlobalError(message) + if (context != null && context is PublicApiConfigurationMapping) { + context.original.reportError(controller) { + message(message) + highlight("offending configuration", context.original.location) + } + } else { + controller.reportGlobalError(message) + } } override fun reportWarning(message: String, context: ConfigurationElement?) { - controller.reportGlobalWarning(message) + if (context != null && context is PublicApiConfigurationMapping) { + context.original.reportWarning(controller) { + message(message) + highlight("offending configuration", context.original.location) + } + } else { + controller.reportGlobalWarning(message) + } } override fun reportInfo(message: String, context: ConfigurationElement?) { - controller.reportGlobalInfo(message) + if (context != null && context is PublicApiConfigurationMapping) { + context.original.reportInfo(controller) { + message(message) + highlight("related configuration", context.original.location) + } + } else { + controller.reportGlobalInfo(message) + } } } @@ -117,27 +146,31 @@ class PublicApiMapper( return object : TransportConfiguration {} } - private fun tools.samt.semantic.ProviderType.Implements.toPublicImplements() = object : ProviderImplements { + private fun tools.samt.semantic.ProviderType.Implements.toPublicImplements() = object : ProvidedService { override val service = this@toPublicImplements.service.toPublicTypeReference().type as ServiceType - override val operations = this@toPublicImplements.operations.map { it.toPublicOperation() } + private val implementedOperationNames by unsafeLazy { this@toPublicImplements.operations.mapTo(mutableSetOf()) { it.name } } + override val implementedOperations by unsafeLazy { service.operations.filter { it.name in implementedOperationNames } } + override val unimplementedOperations by unsafeLazy { service.operations.filter { it.name !in implementedOperationNames } } } private fun tools.samt.semantic.ConsumerType.toPublicConsumer() = object : ConsumerType { - override val provider = this@toPublicConsumer.provider.toPublicTypeReference().type as ProviderType - override val uses = this@toPublicConsumer.uses.map { it.toPublicUses() } - override val targetPackage = this@toPublicConsumer.parentPackage.nameComponents.joinToString(".") + override val provider by unsafeLazy { this@toPublicConsumer.provider.toPublicTypeReference().type as ProviderType } + override val uses by unsafeLazy { this@toPublicConsumer.uses.map { it.toPublicUses() } } + override val samtPackage by unsafeLazy { this@toPublicConsumer.parentPackage.nameComponents.joinToString(".") } } - private fun tools.samt.semantic.ConsumerType.Uses.toPublicUses() = object : ConsumerUses { - override val service = this@toPublicUses.service.toPublicTypeReference().type as ServiceType - override val operations = this@toPublicUses.operations.map { it.toPublicOperation() } + private fun tools.samt.semantic.ConsumerType.Uses.toPublicUses() = object : ConsumedService { + override val service by unsafeLazy { this@toPublicUses.service.toPublicTypeReference().type as ServiceType } + private val consumedOperationNames by unsafeLazy { this@toPublicUses.operations.mapTo(mutableSetOf()) { it.name } } + override val consumedOperations by unsafeLazy { service.operations.filter { it.name in consumedOperationNames } } + override val unconsumedOperations by unsafeLazy { service.operations.filter { it.name !in consumedOperationNames } } } private fun tools.samt.semantic.AliasType.toPublicAlias() = object : AliasType { - override val name = this@toPublicAlias.name - override val qualifiedName = this@toPublicAlias.getQualifiedName() - override val aliasedType = this@toPublicAlias.aliasedType.toPublicTypeReference() - override val fullyResolvedType = this@toPublicAlias.fullyResolvedType.toPublicTypeReference() + override val name get() = this@toPublicAlias.name + override val qualifiedName by unsafeLazy { this@toPublicAlias.getQualifiedName() } + override val aliasedType by unsafeLazy { this@toPublicAlias.aliasedType.toPublicTypeReference() } + override val fullyResolvedType by unsafeLazy { this@toPublicAlias.fullyResolvedType.toPublicTypeReference() } } private inline fun List.findConstraint() = @@ -145,95 +178,109 @@ class PublicApiMapper( private fun tools.samt.semantic.TypeReference?.toPublicTypeReference(): TypeReference { check(this is tools.samt.semantic.ResolvedTypeReference) - val typeReference = this@toPublicTypeReference + val typeReference: tools.samt.semantic.ResolvedTypeReference = this@toPublicTypeReference val runtimeTypeReference = when (val type = typeReference.type) { is tools.samt.semantic.AliasType -> checkNotNull(type.fullyResolvedType) { "Found unresolved alias when generating code" } else -> typeReference } return object : TypeReference { - override val type = typeReference.type.toPublicType() - override val isOptional = typeReference.isOptional - override val rangeConstraint = + override val type by lazy { typeReference.type.toPublicType() } + override val isOptional get() = typeReference.isOptional + override val rangeConstraint by unsafeLazy { typeReference.constraints.findConstraint() ?.toPublicRangeConstraint() - override val sizeConstraint = + } + override val sizeConstraint by unsafeLazy { typeReference.constraints.findConstraint() ?.toPublicSizeConstraint() - override val patternConstraint = + } + override val patternConstraint by unsafeLazy { typeReference.constraints.findConstraint() ?.toPublicPatternConstraint() - override val valueConstraint = + } + override val valueConstraint by unsafeLazy { typeReference.constraints.findConstraint() ?.toPublicValueConstraint() + } - override val runtimeType = runtimeTypeReference.type.toPublicType() - override val isRuntimeOptional = isOptional || runtimeTypeReference.isOptional - override val runtimeRangeConstraint = rangeConstraint - ?: runtimeTypeReference.constraints.findConstraint() - ?.toPublicRangeConstraint() - override val runtimeSizeConstraint = sizeConstraint - ?: runtimeTypeReference.constraints.findConstraint() - ?.toPublicSizeConstraint() - override val runtimePatternConstraint = patternConstraint - ?: runtimeTypeReference.constraints.findConstraint() - ?.toPublicPatternConstraint() - override val runtimeValueConstraint = valueConstraint - ?: runtimeTypeReference.constraints.findConstraint() - ?.toPublicValueConstraint() + override val runtimeType by unsafeLazy { runtimeTypeReference.type.toPublicType() } + override val isRuntimeOptional get() = isOptional || runtimeTypeReference.isOptional + override val runtimeRangeConstraint by unsafeLazy { + rangeConstraint + ?: runtimeTypeReference.constraints.findConstraint() + ?.toPublicRangeConstraint() + } + override val runtimeSizeConstraint by unsafeLazy { + sizeConstraint + ?: runtimeTypeReference.constraints.findConstraint() + ?.toPublicSizeConstraint() + } + override val runtimePatternConstraint by unsafeLazy { + patternConstraint + ?: runtimeTypeReference.constraints.findConstraint() + ?.toPublicPatternConstraint() + } + override val runtimeValueConstraint by unsafeLazy { + valueConstraint + ?: runtimeTypeReference.constraints.findConstraint() + ?.toPublicValueConstraint() + } } } - private fun tools.samt.semantic.Type.toPublicType() = when (this) { - tools.samt.semantic.IntType -> object : IntType {} - tools.samt.semantic.LongType -> object : LongType {} - tools.samt.semantic.FloatType -> object : FloatType {} - tools.samt.semantic.DoubleType -> object : DoubleType {} - tools.samt.semantic.DecimalType -> object : DecimalType {} - tools.samt.semantic.BooleanType -> object : BooleanType {} - tools.samt.semantic.StringType -> object : StringType {} - tools.samt.semantic.BytesType -> object : BytesType {} - tools.samt.semantic.DateType -> object : DateType {} - tools.samt.semantic.DateTimeType -> object : DateTimeType {} - tools.samt.semantic.DurationType -> object : DurationType {} - is tools.samt.semantic.ListType -> object : ListType { - override val elementType = this@toPublicType.elementType.toPublicTypeReference() - } + private fun tools.samt.semantic.Type.toPublicType() = typeCache.computeIfAbsent(this@toPublicType) { + when (this) { + tools.samt.semantic.IntType -> object : IntType {} + tools.samt.semantic.LongType -> object : LongType {} + tools.samt.semantic.FloatType -> object : FloatType {} + tools.samt.semantic.DoubleType -> object : DoubleType {} + tools.samt.semantic.DecimalType -> object : DecimalType {} + tools.samt.semantic.BooleanType -> object : BooleanType {} + tools.samt.semantic.StringType -> object : StringType {} + tools.samt.semantic.BytesType -> object : BytesType {} + tools.samt.semantic.DateType -> object : DateType {} + tools.samt.semantic.DateTimeType -> object : DateTimeType {} + tools.samt.semantic.DurationType -> object : DurationType {} + is tools.samt.semantic.ListType -> object : ListType { + override val elementType by unsafeLazy { this@toPublicType.elementType.toPublicTypeReference() } + } - is tools.samt.semantic.MapType -> object : MapType { - override val keyType = this@toPublicType.keyType.toPublicTypeReference() - override val valueType = this@toPublicType.valueType.toPublicTypeReference() - } + is tools.samt.semantic.MapType -> object : MapType { + override val keyType by unsafeLazy { this@toPublicType.keyType.toPublicTypeReference() } + override val valueType by unsafeLazy { this@toPublicType.valueType.toPublicTypeReference() } + } - is tools.samt.semantic.AliasType -> toPublicAlias() - is tools.samt.semantic.ConsumerType -> toPublicConsumer() - is tools.samt.semantic.EnumType -> toPublicEnum() - is tools.samt.semantic.ProviderType -> toPublicProvider() - is tools.samt.semantic.RecordType -> toPublicRecord() - is tools.samt.semantic.ServiceType -> toPublicService() - is tools.samt.semantic.PackageType -> error("Package type cannot be converted to public API") - tools.samt.semantic.UnknownType -> error("Unknown type cannot be converted to public API") + is tools.samt.semantic.AliasType -> toPublicAlias() + is tools.samt.semantic.ConsumerType -> toPublicConsumer() + is tools.samt.semantic.EnumType -> toPublicEnum() + is tools.samt.semantic.ProviderType -> toPublicProvider() + is tools.samt.semantic.RecordType -> toPublicRecord() + is tools.samt.semantic.ServiceType -> toPublicService() + is tools.samt.semantic.PackageType -> error("Package type cannot be converted to public API") + tools.samt.semantic.UnknownType -> error("Unknown type cannot be converted to public API") + } } private fun tools.samt.semantic.ResolvedTypeReference.Constraint.Range.toPublicRangeConstraint() = object : Constraint.Range { - override val lowerBound = this@toPublicRangeConstraint.lowerBound - override val upperBound = this@toPublicRangeConstraint.upperBound + override val lowerBound get() = this@toPublicRangeConstraint.lowerBound + override val upperBound get() = this@toPublicRangeConstraint.upperBound } private fun tools.samt.semantic.ResolvedTypeReference.Constraint.Size.toPublicSizeConstraint() = object : Constraint.Size { - override val lowerBound = this@toPublicSizeConstraint.lowerBound - override val upperBound = this@toPublicSizeConstraint.upperBound + override val lowerBound get() = this@toPublicSizeConstraint.lowerBound + override val upperBound get() = this@toPublicSizeConstraint.upperBound } private fun tools.samt.semantic.ResolvedTypeReference.Constraint.Pattern.toPublicPatternConstraint() = object : Constraint.Pattern { - override val pattern = this@toPublicPatternConstraint.pattern + override val pattern get() = this@toPublicPatternConstraint.pattern } private fun tools.samt.semantic.ResolvedTypeReference.Constraint.Value.toPublicValueConstraint() = object : Constraint.Value { - override val value = this@toPublicValueConstraint.value + override val value get() = this@toPublicValueConstraint.value } private fun tools.samt.semantic.UserDeclaredNamedType.getQualifiedName(): String { diff --git a/codegen/src/main/kotlin/tools/samt/codegen/TransportConfigurationMapper.kt b/codegen/src/main/kotlin/tools/samt/codegen/TransportConfigurationMapper.kt index 0e4bb018..8225c1c9 100644 --- a/codegen/src/main/kotlin/tools/samt/codegen/TransportConfigurationMapper.kt +++ b/codegen/src/main/kotlin/tools/samt/codegen/TransportConfigurationMapper.kt @@ -3,6 +3,10 @@ package tools.samt.codegen import tools.samt.common.DiagnosticController import tools.samt.parser.reportError +interface PublicApiConfigurationMapping { + val original: tools.samt.parser.Node +} + class TransportConfigurationMapper( private val provider: ProviderType, private val controller: DiagnosticController, @@ -31,152 +35,166 @@ class TransportConfigurationMapper( else -> reportAndThrow("Unexpected expression") } - private fun tools.samt.parser.IntegerNode.toConfigurationValue() = object : ConfigurationValue { - override val asString: String get() = reportAndThrow("Unexpected integer, expected a string") - override val asIdentifier: String get() = reportAndThrow("Unexpected integer, expected an identifier") - - override fun > asEnum(enum: Class): T = - reportAndThrow("Unexpected integer, expected an enum (${enum.simpleName})") - - override val asLong: Long get() = value - override val asDouble: Double = value.toDouble() - override val asBoolean: Boolean get() = reportAndThrow("Unexpected integer, expected a boolean") - override val asServiceName: ServiceType get() = reportAndThrow("Unexpected integer, expected a service name") - override fun asOperationName(service: ServiceType): ServiceOperation = - reportAndThrow("Unexpected integer, expected an operation name") - - override val asObject: ConfigurationObject get() = reportAndThrow("Unexpected integer, expected an object") - override val asValue: ConfigurationValue get() = this - override val asList: ConfigurationList get() = reportAndThrow("Unexpected integer, expected a list") - } - - private fun tools.samt.parser.FloatNode.toConfigurationValue() = object : ConfigurationValue { - override val asString: String get() = reportAndThrow("Unexpected float, expected a string") - override val asIdentifier: String get() = reportAndThrow("Unexpected float, expected an identifier") + private fun tools.samt.parser.IntegerNode.toConfigurationValue() = + object : ConfigurationValue, PublicApiConfigurationMapping { + override val original = this@toConfigurationValue + override val asString: String get() = reportAndThrow("Unexpected integer, expected a string") + override val asIdentifier: String get() = reportAndThrow("Unexpected integer, expected an identifier") override fun > asEnum(enum: Class): T = - reportAndThrow("Unexpected float, expected an enum (${enum.simpleName})") - - override val asLong: Long get() = reportAndThrow("Unexpected float, expected an integer") - override val asDouble: Double = value - override val asBoolean: Boolean get() = reportAndThrow("Unexpected float, expected a boolean") - override val asServiceName: ServiceType get() = reportAndThrow("Unexpected float, expected a service name") - override fun asOperationName(service: ServiceType): ServiceOperation = - reportAndThrow("Unexpected float, expected an operation name") - - override val asObject: ConfigurationObject get() = reportAndThrow("Unexpected float, expected an object") - override val asValue: ConfigurationValue get() = this - override val asList: ConfigurationList get() = reportAndThrow("Unexpected float, expected a list") - } - - private fun tools.samt.parser.StringNode.toConfigurationValue() = object : ConfigurationValue { - override val asString: String get() = value - override val asIdentifier: String get() = reportAndThrow("Unexpected string, expected an identifier") - - override fun > asEnum(enum: Class): T { - check(enum.isEnum) - return enum.enumConstants.find { it.name.equals(value, ignoreCase = true) } - ?: reportAndThrow("Illegal enum value, expected one of ${enum.enumConstants.joinToString { it.name }}") + reportAndThrow("Unexpected integer, expected an enum (${enum.simpleName})") + + override val asLong: Long get() = original.value + override val asDouble: Double = original.value.toDouble() + override val asBoolean: Boolean get() = reportAndThrow("Unexpected integer, expected a boolean") + override val asServiceName: ServiceType get() = reportAndThrow("Unexpected integer, expected a service name") + override fun asOperationName(service: ServiceType): ServiceOperation = + reportAndThrow("Unexpected integer, expected an operation name") + + override val asObject: ConfigurationObject get() = reportAndThrow("Unexpected integer, expected an object") + override val asValue: ConfigurationValue get() = this + override val asList: ConfigurationList get() = reportAndThrow("Unexpected integer, expected a list") } - override val asLong: Long get() = reportAndThrow("Unexpected string, expected an integer") - override val asDouble: Double get() = reportAndThrow("Unexpected string, expected a float") - override val asBoolean: Boolean get() = reportAndThrow("Unexpected string, expected a boolean") - override val asServiceName: ServiceType get() = reportAndThrow("Unexpected string, expected a service name") - override fun asOperationName(service: ServiceType): ServiceOperation = - reportAndThrow("Unexpected string, expected an operation name") - - override val asObject: ConfigurationObject get() = reportAndThrow("Unexpected string, expected an object") - override val asValue: ConfigurationValue get() = this - override val asList: ConfigurationList get() = reportAndThrow("Unexpected string, expected a list") - } - - private fun tools.samt.parser.BooleanNode.toConfigurationValue() = object : ConfigurationValue { - override val asString: String get() = reportAndThrow("Unexpected boolean, expected a string") - override val asIdentifier: String get() = reportAndThrow("Unexpected boolean, expected an identifier") + private fun tools.samt.parser.FloatNode.toConfigurationValue() = + object : ConfigurationValue, PublicApiConfigurationMapping { + override val original = this@toConfigurationValue + override val asString: String get() = reportAndThrow("Unexpected float, expected a string") + override val asIdentifier: String get() = reportAndThrow("Unexpected float, expected an identifier") - override fun > asEnum(enum: Class): T = - reportAndThrow("Unexpected boolean, expected an enum (${enum.simpleName})") + override fun > asEnum(enum: Class): T = + reportAndThrow("Unexpected float, expected an enum (${enum.simpleName})") + + override val asLong: Long get() = reportAndThrow("Unexpected float, expected an integer") + override val asDouble: Double = original.value + override val asBoolean: Boolean get() = reportAndThrow("Unexpected float, expected a boolean") + override val asServiceName: ServiceType get() = reportAndThrow("Unexpected float, expected a service name") + override fun asOperationName(service: ServiceType): ServiceOperation = + reportAndThrow("Unexpected float, expected an operation name") + + override val asObject: ConfigurationObject get() = reportAndThrow("Unexpected float, expected an object") + override val asValue: ConfigurationValue get() = this + override val asList: ConfigurationList get() = reportAndThrow("Unexpected float, expected a list") + } - override val asLong: Long get() = reportAndThrow("Unexpected boolean, expected an integer") - override val asDouble: Double get() = reportAndThrow("Unexpected boolean, expected a float") - override val asBoolean: Boolean get() = value - override val asServiceName: ServiceType get() = reportAndThrow("Unexpected boolean, expected a service name") - override fun asOperationName(service: ServiceType): ServiceOperation = - reportAndThrow("Unexpected boolean, expected an operation name") + private fun tools.samt.parser.StringNode.toConfigurationValue() = + object : ConfigurationValue, PublicApiConfigurationMapping { + override val original = this@toConfigurationValue + override val asString: String get() = original.value + override val asIdentifier: String get() = reportAndThrow("Unexpected string, expected an identifier") - override val asObject: ConfigurationObject get() = reportAndThrow("Unexpected boolean, expected an object") - override val asValue: ConfigurationValue get() = this - override val asList: ConfigurationList get() = reportAndThrow("Unexpected boolean, expected a list") - } + override fun > asEnum(enum: Class): T { + check(enum.isEnum) + return enum.enumConstants.find { it.name.equals(original.value, ignoreCase = true) } + ?: reportAndThrow("Illegal enum value, expected one of ${enum.enumConstants.joinToString { it.name }}") + } - private fun tools.samt.parser.IdentifierNode.toConfigurationValue() = object : ConfigurationValue { - override val asString: String get() = reportAndThrow("Unexpected identifier, expected a string") - override val asIdentifier: String get() = name + override val asLong: Long get() = reportAndThrow("Unexpected string, expected an integer") + override val asDouble: Double get() = reportAndThrow("Unexpected string, expected a float") + override val asBoolean: Boolean get() = reportAndThrow("Unexpected string, expected a boolean") + override val asServiceName: ServiceType get() = reportAndThrow("Unexpected string, expected a service name") + override fun asOperationName(service: ServiceType): ServiceOperation = + reportAndThrow("Unexpected string, expected an operation name") - override fun > asEnum(enum: Class): T = - reportAndThrow("Unexpected identifier, expected an enum (${enum.simpleName})") + override val asObject: ConfigurationObject get() = reportAndThrow("Unexpected string, expected an object") + override val asValue: ConfigurationValue get() = this + override val asList: ConfigurationList get() = reportAndThrow("Unexpected string, expected a list") + } - override val asLong: Long get() = reportAndThrow("Unexpected identifier, expected an integer") - override val asDouble: Double get() = reportAndThrow("Unexpected identifier, expected a float") - override val asBoolean: Boolean get() = reportAndThrow("Unexpected identifier, expected a boolean") - override val asServiceName: ServiceType - get() = provider.implements.find { it.service.name == name }?.service - ?: reportAndThrow("No service with name '$name' found in provider '${provider.name}'") + private fun tools.samt.parser.BooleanNode.toConfigurationValue() = + object : ConfigurationValue, PublicApiConfigurationMapping { + override val original = this@toConfigurationValue + override val asString: String get() = reportAndThrow("Unexpected boolean, expected a string") + override val asIdentifier: String get() = reportAndThrow("Unexpected boolean, expected an identifier") - override fun asOperationName(service: ServiceType): ServiceOperation = - provider.implements.find { it.service.qualifiedName == service.qualifiedName }?.operations?.find { it.name == name } - ?: reportAndThrow("No operation with name '$name' found in service '${service.name}' of provider '${provider.name}'") + override fun > asEnum(enum: Class): T = + reportAndThrow("Unexpected boolean, expected an enum (${enum.simpleName})") + + override val asLong: Long get() = reportAndThrow("Unexpected boolean, expected an integer") + override val asDouble: Double get() = reportAndThrow("Unexpected boolean, expected a float") + override val asBoolean: Boolean get() = value + override val asServiceName: ServiceType get() = reportAndThrow("Unexpected boolean, expected a service name") + override fun asOperationName(service: ServiceType): ServiceOperation = + reportAndThrow("Unexpected boolean, expected an operation name") + + override val asObject: ConfigurationObject get() = reportAndThrow("Unexpected boolean, expected an object") + override val asValue: ConfigurationValue get() = this + override val asList: ConfigurationList get() = reportAndThrow("Unexpected boolean, expected a list") + } - override val asObject: ConfigurationObject get() = reportAndThrow("Unexpected identifier, expected an object") - override val asValue: ConfigurationValue get() = this - override val asList: ConfigurationList get() = reportAndThrow("Unexpected identifier, expected a list") - } + private fun tools.samt.parser.IdentifierNode.toConfigurationValue() = + object : ConfigurationValue, PublicApiConfigurationMapping { + override val original = this@toConfigurationValue + override val asString: String get() = reportAndThrow("Unexpected identifier, expected a string") + override val asIdentifier: String get() = original.name - private fun tools.samt.parser.ArrayNode.toConfigurationList() = object : ConfigurationList { - override val entries: List - get() = this@toConfigurationList.values.map { it.toConfigurationElement() } - override val asObject: ConfigurationObject - get() = reportAndThrow("Unexpected array, expected an object") - override val asValue: ConfigurationValue - get() = reportAndThrow("Unexpected array, expected a value") - override val asList: ConfigurationList - get() = this - } + override fun > asEnum(enum: Class): T = + reportAndThrow("Unexpected identifier, expected an enum (${enum.simpleName})") + + override val asLong: Long get() = reportAndThrow("Unexpected identifier, expected an integer") + override val asDouble: Double get() = reportAndThrow("Unexpected identifier, expected a float") + override val asBoolean: Boolean get() = reportAndThrow("Unexpected identifier, expected a boolean") + override val asServiceName: ServiceType + get() = provider.implements.find { it.service.name == original.name }?.service + ?: reportAndThrow("No service with name '${original.name}' found in provider '${provider.name}'") + + override fun asOperationName(service: ServiceType): ServiceOperation = + provider.implements.find { it.service.qualifiedName == service.qualifiedName }?.implementedOperations?.find { it.name == original.name } + ?: reportAndThrow("No operation with name '${original.name}' found in service '${service.name}' of provider '${provider.name}'") + + override val asObject: ConfigurationObject get() = reportAndThrow("Unexpected identifier, expected an object") + override val asValue: ConfigurationValue get() = this + override val asList: ConfigurationList get() = reportAndThrow("Unexpected identifier, expected a list") + } - private fun tools.samt.parser.ObjectNode.toConfigurationObject() = object : ConfigurationObject { - override val fields: Map - get() = this@toConfigurationObject.fields.associate { it.name.toConfigurationValue() to it.value.toConfigurationElement() } + private fun tools.samt.parser.ArrayNode.toConfigurationList() = + object : ConfigurationList, PublicApiConfigurationMapping { + override val original = this@toConfigurationList + override val entries: List + get() = original.values.map { it.toConfigurationElement() } + override val asObject: ConfigurationObject + get() = reportAndThrow("Unexpected array, expected an object") + override val asValue: ConfigurationValue + get() = reportAndThrow("Unexpected array, expected a value") + override val asList: ConfigurationList + get() = this + } - override fun getField(name: String): ConfigurationElement = - getFieldOrNull(name) ?: run { - this@toConfigurationObject.reportError(controller) { - message("No field with name '$name' found") - highlight("related object", this@toConfigurationObject.location) + private fun tools.samt.parser.ObjectNode.toConfigurationObject() = + object : ConfigurationObject, PublicApiConfigurationMapping { + override val original = this@toConfigurationObject + override val fields: Map + get() = original.fields.associate { it.name.toConfigurationValue() to it.value.toConfigurationElement() } + + override fun getField(name: String): ConfigurationElement = + getFieldOrNull(name) ?: run { + original.reportError(controller) { + message("No field with name '$name' found") + highlight("related object", original.location) + } + throw NoSuchElementException("No field with name '$name' found") } - throw NoSuchElementException("No field with name '$name' found") - } - - override fun getFieldOrNull(name: String): ConfigurationElement? = - this@toConfigurationObject.fields.find { it.name.name == name }?.value?.toConfigurationElement() - override val asObject: ConfigurationObject - get() = this - override val asValue: ConfigurationValue - get() { - this@toConfigurationObject.reportError(controller) { - message("Object is not a value") - highlight("unexpected object, expected value", this@toConfigurationObject.location) + override fun getFieldOrNull(name: String): ConfigurationElement? = + original.fields.find { it.name.name == name }?.value?.toConfigurationElement() + + override val asObject: ConfigurationObject + get() = this + override val asValue: ConfigurationValue + get() { + original.reportError(controller) { + message("Object is not a value") + highlight("unexpected object, expected value", original.location) + } + error("Object is not a value") } - error("Object is not a value") - } - override val asList: ConfigurationList - get() { - this@toConfigurationObject.reportError(controller) { - message("Object is not a list") - highlight("unexpected object, expected list", this@toConfigurationObject.location) + override val asList: ConfigurationList + get() { + original.reportError(controller) { + message("Object is not a list") + highlight("unexpected object, expected list", original.location) + } + error("Object is not a list") } - error("Object is not a list") - } - } + } } diff --git a/codegen/src/main/kotlin/tools/samt/codegen/http/HttpTransport.kt b/codegen/src/main/kotlin/tools/samt/codegen/http/HttpTransport.kt index 52ccce0c..e2946720 100644 --- a/codegen/src/main/kotlin/tools/samt/codegen/http/HttpTransport.kt +++ b/codegen/src/main/kotlin/tools/samt/codegen/http/HttpTransport.kt @@ -6,11 +6,15 @@ object HttpTransportConfigurationParser : TransportConfigurationParser { override val transportName: String get() = "http" - override fun default(): TransportConfiguration = HttpTransportConfiguration( + override fun default(): HttpTransportConfiguration = HttpTransportConfiguration( serializationMode = HttpTransportConfiguration.SerializationMode.Json, services = emptyList(), ) + private val isValidRegex = Regex("""\w+\s+\S+(\s+\{.*? in \S+})*""") + private val methodEndpointRegex = Regex("""(\w+)\s+(\S+)(.*)""") + private val parameterRegex = Regex("""\{(.*?) in (\S+)}""") + override fun parse(params: TransportConfigurationParserParams): HttpTransportConfiguration { val config = params.config val serializationMode = @@ -26,89 +30,97 @@ object HttpTransportConfigurationParser : TransportConfigurationParser { val serviceName = service.name val operationConfiguration = operationsField.asObject - val parsedOperations = operationConfiguration.fields.filterKeys { it.asIdentifier != "basePath" }.map { (key, value) -> - val operationConfig = value.asValue - val operation = key.asOperationName(service) - val operationName = operation.name - val methodEndpointRegex = Regex("""(\w+)\s+(\S+)(.*)""") - val parameterRegex = Regex("""\{(.*?) in (.*?)}""") - - val methodEndpointResult = methodEndpointRegex.matchEntire(operationConfig.asString) - if (methodEndpointResult == null) { - params.reportError( - "Invalid operation config for '$operationName', expected ' '", - operationConfig - ) - error("Invalid operation config for '$operationName', expected ' '") - } + val parsedOperations = operationConfiguration.fields + .filterKeys { it.asIdentifier != "basePath" } + .map { (key, value) -> + val operationConfig = value.asValue + val operation = key.asOperationName(service) + val operationName = operation.name - val (method, path, parameterPart) = methodEndpointResult.destructured - - val methodEnum = when (method) { - "GET" -> HttpTransportConfiguration.HttpMethod.Get - "POST" -> HttpTransportConfiguration.HttpMethod.Post - "PUT" -> HttpTransportConfiguration.HttpMethod.Put - "DELETE" -> HttpTransportConfiguration.HttpMethod.Delete - "PATCH" -> HttpTransportConfiguration.HttpMethod.Patch - else -> { - params.reportError("Invalid http method '$method'", operationConfig) - error("Invalid http method '$method'") + if (!(operationConfig.asString matches isValidRegex)) { + params.reportError( + "Invalid operation config for '$operationName', expected ' '. A valid example: 'POST /${operationName} {parameter1, parameter2 in query}'", + operationConfig + ) + error("Invalid operation config for '$operationName', expected ' '") } - } - val parameters = mutableListOf() - - // parse path and path parameters - val pathComponents = path.split("/") - for (component in pathComponents) { - if (!component.startsWith("{") || !component.endsWith("}")) continue - - val pathParameterName = component.substring(1, component.length - 1) - - if (pathParameterName.isEmpty()) { + val methodEndpointResult = methodEndpointRegex.matchEntire(operationConfig.asString) + if (methodEndpointResult == null) { params.reportError( - "Expected parameter name between curly braces in '$path'", + "Invalid operation config for '$operationName', expected ' '", operationConfig ) - continue + error("Invalid operation config for '$operationName', expected ' '") } - parameters += HttpTransportConfiguration.ParameterConfiguration( - name = pathParameterName, - transportMode = HttpTransportConfiguration.TransportMode.Path, - ) - } + val (method, path, parameterPart) = methodEndpointResult.destructured - val parameterResults = parameterRegex.findAll(parameterPart) - // parse parameter declarations - for (parameterResult in parameterResults) { - val (names, type) = parameterResult.destructured - val transportMode = when (type) { - "query" -> HttpTransportConfiguration.TransportMode.Query - "header" -> HttpTransportConfiguration.TransportMode.Header - "body" -> HttpTransportConfiguration.TransportMode.Body - "cookie" -> HttpTransportConfiguration.TransportMode.Cookie + val methodEnum = when (method) { + "GET" -> HttpTransportConfiguration.HttpMethod.Get + "POST" -> HttpTransportConfiguration.HttpMethod.Post + "PUT" -> HttpTransportConfiguration.HttpMethod.Put + "DELETE" -> HttpTransportConfiguration.HttpMethod.Delete + "PATCH" -> HttpTransportConfiguration.HttpMethod.Patch else -> { - params.reportError("Invalid transport mode '$type'", operationConfig) - continue + params.reportError("Invalid http method '$method'", operationConfig) + error("Invalid http method '$method'") } } - names.split(",").forEach { name -> + val parameters = mutableListOf() + + // parse path and path parameters + val pathComponents = path.split("/") + for (component in pathComponents) { + if (!component.startsWith("{") || !component.endsWith("}")) continue + + val pathParameterName = component.substring(1, component.length - 1) + + if (pathParameterName.isEmpty()) { + params.reportError( + "Expected parameter name between curly braces in '$path'", + operationConfig + ) + continue + } + parameters += HttpTransportConfiguration.ParameterConfiguration( - name = name.trim(), - transportMode = transportMode, + name = pathParameterName, + transportMode = HttpTransportConfiguration.TransportMode.Path, ) } - } - HttpTransportConfiguration.OperationConfiguration( - name = operationName, - method = methodEnum, - path = path, - parameters = parameters, - ) - } + val parameterResults = parameterRegex.findAll(parameterPart) + // parse parameter declarations + for (parameterResult in parameterResults) { + val (names, type) = parameterResult.destructured + val transportMode = when (type) { + "query" -> HttpTransportConfiguration.TransportMode.Query + "header" -> HttpTransportConfiguration.TransportMode.Header + "body" -> HttpTransportConfiguration.TransportMode.Body + "cookie" -> HttpTransportConfiguration.TransportMode.Cookie + else -> { + params.reportError("Invalid transport mode '$type'", operationConfig) + continue + } + } + + names.split(",").forEach { name -> + parameters += HttpTransportConfiguration.ParameterConfiguration( + name = name.trim(), + transportMode = transportMode, + ) + } + } + + HttpTransportConfiguration.OperationConfiguration( + name = operationName, + method = methodEnum, + path = path, + parameters = parameters, + ) + } HttpTransportConfiguration.ServiceConfiguration( name = serviceName, @@ -175,7 +187,7 @@ class HttpTransportConfiguration( Patch, } - fun getService(name: String): ServiceConfiguration? { + private fun getService(name: String): ServiceConfiguration? { return services.firstOrNull { it.name == name } } diff --git a/codegen/src/main/kotlin/tools/samt/codegen/kotlin/ktor/KotlinKtorConsumerGenerator.kt b/codegen/src/main/kotlin/tools/samt/codegen/kotlin/ktor/KotlinKtorConsumerGenerator.kt index a5c2b804..b9fe01d6 100644 --- a/codegen/src/main/kotlin/tools/samt/codegen/kotlin/ktor/KotlinKtorConsumerGenerator.kt +++ b/codegen/src/main/kotlin/tools/samt/codegen/kotlin/ktor/KotlinKtorConsumerGenerator.kt @@ -53,9 +53,9 @@ object KotlinKtorConsumerGenerator : Generator { } } - data class ConsumerInfo(val consumer: ConsumerType, val uses: ConsumerUses) { + data class ConsumerInfo(val consumer: ConsumerType, val uses: ConsumedService) { val service = uses.service - val implementedOperations = uses.operations + val implementedOperations = uses.consumedOperations val notImplementedOperations = service.operations.filter { serviceOp -> implementedOperations.none { it.name == serviceOp.name } } } @@ -196,13 +196,7 @@ object KotlinKtorConsumerGenerator : Generator { appendLine(" // Encode query parameters") queryParameters.forEach { (name, queryParameter) -> - if (queryParameter.type.isRuntimeOptional) { - appendLine(" if ($name != null) {") - appendLine(" this.parameters.append(\"$name\", ${encodeJsonElement(queryParameter.type, options, valueName = name)}.toString())") - appendLine(" }") - } else { - appendLine(" this.parameters.append(\"$name\", ${encodeJsonElement(queryParameter.type, options, valueName = name)}.toString())") - } + appendLine(" this.parameters.append(\"$name\", (${encodeJsonElement(queryParameter.type, options, valueName = name)}).toString())") } appendLine(" }") @@ -217,24 +211,12 @@ object KotlinKtorConsumerGenerator : Generator { // header parameters headerParameters.forEach { (name, headerParameter) -> - if (headerParameter.type.isRuntimeOptional) { - appendLine(" if ($name != null) {") - appendLine(" header(\"${name}\", ${encodeJsonElement(headerParameter.type, options, valueName = name)})") - appendLine(" }") - } else { - appendLine(" header(\"${name}\", ${encodeJsonElement(headerParameter.type, options, valueName = name)})") - } + appendLine(" header(\"${name}\", ${encodeJsonElement(headerParameter.type, options, valueName = name)})") } // cookie parameters cookieParameters.forEach { (name, cookieParameter) -> - if (cookieParameter.type.isRuntimeOptional) { - appendLine(" if ($name != null) {") - appendLine(" cookie(\"${name}\", ${encodeJsonElement(cookieParameter.type, options, valueName = name)}.toString())") - appendLine(" }") - } else { - appendLine(" cookie(\"${name}\", ${encodeJsonElement(cookieParameter.type, options, valueName = name)}.toString())") - } + appendLine(" cookie(\"${name}\", (${encodeJsonElement(cookieParameter.type, options, valueName = name)}).toString())") } // body parameters diff --git a/codegen/src/main/kotlin/tools/samt/codegen/kotlin/ktor/KotlinKtorProviderGenerator.kt b/codegen/src/main/kotlin/tools/samt/codegen/kotlin/ktor/KotlinKtorProviderGenerator.kt index a3a06968..3a4bb7ac 100644 --- a/codegen/src/main/kotlin/tools/samt/codegen/kotlin/ktor/KotlinKtorProviderGenerator.kt +++ b/codegen/src/main/kotlin/tools/samt/codegen/kotlin/ktor/KotlinKtorProviderGenerator.kt @@ -108,7 +108,7 @@ object KotlinKtorProviderGenerator : Generator { emittedFiles.add(file) } - data class ProviderInfo(val implements: ProviderImplements) { + data class ProviderInfo(val implements: ProvidedService) { val service = implements.service val serviceArgumentName = implements.service.name.replaceFirstChar { it.lowercase() } } @@ -159,7 +159,7 @@ object KotlinKtorProviderGenerator : Generator { val service = info.service appendLine(" // Handler for SAMT Service ${info.service.name}") appendLine(" route(\"${transportConfiguration.getPath(service.name)}\") {") - info.implements.operations.forEach { operation -> + info.implements.implementedOperations.forEach { operation -> appendProviderOperation(operation, info, service, transportConfiguration, options) } appendLine(" }") From 0d60e049d8768676a7d3b66ef9f576fea615d798 Mon Sep 17 00:00:00 2001 From: Pascal Honegger Date: Mon, 29 May 2023 23:41:40 +0200 Subject: [PATCH 34/41] refactor(public-api): dedicated public API --- cli/build.gradle.kts | 1 + .../kotlin/tools/samt/cli/OutputWriter.kt | 2 +- codegen/build.gradle.kts | 3 + .../main/kotlin/tools/samt/codegen/Codegen.kt | 9 +- .../kotlin/tools/samt/codegen/PublicApi.kt | 377 ------------------ .../tools/samt/codegen/PublicApiMapper.kt | 2 + .../codegen/TransportConfigurationMapper.kt | 7 + .../tools/samt/codegen/http/HttpTransport.kt | 27 +- .../codegen/kotlin/KotlinGeneratorUtils.kt | 2 +- .../codegen/kotlin/KotlinTypesGenerator.kt | 5 +- .../ktor/KotlinKtorConsumerGenerator.kt | 13 +- .../ktor/KotlinKtorGeneratorUtilities.kt | 2 +- .../ktor/KotlinKtorProviderGenerator.kt | 5 +- public-api/build.gradle.kts | 3 + .../kotlin/tools/samt/api/plugin/Generator.kt | 59 +++ .../kotlin/tools/samt/api/plugin/Transport.kt | 178 +++++++++ .../samt/api/types/StandardLibraryTypes.kt | 40 ++ .../main/kotlin/tools/samt/api/types/Types.kt | 108 +++++ .../kotlin/tools/samt/api/types/UserTypes.kt | 155 +++++++ settings.gradle.kts | 1 + 20 files changed, 601 insertions(+), 398 deletions(-) delete mode 100644 codegen/src/main/kotlin/tools/samt/codegen/PublicApi.kt create mode 100644 public-api/build.gradle.kts create mode 100644 public-api/src/main/kotlin/tools/samt/api/plugin/Generator.kt create mode 100644 public-api/src/main/kotlin/tools/samt/api/plugin/Transport.kt create mode 100644 public-api/src/main/kotlin/tools/samt/api/types/StandardLibraryTypes.kt create mode 100644 public-api/src/main/kotlin/tools/samt/api/types/Types.kt create mode 100644 public-api/src/main/kotlin/tools/samt/api/types/UserTypes.kt diff --git a/cli/build.gradle.kts b/cli/build.gradle.kts index 240b94b4..8260b821 100644 --- a/cli/build.gradle.kts +++ b/cli/build.gradle.kts @@ -16,6 +16,7 @@ dependencies { implementation(project(":semantic")) implementation(project(":samt-config")) implementation(project(":codegen")) + implementation(project(":public-api")) } application { diff --git a/cli/src/main/kotlin/tools/samt/cli/OutputWriter.kt b/cli/src/main/kotlin/tools/samt/cli/OutputWriter.kt index a1f3e0a7..31021656 100644 --- a/cli/src/main/kotlin/tools/samt/cli/OutputWriter.kt +++ b/cli/src/main/kotlin/tools/samt/cli/OutputWriter.kt @@ -1,6 +1,6 @@ package tools.samt.cli -import tools.samt.codegen.CodegenFile +import tools.samt.api.plugin.CodegenFile import java.io.IOException import java.nio.file.InvalidPathException import java.nio.file.Path diff --git a/codegen/build.gradle.kts b/codegen/build.gradle.kts index 8899f1bc..75f2abef 100644 --- a/codegen/build.gradle.kts +++ b/codegen/build.gradle.kts @@ -1,9 +1,12 @@ plugins { id("samt-core.kotlin-conventions") + alias(libs.plugins.kover) } dependencies { implementation(project(":common")) implementation(project(":parser")) implementation(project(":semantic")) + implementation(project(":public-api")) + testImplementation(project(":lexer")) } diff --git a/codegen/src/main/kotlin/tools/samt/codegen/Codegen.kt b/codegen/src/main/kotlin/tools/samt/codegen/Codegen.kt index d66dcfb5..f9f28e6e 100644 --- a/codegen/src/main/kotlin/tools/samt/codegen/Codegen.kt +++ b/codegen/src/main/kotlin/tools/samt/codegen/Codegen.kt @@ -1,14 +1,17 @@ package tools.samt.codegen +import tools.samt.api.plugin.CodegenFile +import tools.samt.api.plugin.Generator +import tools.samt.api.plugin.GeneratorParams +import tools.samt.api.plugin.TransportConfigurationParser +import tools.samt.api.types.SamtPackage import tools.samt.codegen.http.HttpTransportConfigurationParser import tools.samt.codegen.kotlin.KotlinTypesGenerator import tools.samt.codegen.kotlin.ktor.KotlinKtorConsumerGenerator import tools.samt.codegen.kotlin.ktor.KotlinKtorProviderGenerator import tools.samt.common.DiagnosticController import tools.samt.common.SamtGeneratorConfiguration -import tools.samt.semantic.* - -data class CodegenFile(val filepath: String, val source: String) +import tools.samt.semantic.SemanticModel object Codegen { private val generators: List = listOf( diff --git a/codegen/src/main/kotlin/tools/samt/codegen/PublicApi.kt b/codegen/src/main/kotlin/tools/samt/codegen/PublicApi.kt deleted file mode 100644 index 7a3a86e4..00000000 --- a/codegen/src/main/kotlin/tools/samt/codegen/PublicApi.kt +++ /dev/null @@ -1,377 +0,0 @@ -package tools.samt.codegen - -interface GeneratorParams { - val packages: List - val options: Map - - fun reportError(message: String) - fun reportWarning(message: String) - fun reportInfo(message: String) -} - -interface SamtPackage { - val name: String - val qualifiedName: String - val records: List - val enums: List - val services: List - val providers: List - val consumers: List - val aliases: List -} - -interface Generator { - val name: String - fun generate(generatorParams: GeneratorParams): List -} - -interface TransportConfigurationParserParams { - val config: ConfigurationObject - - fun reportError(message: String, context: ConfigurationElement? = null) - fun reportWarning(message: String, context: ConfigurationElement? = null) - fun reportInfo(message: String, context: ConfigurationElement? = null) -} - -interface ConfigurationElement { - val asObject: ConfigurationObject - val asValue: ConfigurationValue - val asList: ConfigurationList -} - -interface ConfigurationObject : ConfigurationElement { - val fields: Map - fun getField(name: String): ConfigurationElement - fun getFieldOrNull(name: String): ConfigurationElement? -} - -interface ConfigurationList : ConfigurationElement { - val entries: List -} - -interface ConfigurationValue : ConfigurationElement { - val asString: String - val asIdentifier: String - fun > asEnum(enum: Class): T - val asLong: Long - val asDouble: Double - val asBoolean: Boolean - val asServiceName: ServiceType - fun asOperationName(service: ServiceType): ServiceOperation -} - -inline fun > ConfigurationValue.asEnum() = asEnum(T::class.java) - -/** - * A transport configuration parser. - * This interface is intended to be implemented by a transport configuration parser, for example HTTP. - * It is used to parse the configuration body into a specific [TransportConfiguration]. - */ -interface TransportConfigurationParser { - val transportName: String - - /** - * Create the default configuration for this transport, used when no configuration body is specified - * @return Default configuration - */ - fun default(): TransportConfiguration - - /** - * Parses the configuration body and returns the configuration object - * @throws RuntimeException if the configuration is invalid and graceful error handling is not possible - * @return Parsed configuration - */ - fun parse(params: TransportConfigurationParserParams): TransportConfiguration -} - -/** - * A base interface for transport configurations. - * This interface is intended to be sub-typed and extended by transport configuration implementations. - */ -interface TransportConfiguration - -/** - * A SAMT type - */ -interface Type - -interface LiteralType : Type - -interface IntType : LiteralType -interface LongType : LiteralType -interface FloatType : LiteralType -interface DoubleType : LiteralType -interface DecimalType : LiteralType -interface BooleanType : LiteralType -interface StringType : LiteralType -interface BytesType : LiteralType -interface DateType : LiteralType -interface DateTimeType : LiteralType -interface DurationType : LiteralType - -/** - * A ordered list of elements - */ -interface ListType : Type { - /** - * The type of the elements in the list - */ - val elementType: TypeReference -} - -/** - * A map of key-value pairs - */ -interface MapType : Type { - /** - * The type of the keys in the map - */ - val keyType: TypeReference - - /** - * The type of the values in the map - */ - val valueType: TypeReference -} - -interface UserType : Type { - val name: String - val qualifiedName: String -} - -interface AliasType : UserType { - /** - * The type this alias stands for, could be another alias - */ - val aliasedType: TypeReference - - /** - * The fully resolved type, will not contain any type aliases anymore, just the underlying merged type - */ - val fullyResolvedType: TypeReference -} - -/** - * A SAMT record - */ -interface RecordType : UserType { - val fields: List -} - -/** - * A field in a record - */ -interface RecordField { - val name: String - val type: TypeReference -} - -/** - * A SAMT enum - */ -interface EnumType : UserType { - val values: List -} - -/** - * A SAMT service - */ -interface ServiceType : UserType { - val operations: List -} - -/** - * An operation in a service - */ -interface ServiceOperation { - val name: String - val parameters: List -} - -/** - * A parameter in a service operation - */ -interface ServiceOperationParameter { - val name: String - val type: TypeReference -} - -/** - * A service operation that returns a response - */ -interface RequestResponseOperation : ServiceOperation { - /** - * The return type of this operation. - * If null, this operation returns nothing. - */ - val returnType: TypeReference? - - /** - * Is true if this operation is asynchronous. - * This could mean that the operation returns a future in Java, or a Promise in JavaScript. - */ - val isAsync: Boolean -} - -/** - * A service operation that is fire-and-forget, never returning a response - */ -interface OnewayOperation : ServiceOperation - -/** - * A SAMT provider - */ -interface ProviderType : UserType { - val implements: List - val transport: TransportConfiguration -} - -/** - * Connects a provider to a service - */ -interface ProvidedService { - /** - * The underlying service this provider implements - */ - val service: ServiceType - - /** - * The operations that are implemented by this provider - */ - val implementedOperations: List - - /** - * The operations that are not implemented by this provider - */ - val unimplementedOperations: List -} - -/** - * A SAMT consumer - */ -interface ConsumerType : Type { - /** - * The provider this consumer is connected to - */ - val provider: ProviderType - - /** - * The services this consumer uses - */ - val uses: List - - /** - * The package this consumer is located in - */ - val samtPackage: String -} - -/** - * Connects a consumer to a service - */ -interface ConsumedService { - /** - * The underlying service this consumer uses - */ - val service: ServiceType - - /** - * The operations that are consumed by this consumer - */ - val consumedOperations: List - - /** - * The operations that are not consumed by this consumer - */ - val unconsumedOperations: List -} - -/** - * A type reference - */ -interface TypeReference { - /** - * The type this reference points to - */ - val type: Type - - /** - * Is true if this type reference is optional, meaning it can be null - */ - val isOptional: Boolean - - /** - * The range constraints placed on this type, if any - */ - val rangeConstraint: Constraint.Range? - - /** - * The size constraints placed on this type, if any - */ - val sizeConstraint: Constraint.Size? - - /** - * The pattern constraints placed on this type, if any - */ - val patternConstraint: Constraint.Pattern? - - /** - * The value constraints placed on this type, if any - */ - val valueConstraint: Constraint.Value? - - /** - * The runtime type this reference points to, could be different from [type] if this is an alias - */ - val runtimeType: Type - - /** - * Is true if this type reference or underlying type is optional, meaning it can be null at runtime - * This is different from [isOptional] in that it will return true for an alias that points to an optional type - */ - val isRuntimeOptional: Boolean - - /** - * The runtime range constraints placed on this type, if any. - * Will differ from [rangeConstraint] if this is an alias - */ - val runtimeRangeConstraint: Constraint.Range? - - /** - * The runtime size constraints placed on this type, if any. - * Will differ from [sizeConstraint] if this is an alias - */ - val runtimeSizeConstraint: Constraint.Size? - - /** - * The runtime pattern constraints placed on this type, if any. - * Will differ from [patternConstraint] if this is an alias - */ - val runtimePatternConstraint: Constraint.Pattern? - - /** - * The runtime value constraints placed on this type, if any. - * Will differ from [valueConstraint] if this is an alias - */ - val runtimeValueConstraint: Constraint.Value? -} - -interface Constraint { - interface Range : Constraint { - val lowerBound: Number? - val upperBound: Number? - } - - interface Size : Constraint { - val lowerBound: Long? - val upperBound: Long? - } - - interface Pattern : Constraint { - val pattern: String - } - - interface Value : Constraint { - val value: Any - } -} diff --git a/codegen/src/main/kotlin/tools/samt/codegen/PublicApiMapper.kt b/codegen/src/main/kotlin/tools/samt/codegen/PublicApiMapper.kt index 709daa32..447ef220 100644 --- a/codegen/src/main/kotlin/tools/samt/codegen/PublicApiMapper.kt +++ b/codegen/src/main/kotlin/tools/samt/codegen/PublicApiMapper.kt @@ -1,5 +1,7 @@ package tools.samt.codegen +import tools.samt.api.plugin.* +import tools.samt.api.types.* import tools.samt.common.DiagnosticController import tools.samt.parser.reportError import tools.samt.parser.reportInfo diff --git a/codegen/src/main/kotlin/tools/samt/codegen/TransportConfigurationMapper.kt b/codegen/src/main/kotlin/tools/samt/codegen/TransportConfigurationMapper.kt index 8225c1c9..a6bcb132 100644 --- a/codegen/src/main/kotlin/tools/samt/codegen/TransportConfigurationMapper.kt +++ b/codegen/src/main/kotlin/tools/samt/codegen/TransportConfigurationMapper.kt @@ -1,5 +1,12 @@ package tools.samt.codegen +import tools.samt.api.plugin.ConfigurationElement +import tools.samt.api.plugin.ConfigurationList +import tools.samt.api.plugin.ConfigurationObject +import tools.samt.api.plugin.ConfigurationValue +import tools.samt.api.types.ProviderType +import tools.samt.api.types.ServiceOperation +import tools.samt.api.types.ServiceType import tools.samt.common.DiagnosticController import tools.samt.parser.reportError diff --git a/codegen/src/main/kotlin/tools/samt/codegen/http/HttpTransport.kt b/codegen/src/main/kotlin/tools/samt/codegen/http/HttpTransport.kt index e2946720..54e4df3f 100644 --- a/codegen/src/main/kotlin/tools/samt/codegen/http/HttpTransport.kt +++ b/codegen/src/main/kotlin/tools/samt/codegen/http/HttpTransport.kt @@ -1,6 +1,9 @@ package tools.samt.codegen.http -import tools.samt.codegen.* +import tools.samt.api.plugin.TransportConfiguration +import tools.samt.api.plugin.TransportConfigurationParser +import tools.samt.api.plugin.TransportConfigurationParserParams +import tools.samt.api.plugin.asEnum object HttpTransportConfigurationParser : TransportConfigurationParser { override val transportName: String @@ -24,7 +27,6 @@ object HttpTransportConfigurationParser : TransportConfigurationParser { val services = config.getFieldOrNull("operations")?.asObject?.let { operations -> operations.asObject.fields.map { (operationsKey, operationsField) -> - // TODO This currently fails horribly if an operation is called basePath val servicePath = operations.getFieldOrNull("basePath")?.asValue?.asString ?: "" val service = operationsKey.asServiceName val serviceName = service.name @@ -32,7 +34,7 @@ object HttpTransportConfigurationParser : TransportConfigurationParser { val parsedOperations = operationConfiguration.fields .filterKeys { it.asIdentifier != "basePath" } - .map { (key, value) -> + .mapNotNull { (key, value) -> val operationConfig = value.asValue val operation = key.asOperationName(service) val operationName = operation.name @@ -42,7 +44,7 @@ object HttpTransportConfigurationParser : TransportConfigurationParser { "Invalid operation config for '$operationName', expected ' '. A valid example: 'POST /${operationName} {parameter1, parameter2 in query}'", operationConfig ) - error("Invalid operation config for '$operationName', expected ' '") + return@mapNotNull null } val methodEndpointResult = methodEndpointRegex.matchEntire(operationConfig.asString) @@ -51,7 +53,7 @@ object HttpTransportConfigurationParser : TransportConfigurationParser { "Invalid operation config for '$operationName', expected ' '", operationConfig ) - error("Invalid operation config for '$operationName', expected ' '") + return@mapNotNull null } val (method, path, parameterPart) = methodEndpointResult.destructured @@ -64,7 +66,7 @@ object HttpTransportConfigurationParser : TransportConfigurationParser { "PATCH" -> HttpTransportConfiguration.HttpMethod.Patch else -> { params.reportError("Invalid http method '$method'", operationConfig) - error("Invalid http method '$method'") + return@mapNotNull null } } @@ -85,6 +87,11 @@ object HttpTransportConfigurationParser : TransportConfigurationParser { continue } + if (operation.parameters.none { it.name == pathParameterName }) { + params.reportError("Path parameter '$pathParameterName' not found in operation '$operationName'", operationConfig) + continue + } + parameters += HttpTransportConfiguration.ParameterConfiguration( name = pathParameterName, transportMode = HttpTransportConfiguration.TransportMode.Path, @@ -106,9 +113,13 @@ object HttpTransportConfigurationParser : TransportConfigurationParser { } } - names.split(",").forEach { name -> + for (name in names.split(",").map { it.trim() }) { + if (operation.parameters.none { it.name == name }) { + params.reportError("Parameter '$name' not found in operation '$operationName'", operationConfig) + continue + } parameters += HttpTransportConfiguration.ParameterConfiguration( - name = name.trim(), + name = name, transportMode = transportMode, ) } diff --git a/codegen/src/main/kotlin/tools/samt/codegen/kotlin/KotlinGeneratorUtils.kt b/codegen/src/main/kotlin/tools/samt/codegen/kotlin/KotlinGeneratorUtils.kt index ef0c6a96..94a6b72c 100644 --- a/codegen/src/main/kotlin/tools/samt/codegen/kotlin/KotlinGeneratorUtils.kt +++ b/codegen/src/main/kotlin/tools/samt/codegen/kotlin/KotlinGeneratorUtils.kt @@ -1,6 +1,6 @@ package tools.samt.codegen.kotlin -import tools.samt.codegen.* +import tools.samt.api.types.* object KotlinGeneratorConfig { const val removePrefixFromSamtPackage = "removePrefixFromSamtPackage" diff --git a/codegen/src/main/kotlin/tools/samt/codegen/kotlin/KotlinTypesGenerator.kt b/codegen/src/main/kotlin/tools/samt/codegen/kotlin/KotlinTypesGenerator.kt index 7bbfe4b5..decd712b 100644 --- a/codegen/src/main/kotlin/tools/samt/codegen/kotlin/KotlinTypesGenerator.kt +++ b/codegen/src/main/kotlin/tools/samt/codegen/kotlin/KotlinTypesGenerator.kt @@ -1,6 +1,9 @@ package tools.samt.codegen.kotlin -import tools.samt.codegen.* +import tools.samt.api.plugin.CodegenFile +import tools.samt.api.plugin.Generator +import tools.samt.api.plugin.GeneratorParams +import tools.samt.api.types.* object KotlinTypesGenerator : Generator { override val name: String = "kotlin-types" diff --git a/codegen/src/main/kotlin/tools/samt/codegen/kotlin/ktor/KotlinKtorConsumerGenerator.kt b/codegen/src/main/kotlin/tools/samt/codegen/kotlin/ktor/KotlinKtorConsumerGenerator.kt index b9fe01d6..b45fb86a 100644 --- a/codegen/src/main/kotlin/tools/samt/codegen/kotlin/ktor/KotlinKtorConsumerGenerator.kt +++ b/codegen/src/main/kotlin/tools/samt/codegen/kotlin/ktor/KotlinKtorConsumerGenerator.kt @@ -1,6 +1,9 @@ package tools.samt.codegen.kotlin.ktor -import tools.samt.codegen.* +import tools.samt.api.plugin.CodegenFile +import tools.samt.api.plugin.Generator +import tools.samt.api.plugin.GeneratorParams +import tools.samt.api.types.* import tools.samt.codegen.http.HttpTransportConfiguration import tools.samt.codegen.kotlin.GeneratedFilePreamble import tools.samt.codegen.kotlin.KotlinTypesGenerator @@ -55,8 +58,8 @@ object KotlinKtorConsumerGenerator : Generator { data class ConsumerInfo(val consumer: ConsumerType, val uses: ConsumedService) { val service = uses.service - val implementedOperations = uses.consumedOperations - val notImplementedOperations = service.operations.filter { serviceOp -> implementedOperations.none { it.name == serviceOp.name } } + val consumedOperations = uses.consumedOperations + val unconsumedOperations = uses.unconsumedOperations } private fun StringBuilder.appendConsumer(consumer: ConsumerType, transportConfiguration: HttpTransportConfiguration, options: Map) { @@ -92,7 +95,7 @@ object KotlinKtorConsumerGenerator : Generator { appendLine(" private val onewayScope = CoroutineScope(Dispatchers.IO)") appendLine() - info.implementedOperations.forEach { operation -> + info.consumedOperations.forEach { operation -> val operationParameters = operation.parameters.joinToString { "${it.name}: ${it.type.getQualifiedName(options)}" } when (operation) { @@ -124,7 +127,7 @@ object KotlinKtorConsumerGenerator : Generator { appendLine() } - info.notImplementedOperations.forEach { operation -> + info.unconsumedOperations.forEach { operation -> val operationParameters = operation.parameters.joinToString { "${it.name}: ${it.type.getQualifiedName(options)}" } when (operation) { diff --git a/codegen/src/main/kotlin/tools/samt/codegen/kotlin/ktor/KotlinKtorGeneratorUtilities.kt b/codegen/src/main/kotlin/tools/samt/codegen/kotlin/ktor/KotlinKtorGeneratorUtilities.kt index 4d3fe243..e9495b3f 100644 --- a/codegen/src/main/kotlin/tools/samt/codegen/kotlin/ktor/KotlinKtorGeneratorUtilities.kt +++ b/codegen/src/main/kotlin/tools/samt/codegen/kotlin/ktor/KotlinKtorGeneratorUtilities.kt @@ -1,6 +1,6 @@ package tools.samt.codegen.kotlin.ktor -import tools.samt.codegen.* +import tools.samt.api.types.* import tools.samt.codegen.kotlin.GeneratedFilePreamble import tools.samt.codegen.kotlin.getQualifiedName import tools.samt.codegen.kotlin.getTargetPackage diff --git a/codegen/src/main/kotlin/tools/samt/codegen/kotlin/ktor/KotlinKtorProviderGenerator.kt b/codegen/src/main/kotlin/tools/samt/codegen/kotlin/ktor/KotlinKtorProviderGenerator.kt index 3a4bb7ac..19c1da95 100644 --- a/codegen/src/main/kotlin/tools/samt/codegen/kotlin/ktor/KotlinKtorProviderGenerator.kt +++ b/codegen/src/main/kotlin/tools/samt/codegen/kotlin/ktor/KotlinKtorProviderGenerator.kt @@ -1,6 +1,9 @@ package tools.samt.codegen.kotlin.ktor -import tools.samt.codegen.* +import tools.samt.api.plugin.CodegenFile +import tools.samt.api.plugin.Generator +import tools.samt.api.plugin.GeneratorParams +import tools.samt.api.types.* import tools.samt.codegen.http.HttpTransportConfiguration import tools.samt.codegen.kotlin.GeneratedFilePreamble import tools.samt.codegen.kotlin.KotlinTypesGenerator diff --git a/public-api/build.gradle.kts b/public-api/build.gradle.kts new file mode 100644 index 00000000..d194b3df --- /dev/null +++ b/public-api/build.gradle.kts @@ -0,0 +1,3 @@ +plugins { + id("samt-core.kotlin-conventions") +} diff --git a/public-api/src/main/kotlin/tools/samt/api/plugin/Generator.kt b/public-api/src/main/kotlin/tools/samt/api/plugin/Generator.kt new file mode 100644 index 00000000..8e93c7c6 --- /dev/null +++ b/public-api/src/main/kotlin/tools/samt/api/plugin/Generator.kt @@ -0,0 +1,59 @@ +package tools.samt.api.plugin + +import tools.samt.api.types.SamtPackage + +/** + * A code generator. + * This interface is intended to be implemented by a code generator, for example Kotlin-Ktor. + */ +interface Generator { + /** + * The name of the generator, used to identify it in the configuration + */ + val name: String + + /** + * Generate code for the given packages + * @param generatorParams The parameters for the generator + * @return A list of generated files, which will be written to disk + */ + fun generate(generatorParams: GeneratorParams): List +} + +/** + * This class represents a file generated by a [Generator]. + */ +data class CodegenFile(val filepath: String, val source: String) + +/** + * The parameters for a [Generator]. + */ +interface GeneratorParams { + /** + * The packages to generate code for, includes all SAMT subpackages + */ + val packages: List + + /** + * The configuration for the generator as specified in the SAMT configuration + */ + val options: Map + + /** + * Report an error + * @param message The error message + */ + fun reportError(message: String) + + /** + * Report a warning + * @param message The warning message + */ + fun reportWarning(message: String) + + /** + * Report an info message + * @param message The info message + */ + fun reportInfo(message: String) +} diff --git a/public-api/src/main/kotlin/tools/samt/api/plugin/Transport.kt b/public-api/src/main/kotlin/tools/samt/api/plugin/Transport.kt new file mode 100644 index 00000000..1b37f851 --- /dev/null +++ b/public-api/src/main/kotlin/tools/samt/api/plugin/Transport.kt @@ -0,0 +1,178 @@ +package tools.samt.api.plugin + +import tools.samt.api.types.ServiceOperation +import tools.samt.api.types.ServiceType + +/** + * A transport configuration parser. + * This interface is intended to be implemented by a transport configuration parser, for example HTTP. + * It is used to parse the configuration body into a specific [TransportConfiguration]. + */ +interface TransportConfigurationParser { + /** + * The name of the transport, used to identify it in the configuration + */ + val transportName: String + + /** + * Create the default configuration for this transport, used when no configuration body is specified + * @return Default configuration + */ + fun default(): TransportConfiguration + + /** + * Parses the configuration body and returns the configuration object + * @throws RuntimeException if the configuration is invalid and graceful error handling is not possible + * @return Parsed configuration + */ + fun parse(params: TransportConfigurationParserParams): TransportConfiguration +} + +/** + * A base interface for transport configurations. + * This interface is intended to be sub-typed and extended by transport configuration implementations. + */ +interface TransportConfiguration + +/** + * The parameters for a [TransportConfigurationParser]. + */ +interface TransportConfigurationParserParams { + /** + * The configuration body to parse + */ + val config: ConfigurationObject + + /** + * Report an error + * @param message The error message + * @param context The configuration element that caused the error, will be highlighted in the editor + */ + fun reportError(message: String, context: ConfigurationElement? = null) + + /** + * Report a warning + * @param message The warning message + * @param context The configuration element that caused the warning, will be highlighted in the editor + */ + fun reportWarning(message: String, context: ConfigurationElement? = null) + + /** + * Report an info message + * @param message The info message + * @param context The configuration element that caused the info message, will be highlighted in the editor + */ + fun reportInfo(message: String, context: ConfigurationElement? = null) +} + +/** + * A configuration element + */ +interface ConfigurationElement { + /** + * This element as an [ConfigurationObject] + * @throws RuntimeException if this element is not an object + */ + val asObject: ConfigurationObject + + /** + * This element as an [ConfigurationValue] + * @throws RuntimeException if this element is not a primitive value + */ + val asValue: ConfigurationValue + + /** + * This element as an [ConfigurationList] + * @throws RuntimeException if this element is not a list + */ + val asList: ConfigurationList +} + +/** + * A configuration object, contains a map of fields + */ +interface ConfigurationObject : ConfigurationElement { + /** + * The fields of this object + */ + val fields: Map + + /** + * Get a field by name + * @throws RuntimeException if the field does not exist + */ + fun getField(name: String): ConfigurationElement + + /** + * Get a field by name, or null if it does not exist + */ + fun getFieldOrNull(name: String): ConfigurationElement? +} + +/** + * A configuration list, contains a list of elements + */ +interface ConfigurationList : ConfigurationElement { + /** + * The entries of this list + */ + val entries: List +} + +/** + * A primitive configuration value + */ +interface ConfigurationValue : ConfigurationElement { + /** + * This value as a string + * @throws RuntimeException if this value is not a string + */ + val asString: String + + /** + * This value as an identifier + * @throws RuntimeException if this value is not an identifier + */ + val asIdentifier: String + + /** + * This value as an enum, matches the enum value by name case-insensitively (e.g. "get" matches HttpMethod.GET) + * @throws RuntimeException if this value is not convertible to the provided [enum] + */ + fun > asEnum(enum: Class): T + + /** + * This value as a long + * @throws RuntimeException if this value is not a long + */ + val asLong: Long + + /** + * This value as a double + * @throws RuntimeException if this value is not a double + */ + val asDouble: Double + + /** + * This value as a boolean + * @throws RuntimeException if this value is not a boolean + */ + val asBoolean: Boolean + + /** + * This value as a service name, matches against services in the current provider context + * @throws RuntimeException if this value is not a service within the current provider context + */ + val asServiceName: ServiceType + + /** + * This value as a service operation name, matches against operations in the current provider context and [service] + * @throws RuntimeException if this value is not a service operation within the current provider context and [service] + */ + fun asOperationName(service: ServiceType): ServiceOperation +} + +/** + * Convenience wrapper for [ConfigurationValue.asEnum] + */ +inline fun > ConfigurationValue.asEnum() = asEnum(T::class.java) diff --git a/public-api/src/main/kotlin/tools/samt/api/types/StandardLibraryTypes.kt b/public-api/src/main/kotlin/tools/samt/api/types/StandardLibraryTypes.kt new file mode 100644 index 00000000..4b18c888 --- /dev/null +++ b/public-api/src/main/kotlin/tools/samt/api/types/StandardLibraryTypes.kt @@ -0,0 +1,40 @@ +package tools.samt.api.types + +interface LiteralType : Type + +interface IntType : LiteralType +interface LongType : LiteralType +interface FloatType : LiteralType +interface DoubleType : LiteralType +interface DecimalType : LiteralType +interface BooleanType : LiteralType +interface StringType : LiteralType +interface BytesType : LiteralType +interface DateType : LiteralType +interface DateTimeType : LiteralType +interface DurationType : LiteralType + +/** + * An ordered list of elements + */ +interface ListType : Type { + /** + * The type of the elements in the list + */ + val elementType: TypeReference +} + +/** + * A map of key-value pairs + */ +interface MapType : Type { + /** + * The type of the keys in the map + */ + val keyType: TypeReference + + /** + * The type of the values in the map + */ + val valueType: TypeReference +} diff --git a/public-api/src/main/kotlin/tools/samt/api/types/Types.kt b/public-api/src/main/kotlin/tools/samt/api/types/Types.kt new file mode 100644 index 00000000..b4fbfbb9 --- /dev/null +++ b/public-api/src/main/kotlin/tools/samt/api/types/Types.kt @@ -0,0 +1,108 @@ +package tools.samt.api.types + +interface SamtPackage { + val name: String + val qualifiedName: String + val records: List + val enums: List + val services: List + val providers: List + val consumers: List + val aliases: List +} + +/** + * A SAMT type + */ +interface Type + + +/** + * A type reference + */ +interface TypeReference { + /** + * The type this reference points to + */ + val type: Type + + /** + * Is true if this type reference is optional, meaning it can be null + */ + val isOptional: Boolean + + /** + * The range constraints placed on this type, if any + */ + val rangeConstraint: Constraint.Range? + + /** + * The size constraints placed on this type, if any + */ + val sizeConstraint: Constraint.Size? + + /** + * The pattern constraints placed on this type, if any + */ + val patternConstraint: Constraint.Pattern? + + /** + * The value constraints placed on this type, if any + */ + val valueConstraint: Constraint.Value? + + /** + * The runtime type this reference points to, could be different from [type] if this is an alias + */ + val runtimeType: Type + + /** + * Is true if this type reference or underlying type is optional, meaning it can be null at runtime + * This is different from [isOptional] in that it will return true for an alias that points to an optional type + */ + val isRuntimeOptional: Boolean + + /** + * The runtime range constraints placed on this type, if any. + * Will differ from [rangeConstraint] if this is an alias + */ + val runtimeRangeConstraint: Constraint.Range? + + /** + * The runtime size constraints placed on this type, if any. + * Will differ from [sizeConstraint] if this is an alias + */ + val runtimeSizeConstraint: Constraint.Size? + + /** + * The runtime pattern constraints placed on this type, if any. + * Will differ from [patternConstraint] if this is an alias + */ + val runtimePatternConstraint: Constraint.Pattern? + + /** + * The runtime value constraints placed on this type, if any. + * Will differ from [valueConstraint] if this is an alias + */ + val runtimeValueConstraint: Constraint.Value? +} + +interface Constraint { + interface Range : Constraint { + val lowerBound: Number? + val upperBound: Number? + } + + interface Size : Constraint { + val lowerBound: Long? + val upperBound: Long? + } + + interface Pattern : Constraint { + val pattern: String + } + + interface Value : Constraint { + val value: Any + } +} diff --git a/public-api/src/main/kotlin/tools/samt/api/types/UserTypes.kt b/public-api/src/main/kotlin/tools/samt/api/types/UserTypes.kt new file mode 100644 index 00000000..80a5b7e9 --- /dev/null +++ b/public-api/src/main/kotlin/tools/samt/api/types/UserTypes.kt @@ -0,0 +1,155 @@ +package tools.samt.api.types + +import tools.samt.api.plugin.TransportConfiguration + +interface UserType : Type { + val name: String + val qualifiedName: String +} + +interface AliasType : UserType { + /** + * The type this alias stands for, could be another alias + */ + val aliasedType: TypeReference + + /** + * The fully resolved type, will not contain any type aliases anymore, just the underlying merged type + */ + val fullyResolvedType: TypeReference +} + +/** + * A SAMT record + */ +interface RecordType : UserType { + val fields: List +} + +/** + * A field in a record + */ +interface RecordField { + val name: String + val type: TypeReference +} + +/** + * A SAMT enum + */ +interface EnumType : UserType { + val values: List +} + +/** + * A SAMT service + */ +interface ServiceType : UserType { + val operations: List +} + +/** + * An operation in a service + */ +interface ServiceOperation { + val name: String + val parameters: List +} + +/** + * A parameter in a service operation + */ +interface ServiceOperationParameter { + val name: String + val type: TypeReference +} + +/** + * A service operation that returns a response + */ +interface RequestResponseOperation : ServiceOperation { + /** + * The return type of this operation. + * If null, this operation returns nothing. + */ + val returnType: TypeReference? + + /** + * Is true if this operation is asynchronous. + * This could mean that the operation returns a future in Java, or a Promise in JavaScript. + */ + val isAsync: Boolean +} + +/** + * A service operation that is fire-and-forget, never returning a response + */ +interface OnewayOperation : ServiceOperation + +/** + * A SAMT provider + */ +interface ProviderType : UserType { + val implements: List + val transport: TransportConfiguration +} + +/** + * Connects a provider to a service + */ +interface ProvidedService { + /** + * The underlying service this provider implements + */ + val service: ServiceType + + /** + * The operations that are implemented by this provider + */ + val implementedOperations: List + + /** + * The operations that are not implemented by this provider + */ + val unimplementedOperations: List +} + +/** + * A SAMT consumer + */ +interface ConsumerType : Type { + /** + * The provider this consumer is connected to + */ + val provider: ProviderType + + /** + * The services this consumer uses + */ + val uses: List + + /** + * The package this consumer is located in + */ + val samtPackage: String +} + +/** + * Connects a consumer to a service + */ +interface ConsumedService { + /** + * The underlying service this consumer uses + */ + val service: ServiceType + + /** + * The operations that are consumed by this consumer + */ + val consumedOperations: List + + /** + * The operations that are not consumed by this consumer + */ + val unconsumedOperations: List +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 8c573d39..bbf7bfa5 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -8,6 +8,7 @@ include( ":language-server", ":samt-config", ":codegen", + ":public-api", ) dependencyResolutionManagement { From f9ea43c250a5e96cbf5670e9b6c65c1bc895dbce Mon Sep 17 00:00:00 2001 From: Pascal Honegger Date: Tue, 30 May 2023 00:17:07 +0200 Subject: [PATCH 35/41] feat(codegen): add unit tests --- codegen/build.gradle.kts | 1 + .../kotlin/tools/samt/codegen/CodegenTest.kt | 55 +++- .../samt/codegen/http/HttpTransportTest.kt | 302 ++++++++++++++++++ .../generator-test-model/.samtrc.yaml | 1 + .../resources/generator-test-model/README.md | 4 + .../client/generated/consumer/Consumer.kt | 109 +++++++ .../client/generated/greeter/KtorMappings.kt | 112 +++++++ .../samt/client/generated/greeter/Types.kt | 42 +++ .../generated/greeter/GreeterEndpoint.kt | 129 ++++++++ .../server/generated/greeter/KtorMappings.kt | 112 +++++++ .../server/generated/greeter/KtorServer.kt | 28 ++ .../samt/server/generated/greeter/Types.kt | 42 +++ .../resources/generator-test-model/samt.yaml | 11 + .../src/greeter-consumer.samt | 9 + .../src/greeter-provider.samt | 15 + .../generator-test-model/src/greeter.samt | 32 ++ 16 files changed, 998 insertions(+), 6 deletions(-) create mode 100644 codegen/src/test/kotlin/tools/samt/codegen/http/HttpTransportTest.kt create mode 100644 codegen/src/test/resources/generator-test-model/.samtrc.yaml create mode 100644 codegen/src/test/resources/generator-test-model/README.md create mode 100644 codegen/src/test/resources/generator-test-model/out/ktor-client/tools/samt/client/generated/consumer/Consumer.kt create mode 100644 codegen/src/test/resources/generator-test-model/out/ktor-client/tools/samt/client/generated/greeter/KtorMappings.kt create mode 100644 codegen/src/test/resources/generator-test-model/out/ktor-client/tools/samt/client/generated/greeter/Types.kt create mode 100644 codegen/src/test/resources/generator-test-model/out/ktor-server/tools/samt/server/generated/greeter/GreeterEndpoint.kt create mode 100644 codegen/src/test/resources/generator-test-model/out/ktor-server/tools/samt/server/generated/greeter/KtorMappings.kt create mode 100644 codegen/src/test/resources/generator-test-model/out/ktor-server/tools/samt/server/generated/greeter/KtorServer.kt create mode 100644 codegen/src/test/resources/generator-test-model/out/ktor-server/tools/samt/server/generated/greeter/Types.kt create mode 100644 codegen/src/test/resources/generator-test-model/samt.yaml create mode 100644 codegen/src/test/resources/generator-test-model/src/greeter-consumer.samt create mode 100644 codegen/src/test/resources/generator-test-model/src/greeter-provider.samt create mode 100644 codegen/src/test/resources/generator-test-model/src/greeter.samt diff --git a/codegen/build.gradle.kts b/codegen/build.gradle.kts index 75f2abef..dbbdcad2 100644 --- a/codegen/build.gradle.kts +++ b/codegen/build.gradle.kts @@ -9,4 +9,5 @@ dependencies { implementation(project(":semantic")) implementation(project(":public-api")) testImplementation(project(":lexer")) + testImplementation(project(":samt-config")) } diff --git a/codegen/src/test/kotlin/tools/samt/codegen/CodegenTest.kt b/codegen/src/test/kotlin/tools/samt/codegen/CodegenTest.kt index 7b36ce39..d28831b2 100644 --- a/codegen/src/test/kotlin/tools/samt/codegen/CodegenTest.kt +++ b/codegen/src/test/kotlin/tools/samt/codegen/CodegenTest.kt @@ -1,15 +1,58 @@ package tools.samt.codegen +import tools.samt.api.plugin.CodegenFile +import tools.samt.common.DiagnosticController +import tools.samt.common.collectSamtFiles +import tools.samt.common.readSamtSource +import tools.samt.config.SamtConfigurationParser +import tools.samt.lexer.Lexer +import tools.samt.parser.Parser +import tools.samt.semantic.SemanticModel +import java.net.URI +import kotlin.io.path.Path import kotlin.test.Test -import org.junit.jupiter.api.Nested import kotlin.test.assertEquals +import kotlin.test.assertFalse class CodegenTest { - @Nested - inner class SomeRandomTests { - @Test - fun `test that two numbers are equal`() { - assertEquals(1, 1) + private val testDirectory = Path("src/test/resources/generator-test-model") + + @Test + fun `correctly compiles test model`() { + val controller = DiagnosticController(URI("file:///tmp")) + + val configuration = SamtConfigurationParser.parseConfiguration(testDirectory.resolve("samt.yaml")) + val sourceFiles = collectSamtFiles(configuration.source.toUri()).readSamtSource(controller) + + assertFalse(controller.hasErrors()) + + // attempt to parse each source file into an AST + val fileNodes = buildList { + for (source in sourceFiles) { + val context = controller.getOrCreateContext(source) + val tokenStream = Lexer.scan(source.content.reader(), context) + + add(Parser.parse(source, tokenStream, context)) + } } + + assertFalse(controller.hasErrors()) + + // build up the semantic model from the AST + val model = SemanticModel.build(fileNodes, controller) + + assertFalse(controller.hasErrors()) + + val actualFiles = mutableListOf() + for (generator in configuration.generators) { + actualFiles += Codegen.generate(model, generator, controller).map { it.copy(filepath = generator.output.resolve(it.filepath).toString()) } + } + + val expectedFiles = testDirectory.toFile().walkTopDown().filter { it.isFile && it.extension == "kt" }.toList() + + val expected = expectedFiles.associate { it.toPath() to it.readText() }.toSortedMap() + val actual = actualFiles.associate { Path(it.filepath) to it.source }.toSortedMap() + + assertEquals(expected, actual) } } diff --git a/codegen/src/test/kotlin/tools/samt/codegen/http/HttpTransportTest.kt b/codegen/src/test/kotlin/tools/samt/codegen/http/HttpTransportTest.kt new file mode 100644 index 00000000..b6bed20a --- /dev/null +++ b/codegen/src/test/kotlin/tools/samt/codegen/http/HttpTransportTest.kt @@ -0,0 +1,302 @@ +package tools.samt.codegen.http + +import tools.samt.api.plugin.TransportConfiguration +import tools.samt.codegen.PublicApiMapper +import tools.samt.common.DiagnosticController +import tools.samt.common.SourceFile +import tools.samt.lexer.Lexer +import tools.samt.parser.Parser +import tools.samt.semantic.SemanticModel +import java.net.URI +import kotlin.test.* + +class HttpTransportTest { + private val diagnosticController = DiagnosticController(URI("file:///tmp")) + + @BeforeTest + fun setup() { + diagnosticController.contexts.clear() + diagnosticController.globalMessages.clear() + } + + @Test + fun `default configuration return default values for operations`() { + val config = HttpTransportConfigurationParser.default() + assertEquals(HttpTransportConfiguration.SerializationMode.Json, config.serializationMode) + assertEquals(emptyList(), config.services) + assertEquals(HttpTransportConfiguration.HttpMethod.Post, config.getMethod("service", "operation")) + assertEquals("", config.getPath("service")) + assertEquals("/operation", config.getPath("service", "operation")) + assertEquals(HttpTransportConfiguration.TransportMode.Body, config.getTransportMode("service", "operation", "parameter")) + } + + @Test + fun `correctly parses complex example`() { + val source = """ + package tools.samt.greeter + + typealias ID = String? (1..50) + + record Greeting { + message: String (0..128) + } + + enum GreetingType { + HELLO, + HI, + HEY + } + + service Greeter { + greet(id: ID, + name: String (1..50), + type: GreetingType, + reference: Greeting + ): Greeting + greetAll(names: List): Map + get() + put() + oneway delete() + patch() + default() + } + + provide GreeterEndpoint { + implements Greeter + + transport http { + operations: { + Greeter: { + greet: "POST /greet/{id} {name in header} {type in cookie}", + greetAll: "GET /greet/all {names in query}", + get: "GET /", + put: "PUT /", + delete: "DELETE /", + patch: "PATCH /" + } + } + } + } + """.trimIndent() + + val transport = parseAndCheck(source to emptyList()) + assertIs(transport) + + assertEquals(HttpTransportConfiguration.SerializationMode.Json, transport.serializationMode) + assertEquals(listOf("Greeter"), transport.services.map { it.name }) + + assertEquals(HttpTransportConfiguration.HttpMethod.Post, transport.getMethod("Greeter", "greet")) + assertEquals("/greet/{id}", transport.getPath("Greeter", "greet")) + assertEquals(HttpTransportConfiguration.TransportMode.Path, transport.getTransportMode("Greeter", "greet", "id")) + assertEquals(HttpTransportConfiguration.TransportMode.Header, transport.getTransportMode("Greeter", "greet", "name")) + assertEquals(HttpTransportConfiguration.TransportMode.Cookie, transport.getTransportMode("Greeter", "greet", "type")) + assertEquals(HttpTransportConfiguration.TransportMode.Body, transport.getTransportMode("Greeter", "greet", "reference")) + + assertEquals(HttpTransportConfiguration.HttpMethod.Get, transport.getMethod("Greeter", "greetAll")) + assertEquals("/greet/all", transport.getPath("Greeter", "greetAll")) + assertEquals(HttpTransportConfiguration.TransportMode.Query, transport.getTransportMode("Greeter", "greetAll", "names")) + + assertEquals(HttpTransportConfiguration.HttpMethod.Get, transport.getMethod("Greeter", "get")) + assertEquals("/", transport.getPath("Greeter", "get")) + assertEquals(HttpTransportConfiguration.HttpMethod.Put, transport.getMethod("Greeter", "put")) + assertEquals("/", transport.getPath("Greeter", "put")) + assertEquals(HttpTransportConfiguration.HttpMethod.Delete, transport.getMethod("Greeter", "delete")) + assertEquals("/", transport.getPath("Greeter", "delete")) + assertEquals(HttpTransportConfiguration.HttpMethod.Patch, transport.getMethod("Greeter", "patch")) + assertEquals("/", transport.getPath("Greeter", "patch")) + assertEquals(HttpTransportConfiguration.HttpMethod.Post, transport.getMethod("Greeter", "default")) + assertEquals("/default", transport.getPath("Greeter", "default")) + } + + @Test + fun `fails for invalid HTTP method`() { + val source = """ + package tools.samt.greeter + + service Greeter { + greet(name: String): String + } + + provide GreeterEndpoint { + implements Greeter + + transport http { + operations: { + Greeter: { + greet: "YEET /greet" + } + } + } + } + """.trimIndent() + + parseAndCheck(source to listOf("Error: Invalid http method 'YEET'")) + } + + @Test + fun `fails for invalid parameter binding`() { + val source = """ + package tools.samt.greeter + + service Greeter { + greet(name: String): String + foo() + } + + provide GreeterEndpoint { + implements Greeter + + transport http { + operations: { + Greeter: { + greet: "POST /greet {name in yeet}", + foo: "POST /foo {name in header}" + } + } + } + } + """.trimIndent() + + parseAndCheck(source to listOf("Error: Invalid transport mode 'yeet'", "Error: Parameter 'name' not found in operation 'foo'")) + } + + @Test + fun `fails for invalid path parameter binding`() { + val source = """ + package tools.samt.greeter + + service Greeter { + greet(name: String): String + foo() + } + + provide GreeterEndpoint { + implements Greeter + + transport http { + operations: { + Greeter: { + greet: "POST /greet/{}/me", + foo: "POST /foo/{name}" + } + } + } + } + """.trimIndent() + + parseAndCheck(source to listOf("Error: Expected parameter name between curly braces in '/greet/{}/me'", "Error: Path parameter 'name' not found in operation 'foo'")) + } + + @Test + fun `fails for invalid syntax`() { + val source = """ + package tools.samt.greeter + + service Greeter { + greet(name: String): String + } + + provide GreeterEndpoint { + implements Greeter + + transport http { + operations: { + Greeter: { + greet: "POST /greet {header:name}" + } + } + } + } + """.trimIndent() + + parseAndCheck(source to listOf("Error: Invalid operation config for 'greet', expected ' '. A valid example: 'POST /greet {parameter1, parameter2 in query}'")) + } + + @Test + fun `fails for non-existent service`() { + val source = """ + package tools.samt.greeter + + service Greeter { + greet(name: String): String + } + + service Foo { + bar() + } + + provide GreeterEndpoint { + implements Greeter + + transport http { + operations: { + Foo: { + bar: "PUT /bar" + } + } + } + } + """.trimIndent() + + parseAndCheck(source to listOf("Error: No service with name 'Foo' found in provider 'GreeterEndpoint'")) + } + + @Test + fun `fails for non-implemented operation`() { + val source = """ + package tools.samt.greeter + + service Greeter { + greet(name: String): String + bar() + } + + provide GreeterEndpoint { + implements Greeter { greet } + + transport http { + operations: { + Greeter: { + bar: "PUT /bar" + } + } + } + } + """.trimIndent() + + parseAndCheck(source to listOf("Error: No operation with name 'bar' found in service 'Greeter' of provider 'GreeterEndpoint'")) + } + + private fun parseAndCheck( + vararg sourceAndExpectedMessages: Pair>, + ): TransportConfiguration { + val fileTree = sourceAndExpectedMessages.mapIndexed { index, (source) -> + val filePath = URI("file:///tmp/HttpTransportTest-${index}.samt") + val sourceFile = SourceFile(filePath, source) + val parseContext = diagnosticController.getOrCreateContext(sourceFile) + val stream = Lexer.scan(source.reader(), parseContext) + val fileTree = Parser.parse(sourceFile, stream, parseContext) + assertFalse(parseContext.hasErrors(), "Expected no parse errors, but had errors: ${parseContext.messages}}") + fileTree + } + + val parseMessageCount = diagnosticController.contexts.associate { it.source.content to it.messages.size } + + val semanticModel = SemanticModel.build(fileTree, diagnosticController) + + val publicApiMapper = PublicApiMapper(listOf(HttpTransportConfigurationParser), diagnosticController) + + val transport = semanticModel.global.allSubPackages.map { publicApiMapper.toPublicApi(it) }.flatMap { it.providers }.single().transport + + for ((source, expectedMessages) in sourceAndExpectedMessages) { + val messages = diagnosticController.contexts + .first { it.source.content == source } + .messages + .drop(parseMessageCount.getValue(source)) + .map { "${it.severity}: ${it.message}" } + assertEquals(expectedMessages, messages) + } + + return transport + } +} diff --git a/codegen/src/test/resources/generator-test-model/.samtrc.yaml b/codegen/src/test/resources/generator-test-model/.samtrc.yaml new file mode 100644 index 00000000..c0d46ba1 --- /dev/null +++ b/codegen/src/test/resources/generator-test-model/.samtrc.yaml @@ -0,0 +1 @@ +extends: recommended diff --git a/codegen/src/test/resources/generator-test-model/README.md b/codegen/src/test/resources/generator-test-model/README.md new file mode 100644 index 00000000..26d76652 --- /dev/null +++ b/codegen/src/test/resources/generator-test-model/README.md @@ -0,0 +1,4 @@ +# Test Project + +This is a test project for the code generator. +It ensures that the code generator produces the expected output for the given input. diff --git a/codegen/src/test/resources/generator-test-model/out/ktor-client/tools/samt/client/generated/consumer/Consumer.kt b/codegen/src/test/resources/generator-test-model/out/ktor-client/tools/samt/client/generated/consumer/Consumer.kt new file mode 100644 index 00000000..35abfd18 --- /dev/null +++ b/codegen/src/test/resources/generator-test-model/out/ktor-client/tools/samt/client/generated/consumer/Consumer.kt @@ -0,0 +1,109 @@ +@file:Suppress("RemoveRedundantQualifierName", "unused", "UnusedImport", "LocalVariableName", "FunctionName", "ConvertTwoComparisonsToRangeCheck", "ReplaceSizeCheckWithIsNotEmpty", "NAME_SHADOWING", "UNUSED_VARIABLE", "NestedLambdaShadowedImplicitParameter", "KotlinRedundantDiagnosticSuppress") + +/* + * This file is generated by SAMT, manual changes will be overwritten. + * Visit the SAMT GitHub for more details: https://github.com/samtkit/core + */ + +package tools.samt.client.generated.consumer + +import io.ktor.client.* +import io.ktor.client.engine.cio.* +import io.ktor.client.plugins.contentnegotiation.* +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* +import io.ktor.serialization.kotlinx.json.* +import io.ktor.util.* +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.* +import kotlinx.coroutines.* + +class GreeterEndpointImpl(private val baseUrl: String) : tools.samt.client.generated.greeter.Greeter { + private val client = HttpClient(CIO) { + install(ContentNegotiation) { + json() + } + } + + /** Used to launch oneway operations asynchronously */ + private val onewayScope = CoroutineScope(Dispatchers.IO) + + override fun greet(id: tools.samt.client.generated.greeter.ID, name: String, type: tools.samt.client.generated.greeter.GreetingType?): tools.samt.client.generated.greeter.Greeting = runBlocking { + // Make actual network call + val `client response` = client.request(this@GreeterEndpointImpl.baseUrl) { + url { + // Construct path and encode path parameters + appendPathSegments("greet", encodeSlash = true) + appendPathSegments(name, encodeSlash = true) + + // Encode query parameters + this.parameters.append("type", (type?.let { type -> tools.samt.client.generated.greeter.`encode GreetingType`(type) } ?: JsonNull).toString()) + } + contentType(ContentType.Application.Json) + this.method = HttpMethod.Post + header("id", id?.let { id -> tools.samt.client.generated.greeter.`encode ID`(id) } ?: JsonNull) + setBody( + buildJsonObject { + } + ) + } + check(`client response`.status.isSuccess()) { "greet failed with status ${`client response`.status}" } + val bodyAsText = `client response`.bodyAsText() + val jsonElement = Json.parseToJsonElement(bodyAsText) + + tools.samt.client.generated.greeter.`decode Greeting`(jsonElement) + } + + override fun greetAll(names: List): Map = runBlocking { + // Make actual network call + val `client response` = client.request(this@GreeterEndpointImpl.baseUrl) { + url { + // Construct path and encode path parameters + appendPathSegments("greet", encodeSlash = true) + appendPathSegments("all", encodeSlash = true) + + // Encode query parameters + this.parameters.append("names", (JsonArray(names.map { it?.let { it -> JsonPrimitive(it.also { require(it.length >= 1 && it.length <= 50) }) } ?: JsonNull })).toString()) + } + contentType(ContentType.Application.Json) + this.method = HttpMethod.Get + setBody( + buildJsonObject { + } + ) + } + check(`client response`.status.isSuccess()) { "greetAll failed with status ${`client response`.status}" } + val bodyAsText = `client response`.bodyAsText() + val jsonElement = Json.parseToJsonElement(bodyAsText) + + jsonElement.jsonObject.mapValues { (_, value) -> value.takeUnless { it is JsonNull }?.let { value -> tools.samt.client.generated.greeter.`decode Greeting`(value) } } + } + + override fun greeting(who: tools.samt.client.generated.greeter.Person): String = runBlocking { + // Make actual network call + val `client response` = client.request(this@GreeterEndpointImpl.baseUrl) { + url { + // Construct path and encode path parameters + appendPathSegments("greeting", encodeSlash = true) + + // Encode query parameters + } + contentType(ContentType.Application.Json) + this.method = HttpMethod.Post + setBody( + buildJsonObject { + put("who", tools.samt.client.generated.greeter.`encode Person`(who)) + } + ) + } + check(`client response`.status.isSuccess()) { "greeting failed with status ${`client response`.status}" } + val bodyAsText = `client response`.bodyAsText() + val jsonElement = Json.parseToJsonElement(bodyAsText) + + jsonElement.jsonPrimitive.content.also { require(it.length >= 1 && it.length <= 100) } + } + + override suspend fun legacy(): Unit { + = error("Not used in SAMT consumer and therefore not generated") +} diff --git a/codegen/src/test/resources/generator-test-model/out/ktor-client/tools/samt/client/generated/greeter/KtorMappings.kt b/codegen/src/test/resources/generator-test-model/out/ktor-client/tools/samt/client/generated/greeter/KtorMappings.kt new file mode 100644 index 00000000..abb943d1 --- /dev/null +++ b/codegen/src/test/resources/generator-test-model/out/ktor-client/tools/samt/client/generated/greeter/KtorMappings.kt @@ -0,0 +1,112 @@ +@file:Suppress("RemoveRedundantQualifierName", "unused", "UnusedImport", "LocalVariableName", "FunctionName", "ConvertTwoComparisonsToRangeCheck", "ReplaceSizeCheckWithIsNotEmpty", "NAME_SHADOWING", "UNUSED_VARIABLE", "NestedLambdaShadowedImplicitParameter", "KotlinRedundantDiagnosticSuppress") + +/* + * This file is generated by SAMT, manual changes will be overwritten. + * Visit the SAMT GitHub for more details: https://github.com/samtkit/core + */ + +package tools.samt.client.generated.greeter + +import io.ktor.util.* +import kotlinx.serialization.json.* + +/** Encode and validate record tools.samt.greeter.Greeting to JSON */ +fun `encode Greeting`(record: tools.samt.client.generated.greeter.Greeting): JsonElement { + // Encode field message + val `field message` = run { + val value = record.message + JsonPrimitive(value.also { require(it.length >= 0 && it.length <= 128) }) + } + // Create JSON for tools.samt.greeter.Greeting + return buildJsonObject { + put("message", `field message`) + } +} +/** Decode and validate record tools.samt.greeter.Greeting from JSON */ +fun `decode Greeting`(json: JsonElement): tools.samt.client.generated.greeter.Greeting { + // Decode field message + val `field message` = run { + val jsonElement = json.jsonObject["message"]!! + jsonElement.jsonPrimitive.content.also { require(it.length >= 0 && it.length <= 128) } + } + // Create record tools.samt.greeter.Greeting + return tools.samt.client.generated.greeter.Greeting( + message = `field message`, + ) +} + +/** Encode and validate record tools.samt.greeter.Person to JSON */ +fun `encode Person`(record: tools.samt.client.generated.greeter.Person): JsonElement { + // Encode field id + val `field id` = run { + val value = record.id + value?.let { value -> tools.samt.client.generated.greeter.`encode ID`(value) } ?: JsonNull + } + // Encode field name + val `field name` = run { + val value = record.name + JsonPrimitive(value) + } + // Encode field age + val `field age` = run { + val value = record.age + JsonPrimitive(value.also { require(it >= 1) }) + } + // Create JSON for tools.samt.greeter.Person + return buildJsonObject { + put("id", `field id`) + put("name", `field name`) + put("age", `field age`) + } +} +/** Decode and validate record tools.samt.greeter.Person from JSON */ +fun `decode Person`(json: JsonElement): tools.samt.client.generated.greeter.Person { + // Decode field id + val `field id` = run { + val jsonElement = json.jsonObject["id"] ?: JsonNull + tools.samt.client.generated.greeter.`decode ID`(jsonElement) + } + // Decode field name + val `field name` = run { + val jsonElement = json.jsonObject["name"]!! + jsonElement.jsonPrimitive.content + } + // Decode field age + val `field age` = run { + val jsonElement = json.jsonObject["age"]!! + jsonElement.jsonPrimitive.int.also { require(it >= 1) } + } + // Create record tools.samt.greeter.Person + return tools.samt.client.generated.greeter.Person( + id = `field id`, + name = `field name`, + age = `field age`, + ) +} + +/** Encode enum tools.samt.greeter.GreetingType to JSON */ +fun `encode GreetingType`(value: tools.samt.client.generated.greeter.GreetingType?): JsonElement = when(value) { + null -> JsonNull + tools.samt.client.generated.greeter.GreetingType.HELLO -> JsonPrimitive("HELLO") + tools.samt.client.generated.greeter.GreetingType.HI -> JsonPrimitive("HI") + tools.samt.client.generated.greeter.GreetingType.HEY -> JsonPrimitive("HEY") + tools.samt.client.generated.greeter.GreetingType.FAILED_TO_PARSE -> error("Cannot encode FAILED_TO_PARSE value") +} +/** Decode enum tools.samt.greeter.GreetingType from JSON */ +fun `decode GreetingType`(json: JsonElement): tools.samt.client.generated.greeter.GreetingType = when(json.jsonPrimitive.content) { + "HELLO" -> tools.samt.client.generated.greeter.GreetingType.HELLO + "HI" -> tools.samt.client.generated.greeter.GreetingType.HI + "HEY" -> tools.samt.client.generated.greeter.GreetingType.HEY + // Value not found in enum tools.samt.greeter.GreetingType + else -> tools.samt.client.generated.greeter.GreetingType.FAILED_TO_PARSE +} + +/** Encode alias tools.samt.greeter.ID to JSON */ +fun `encode ID`(value: tools.samt.client.generated.greeter.ID): JsonElement = + value?.let { value -> JsonPrimitive(value.also { require(it.length >= 1 && it.length <= 50) }) } ?: JsonNull +/** Decode alias tools.samt.greeter.ID from JSON */ +fun `decode ID`(json: JsonElement): String? { + if (json is JsonNull) return null + return json.jsonPrimitive.content.also { require(it.length >= 1 && it.length <= 50) } +} + diff --git a/codegen/src/test/resources/generator-test-model/out/ktor-client/tools/samt/client/generated/greeter/Types.kt b/codegen/src/test/resources/generator-test-model/out/ktor-client/tools/samt/client/generated/greeter/Types.kt new file mode 100644 index 00000000..82579921 --- /dev/null +++ b/codegen/src/test/resources/generator-test-model/out/ktor-client/tools/samt/client/generated/greeter/Types.kt @@ -0,0 +1,42 @@ +@file:Suppress("RemoveRedundantQualifierName", "unused", "UnusedImport", "LocalVariableName", "FunctionName", "ConvertTwoComparisonsToRangeCheck", "ReplaceSizeCheckWithIsNotEmpty", "NAME_SHADOWING", "UNUSED_VARIABLE", "NestedLambdaShadowedImplicitParameter", "KotlinRedundantDiagnosticSuppress") + +/* + * This file is generated by SAMT, manual changes will be overwritten. + * Visit the SAMT GitHub for more details: https://github.com/samtkit/core + */ + +package tools.samt.client.generated.greeter + +data class Greeting( + val message: String, +) + +data class Person( + val id: tools.samt.client.generated.greeter.ID = null, + val name: String, + val age: Int, +) + +enum class GreetingType { + /** Default value used when the enum could not be parsed */ + FAILED_TO_PARSE, + HELLO, + HI, + HEY, +} +typealias ID = String? +interface Greeter { + fun greet( + id: tools.samt.client.generated.greeter.ID = null, + name: String, + type: tools.samt.client.generated.greeter.GreetingType? = null, + ): tools.samt.client.generated.greeter.Greeting + fun greetAll( + names: List, + ): Map + fun greeting( + who: tools.samt.client.generated.greeter.Person, + ): String + suspend fun legacy( + ) +} diff --git a/codegen/src/test/resources/generator-test-model/out/ktor-server/tools/samt/server/generated/greeter/GreeterEndpoint.kt b/codegen/src/test/resources/generator-test-model/out/ktor-server/tools/samt/server/generated/greeter/GreeterEndpoint.kt new file mode 100644 index 00000000..f8fabd43 --- /dev/null +++ b/codegen/src/test/resources/generator-test-model/out/ktor-server/tools/samt/server/generated/greeter/GreeterEndpoint.kt @@ -0,0 +1,129 @@ +@file:Suppress("RemoveRedundantQualifierName", "unused", "UnusedImport", "LocalVariableName", "FunctionName", "ConvertTwoComparisonsToRangeCheck", "ReplaceSizeCheckWithIsNotEmpty", "NAME_SHADOWING", "UNUSED_VARIABLE", "NestedLambdaShadowedImplicitParameter", "KotlinRedundantDiagnosticSuppress") + +/* + * This file is generated by SAMT, manual changes will be overwritten. + * Visit the SAMT GitHub for more details: https://github.com/samtkit/core + */ + +package tools.samt.server.generated.greeter + +import io.ktor.http.* +import io.ktor.serialization.kotlinx.json.* +import io.ktor.server.application.* +import io.ktor.server.plugins.contentnegotiation.* +import io.ktor.server.request.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import io.ktor.util.* +import kotlinx.serialization.json.* + +/** Connector for SAMT provider GreeterEndpoint */ +fun Routing.routeGreeterEndpoint( + greeter: tools.samt.server.generated.greeter.Greeter, +) { + /** Utility used to convert string to JSON element */ + fun String.toJson() = Json.parseToJsonElement(this) + /** Utility used to convert string to JSON element or null */ + fun String.toJsonOrNull() = Json.parseToJsonElement(this).takeUnless { it is JsonNull } + + // Handler for SAMT Service Greeter + route("") { + // Handler for SAMT operation greet + post("/greet/{name}") { + // Parse body lazily in case no parameter is transported in the body + val bodyAsText = call.receiveText() + val body by lazy { bodyAsText.toJson() } + + // Decode parameter id + val `parameter id` = run { + // Read from header + val jsonElement = call.request.headers["id"]?.toJsonOrNull() ?: return@run null + tools.samt.server.generated.greeter.`decode ID`(jsonElement) + } + + // Decode parameter name + val `parameter name` = run { + // Read from path + val jsonElement = call.parameters["name"]!!.toJson() + jsonElement.jsonPrimitive.content.also { require(it.length >= 1 && it.length <= 50) } + } + + // Decode parameter type + val `parameter type` = run { + // Read from query + val jsonElement = call.request.queryParameters["type"]?.toJsonOrNull() ?: return@run null + tools.samt.server.generated.greeter.`decode GreetingType`(jsonElement) + } + + // Call user provided implementation + val value = greeter.greet(`parameter id`, `parameter name`, `parameter type`) + + // Encode response + val response = tools.samt.server.generated.greeter.`encode Greeting`(value) + + // Return response with 200 OK + call.respond(HttpStatusCode.OK, response) + } + + // Handler for SAMT operation greetAll + get("/greet/all") { + // Parse body lazily in case no parameter is transported in the body + val bodyAsText = call.receiveText() + val body by lazy { bodyAsText.toJson() } + + // Decode parameter names + val `parameter names` = run { + // Read from query + val jsonElement = call.request.queryParameters["names"]!!.toJson() + jsonElement.jsonArray.map { it.takeUnless { it is JsonNull }?.let { it.jsonPrimitive.content.also { require(it.length >= 1 && it.length <= 50) } } } + } + + // Call user provided implementation + val value = greeter.greetAll(`parameter names`) + + // Encode response + val response = JsonObject(value.mapValues { (_, value) -> value?.let { value -> tools.samt.server.generated.greeter.`encode Greeting`(value) } ?: JsonNull }) + + // Return response with 200 OK + call.respond(HttpStatusCode.OK, response) + } + + // Handler for SAMT operation greeting + post("/greeting") { + // Parse body lazily in case no parameter is transported in the body + val bodyAsText = call.receiveText() + val body by lazy { bodyAsText.toJson() } + + // Decode parameter who + val `parameter who` = run { + // Read from body + val jsonElement = body.jsonObject["who"]!! + tools.samt.server.generated.greeter.`decode Person`(jsonElement) + } + + // Call user provided implementation + val value = greeter.greeting(`parameter who`) + + // Encode response + val response = JsonPrimitive(value.also { require(it.length >= 1 && it.length <= 100) }) + + // Return response with 200 OK + call.respond(HttpStatusCode.OK, response) + } + + // Handler for SAMT operation legacy + post("/legacy") { + // Parse body lazily in case no parameter is transported in the body + val bodyAsText = call.receiveText() + val body by lazy { bodyAsText.toJson() } + + // Call user provided implementation + greeter.legacy() + + // Return 204 No Content + call.respond(HttpStatusCode.NoContent) + } + + } + +} diff --git a/codegen/src/test/resources/generator-test-model/out/ktor-server/tools/samt/server/generated/greeter/KtorMappings.kt b/codegen/src/test/resources/generator-test-model/out/ktor-server/tools/samt/server/generated/greeter/KtorMappings.kt new file mode 100644 index 00000000..1ae105d4 --- /dev/null +++ b/codegen/src/test/resources/generator-test-model/out/ktor-server/tools/samt/server/generated/greeter/KtorMappings.kt @@ -0,0 +1,112 @@ +@file:Suppress("RemoveRedundantQualifierName", "unused", "UnusedImport", "LocalVariableName", "FunctionName", "ConvertTwoComparisonsToRangeCheck", "ReplaceSizeCheckWithIsNotEmpty", "NAME_SHADOWING", "UNUSED_VARIABLE", "NestedLambdaShadowedImplicitParameter", "KotlinRedundantDiagnosticSuppress") + +/* + * This file is generated by SAMT, manual changes will be overwritten. + * Visit the SAMT GitHub for more details: https://github.com/samtkit/core + */ + +package tools.samt.server.generated.greeter + +import io.ktor.util.* +import kotlinx.serialization.json.* + +/** Encode and validate record tools.samt.greeter.Greeting to JSON */ +fun `encode Greeting`(record: tools.samt.server.generated.greeter.Greeting): JsonElement { + // Encode field message + val `field message` = run { + val value = record.message + JsonPrimitive(value.also { require(it.length >= 0 && it.length <= 128) }) + } + // Create JSON for tools.samt.greeter.Greeting + return buildJsonObject { + put("message", `field message`) + } +} +/** Decode and validate record tools.samt.greeter.Greeting from JSON */ +fun `decode Greeting`(json: JsonElement): tools.samt.server.generated.greeter.Greeting { + // Decode field message + val `field message` = run { + val jsonElement = json.jsonObject["message"]!! + jsonElement.jsonPrimitive.content.also { require(it.length >= 0 && it.length <= 128) } + } + // Create record tools.samt.greeter.Greeting + return tools.samt.server.generated.greeter.Greeting( + message = `field message`, + ) +} + +/** Encode and validate record tools.samt.greeter.Person to JSON */ +fun `encode Person`(record: tools.samt.server.generated.greeter.Person): JsonElement { + // Encode field id + val `field id` = run { + val value = record.id + value?.let { value -> tools.samt.server.generated.greeter.`encode ID`(value) } ?: JsonNull + } + // Encode field name + val `field name` = run { + val value = record.name + JsonPrimitive(value) + } + // Encode field age + val `field age` = run { + val value = record.age + JsonPrimitive(value.also { require(it >= 1) }) + } + // Create JSON for tools.samt.greeter.Person + return buildJsonObject { + put("id", `field id`) + put("name", `field name`) + put("age", `field age`) + } +} +/** Decode and validate record tools.samt.greeter.Person from JSON */ +fun `decode Person`(json: JsonElement): tools.samt.server.generated.greeter.Person { + // Decode field id + val `field id` = run { + val jsonElement = json.jsonObject["id"] ?: JsonNull + tools.samt.server.generated.greeter.`decode ID`(jsonElement) + } + // Decode field name + val `field name` = run { + val jsonElement = json.jsonObject["name"]!! + jsonElement.jsonPrimitive.content + } + // Decode field age + val `field age` = run { + val jsonElement = json.jsonObject["age"]!! + jsonElement.jsonPrimitive.int.also { require(it >= 1) } + } + // Create record tools.samt.greeter.Person + return tools.samt.server.generated.greeter.Person( + id = `field id`, + name = `field name`, + age = `field age`, + ) +} + +/** Encode enum tools.samt.greeter.GreetingType to JSON */ +fun `encode GreetingType`(value: tools.samt.server.generated.greeter.GreetingType?): JsonElement = when(value) { + null -> JsonNull + tools.samt.server.generated.greeter.GreetingType.HELLO -> JsonPrimitive("HELLO") + tools.samt.server.generated.greeter.GreetingType.HI -> JsonPrimitive("HI") + tools.samt.server.generated.greeter.GreetingType.HEY -> JsonPrimitive("HEY") + tools.samt.server.generated.greeter.GreetingType.FAILED_TO_PARSE -> error("Cannot encode FAILED_TO_PARSE value") +} +/** Decode enum tools.samt.greeter.GreetingType from JSON */ +fun `decode GreetingType`(json: JsonElement): tools.samt.server.generated.greeter.GreetingType = when(json.jsonPrimitive.content) { + "HELLO" -> tools.samt.server.generated.greeter.GreetingType.HELLO + "HI" -> tools.samt.server.generated.greeter.GreetingType.HI + "HEY" -> tools.samt.server.generated.greeter.GreetingType.HEY + // Value not found in enum tools.samt.greeter.GreetingType + else -> tools.samt.server.generated.greeter.GreetingType.FAILED_TO_PARSE +} + +/** Encode alias tools.samt.greeter.ID to JSON */ +fun `encode ID`(value: tools.samt.server.generated.greeter.ID): JsonElement = + value?.let { value -> JsonPrimitive(value.also { require(it.length >= 1 && it.length <= 50) }) } ?: JsonNull +/** Decode alias tools.samt.greeter.ID from JSON */ +fun `decode ID`(json: JsonElement): String? { + if (json is JsonNull) return null + return json.jsonPrimitive.content.also { require(it.length >= 1 && it.length <= 50) } +} + diff --git a/codegen/src/test/resources/generator-test-model/out/ktor-server/tools/samt/server/generated/greeter/KtorServer.kt b/codegen/src/test/resources/generator-test-model/out/ktor-server/tools/samt/server/generated/greeter/KtorServer.kt new file mode 100644 index 00000000..0544f452 --- /dev/null +++ b/codegen/src/test/resources/generator-test-model/out/ktor-server/tools/samt/server/generated/greeter/KtorServer.kt @@ -0,0 +1,28 @@ +@file:Suppress("RemoveRedundantQualifierName", "unused", "UnusedImport", "LocalVariableName", "FunctionName", "ConvertTwoComparisonsToRangeCheck", "ReplaceSizeCheckWithIsNotEmpty", "NAME_SHADOWING", "UNUSED_VARIABLE", "NestedLambdaShadowedImplicitParameter", "KotlinRedundantDiagnosticSuppress") + +/* + * This file is generated by SAMT, manual changes will be overwritten. + * Visit the SAMT GitHub for more details: https://github.com/samtkit/core + */ + +package tools.samt.server.generated.greeter + +import io.ktor.http.* +import io.ktor.serialization.kotlinx.json.* +import io.ktor.server.plugins.contentnegotiation.* +import io.ktor.server.response.* +import io.ktor.server.application.* +import io.ktor.server.request.* +import io.ktor.server.routing.* +import kotlinx.serialization.json.* + +fun Application.configureSerialization() { + install(ContentNegotiation) { + json() + } + routing { + routeGreeterEndpoint( + greeter = TODO("Implement tools.samt.server.generated.greeter.Greeter"), + ) + } +} diff --git a/codegen/src/test/resources/generator-test-model/out/ktor-server/tools/samt/server/generated/greeter/Types.kt b/codegen/src/test/resources/generator-test-model/out/ktor-server/tools/samt/server/generated/greeter/Types.kt new file mode 100644 index 00000000..9c022f1b --- /dev/null +++ b/codegen/src/test/resources/generator-test-model/out/ktor-server/tools/samt/server/generated/greeter/Types.kt @@ -0,0 +1,42 @@ +@file:Suppress("RemoveRedundantQualifierName", "unused", "UnusedImport", "LocalVariableName", "FunctionName", "ConvertTwoComparisonsToRangeCheck", "ReplaceSizeCheckWithIsNotEmpty", "NAME_SHADOWING", "UNUSED_VARIABLE", "NestedLambdaShadowedImplicitParameter", "KotlinRedundantDiagnosticSuppress") + +/* + * This file is generated by SAMT, manual changes will be overwritten. + * Visit the SAMT GitHub for more details: https://github.com/samtkit/core + */ + +package tools.samt.server.generated.greeter + +data class Greeting( + val message: String, +) + +data class Person( + val id: tools.samt.server.generated.greeter.ID = null, + val name: String, + val age: Int, +) + +enum class GreetingType { + /** Default value used when the enum could not be parsed */ + FAILED_TO_PARSE, + HELLO, + HI, + HEY, +} +typealias ID = String? +interface Greeter { + fun greet( + id: tools.samt.server.generated.greeter.ID = null, + name: String, + type: tools.samt.server.generated.greeter.GreetingType? = null, + ): tools.samt.server.generated.greeter.Greeting + fun greetAll( + names: List, + ): Map + fun greeting( + who: tools.samt.server.generated.greeter.Person, + ): String + suspend fun legacy( + ) +} diff --git a/codegen/src/test/resources/generator-test-model/samt.yaml b/codegen/src/test/resources/generator-test-model/samt.yaml new file mode 100644 index 00000000..16047642 --- /dev/null +++ b/codegen/src/test/resources/generator-test-model/samt.yaml @@ -0,0 +1,11 @@ +generators: + - name: kotlin-ktor-provider + output: ./out/ktor-server/ + options: + removePrefixFromSamtPackage: tools.samt + addPrefixToKotlinPackage: tools.samt.server.generated + - name: kotlin-ktor-consumer + output: ./out/ktor-client/ + options: + removePrefixFromSamtPackage: tools.samt + addPrefixToKotlinPackage: tools.samt.client.generated diff --git a/codegen/src/test/resources/generator-test-model/src/greeter-consumer.samt b/codegen/src/test/resources/generator-test-model/src/greeter-consumer.samt new file mode 100644 index 00000000..a2563b97 --- /dev/null +++ b/codegen/src/test/resources/generator-test-model/src/greeter-consumer.samt @@ -0,0 +1,9 @@ +import tools.samt.greeter.GreeterEndpoint +import tools.samt.greeter.Greeter + +// Usually belongs to another package +package tools.samt.consumer + +consume GreeterEndpoint { + uses Greeter { greet, greetAll, greeting } +} diff --git a/codegen/src/test/resources/generator-test-model/src/greeter-provider.samt b/codegen/src/test/resources/generator-test-model/src/greeter-provider.samt new file mode 100644 index 00000000..36d3e8f6 --- /dev/null +++ b/codegen/src/test/resources/generator-test-model/src/greeter-provider.samt @@ -0,0 +1,15 @@ +package tools.samt.greeter + +provide GreeterEndpoint { + implements Greeter + + transport http { + operations: { + Greeter: { + greet: "POST /greet/{name} {id in header} {type in query}", + greetAll: "GET /greet/all {names in query}", + greeting: "POST /greeting" + } + } + } +} diff --git a/codegen/src/test/resources/generator-test-model/src/greeter.samt b/codegen/src/test/resources/generator-test-model/src/greeter.samt new file mode 100644 index 00000000..b4137142 --- /dev/null +++ b/codegen/src/test/resources/generator-test-model/src/greeter.samt @@ -0,0 +1,32 @@ +package tools.samt.greeter + +typealias ID = String? (1..50) + +record Greeting { + message: String (0..128) +} + +record Person { + id: ID + name: String + age: Int (1..*) +} + +enum GreetingType { + HELLO, + HI, + HEY +} + +service Greeter { + greet(id: ID, + name: String (1..50), + type: GreetingType? + ): Greeting + // Nullability to verify edge-cases + greetAll(names: List): Map + greeting(who: Person): String (1..100) + + @Deprecated("Do not use anymore!") + async legacy() +} From 43ad5ecf49d0579e1887384bf39243356afa5e09 Mon Sep 17 00:00:00 2001 From: Pascal Honegger Date: Tue, 30 May 2023 00:17:23 +0200 Subject: [PATCH 36/41] fix(cli): create all missing parent directories --- cli/src/main/kotlin/tools/samt/cli/OutputWriter.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/src/main/kotlin/tools/samt/cli/OutputWriter.kt b/cli/src/main/kotlin/tools/samt/cli/OutputWriter.kt index 31021656..7a031474 100644 --- a/cli/src/main/kotlin/tools/samt/cli/OutputWriter.kt +++ b/cli/src/main/kotlin/tools/samt/cli/OutputWriter.kt @@ -11,7 +11,7 @@ internal object OutputWriter { fun write(outputDirectory: Path, files: List) { if (!outputDirectory.exists()) { try { - outputDirectory.createDirectory() + outputDirectory.createDirectories() } catch (e: IOException) { throw IOException("Failed to create output directory '${outputDirectory}'", e) } From 6d649b5e9f2c40618200e3ba4f6d9eec17f55317 Mon Sep 17 00:00:00 2001 From: Pascal Honegger Date: Tue, 30 May 2023 00:49:12 +0200 Subject: [PATCH 37/41] fix(codegen): correctly handle unimplemented async operation --- .../samt/codegen/kotlin/ktor/KotlinKtorConsumerGenerator.kt | 2 +- .../tools/samt/client/generated/consumer/Consumer.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/codegen/src/main/kotlin/tools/samt/codegen/kotlin/ktor/KotlinKtorConsumerGenerator.kt b/codegen/src/main/kotlin/tools/samt/codegen/kotlin/ktor/KotlinKtorConsumerGenerator.kt index b45fb86a..1b20f0a8 100644 --- a/codegen/src/main/kotlin/tools/samt/codegen/kotlin/ktor/KotlinKtorConsumerGenerator.kt +++ b/codegen/src/main/kotlin/tools/samt/codegen/kotlin/ktor/KotlinKtorConsumerGenerator.kt @@ -133,7 +133,7 @@ object KotlinKtorConsumerGenerator : Generator { when (operation) { is RequestResponseOperation -> { if (operation.isAsync) { - appendLine(" override suspend fun ${operation.name}($operationParameters): ${operation.returnType?.getQualifiedName(options) ?: "Unit"} {") + appendLine(" override suspend fun ${operation.name}($operationParameters): ${operation.returnType?.getQualifiedName(options) ?: "Unit"}") } else { appendLine(" override fun ${operation.name}($operationParameters): ${operation.returnType?.getQualifiedName(options) ?: "Unit"}") } diff --git a/codegen/src/test/resources/generator-test-model/out/ktor-client/tools/samt/client/generated/consumer/Consumer.kt b/codegen/src/test/resources/generator-test-model/out/ktor-client/tools/samt/client/generated/consumer/Consumer.kt index 35abfd18..d2b94a27 100644 --- a/codegen/src/test/resources/generator-test-model/out/ktor-client/tools/samt/client/generated/consumer/Consumer.kt +++ b/codegen/src/test/resources/generator-test-model/out/ktor-client/tools/samt/client/generated/consumer/Consumer.kt @@ -104,6 +104,6 @@ class GreeterEndpointImpl(private val baseUrl: String) : tools.samt.client.gener jsonElement.jsonPrimitive.content.also { require(it.length >= 1 && it.length <= 100) } } - override suspend fun legacy(): Unit { + override suspend fun legacy(): Unit = error("Not used in SAMT consumer and therefore not generated") } From 8fc49148cd91bc1731a59213ad3552bf2bff18f2 Mon Sep 17 00:00:00 2001 From: Pascal Honegger Date: Tue, 30 May 2023 00:50:28 +0200 Subject: [PATCH 38/41] feat(codegen): add more types to test model --- .../client/generated/consumer/Consumer.kt | 27 ++++++++ .../samt/client/generated/greeter/Types.kt | 10 +++ .../generated/greeter/GreeterEndpoint.kt | 69 +++++++++++++++++++ .../samt/server/generated/greeter/Types.kt | 10 +++ .../src/greeter-consumer.samt | 2 +- .../generator-test-model/src/greeter.samt | 12 ++++ 6 files changed, 129 insertions(+), 1 deletion(-) diff --git a/codegen/src/test/resources/generator-test-model/out/ktor-client/tools/samt/client/generated/consumer/Consumer.kt b/codegen/src/test/resources/generator-test-model/out/ktor-client/tools/samt/client/generated/consumer/Consumer.kt index d2b94a27..6bc28611 100644 --- a/codegen/src/test/resources/generator-test-model/out/ktor-client/tools/samt/client/generated/consumer/Consumer.kt +++ b/codegen/src/test/resources/generator-test-model/out/ktor-client/tools/samt/client/generated/consumer/Consumer.kt @@ -104,6 +104,33 @@ class GreeterEndpointImpl(private val baseUrl: String) : tools.samt.client.gener jsonElement.jsonPrimitive.content.also { require(it.length >= 1 && it.length <= 100) } } + override fun allTheTypes(long: Long, float: Float, double: Double, decimal: java.math.BigDecimal, boolean: Boolean, date: java.time.LocalDate, dateTime: java.time.LocalDateTime, duration: java.time.Duration): Unit = runBlocking { + // Make actual network call + val `client response` = client.request(this@GreeterEndpointImpl.baseUrl) { + url { + // Construct path and encode path parameters + appendPathSegments("allTheTypes", encodeSlash = true) + + // Encode query parameters + } + contentType(ContentType.Application.Json) + this.method = HttpMethod.Post + setBody( + buildJsonObject { + put("long", JsonPrimitive(long)) + put("float", JsonPrimitive(float)) + put("double", JsonPrimitive(double)) + put("decimal", JsonPrimitive(decimal.toPlainString())) + put("boolean", JsonPrimitive(boolean)) + put("date", JsonPrimitive(date.toString())) + put("dateTime", JsonPrimitive(dateTime.toString())) + put("duration", JsonPrimitive(duration.toString())) + } + ) + } + check(`client response`.status.isSuccess()) { "allTheTypes failed with status ${`client response`.status}" } + } + override suspend fun legacy(): Unit = error("Not used in SAMT consumer and therefore not generated") } diff --git a/codegen/src/test/resources/generator-test-model/out/ktor-client/tools/samt/client/generated/greeter/Types.kt b/codegen/src/test/resources/generator-test-model/out/ktor-client/tools/samt/client/generated/greeter/Types.kt index 82579921..fb70074b 100644 --- a/codegen/src/test/resources/generator-test-model/out/ktor-client/tools/samt/client/generated/greeter/Types.kt +++ b/codegen/src/test/resources/generator-test-model/out/ktor-client/tools/samt/client/generated/greeter/Types.kt @@ -37,6 +37,16 @@ interface Greeter { fun greeting( who: tools.samt.client.generated.greeter.Person, ): String + fun allTheTypes( + long: Long, + float: Float, + double: Double, + decimal: java.math.BigDecimal, + boolean: Boolean, + date: java.time.LocalDate, + dateTime: java.time.LocalDateTime, + duration: java.time.Duration, + ) suspend fun legacy( ) } diff --git a/codegen/src/test/resources/generator-test-model/out/ktor-server/tools/samt/server/generated/greeter/GreeterEndpoint.kt b/codegen/src/test/resources/generator-test-model/out/ktor-server/tools/samt/server/generated/greeter/GreeterEndpoint.kt index f8fabd43..8511c114 100644 --- a/codegen/src/test/resources/generator-test-model/out/ktor-server/tools/samt/server/generated/greeter/GreeterEndpoint.kt +++ b/codegen/src/test/resources/generator-test-model/out/ktor-server/tools/samt/server/generated/greeter/GreeterEndpoint.kt @@ -111,6 +111,75 @@ fun Routing.routeGreeterEndpoint( call.respond(HttpStatusCode.OK, response) } + // Handler for SAMT operation allTheTypes + post("/allTheTypes") { + // Parse body lazily in case no parameter is transported in the body + val bodyAsText = call.receiveText() + val body by lazy { bodyAsText.toJson() } + + // Decode parameter long + val `parameter long` = run { + // Read from body + val jsonElement = body.jsonObject["long"]!! + jsonElement.jsonPrimitive.long + } + + // Decode parameter float + val `parameter float` = run { + // Read from body + val jsonElement = body.jsonObject["float"]!! + jsonElement.jsonPrimitive.float + } + + // Decode parameter double + val `parameter double` = run { + // Read from body + val jsonElement = body.jsonObject["double"]!! + jsonElement.jsonPrimitive.double + } + + // Decode parameter decimal + val `parameter decimal` = run { + // Read from body + val jsonElement = body.jsonObject["decimal"]!! + jsonElement.jsonPrimitive.content.let { java.math.BigDecimal(it) } + } + + // Decode parameter boolean + val `parameter boolean` = run { + // Read from body + val jsonElement = body.jsonObject["boolean"]!! + jsonElement.jsonPrimitive.boolean + } + + // Decode parameter date + val `parameter date` = run { + // Read from body + val jsonElement = body.jsonObject["date"]!! + jsonElement.jsonPrimitive.content.let { java.time.LocalDate.parse(it) } + } + + // Decode parameter dateTime + val `parameter dateTime` = run { + // Read from body + val jsonElement = body.jsonObject["dateTime"]!! + jsonElement.jsonPrimitive.content.let { java.time.LocalDateTime.parse(it) } + } + + // Decode parameter duration + val `parameter duration` = run { + // Read from body + val jsonElement = body.jsonObject["duration"]!! + jsonElement.jsonPrimitive.content.let { java.time.Duration.parse(it) } + } + + // Call user provided implementation + greeter.allTheTypes(`parameter long`, `parameter float`, `parameter double`, `parameter decimal`, `parameter boolean`, `parameter date`, `parameter dateTime`, `parameter duration`) + + // Return 204 No Content + call.respond(HttpStatusCode.NoContent) + } + // Handler for SAMT operation legacy post("/legacy") { // Parse body lazily in case no parameter is transported in the body diff --git a/codegen/src/test/resources/generator-test-model/out/ktor-server/tools/samt/server/generated/greeter/Types.kt b/codegen/src/test/resources/generator-test-model/out/ktor-server/tools/samt/server/generated/greeter/Types.kt index 9c022f1b..f3a95446 100644 --- a/codegen/src/test/resources/generator-test-model/out/ktor-server/tools/samt/server/generated/greeter/Types.kt +++ b/codegen/src/test/resources/generator-test-model/out/ktor-server/tools/samt/server/generated/greeter/Types.kt @@ -37,6 +37,16 @@ interface Greeter { fun greeting( who: tools.samt.server.generated.greeter.Person, ): String + fun allTheTypes( + long: Long, + float: Float, + double: Double, + decimal: java.math.BigDecimal, + boolean: Boolean, + date: java.time.LocalDate, + dateTime: java.time.LocalDateTime, + duration: java.time.Duration, + ) suspend fun legacy( ) } diff --git a/codegen/src/test/resources/generator-test-model/src/greeter-consumer.samt b/codegen/src/test/resources/generator-test-model/src/greeter-consumer.samt index a2563b97..e56e400b 100644 --- a/codegen/src/test/resources/generator-test-model/src/greeter-consumer.samt +++ b/codegen/src/test/resources/generator-test-model/src/greeter-consumer.samt @@ -5,5 +5,5 @@ import tools.samt.greeter.Greeter package tools.samt.consumer consume GreeterEndpoint { - uses Greeter { greet, greetAll, greeting } + uses Greeter { greet, greetAll, greeting, allTheTypes } } diff --git a/codegen/src/test/resources/generator-test-model/src/greeter.samt b/codegen/src/test/resources/generator-test-model/src/greeter.samt index b4137142..06c62872 100644 --- a/codegen/src/test/resources/generator-test-model/src/greeter.samt +++ b/codegen/src/test/resources/generator-test-model/src/greeter.samt @@ -27,6 +27,18 @@ service Greeter { greetAll(names: List): Map greeting(who: Person): String (1..100) + @Description("Used to test all the types") + allTheTypes( + long: Long, + float: Float, + double: Double, + decimal: Decimal, + boolean: Boolean, + date: Date, + dateTime: DateTime, + duration: Duration + ) + @Deprecated("Do not use anymore!") async legacy() } From 109354fdb3813d3fdfe026cce9a5622be8061f95 Mon Sep 17 00:00:00 2001 From: Pascal Honegger Date: Tue, 30 May 2023 01:01:45 +0200 Subject: [PATCH 39/41] feat(codegen): add oneway operation to test --- .../client/generated/consumer/Consumer.kt | 22 +++++++++++++++++++ .../samt/client/generated/greeter/Types.kt | 3 +++ .../generated/greeter/GreeterEndpoint.kt | 22 +++++++++++++++++++ .../samt/server/generated/greeter/Types.kt | 3 +++ .../src/greeter-consumer.samt | 2 +- .../src/greeter-provider.samt | 3 ++- .../generator-test-model/src/greeter.samt | 2 ++ 7 files changed, 55 insertions(+), 2 deletions(-) diff --git a/codegen/src/test/resources/generator-test-model/out/ktor-client/tools/samt/client/generated/consumer/Consumer.kt b/codegen/src/test/resources/generator-test-model/out/ktor-client/tools/samt/client/generated/consumer/Consumer.kt index 6bc28611..ea986154 100644 --- a/codegen/src/test/resources/generator-test-model/out/ktor-client/tools/samt/client/generated/consumer/Consumer.kt +++ b/codegen/src/test/resources/generator-test-model/out/ktor-client/tools/samt/client/generated/consumer/Consumer.kt @@ -131,6 +131,28 @@ class GreeterEndpointImpl(private val baseUrl: String) : tools.samt.client.gener check(`client response`.status.isSuccess()) { "allTheTypes failed with status ${`client response`.status}" } } + override fun fireAndForget(deleteWorld: Boolean): Unit { + onewayScope.launch { + // Make actual network call + val `client response` = client.request(this@GreeterEndpointImpl.baseUrl) { + url { + // Construct path and encode path parameters + appendPathSegments("world", encodeSlash = true) + + // Encode query parameters + } + contentType(ContentType.Application.Json) + this.method = HttpMethod.Put + cookie("deleteWorld", (JsonPrimitive(deleteWorld.also { require(it == true)) })).toString()) + setBody( + buildJsonObject { + } + ) + } + check(`client response`.status.isSuccess()) { "fireAndForget failed with status ${`client response`.status}" } + } + } + override suspend fun legacy(): Unit = error("Not used in SAMT consumer and therefore not generated") } diff --git a/codegen/src/test/resources/generator-test-model/out/ktor-client/tools/samt/client/generated/greeter/Types.kt b/codegen/src/test/resources/generator-test-model/out/ktor-client/tools/samt/client/generated/greeter/Types.kt index fb70074b..eeeab92b 100644 --- a/codegen/src/test/resources/generator-test-model/out/ktor-client/tools/samt/client/generated/greeter/Types.kt +++ b/codegen/src/test/resources/generator-test-model/out/ktor-client/tools/samt/client/generated/greeter/Types.kt @@ -47,6 +47,9 @@ interface Greeter { dateTime: java.time.LocalDateTime, duration: java.time.Duration, ) + fun fireAndForget( + deleteWorld: Boolean, + ) suspend fun legacy( ) } diff --git a/codegen/src/test/resources/generator-test-model/out/ktor-server/tools/samt/server/generated/greeter/GreeterEndpoint.kt b/codegen/src/test/resources/generator-test-model/out/ktor-server/tools/samt/server/generated/greeter/GreeterEndpoint.kt index 8511c114..5efeaf86 100644 --- a/codegen/src/test/resources/generator-test-model/out/ktor-server/tools/samt/server/generated/greeter/GreeterEndpoint.kt +++ b/codegen/src/test/resources/generator-test-model/out/ktor-server/tools/samt/server/generated/greeter/GreeterEndpoint.kt @@ -180,6 +180,28 @@ fun Routing.routeGreeterEndpoint( call.respond(HttpStatusCode.NoContent) } + // Handler for SAMT oneway operation fireAndForget + put("/world") { + // Parse body lazily in case no parameter is transported in the body + val bodyAsText = call.receiveText() + val body by lazy { bodyAsText.toJson() } + + // Decode parameter deleteWorld + val `parameter deleteWorld` = run { + // Read from cookie + val jsonElement = call.request.cookies["deleteWorld"]!!.toJson() + jsonElement.jsonPrimitive.boolean.also { require(it == true)) } + } + + // Use launch to handle the request asynchronously, not waiting for the response + launch { + // Call user provided implementation + greeter.fireAndForget(`parameter deleteWorld`) + } + + // Oneway operation always returns 204 No Content + call.respond(HttpStatusCode.NoContent) + } // Handler for SAMT operation legacy post("/legacy") { // Parse body lazily in case no parameter is transported in the body diff --git a/codegen/src/test/resources/generator-test-model/out/ktor-server/tools/samt/server/generated/greeter/Types.kt b/codegen/src/test/resources/generator-test-model/out/ktor-server/tools/samt/server/generated/greeter/Types.kt index f3a95446..59a8973e 100644 --- a/codegen/src/test/resources/generator-test-model/out/ktor-server/tools/samt/server/generated/greeter/Types.kt +++ b/codegen/src/test/resources/generator-test-model/out/ktor-server/tools/samt/server/generated/greeter/Types.kt @@ -47,6 +47,9 @@ interface Greeter { dateTime: java.time.LocalDateTime, duration: java.time.Duration, ) + fun fireAndForget( + deleteWorld: Boolean, + ) suspend fun legacy( ) } diff --git a/codegen/src/test/resources/generator-test-model/src/greeter-consumer.samt b/codegen/src/test/resources/generator-test-model/src/greeter-consumer.samt index e56e400b..c58726ba 100644 --- a/codegen/src/test/resources/generator-test-model/src/greeter-consumer.samt +++ b/codegen/src/test/resources/generator-test-model/src/greeter-consumer.samt @@ -5,5 +5,5 @@ import tools.samt.greeter.Greeter package tools.samt.consumer consume GreeterEndpoint { - uses Greeter { greet, greetAll, greeting, allTheTypes } + uses Greeter { greet, greetAll, greeting, allTheTypes, fireAndForget } } diff --git a/codegen/src/test/resources/generator-test-model/src/greeter-provider.samt b/codegen/src/test/resources/generator-test-model/src/greeter-provider.samt index 36d3e8f6..d1418ba1 100644 --- a/codegen/src/test/resources/generator-test-model/src/greeter-provider.samt +++ b/codegen/src/test/resources/generator-test-model/src/greeter-provider.samt @@ -8,7 +8,8 @@ provide GreeterEndpoint { Greeter: { greet: "POST /greet/{name} {id in header} {type in query}", greetAll: "GET /greet/all {names in query}", - greeting: "POST /greeting" + greeting: "POST /greeting", + fireAndForget: "PUT /world {deleteWorld in cookie}" } } } diff --git a/codegen/src/test/resources/generator-test-model/src/greeter.samt b/codegen/src/test/resources/generator-test-model/src/greeter.samt index 06c62872..d22b7b62 100644 --- a/codegen/src/test/resources/generator-test-model/src/greeter.samt +++ b/codegen/src/test/resources/generator-test-model/src/greeter.samt @@ -39,6 +39,8 @@ service Greeter { duration: Duration ) + oneway fireAndForget(deleteWorld: Boolean (value(true))) + @Deprecated("Do not use anymore!") async legacy() } From 9509b2581a66b7db5a413ac1f24d7e2086f20630 Mon Sep 17 00:00:00 2001 From: Pascal Honegger Date: Tue, 30 May 2023 01:08:26 +0200 Subject: [PATCH 40/41] feat(codegen): ensure test works across operating systems --- codegen/src/test/kotlin/tools/samt/codegen/CodegenTest.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/codegen/src/test/kotlin/tools/samt/codegen/CodegenTest.kt b/codegen/src/test/kotlin/tools/samt/codegen/CodegenTest.kt index d28831b2..1d76b6ff 100644 --- a/codegen/src/test/kotlin/tools/samt/codegen/CodegenTest.kt +++ b/codegen/src/test/kotlin/tools/samt/codegen/CodegenTest.kt @@ -50,8 +50,8 @@ class CodegenTest { val expectedFiles = testDirectory.toFile().walkTopDown().filter { it.isFile && it.extension == "kt" }.toList() - val expected = expectedFiles.associate { it.toPath() to it.readText() }.toSortedMap() - val actual = actualFiles.associate { Path(it.filepath) to it.source }.toSortedMap() + val expected = expectedFiles.associate { it.toPath().normalize() to it.readText().replace("\r\n", "\n") }.toSortedMap() + val actual = actualFiles.associate { Path(it.filepath).normalize() to it.source.replace("\r\n", "\n") }.toSortedMap() assertEquals(expected, actual) } From c35ead5acb8f24847dce78bdc66a54c4aa2d4faf Mon Sep 17 00:00:00 2001 From: Pascal Honegger Date: Tue, 30 May 2023 12:42:44 +0200 Subject: [PATCH 41/41] feat(codegen): allow multiple spaces in transport configuration --- .../src/main/kotlin/tools/samt/codegen/http/HttpTransport.kt | 4 ++-- .../test/kotlin/tools/samt/codegen/http/HttpTransportTest.kt | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/codegen/src/main/kotlin/tools/samt/codegen/http/HttpTransport.kt b/codegen/src/main/kotlin/tools/samt/codegen/http/HttpTransport.kt index 54e4df3f..ad0d1181 100644 --- a/codegen/src/main/kotlin/tools/samt/codegen/http/HttpTransport.kt +++ b/codegen/src/main/kotlin/tools/samt/codegen/http/HttpTransport.kt @@ -14,9 +14,9 @@ object HttpTransportConfigurationParser : TransportConfigurationParser { services = emptyList(), ) - private val isValidRegex = Regex("""\w+\s+\S+(\s+\{.*? in \S+})*""") + private val isValidRegex = Regex("""\w+\s+\S+(\s+\{.*?\s+in\s+\S+})*""") private val methodEndpointRegex = Regex("""(\w+)\s+(\S+)(.*)""") - private val parameterRegex = Regex("""\{(.*?) in (\S+)}""") + private val parameterRegex = Regex("""\{(.*?)\s+in\s+(\S+)}""") override fun parse(params: TransportConfigurationParserParams): HttpTransportConfiguration { val config = params.config diff --git a/codegen/src/test/kotlin/tools/samt/codegen/http/HttpTransportTest.kt b/codegen/src/test/kotlin/tools/samt/codegen/http/HttpTransportTest.kt index b6bed20a..4ac7acf3 100644 --- a/codegen/src/test/kotlin/tools/samt/codegen/http/HttpTransportTest.kt +++ b/codegen/src/test/kotlin/tools/samt/codegen/http/HttpTransportTest.kt @@ -68,7 +68,7 @@ class HttpTransportTest { operations: { Greeter: { greet: "POST /greet/{id} {name in header} {type in cookie}", - greetAll: "GET /greet/all {names in query}", + greetAll: "GET /greet/all {names in query}", get: "GET /", put: "PUT /", delete: "DELETE /",