From 977b883a753b54d2f8fefe36a99c9ad9555d2235 Mon Sep 17 00:00:00 2001 From: Pascal Honegger Date: Sat, 3 Jun 2023 19:22:11 +0200 Subject: [PATCH 1/5] feat(http): rename query to queryParam --- .../tools/samt/codegen/http/HttpTransport.kt | 54 +++++++++++++------ .../ktor/KotlinKtorConsumerGenerator.kt | 3 +- .../ktor/KotlinKtorProviderGenerator.kt | 6 +-- .../samt/codegen/http/HttpTransportTest.kt | 8 +-- .../generated/greeter/GreeterEndpoint.kt | 4 +- .../examples/petstore/src/pet-provider.samt | 6 +-- .../examples/petstore/src/user-provider.samt | 2 +- 7 files changed, 54 insertions(+), 29 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 46eca267..c4e94fdc 100644 --- a/codegen/src/main/kotlin/tools/samt/codegen/http/HttpTransport.kt +++ b/codegen/src/main/kotlin/tools/samt/codegen/http/HttpTransport.kt @@ -36,14 +36,14 @@ object HttpTransportConfigurationParser : TransportConfigurationParser { val operationConfigurations = operationConfiguration.fields.filterKeys { it.asIdentifier != "basePath" } val parsedOperations = mutableListOf() - operationConfigLoop@for ((key, value) in operationConfigurations) { + operationConfigLoop@ for ((key, value) in operationConfigurations) { val operationConfig = value.asValue val operation = key.asOperationName(service) val operationName = operation.name if (!(operationConfig.asString matches isValidRegex)) { params.reportError( - "Invalid operation config for '$operationName', expected ' '. A valid example: 'POST /${operationName} {parameter1, parameter2 in query}'", + "Invalid operation config for '$operationName', expected ' '. A valid example: 'POST /${operationName} {parameter1, parameter2 in queryParam}'", operationConfig ) continue @@ -124,7 +124,10 @@ object HttpTransportConfigurationParser : TransportConfigurationParser { } if (operation.parameters.none { it.name == pathParameterName }) { - params.reportError("Path parameter '$pathParameterName' not found in operation '$operationName'", operationConfig) + params.reportError( + "Path parameter '$pathParameterName' not found in operation '$operationName'", + operationConfig + ) continue } @@ -139,10 +142,16 @@ object HttpTransportConfigurationParser : TransportConfigurationParser { for (parameterResult in parameterResults) { val (names, type) = parameterResult.destructured val transportMode = when (type) { - "query" -> HttpTransportConfiguration.TransportMode.Query - "header" -> HttpTransportConfiguration.TransportMode.Header + "query", + "queryParam", + "queryParams", + "queryParameter", + "queryParameters", + -> HttpTransportConfiguration.TransportMode.QueryParameter + + "header", "headers" -> HttpTransportConfiguration.TransportMode.Header "body" -> HttpTransportConfiguration.TransportMode.Body - "cookie" -> HttpTransportConfiguration.TransportMode.Cookie + "cookie", "cookies" -> HttpTransportConfiguration.TransportMode.Cookie else -> { params.reportError("Invalid transport mode '$type'", operationConfig) continue @@ -151,12 +160,18 @@ object HttpTransportConfigurationParser : TransportConfigurationParser { 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) + params.reportError( + "Parameter '$name' not found in operation '$operationName'", + operationConfig + ) continue } if (transportMode == HttpTransportConfiguration.TransportMode.Body && methodEnum == HttpTransportConfiguration.HttpMethod.Get) { - params.reportError("HTTP GET method doesn't accept '$name' as a BODY parameter", operationConfig) + params.reportError( + "HTTP GET method doesn't accept '$name' as a BODY parameter", + operationConfig + ) continue } @@ -199,7 +214,7 @@ class HttpTransportConfiguration( class ServiceConfiguration( val name: String, val path: String, - val operations: List + val operations: List, ) { fun getOperation(name: String): OperationConfiguration? { return operations.firstOrNull { it.name == name } @@ -227,11 +242,20 @@ class HttpTransportConfiguration( } enum class TransportMode { - 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 + /** encoded in request body via serializationMode */ + Body, + + /** encoded as url query parameter */ + QueryParameter, + + /** encoded as part of url path */ + Path, + + /** encoded as HTTP header */ + Header, + + /** encoded as HTTP cookie */ + Cookie, } enum class HttpMethod { @@ -273,7 +297,7 @@ class HttpTransportConfiguration( return mode return if (operation?.method == HttpMethod.Get) { - TransportMode.Query + TransportMode.QueryParameter } else { TransportMode.Body } 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 1b20f0a8..db159fa6 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 @@ -169,7 +169,8 @@ object KotlinKtorConsumerGenerator : Generator { HttpTransportConfiguration.TransportMode.Path -> { pathParameters[name] = it } - HttpTransportConfiguration.TransportMode.Query -> { + + HttpTransportConfiguration.TransportMode.QueryParameter -> { queryParameters[name] = it } } 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 19c1da95..b479e178 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 @@ -286,12 +286,12 @@ object KotlinKtorProviderGenerator : Generator { parameter: ServiceOperationParameter, transportMode: HttpTransportConfiguration.TransportMode, ) { - appendLine(" // Read from ${transportMode.name.lowercase()}") + appendLine(" // Read from ${transportMode.name.replaceFirstChar { it.lowercase() }}") append(" val jsonElement = ") 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()") + HttpTransportConfiguration.TransportMode.QueryParameter -> 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()") @@ -300,7 +300,7 @@ object KotlinKtorProviderGenerator : Generator { } else { when (transportMode) { HttpTransportConfiguration.TransportMode.Body -> append("body.jsonObject[\"${parameter.name}\"]!!") - HttpTransportConfiguration.TransportMode.Query -> append("call.request.queryParameters[\"${parameter.name}\"]!!.toJson()") + HttpTransportConfiguration.TransportMode.QueryParameter -> 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()") 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 a2b275a7..cc917b4f 100644 --- a/codegen/src/test/kotlin/tools/samt/codegen/http/HttpTransportTest.kt +++ b/codegen/src/test/kotlin/tools/samt/codegen/http/HttpTransportTest.kt @@ -71,7 +71,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 queryParam}", get: "GET /", put: "PUT /", delete: "DELETE /", @@ -110,14 +110,14 @@ class HttpTransportTest { assertEquals(HttpTransportConfiguration.HttpMethod.Get, transport.getMethod("Greeter", "greetAll")) assertEquals("/greet/all", transport.getPath("Greeter", "greetAll")) assertEquals( - HttpTransportConfiguration.TransportMode.Query, + HttpTransportConfiguration.TransportMode.QueryParameter, transport.getTransportMode("Greeter", "greetAll", "names") ) assertEquals(HttpTransportConfiguration.HttpMethod.Get, transport.getMethod("Greeter", "get")) assertEquals("/", transport.getPath("Greeter", "get")) assertEquals( - HttpTransportConfiguration.TransportMode.Query, + HttpTransportConfiguration.TransportMode.QueryParameter, transport.getTransportMode("Greeter", "get", "name") ) @@ -242,7 +242,7 @@ class HttpTransportTest { } """.trimIndent() - parseAndCheck(source to listOf("Error: Invalid operation config for 'greet', expected ' '. A valid example: 'POST /greet {parameter1, parameter2 in query}'")) + parseAndCheck(source to listOf("Error: Invalid operation config for 'greet', expected ' '. A valid example: 'POST /greet {parameter1, parameter2 in queryParam}'")) } @Test 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 5efeaf86..3cccb284 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 @@ -50,7 +50,7 @@ fun Routing.routeGreeterEndpoint( // Decode parameter type val `parameter type` = run { - // Read from query + // Read from queryParameter val jsonElement = call.request.queryParameters["type"]?.toJsonOrNull() ?: return@run null tools.samt.server.generated.greeter.`decode GreetingType`(jsonElement) } @@ -73,7 +73,7 @@ fun Routing.routeGreeterEndpoint( // Decode parameter names val `parameter names` = run { - // Read from query + // Read from queryParameter 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) } } } } diff --git a/specification/examples/petstore/src/pet-provider.samt b/specification/examples/petstore/src/pet-provider.samt index 06904843..7ce93dfc 100644 --- a/specification/examples/petstore/src/pet-provider.samt +++ b/specification/examples/petstore/src/pet-provider.samt @@ -10,10 +10,10 @@ provide PetEndpointHTTP { basePath: "/pet", addPet: "POST /", updatePet: "PUT /", - findPetsByStatus: "GET /findByStatus {status in query}", - findPetsByTags: "GET /findByTags {tags in query}", + findPetsByStatus: "GET /findByStatus {status in queryParam}", + findPetsByTags: "GET /findByTags {tags in queryParam}", getPetById: "GET /{petId}", - updatePetWithForm: "POST /{petId} {name, status in query}", + updatePetWithForm: "POST /{petId} {name, status in queryParams}", deletePet: "DELETE /petId}" } } diff --git a/specification/examples/petstore/src/user-provider.samt b/specification/examples/petstore/src/user-provider.samt index dad6ace6..ec1285fe 100644 --- a/specification/examples/petstore/src/user-provider.samt +++ b/specification/examples/petstore/src/user-provider.samt @@ -10,7 +10,7 @@ provide UserEndpointHTTP { basePath: "/user", createUser: "POST /", createUsers: "POST /createWithList", - login: "GET /login {username, password in query}", + login: "GET /login {username, password in queryParams}", logout: "GET /logout", getUserByUsername: "GET /{username}", updateUser: "PUT /{username}", From c063a00583bec1fda10f54d11f0d3ee5a74023bd Mon Sep 17 00:00:00 2001 From: Pascal Honegger Date: Sat, 3 Jun 2023 19:28:04 +0200 Subject: [PATCH 2/5] refactor(transport): remove default() function --- .../tools/samt/codegen/PublicApiMapper.kt | 21 +++++----- .../tools/samt/codegen/http/HttpTransport.kt | 10 ++--- .../samt/codegen/http/HttpTransportTest.kt | 38 ++++++++++++++----- .../kotlin/tools/samt/api/plugin/Transport.kt | 12 ++---- 4 files changed, 45 insertions(+), 36 deletions(-) diff --git a/codegen/src/main/kotlin/tools/samt/codegen/PublicApiMapper.kt b/codegen/src/main/kotlin/tools/samt/codegen/PublicApiMapper.kt index 447ef220..e0879be6 100644 --- a/codegen/src/main/kotlin/tools/samt/codegen/PublicApiMapper.kt +++ b/codegen/src/main/kotlin/tools/samt/codegen/PublicApiMapper.kt @@ -85,8 +85,8 @@ class PublicApiMapper( } private class Params( - override val config: ConfigurationObject, - val controller: DiagnosticController + override val config: ConfigurationObject?, + val controller: DiagnosticController, ) : TransportConfigurationParserParams { override fun reportError(message: String, context: ConfigurationElement?) { @@ -129,16 +129,13 @@ class PublicApiMapper( 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() + val transportConfigNode = + configuration?.let { TransportConfigurationMapper(provider, controller).parse(it) } + 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}") } } 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 c4e94fdc..79611bf3 100644 --- a/codegen/src/main/kotlin/tools/samt/codegen/http/HttpTransport.kt +++ b/codegen/src/main/kotlin/tools/samt/codegen/http/HttpTransport.kt @@ -9,17 +9,15 @@ object HttpTransportConfigurationParser : TransportConfigurationParser { override val transportName: String get() = "http" - override fun default(): HttpTransportConfiguration = HttpTransportConfiguration( - serializationMode = HttpTransportConfiguration.SerializationMode.Json, - services = emptyList(), - ) - private val isValidRegex = Regex("""\w+\s+\S+(\s+\{.*?\s+in\s+\S+})*""") private val methodEndpointRegex = Regex("""(\w+)\s+(\S+)(.*)""") private val parameterRegex = Regex("""\{(.*?)\s+in\s+(\S+)}""") override fun parse(params: TransportConfigurationParserParams): HttpTransportConfiguration { - val config = params.config + val config = params.config ?: return HttpTransportConfiguration( + serializationMode = HttpTransportConfiguration.SerializationMode.Json, + services = emptyList(), + ) val serializationMode = config.getFieldOrNull("serialization")?.asValue?.asEnum() ?: HttpTransportConfiguration.SerializationMode.Json 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 cc917b4f..2b89b6e5 100644 --- a/codegen/src/test/kotlin/tools/samt/codegen/http/HttpTransportTest.kt +++ b/codegen/src/test/kotlin/tools/samt/codegen/http/HttpTransportTest.kt @@ -1,6 +1,9 @@ package tools.samt.codegen.http +import tools.samt.api.plugin.ConfigurationElement +import tools.samt.api.plugin.ConfigurationObject import tools.samt.api.plugin.TransportConfiguration +import tools.samt.api.plugin.TransportConfigurationParserParams import tools.samt.codegen.PublicApiMapper import tools.samt.common.DiagnosticController import tools.samt.common.SourceFile @@ -21,7 +24,20 @@ class HttpTransportTest { @Test fun `default configuration return default values for operations`() { - val config = HttpTransportConfigurationParser.default() + val config = HttpTransportConfigurationParser.parse(object : TransportConfigurationParserParams { + override val config: ConfigurationObject? get() = null + override fun reportInfo(message: String, context: ConfigurationElement?) { + fail("Unexpected info message: $message") + } + + override fun reportWarning(message: String, context: ConfigurationElement?) { + fail("Unexpected warning message: $message") + } + + override fun reportError(message: String, context: ConfigurationElement?) { + fail("Unexpected error message: $message") + } + }) assertEquals(HttpTransportConfiguration.SerializationMode.Json, config.serializationMode) assertEquals(emptyList(), config.services) assertEquals(HttpTransportConfiguration.HttpMethod.Post, config.getMethod("service", "operation")) @@ -358,10 +374,12 @@ class HttpTransportTest { } """.trimIndent() - parseAndCheck(source to listOf( - "Error: Operation 'Greeter.greetTwo' cannot be mapped to the same method and path combination (GET /greet) as operation 'Greeter.greet'", - "Error: Operation 'Greeter.greetFour' cannot be mapped to the same method and path combination (POST /greet/{name}) as operation 'Greeter.greetThree'" - )) + parseAndCheck( + source to listOf( + "Error: Operation 'Greeter.greetTwo' cannot be mapped to the same method and path combination (GET /greet) as operation 'Greeter.greet'", + "Error: Operation 'Greeter.greetFour' cannot be mapped to the same method and path combination (POST /greet/{name}) as operation 'Greeter.greetThree'" + ) + ) } @Test @@ -396,11 +414,13 @@ class HttpTransportTest { } """.trimIndent() - parseAndCheck(source to listOf( - "Error: Operation 'GoodbyeSayer.say' cannot be mapped to the same method and path combination (GET /sayer/say) as operation 'HelloSayer.say'", - )) + parseAndCheck( + source to listOf( + "Error: Operation 'GoodbyeSayer.say' cannot be mapped to the same method and path combination (GET /sayer/say) as operation 'HelloSayer.say'", + ) + ) } - + private fun parseAndCheck( vararg sourceAndExpectedMessages: Pair>, ): TransportConfiguration { 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 index 1b37f851..acaf82ce 100644 --- a/public-api/src/main/kotlin/tools/samt/api/plugin/Transport.kt +++ b/public-api/src/main/kotlin/tools/samt/api/plugin/Transport.kt @@ -15,13 +15,7 @@ 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 + * Parses the configuration and returns the configuration object * @throws RuntimeException if the configuration is invalid and graceful error handling is not possible * @return Parsed configuration */ @@ -39,9 +33,9 @@ interface TransportConfiguration */ interface TransportConfigurationParserParams { /** - * The configuration body to parse + * The configuration body to parse, or null if no configuration body was specified */ - val config: ConfigurationObject + val config: ConfigurationObject? /** * Report an error From d6586adadd2c200ecea31f0aa83b77ce6c423d41 Mon Sep 17 00:00:00 2001 From: Pascal Honegger Date: Sat, 3 Jun 2023 19:41:33 +0200 Subject: [PATCH 3/5] feat(api): expose HTTP transport --- .../tools/samt/codegen/http/HttpTransport.kt | 84 +++++------ .../ktor/KotlinKtorConsumerGenerator.kt | 134 ++++++++++++++---- .../ktor/KotlinKtorProviderGenerator.kt | 38 ++--- .../samt/codegen/http/HttpTransportTest.kt | 49 ++++--- .../samt/api/transports/http/HttpMethod.kt | 19 +++ .../api/transports/http/SamtHttpTransport.kt | 38 +++++ .../api/transports/http/SerializationMode.kt | 9 ++ .../samt/api/transports/http/TransportMode.kt | 19 +++ 8 files changed, 276 insertions(+), 114 deletions(-) create mode 100644 public-api/src/main/kotlin/tools/samt/api/transports/http/HttpMethod.kt create mode 100644 public-api/src/main/kotlin/tools/samt/api/transports/http/SamtHttpTransport.kt create mode 100644 public-api/src/main/kotlin/tools/samt/api/transports/http/SerializationMode.kt create mode 100644 public-api/src/main/kotlin/tools/samt/api/transports/http/TransportMode.kt 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 79611bf3..50fbffaa 100644 --- a/codegen/src/main/kotlin/tools/samt/codegen/http/HttpTransport.kt +++ b/codegen/src/main/kotlin/tools/samt/codegen/http/HttpTransport.kt @@ -1,9 +1,12 @@ package tools.samt.codegen.http -import tools.samt.api.plugin.TransportConfiguration import tools.samt.api.plugin.TransportConfigurationParser import tools.samt.api.plugin.TransportConfigurationParserParams import tools.samt.api.plugin.asEnum +import tools.samt.api.transports.http.HttpMethod +import tools.samt.api.transports.http.SamtHttpTransport +import tools.samt.api.transports.http.SerializationMode +import tools.samt.api.transports.http.TransportMode object HttpTransportConfigurationParser : TransportConfigurationParser { override val transportName: String @@ -15,12 +18,12 @@ object HttpTransportConfigurationParser : TransportConfigurationParser { override fun parse(params: TransportConfigurationParserParams): HttpTransportConfiguration { val config = params.config ?: return HttpTransportConfiguration( - serializationMode = HttpTransportConfiguration.SerializationMode.Json, + serializationMode = SerializationMode.Json, services = emptyList(), ) val serializationMode = - config.getFieldOrNull("serialization")?.asValue?.asEnum() - ?: HttpTransportConfiguration.SerializationMode.Json + config.getFieldOrNull("serialization")?.asValue?.asEnum() + ?: SerializationMode.Json val services = config.getFieldOrNull("operations")?.asObject?.let { operations -> val parsedServices = mutableListOf() @@ -59,11 +62,11 @@ object HttpTransportConfigurationParser : TransportConfigurationParser { 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 + "GET" -> HttpMethod.Get + "POST" -> HttpMethod.Post + "PUT" -> HttpMethod.Put + "DELETE" -> HttpMethod.Delete + "PATCH" -> HttpMethod.Patch else -> { params.reportError("Invalid http method '$method'", operationConfig) continue @@ -131,7 +134,7 @@ object HttpTransportConfigurationParser : TransportConfigurationParser { parameters += HttpTransportConfiguration.ParameterConfiguration( name = pathParameterName, - transportMode = HttpTransportConfiguration.TransportMode.Path, + transportMode = TransportMode.Path, ) } @@ -145,11 +148,11 @@ object HttpTransportConfigurationParser : TransportConfigurationParser { "queryParams", "queryParameter", "queryParameters", - -> HttpTransportConfiguration.TransportMode.QueryParameter + -> TransportMode.QueryParameter - "header", "headers" -> HttpTransportConfiguration.TransportMode.Header - "body" -> HttpTransportConfiguration.TransportMode.Body - "cookie", "cookies" -> HttpTransportConfiguration.TransportMode.Cookie + "header", "headers" -> TransportMode.Header + "body" -> TransportMode.Body + "cookie", "cookies" -> TransportMode.Cookie else -> { params.reportError("Invalid transport mode '$type'", operationConfig) continue @@ -165,7 +168,7 @@ object HttpTransportConfigurationParser : TransportConfigurationParser { continue } - if (transportMode == HttpTransportConfiguration.TransportMode.Body && methodEnum == HttpTransportConfiguration.HttpMethod.Get) { + if (transportMode == TransportMode.Body && methodEnum == HttpMethod.Get) { params.reportError( "HTTP GET method doesn't accept '$name' as a BODY parameter", operationConfig @@ -206,9 +209,9 @@ object HttpTransportConfigurationParser : TransportConfigurationParser { } class HttpTransportConfiguration( - val serializationMode: SerializationMode, + override val serializationMode: SerializationMode, val services: List, -) : TransportConfiguration { +) : SamtHttpTransport { class ServiceConfiguration( val name: String, val path: String, @@ -235,57 +238,38 @@ class HttpTransportConfiguration( val transportMode: TransportMode, ) - enum class SerializationMode { - Json, - } - - enum class TransportMode { - /** encoded in request body via serializationMode */ - Body, - - /** encoded as url query parameter */ - QueryParameter, - - /** encoded as part of url path */ - Path, - - /** encoded as HTTP header */ - Header, - - /** encoded as HTTP cookie */ - Cookie, - } - - enum class HttpMethod { - Get, - Post, - Put, - Delete, - Patch, - } - private fun getService(name: String): ServiceConfiguration? { return services.firstOrNull { it.name == name } } - fun getMethod(serviceName: String, operationName: String): HttpMethod { + override fun getMethod(serviceName: String, operationName: String): HttpMethod { val service = getService(serviceName) val operation = service?.getOperation(operationName) return operation?.method ?: HttpMethod.Post } - fun getPath(serviceName: String, operationName: String): String { + override fun getPath(serviceName: String, operationName: String): String { val service = getService(serviceName) val operation = service?.getOperation(operationName) return operation?.path ?: "/$operationName" } - fun getPath(serviceName: String): String { + override fun getFullPath(serviceName: String, operationName: String): String { + val service = getService(serviceName) + val operation = service?.getOperation(operationName) + return "${service?.path ?: ""}${operation?.path ?: "/$operationName"}" + } + + override fun getServicePath(serviceName: String): String { val service = getService(serviceName) return service?.path ?: "" } - fun getTransportMode(serviceName: String, operationName: String, parameterName: String): TransportMode { + override fun getTransportMode( + serviceName: String, + operationName: String, + parameterName: String, + ): TransportMode { val service = getService(serviceName) val operation = service?.getOperation(operationName) val parameter = operation?.getParameter(parameterName) 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 db159fa6..cf84e05f 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 @@ -3,8 +3,10 @@ package tools.samt.codegen.kotlin.ktor import tools.samt.api.plugin.CodegenFile import tools.samt.api.plugin.Generator import tools.samt.api.plugin.GeneratorParams +import tools.samt.api.transports.http.SamtHttpTransport +import tools.samt.api.transports.http.SerializationMode +import tools.samt.api.transports.http.TransportMode import tools.samt.api.types.* -import tools.samt.codegen.http.HttpTransportConfiguration import tools.samt.codegen.kotlin.GeneratedFilePreamble import tools.samt.codegen.kotlin.KotlinTypesGenerator import tools.samt.codegen.kotlin.getQualifiedName @@ -34,11 +36,11 @@ object KotlinKtorConsumerGenerator : Generator { } private fun generatePackage(pack: SamtPackage, options: Map) { - val relevantConsumers = pack.consumers.filter { it.provider.transport is HttpTransportConfiguration } + val relevantConsumers = pack.consumers.filter { it.provider.transport is SamtHttpTransport } if (relevantConsumers.isNotEmpty()) { // generate ktor consumers relevantConsumers.forEach { consumer -> - val transportConfiguration = consumer.provider.transport as HttpTransportConfiguration + val transportConfiguration = consumer.provider.transport as SamtHttpTransport val packageSource = buildString { appendLine(GeneratedFilePreamble) @@ -62,7 +64,11 @@ object KotlinKtorConsumerGenerator : Generator { val unconsumedOperations = uses.unconsumedOperations } - private fun StringBuilder.appendConsumer(consumer: ConsumerType, transportConfiguration: HttpTransportConfiguration, options: Map) { + private fun StringBuilder.appendConsumer( + consumer: ConsumerType, + transportConfiguration: SamtHttpTransport, + options: Map, + ) { appendLine("import io.ktor.client.*") appendLine("import io.ktor.client.engine.cio.*") appendLine("import io.ktor.client.plugins.contentnegotiation.*") @@ -77,14 +83,26 @@ object KotlinKtorConsumerGenerator : Generator { appendLine() val implementedServices = consumer.uses.map { ConsumerInfo(consumer, it) } - appendLine("class ${consumer.className}(private val baseUrl: String) : ${implementedServices.joinToString { it.service.getQualifiedName(options) }} {") + appendLine( + "class ${consumer.className}(private val 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) { + private fun StringBuilder.appendConsumerOperations( + info: ConsumerInfo, + transportConfiguration: SamtHttpTransport, + options: Map, + ) { appendLine(" private val client = HttpClient(CIO) {") appendLine(" install(ContentNegotiation) {") appendLine(" json()") @@ -96,14 +114,27 @@ object KotlinKtorConsumerGenerator : Generator { appendLine() info.consumedOperations.forEach { operation -> - val operationParameters = operation.parameters.joinToString { "${it.name}: ${it.type.getQualifiedName(options)}" } + 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"} = run {") + 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 {") + appendLine( + " override fun ${operation.name}($operationParameters): ${ + operation.returnType?.getQualifiedName( + options + ) ?: "Unit" + } = runBlocking {" + ) } appendConsumerServiceCall(info, operation, transportConfiguration, options) @@ -128,14 +159,27 @@ object KotlinKtorConsumerGenerator : Generator { } info.unconsumedOperations.forEach { operation -> - val operationParameters = operation.parameters.joinToString { "${it.name}: ${it.type.getQualifiedName(options)}" } + 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"}") + appendLine( + " override suspend fun ${operation.name}($operationParameters): ${ + operation.returnType?.getQualifiedName( + options + ) ?: "Unit" + }" + ) } else { - appendLine(" override fun ${operation.name}($operationParameters): ${operation.returnType?.getQualifiedName(options) ?: "Unit"}") + appendLine( + " override fun ${operation.name}($operationParameters): ${ + operation.returnType?.getQualifiedName( + options + ) ?: "Unit" + }" + ) } } @@ -147,7 +191,12 @@ object KotlinKtorConsumerGenerator : Generator { } } - private fun StringBuilder.appendConsumerServiceCall(info: ConsumerInfo, operation: ServiceOperation, transport: HttpTransportConfiguration, options: Map) { + private fun StringBuilder.appendConsumerServiceCall( + info: ConsumerInfo, + operation: ServiceOperation, + transport: SamtHttpTransport, + options: Map, + ) { // collect parameters for each transport type val headerParameters = mutableMapOf() val cookieParameters = mutableMapOf() @@ -157,20 +206,23 @@ object KotlinKtorConsumerGenerator : Generator { operation.parameters.forEach { val name = it.name when (transport.getTransportMode(info.service.name, operation.name, name)) { - HttpTransportConfiguration.TransportMode.Header -> { + TransportMode.Header -> { headerParameters[name] = it } - HttpTransportConfiguration.TransportMode.Cookie -> { + + TransportMode.Cookie -> { cookieParameters[name] = it } - HttpTransportConfiguration.TransportMode.Body -> { + + TransportMode.Body -> { bodyParameters[name] = it } - HttpTransportConfiguration.TransportMode.Path -> { + + TransportMode.Path -> { pathParameters[name] = it } - HttpTransportConfiguration.TransportMode.QueryParameter -> { + TransportMode.QueryParameter -> { queryParameters[name] = it } } @@ -183,7 +235,7 @@ object KotlinKtorConsumerGenerator : Generator { // 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 transportPath = transport.getFullPath(info.service.name, operation.name) val transportPathComponents = transportPath.split("/") appendLine(" url {") appendLine(" // Construct path and encode path parameters") @@ -200,13 +252,21 @@ object KotlinKtorConsumerGenerator : Generator { appendLine(" // Encode query parameters") queryParameters.forEach { (name, queryParameter) -> - 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(" }") // serialization mode when (transport.serializationMode) { - HttpTransportConfiguration.SerializationMode.Json -> appendLine(" contentType(ContentType.Application.Json)") + SerializationMode.Json -> appendLine(" contentType(ContentType.Application.Json)") } // transport method @@ -215,19 +275,43 @@ object KotlinKtorConsumerGenerator : Generator { // header parameters headerParameters.forEach { (name, headerParameter) -> - appendLine(" header(\"${name}\", ${encodeJsonElement(headerParameter.type, options, valueName = name)})") + appendLine( + " header(\"${name}\", ${ + encodeJsonElement( + headerParameter.type, + options, + valueName = name + ) + })" + ) } // cookie parameters cookieParameters.forEach { (name, cookieParameter) -> - appendLine(" cookie(\"${name}\", (${encodeJsonElement(cookieParameter.type, options, valueName = name)}).toString())") + appendLine( + " cookie(\"${name}\", (${ + encodeJsonElement( + cookieParameter.type, + options, + valueName = name + ) + }).toString())" + ) } // body parameters appendLine(" setBody(") appendLine(" buildJsonObject {") bodyParameters.forEach { (name, bodyParameter) -> - appendLine(" put(\"$name\", ${encodeJsonElement(bodyParameter.type, options, valueName = name)})") + appendLine( + " put(\"$name\", ${ + encodeJsonElement( + bodyParameter.type, + options, + valueName = name + ) + })" + ) } appendLine(" }") appendLine(" )") @@ -241,7 +325,7 @@ object KotlinKtorConsumerGenerator : Generator { private fun StringBuilder.appendConsumerResponseParsing( operation: RequestResponseOperation, - options: Map + options: Map, ) { operation.returnType?.let { returnType -> appendLine(" val bodyAsText = `client response`.bodyAsText()") 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 b479e178..e62ba4c2 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 @@ -3,6 +3,8 @@ package tools.samt.codegen.kotlin.ktor import tools.samt.api.plugin.CodegenFile import tools.samt.api.plugin.Generator import tools.samt.api.plugin.GeneratorParams +import tools.samt.api.transports.http.HttpMethod +import tools.samt.api.transports.http.TransportMode import tools.samt.api.types.* import tools.samt.codegen.http.HttpTransportConfiguration import tools.samt.codegen.kotlin.GeneratedFilePreamble @@ -161,7 +163,7 @@ object KotlinKtorProviderGenerator : Generator { ) { val service = info.service appendLine(" // Handler for SAMT Service ${info.service.name}") - appendLine(" route(\"${transportConfiguration.getPath(service.name)}\") {") + appendLine(" route(\"${transportConfiguration.getServicePath(service.name)}\") {") info.implements.implementedOperations.forEach { operation -> appendProviderOperation(operation, info, service, transportConfiguration, options) } @@ -244,11 +246,11 @@ object KotlinKtorProviderGenerator : Generator { 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" + HttpMethod.Get -> "get" + HttpMethod.Post -> "post" + HttpMethod.Put -> "put" + HttpMethod.Delete -> "delete" + HttpMethod.Patch -> "patch" } val path = transportConfiguration.getPath(service.name, operation.name) return "${method}(\"${path}\")" @@ -275,7 +277,7 @@ object KotlinKtorProviderGenerator : Generator { private fun StringBuilder.appendParameterDeserialization( parameter: ServiceOperationParameter, - transportMode: HttpTransportConfiguration.TransportMode, + transportMode: TransportMode, options: Map, ) { appendReadParameterJsonElement(parameter, transportMode) @@ -284,26 +286,26 @@ object KotlinKtorProviderGenerator : Generator { private fun StringBuilder.appendReadParameterJsonElement( parameter: ServiceOperationParameter, - transportMode: HttpTransportConfiguration.TransportMode, + transportMode: TransportMode, ) { appendLine(" // Read from ${transportMode.name.replaceFirstChar { it.lowercase() }}") append(" val jsonElement = ") if (parameter.type.isRuntimeOptional) { when (transportMode) { - HttpTransportConfiguration.TransportMode.Body -> append("body.jsonObject[\"${parameter.name}\"]?.takeUnless { it is JsonNull }") - HttpTransportConfiguration.TransportMode.QueryParameter -> 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()") + TransportMode.Body -> append("body.jsonObject[\"${parameter.name}\"]?.takeUnless { it is JsonNull }") + TransportMode.QueryParameter -> append("call.request.queryParameters[\"${parameter.name}\"]?.toJsonOrNull()") + TransportMode.Path -> append("call.parameters[\"${parameter.name}\"]?.toJsonOrNull()") + TransportMode.Header -> append("call.request.headers[\"${parameter.name}\"]?.toJsonOrNull()") + TransportMode.Cookie -> append("call.request.cookies[\"${parameter.name}\"]?.toJsonOrNull()") } append(" ?: return@run null") } else { when (transportMode) { - HttpTransportConfiguration.TransportMode.Body -> append("body.jsonObject[\"${parameter.name}\"]!!") - HttpTransportConfiguration.TransportMode.QueryParameter -> 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()") + TransportMode.Body -> append("body.jsonObject[\"${parameter.name}\"]!!") + TransportMode.QueryParameter -> append("call.request.queryParameters[\"${parameter.name}\"]!!.toJson()") + TransportMode.Path -> append("call.parameters[\"${parameter.name}\"]!!.toJson()") + TransportMode.Header -> append("call.request.headers[\"${parameter.name}\"]!!.toJson()") + TransportMode.Cookie -> append("call.request.cookies[\"${parameter.name}\"]!!.toJson()") } } appendLine() 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 2b89b6e5..26d43781 100644 --- a/codegen/src/test/kotlin/tools/samt/codegen/http/HttpTransportTest.kt +++ b/codegen/src/test/kotlin/tools/samt/codegen/http/HttpTransportTest.kt @@ -4,6 +4,9 @@ import tools.samt.api.plugin.ConfigurationElement import tools.samt.api.plugin.ConfigurationObject import tools.samt.api.plugin.TransportConfiguration import tools.samt.api.plugin.TransportConfigurationParserParams +import tools.samt.api.transports.http.HttpMethod +import tools.samt.api.transports.http.SerializationMode +import tools.samt.api.transports.http.TransportMode import tools.samt.codegen.PublicApiMapper import tools.samt.common.DiagnosticController import tools.samt.common.SourceFile @@ -38,13 +41,14 @@ class HttpTransportTest { fail("Unexpected error message: $message") } }) - assertEquals(HttpTransportConfiguration.SerializationMode.Json, config.serializationMode) + assertEquals(SerializationMode.Json, config.serializationMode) assertEquals(emptyList(), config.services) - assertEquals(HttpTransportConfiguration.HttpMethod.Post, config.getMethod("service", "operation")) - assertEquals("", config.getPath("service")) + assertEquals(HttpMethod.Post, config.getMethod("service", "operation")) + assertEquals("", config.getServicePath("service")) assertEquals("/operation", config.getPath("service", "operation")) + assertEquals("/operation", config.getFullPath("service", "operation")) assertEquals( - HttpTransportConfiguration.TransportMode.Body, + TransportMode.Body, config.getTransportMode("service", "operation", "parameter") ) } @@ -86,6 +90,7 @@ class HttpTransportTest { transport http { operations: { Greeter: { + basePath: "/greeter", greet: "POST /greet/{id} {name in header} {type in cookie}", greetAll: "GET /greet/all {names in queryParam}", get: "GET /", @@ -101,50 +106,52 @@ class HttpTransportTest { val transport = parseAndCheck(source to emptyList()) assertIs(transport) - assertEquals(HttpTransportConfiguration.SerializationMode.Json, transport.serializationMode) + assertEquals(SerializationMode.Json, transport.serializationMode) assertEquals(listOf("Greeter"), transport.services.map { it.name }) - assertEquals(HttpTransportConfiguration.HttpMethod.Post, transport.getMethod("Greeter", "greet")) + assertEquals(HttpMethod.Post, transport.getMethod("Greeter", "greet")) assertEquals("/greet/{id}", transport.getPath("Greeter", "greet")) assertEquals( - HttpTransportConfiguration.TransportMode.Path, + TransportMode.Path, transport.getTransportMode("Greeter", "greet", "id") ) assertEquals( - HttpTransportConfiguration.TransportMode.Header, + TransportMode.Header, transport.getTransportMode("Greeter", "greet", "name") ) assertEquals( - HttpTransportConfiguration.TransportMode.Cookie, + TransportMode.Cookie, transport.getTransportMode("Greeter", "greet", "type") ) assertEquals( - HttpTransportConfiguration.TransportMode.Body, + TransportMode.Body, transport.getTransportMode("Greeter", "greet", "reference") ) - assertEquals(HttpTransportConfiguration.HttpMethod.Get, transport.getMethod("Greeter", "greetAll")) + assertEquals("/greeter", transport.getServicePath("Greeter")) + + assertEquals(HttpMethod.Get, transport.getMethod("Greeter", "greetAll")) assertEquals("/greet/all", transport.getPath("Greeter", "greetAll")) + assertEquals("/greeter/greet/all", transport.getFullPath("Greeter", "greetAll")) assertEquals( - HttpTransportConfiguration.TransportMode.QueryParameter, + TransportMode.QueryParameter, transport.getTransportMode("Greeter", "greetAll", "names") ) - assertEquals(HttpTransportConfiguration.HttpMethod.Get, transport.getMethod("Greeter", "get")) + assertEquals(HttpMethod.Get, transport.getMethod("Greeter", "get")) assertEquals("/", transport.getPath("Greeter", "get")) + assertEquals("/greeter/", transport.getFullPath("Greeter", "get")) assertEquals( - HttpTransportConfiguration.TransportMode.QueryParameter, + TransportMode.QueryParameter, transport.getTransportMode("Greeter", "get", "name") ) - 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(HttpMethod.Put, transport.getMethod("Greeter", "put")) + assertEquals(HttpMethod.Delete, transport.getMethod("Greeter", "delete")) + assertEquals(HttpMethod.Patch, transport.getMethod("Greeter", "patch")) + assertEquals(HttpMethod.Post, transport.getMethod("Greeter", "default")) assertEquals("/default", transport.getPath("Greeter", "default")) + assertEquals("/greeter/default", transport.getFullPath("Greeter", "default")) } @Test diff --git a/public-api/src/main/kotlin/tools/samt/api/transports/http/HttpMethod.kt b/public-api/src/main/kotlin/tools/samt/api/transports/http/HttpMethod.kt new file mode 100644 index 00000000..ba56f31a --- /dev/null +++ b/public-api/src/main/kotlin/tools/samt/api/transports/http/HttpMethod.kt @@ -0,0 +1,19 @@ +package tools.samt.api.transports.http + +/** HTTP Method is used for a given operation */ +enum class HttpMethod { + /** HTTP GET */ + Get, + + /** HTTP POST */ + Post, + + /** HTTP PUT */ + Put, + + /** HTTP DELETE */ + Delete, + + /** HTTP PATCH */ + Patch, +} diff --git a/public-api/src/main/kotlin/tools/samt/api/transports/http/SamtHttpTransport.kt b/public-api/src/main/kotlin/tools/samt/api/transports/http/SamtHttpTransport.kt new file mode 100644 index 00000000..67133885 --- /dev/null +++ b/public-api/src/main/kotlin/tools/samt/api/transports/http/SamtHttpTransport.kt @@ -0,0 +1,38 @@ +package tools.samt.api.transports.http + +import tools.samt.api.plugin.TransportConfiguration + +/** + * A transport configuration for HTTP-based services. + */ +interface SamtHttpTransport : TransportConfiguration { + + val serializationMode: SerializationMode + + /** + * Get the HTTP method for the given service and operation. + */ + fun getMethod(serviceName: String, operationName: String): HttpMethod + + /** + * Get the full path for the given service and operation, essentially joining [getServicePath] and [getPath] with a slash. + */ + fun getFullPath(serviceName: String, operationName: String): String + + /** + * Get the path for the given service and operation. + * The path might contain URL parameters, which are surrounded by curly braces (e.g. /person/{personId}). + */ + fun getPath(serviceName: String, operationName: String): String + + /** + * Get the base path for the given service. + */ + fun getServicePath(serviceName: String): String + + /** + * Get the transport mode for the given parameter. + * Defaults to [TransportMode.Body] for POST operations and [TransportMode.QueryParameter] for GET operations. + */ + fun getTransportMode(serviceName: String, operationName: String, parameterName: String): TransportMode +} diff --git a/public-api/src/main/kotlin/tools/samt/api/transports/http/SerializationMode.kt b/public-api/src/main/kotlin/tools/samt/api/transports/http/SerializationMode.kt new file mode 100644 index 00000000..6d3422d3 --- /dev/null +++ b/public-api/src/main/kotlin/tools/samt/api/transports/http/SerializationMode.kt @@ -0,0 +1,9 @@ +package tools.samt.api.transports.http + +/** How a given parameter is serialized over HTTP */ +enum class SerializationMode { + /** serialized as JSON */ + Json { + override fun toString() = "application/json" + }, +} diff --git a/public-api/src/main/kotlin/tools/samt/api/transports/http/TransportMode.kt b/public-api/src/main/kotlin/tools/samt/api/transports/http/TransportMode.kt new file mode 100644 index 00000000..e48e50fc --- /dev/null +++ b/public-api/src/main/kotlin/tools/samt/api/transports/http/TransportMode.kt @@ -0,0 +1,19 @@ +package tools.samt.api.transports.http + +/** How a given parameter is transported over HTTP */ +enum class TransportMode { + /** encoded in request body via serializationMode */ + Body, + + /** encoded as url query parameter */ + QueryParameter, + + /** encoded as part of url path */ + Path, + + /** encoded as HTTP header */ + Header, + + /** encoded as HTTP cookie */ + Cookie, +} From b7dec4aa3889898a6f5fd78291ed9db476b49b0a Mon Sep 17 00:00:00 2001 From: Pascal Honegger Date: Sat, 3 Jun 2023 20:05:04 +0200 Subject: [PATCH 4/5] feat(api): make published APIs easier to call from Java --- .../src/main/kotlin/tools/samt/lexer/Lexer.kt | 3 +- .../main/kotlin/tools/samt/lexer/Tokens.kt | 85 ++++++++++--------- .../main/kotlin/tools/samt/parser/Nodes.kt | 6 +- .../main/kotlin/tools/samt/parser/Parser.kt | 7 +- .../tools/samt/semantic/ConstraintBuilder.kt | 4 +- .../tools/samt/semantic/SemanticModel.kt | 1 + .../SemanticModelAnnotationProcessor.kt | 14 ++- .../semantic/SemanticModelPostProcessor.kt | 2 + .../tools/samt/semantic/UserMetadata.kt | 5 +- .../tools/samt/config/SamtConfiguration.kt | 2 +- .../samt/config/SamtConfigurationParser.kt | 2 + .../samt/config/SamtLinterConfiguration.kt | 16 ++-- 12 files changed, 91 insertions(+), 56 deletions(-) diff --git a/compiler/src/main/kotlin/tools/samt/lexer/Lexer.kt b/compiler/src/main/kotlin/tools/samt/lexer/Lexer.kt index bb8b7e2d..e65814a2 100644 --- a/compiler/src/main/kotlin/tools/samt/lexer/Lexer.kt +++ b/compiler/src/main/kotlin/tools/samt/lexer/Lexer.kt @@ -410,7 +410,7 @@ class Lexer private constructor( } companion object { - val KEYWORDS: Map StaticToken> = mapOf( + private val KEYWORDS: Map StaticToken> = mapOf( "record" to { RecordToken(it) }, "enum" to { EnumToken(it) }, "service" to { ServiceToken(it) }, @@ -431,6 +431,7 @@ class Lexer private constructor( "false" to { FalseToken(it) }, ) + @JvmStatic fun scan(reader: Reader, diagnostics: DiagnosticContext): Sequence { return Lexer(reader, diagnostics).readTokenStream() } diff --git a/compiler/src/main/kotlin/tools/samt/lexer/Tokens.kt b/compiler/src/main/kotlin/tools/samt/lexer/Tokens.kt index 5ac6145f..8c5b37ee 100644 --- a/compiler/src/main/kotlin/tools/samt/lexer/Tokens.kt +++ b/compiler/src/main/kotlin/tools/samt/lexer/Tokens.kt @@ -7,58 +7,61 @@ sealed interface Token { val location: Location } -data class EndOfFileToken(override val location: Location): Token +data class EndOfFileToken(override val location: Location) : Token -sealed interface ValueToken: Token +sealed interface ValueToken : Token data class StringToken(override val location: Location, val value: String) : ValueToken data class IdentifierToken(override val location: Location, val value: String) : ValueToken -sealed interface NumberToken : ValueToken { val value: Number } +sealed interface NumberToken : ValueToken { + val value: Number +} + data class IntegerToken(override val location: Location, override val value: Long) : NumberToken data class FloatToken(override val location: Location, override val value: Double) : NumberToken -sealed interface StaticToken: Token -data class RecordToken(override val location: Location): StaticToken -data class EnumToken(override val location: Location): StaticToken -data class ServiceToken(override val location: Location): StaticToken -data class TypealiasToken(override val location: Location): StaticToken -data class PackageToken(override val location: Location): StaticToken -data class ImportToken(override val location: Location): StaticToken -data class ProvideToken(override val location: Location): StaticToken -data class ConsumeToken(override val location: Location): StaticToken -data class TransportToken(override val location: Location): StaticToken -data class ImplementsToken(override val location: Location): StaticToken -data class UsesToken(override val location: Location): StaticToken -data class ExtendsToken(override val location: Location): StaticToken -data class AsToken(override val location: Location): StaticToken -data class AsyncToken(override val location: Location): StaticToken -data class OnewayToken(override val location: Location): StaticToken -data class RaisesToken(override val location: Location): StaticToken -data class TrueToken(override val location: Location): StaticToken -data class FalseToken(override val location: Location): StaticToken +sealed interface StaticToken : Token +data class RecordToken(override val location: Location) : StaticToken +data class EnumToken(override val location: Location) : StaticToken +data class ServiceToken(override val location: Location) : StaticToken +data class TypealiasToken(override val location: Location) : StaticToken +data class PackageToken(override val location: Location) : StaticToken +data class ImportToken(override val location: Location) : StaticToken +data class ProvideToken(override val location: Location) : StaticToken +data class ConsumeToken(override val location: Location) : StaticToken +data class TransportToken(override val location: Location) : StaticToken +data class ImplementsToken(override val location: Location) : StaticToken +data class UsesToken(override val location: Location) : StaticToken +data class ExtendsToken(override val location: Location) : StaticToken +data class AsToken(override val location: Location) : StaticToken +data class AsyncToken(override val location: Location) : StaticToken +data class OnewayToken(override val location: Location) : StaticToken +data class RaisesToken(override val location: Location) : StaticToken +data class TrueToken(override val location: Location) : StaticToken +data class FalseToken(override val location: Location) : StaticToken -sealed interface StructureToken: Token -data class OpenBraceToken(override val location: Location): StructureToken -data class CloseBraceToken(override val location: Location): StructureToken -data class OpenBracketToken(override val location: Location): StructureToken -data class CloseBracketToken(override val location: Location): StructureToken -data class OpenParenthesisToken(override val location: Location): StructureToken -data class CloseParenthesisToken(override val location: Location): StructureToken -data class CommaToken(override val location: Location): StructureToken -data class PeriodToken(override val location: Location): StructureToken -data class DoublePeriodToken(override val location: Location): StructureToken -data class ColonToken(override val location: Location): StructureToken -data class AsteriskToken(override val location: Location): StructureToken -data class AtSignToken(override val location: Location): StructureToken -data class QuestionMarkToken(override val location: Location): StructureToken -data class EqualsToken(override val location: Location): StructureToken -data class LessThanSignToken(override val location: Location): StructureToken -data class GreaterThanSignToken(override val location: Location): StructureToken -data class ForwardSlashToken(override val location: Location): StructureToken +sealed interface StructureToken : Token +data class OpenBraceToken(override val location: Location) : StructureToken +data class CloseBraceToken(override val location: Location) : StructureToken +data class OpenBracketToken(override val location: Location) : StructureToken +data class CloseBracketToken(override val location: Location) : StructureToken +data class OpenParenthesisToken(override val location: Location) : StructureToken +data class CloseParenthesisToken(override val location: Location) : StructureToken +data class CommaToken(override val location: Location) : StructureToken +data class PeriodToken(override val location: Location) : StructureToken +data class DoublePeriodToken(override val location: Location) : StructureToken +data class ColonToken(override val location: Location) : StructureToken +data class AsteriskToken(override val location: Location) : StructureToken +data class AtSignToken(override val location: Location) : StructureToken +data class QuestionMarkToken(override val location: Location) : StructureToken +data class EqualsToken(override val location: Location) : StructureToken +data class LessThanSignToken(override val location: Location) : StructureToken +data class GreaterThanSignToken(override val location: Location) : StructureToken +data class ForwardSlashToken(override val location: Location) : StructureToken inline fun getHumanReadableName() = getHumanReadableTokenName(T::class) -fun Token.getHumanReadableName() = when(this) { +fun Token.getHumanReadableName() = when (this) { is NumberToken -> value.toString() is IdentifierToken -> value is StringToken -> "\"$value\"" diff --git a/compiler/src/main/kotlin/tools/samt/parser/Nodes.kt b/compiler/src/main/kotlin/tools/samt/parser/Nodes.kt index 18bbbc9c..b0f4dcb2 100644 --- a/compiler/src/main/kotlin/tools/samt/parser/Nodes.kt +++ b/compiler/src/main/kotlin/tools/samt/parser/Nodes.kt @@ -6,7 +6,11 @@ sealed interface Node { val location: Location } -inline fun Node.report(controller: DiagnosticController, severity: DiagnosticSeverity, block: DiagnosticMessageBuilder.() -> Unit) { +inline fun Node.report( + controller: DiagnosticController, + severity: DiagnosticSeverity, + block: DiagnosticMessageBuilder.() -> Unit, +) { controller.getOrCreateContext(location.source).report(severity, block) } diff --git a/compiler/src/main/kotlin/tools/samt/parser/Parser.kt b/compiler/src/main/kotlin/tools/samt/parser/Parser.kt index 30300e80..790e3675 100644 --- a/compiler/src/main/kotlin/tools/samt/parser/Parser.kt +++ b/compiler/src/main/kotlin/tools/samt/parser/Parser.kt @@ -324,7 +324,11 @@ class Parser private constructor( diagnostic.error { message("Too many transport declarations for provider '${name.name}'") highlight("extraneous declaration", transport!!.location, highlightBeginningOnly = true) - highlight("previous declaration", previousDeclaration.location, highlightBeginningOnly = true) + highlight( + "previous declaration", + previousDeclaration.location, + highlightBeginningOnly = true + ) } } @@ -654,6 +658,7 @@ class Parser private constructor( private fun locationFromStart(start: FileOffset) = Location(diagnostic.source, start, previousEnd) companion object { + @JvmStatic fun parse(source: SourceFile, tokenStream: Sequence, context: DiagnosticContext): FileNode { return Parser(source, tokenStream, context).parseFile() } diff --git a/compiler/src/main/kotlin/tools/samt/semantic/ConstraintBuilder.kt b/compiler/src/main/kotlin/tools/samt/semantic/ConstraintBuilder.kt index 0bda554c..525de0d9 100644 --- a/compiler/src/main/kotlin/tools/samt/semantic/ConstraintBuilder.kt +++ b/compiler/src/main/kotlin/tools/samt/semantic/ConstraintBuilder.kt @@ -101,7 +101,9 @@ internal class ConstraintBuilder(private val controller: DiagnosticController) { ): ResolvedTypeReference.Constraint.Pattern? { val pattern = argument.value - try { Regex(pattern) } catch (e: Exception) { + try { + Regex(pattern) + } catch (e: Exception) { argument.reportError(controller) { message("Invalid regex pattern: '${e.message}'") highlight(argument.location) diff --git a/compiler/src/main/kotlin/tools/samt/semantic/SemanticModel.kt b/compiler/src/main/kotlin/tools/samt/semantic/SemanticModel.kt index 806ca2f1..144fdcf2 100644 --- a/compiler/src/main/kotlin/tools/samt/semantic/SemanticModel.kt +++ b/compiler/src/main/kotlin/tools/samt/semantic/SemanticModel.kt @@ -9,6 +9,7 @@ class SemanticModel( val userMetadata: UserMetadata, ) { companion object { + @JvmStatic fun build(files: List, controller: DiagnosticController): SemanticModel { // Sort by path to ensure deterministic order return SemanticModelBuilder(files.sortedBy { it.sourceFile.path }, controller).build() diff --git a/compiler/src/main/kotlin/tools/samt/semantic/SemanticModelAnnotationProcessor.kt b/compiler/src/main/kotlin/tools/samt/semantic/SemanticModelAnnotationProcessor.kt index cec0dc0a..9af1f379 100644 --- a/compiler/src/main/kotlin/tools/samt/semantic/SemanticModelAnnotationProcessor.kt +++ b/compiler/src/main/kotlin/tools/samt/semantic/SemanticModelAnnotationProcessor.kt @@ -6,7 +6,7 @@ import tools.samt.parser.StringNode import tools.samt.parser.reportError internal class SemanticModelAnnotationProcessor( - private val controller: DiagnosticController + private val controller: DiagnosticController, ) { fun process(global: Package): UserMetadata { val descriptions = mutableMapOf() @@ -19,21 +19,29 @@ internal class SemanticModelAnnotationProcessor( annotation.reportError(controller) { message("Duplicate @Description annotation") highlight("duplicate annotation", annotation.location) - highlight("previous annotation", element.annotations.first { it.name.name == "Description" }.location) + highlight( + "previous annotation", + element.annotations.first { it.name.name == "Description" }.location + ) } } descriptions[element] = getDescription(annotation) } + "Deprecated" -> { if (element in deprecations) { annotation.reportError(controller) { message("Duplicate @Deprecated annotation") highlight("duplicate annotation", annotation.location) - highlight("previous annotation", element.annotations.first { it.name.name == "Deprecated" }.location) + highlight( + "previous annotation", + element.annotations.first { it.name.name == "Deprecated" }.location + ) } } deprecations[element] = getDeprecation(annotation) } + else -> { annotation.reportError(controller) { message("Unknown annotation @${name}, allowed annotations are @Description and @Deprecated") diff --git a/compiler/src/main/kotlin/tools/samt/semantic/SemanticModelPostProcessor.kt b/compiler/src/main/kotlin/tools/samt/semantic/SemanticModelPostProcessor.kt index 5749f57c..06ab6e67 100644 --- a/compiler/src/main/kotlin/tools/samt/semantic/SemanticModelPostProcessor.kt +++ b/compiler/src/main/kotlin/tools/samt/semantic/SemanticModelPostProcessor.kt @@ -201,6 +201,7 @@ internal class SemanticModelPostProcessor(private val controller: DiagnosticCont record = type newVisited = visited + record } + is AliasType -> { val reference = type.fullyResolvedType ?: return val actualType = reference.type @@ -211,6 +212,7 @@ internal class SemanticModelPostProcessor(private val controller: DiagnosticCont newVisited = visited + listOf(type, actualType) isOptional = isOptional || reference.isOptional } + else -> return } diff --git a/compiler/src/main/kotlin/tools/samt/semantic/UserMetadata.kt b/compiler/src/main/kotlin/tools/samt/semantic/UserMetadata.kt index 5da96d15..4d7a5a79 100644 --- a/compiler/src/main/kotlin/tools/samt/semantic/UserMetadata.kt +++ b/compiler/src/main/kotlin/tools/samt/semantic/UserMetadata.kt @@ -1,6 +1,9 @@ package tools.samt.semantic -class UserMetadata(private val descriptions: Map, private val deprecations: Map) { +class UserMetadata( + private val descriptions: Map, + private val deprecations: Map, +) { data class Deprecation(val message: String?) fun getDescription(element: UserDeclared): String? = descriptions[element] diff --git a/samt-config/src/main/kotlin/tools/samt/config/SamtConfiguration.kt b/samt-config/src/main/kotlin/tools/samt/config/SamtConfiguration.kt index cb2d1720..25b77682 100644 --- a/samt-config/src/main/kotlin/tools/samt/config/SamtConfiguration.kt +++ b/samt-config/src/main/kotlin/tools/samt/config/SamtConfiguration.kt @@ -13,7 +13,7 @@ internal data class SamtConfiguration( @Serializable internal data class SamtRepositoriesConfiguration( - val maven: String = "https://repo.maven.apache.org/maven2" + val maven: String = "https://repo.maven.apache.org/maven2", ) @Serializable 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 fcba4c7b..ad138429 100644 --- a/samt-config/src/main/kotlin/tools/samt/config/SamtConfigurationParser.kt +++ b/samt-config/src/main/kotlin/tools/samt/config/SamtConfigurationParser.kt @@ -27,6 +27,7 @@ object SamtConfigurationParser { class ParseException(exception: Throwable) : RuntimeException(exception.message, exception) + @JvmStatic fun parseConfiguration(path: Path): CommonSamtConfiguration { val parsedConfiguration: SamtConfiguration = if (path.exists()) { try { @@ -74,6 +75,7 @@ object SamtConfigurationParser { ) } + @JvmStatic fun parseLinterConfiguration(path: Path): CommonLinterConfiguration { val parsedLinterConfiguration: SamtLinterConfiguration = if (path.exists()) { yaml.decodeFromStream(path.inputStream()) diff --git a/samt-config/src/main/kotlin/tools/samt/config/SamtLinterConfiguration.kt b/samt-config/src/main/kotlin/tools/samt/config/SamtLinterConfiguration.kt index 5021dea9..57b96db6 100644 --- a/samt-config/src/main/kotlin/tools/samt/config/SamtLinterConfiguration.kt +++ b/samt-config/src/main/kotlin/tools/samt/config/SamtLinterConfiguration.kt @@ -4,12 +4,12 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable -data class SamtLinterConfiguration( +internal data class SamtLinterConfiguration( val extends: String = "recommended", val rules: List = emptyList(), ) -enum class DiagnosticSeverity { +internal enum class DiagnosticSeverity { @SerialName("error") Error, @@ -24,19 +24,19 @@ enum class DiagnosticSeverity { } @Serializable -sealed interface SamtRuleConfiguration { +internal sealed interface SamtRuleConfiguration { val level: DiagnosticSeverity? } @Serializable @SerialName("split-model-and-providers") -data class SplitModelAndProvidersConfiguration( +internal data class SplitModelAndProvidersConfiguration( override val level: DiagnosticSeverity? = null, ) : SamtRuleConfiguration @Serializable @SerialName("naming-conventions") -data class NamingConventionsConfiguration( +internal data class NamingConventionsConfiguration( override val level: DiagnosticSeverity? = null, val record: NamingConventions? = null, val recordField: NamingConventions? = null, @@ -51,15 +51,19 @@ data class NamingConventionsConfiguration( val samtPackage: NamingConventions? = null, val fileName: NamingConventions? = null, ) : SamtRuleConfiguration { - enum class NamingConventions { + internal enum class NamingConventions { @SerialName("PascalCase") PascalCase, + @SerialName("camelCase") CamelCase, + @SerialName("snake_case") SnakeCase, + @SerialName("kebab-case") KebabCase, + @SerialName("SCREAMING_SNAKE_CASE") ScreamingSnakeCase, } From ab981622a8f552e6104e0b83690120c138977c21 Mon Sep 17 00:00:00 2001 From: Pascal Honegger Date: Sat, 3 Jun 2023 20:22:55 +0200 Subject: [PATCH 5/5] feat(semantic): limit size to positive numbers --- .../client/generated/greeter/KtorMappings.kt | 4 +- .../server/generated/greeter/KtorMappings.kt | 4 +- .../tools/samt/semantic/ConstraintBuilder.kt | 39 ++++++++++++++++--- .../main/kotlin/tools/samt/semantic/Types.kt | 2 +- .../kotlin/tools/samt/parser/ParserTest.kt | 4 +- .../tools/samt/semantic/SemanticModelTest.kt | 10 ++++- .../examples/todo-service/todo-service.samt | 4 +- 7 files changed, 50 insertions(+), 17 deletions(-) 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 index abb943d1..f6c75715 100644 --- 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 @@ -15,7 +15,7 @@ fun `encode Greeting`(record: tools.samt.client.generated.greeter.Greeting): Jso // Encode field message val `field message` = run { val value = record.message - JsonPrimitive(value.also { require(it.length >= 0 && it.length <= 128) }) + JsonPrimitive(value.also { require(it.length <= 128) }) } // Create JSON for tools.samt.greeter.Greeting return buildJsonObject { @@ -27,7 +27,7 @@ fun `decode Greeting`(json: JsonElement): tools.samt.client.generated.greeter.Gr // Decode field message val `field message` = run { val jsonElement = json.jsonObject["message"]!! - jsonElement.jsonPrimitive.content.also { require(it.length >= 0 && it.length <= 128) } + jsonElement.jsonPrimitive.content.also { require(it.length <= 128) } } // Create record tools.samt.greeter.Greeting return tools.samt.client.generated.greeter.Greeting( 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 index 1ae105d4..97b53e06 100644 --- 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 @@ -15,7 +15,7 @@ fun `encode Greeting`(record: tools.samt.server.generated.greeter.Greeting): Jso // Encode field message val `field message` = run { val value = record.message - JsonPrimitive(value.also { require(it.length >= 0 && it.length <= 128) }) + JsonPrimitive(value.also { require(it.length <= 128) }) } // Create JSON for tools.samt.greeter.Greeting return buildJsonObject { @@ -27,7 +27,7 @@ fun `decode Greeting`(json: JsonElement): tools.samt.server.generated.greeter.Gr // Decode field message val `field message` = run { val jsonElement = json.jsonObject["message"]!! - jsonElement.jsonPrimitive.content.also { require(it.length >= 0 && it.length <= 128) } + jsonElement.jsonPrimitive.content.also { require(it.length <= 128) } } // Create record tools.samt.greeter.Greeting return tools.samt.server.generated.greeter.Greeting( diff --git a/compiler/src/main/kotlin/tools/samt/semantic/ConstraintBuilder.kt b/compiler/src/main/kotlin/tools/samt/semantic/ConstraintBuilder.kt index 525de0d9..77e83f18 100644 --- a/compiler/src/main/kotlin/tools/samt/semantic/ConstraintBuilder.kt +++ b/compiler/src/main/kotlin/tools/samt/semantic/ConstraintBuilder.kt @@ -60,22 +60,33 @@ internal class ConstraintBuilder(private val controller: DiagnosticController) { else -> { expressionNode.reportError(controller) { - message("Expected size constraint argument to be a whole number or wildcard") + message("Expected size constraint argument to be a positive 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)") + help("A valid constraint would be size(1..10), size(1..*) or size(0..10)") } null } } - val lower = resolveSide(argument.left) + val lower = when (val lowerRaw = resolveSide(argument.left)) { + null -> { + argument.left.reportWarning(controller) { + message("Size constraint lower bound should be '0' instead of '*' to avoid confusion") + highlight("dubious wildcard", argument.left.location, suggestChange = "0") + } + null + } + + 0L -> null + else -> lowerRaw + } val higher = resolveSide(argument.right) if (lower == null && higher == null) { - argument.reportError(controller) { - message("Constraint parameters cannot both be wildcards") + argument.reportWarning(controller) { + message("Constraint has no effect as it has no valid number, ignoring") highlight("invalid constraint", argument.location) - help("A valid constraint would be range(1..10.5) or range(1..*)") + help("A valid constraint would be size(1..10), size(1..*) or size(0..10)") } return null } @@ -88,6 +99,22 @@ internal class ConstraintBuilder(private val controller: DiagnosticController) { return null } + if (lower != null && lower < 0) { + argument.left.reportError(controller) { + message("Size constraint lower bound must be greater than or equal to 0") + highlight("invalid constraint", argument.location) + } + return null + } + + if (higher != null && higher < 0) { + argument.right.reportError(controller) { + message("Size constraint upper bound must be greater than or equal to 0") + highlight("invalid constraint", argument.location) + } + return null + } + return ResolvedTypeReference.Constraint.Size( node = expression, lowerBound = lower, diff --git a/compiler/src/main/kotlin/tools/samt/semantic/Types.kt b/compiler/src/main/kotlin/tools/samt/semantic/Types.kt index 5cbae41b..4fb0adda 100644 --- a/compiler/src/main/kotlin/tools/samt/semantic/Types.kt +++ b/compiler/src/main/kotlin/tools/samt/semantic/Types.kt @@ -289,7 +289,7 @@ data class ResolvedTypeReference( val upperBound: Long?, ) : Constraint { override val humanReadableName: String - get() = "size(${lowerBound ?: '*'}..${upperBound ?: '*'})" + get() = "size(${lowerBound ?: '0'}..${upperBound ?: '*'})" } data class Pattern( diff --git a/compiler/src/test/kotlin/tools/samt/parser/ParserTest.kt b/compiler/src/test/kotlin/tools/samt/parser/ParserTest.kt index 4f261ccc..7dddbfc1 100644 --- a/compiler/src/test/kotlin/tools/samt/parser/ParserTest.kt +++ b/compiler/src/test/kotlin/tools/samt/parser/ParserTest.kt @@ -170,7 +170,7 @@ class ParserTest { record Person { password: String ( size(16..100) ) - name: String ( size(*..256), pattern("A-Za-z", true) )? + name: String ( size(0..256), pattern("A-Za-z", true) )? age: Integer? ( range(18..*) ) } """ @@ -194,7 +194,7 @@ class ParserTest { callExpression({ bundleIdentifier("String") }) { callExpression({ bundleIdentifier("size") }) { rangeExpression( - { wildcard() }, + { integer(0) }, { integer(256) } ) } diff --git a/compiler/src/test/kotlin/tools/samt/semantic/SemanticModelTest.kt b/compiler/src/test/kotlin/tools/samt/semantic/SemanticModelTest.kt index 88209ba0..09a61998 100644 --- a/compiler/src/test/kotlin/tools/samt/semantic/SemanticModelTest.kt +++ b/compiler/src/test/kotlin/tools/samt/semantic/SemanticModelTest.kt @@ -319,7 +319,10 @@ class SemanticModelTest { package complex record Complex { - int: List (size(1..5.5)) + list1: List (size(1..5.5)) + list2: List (size(-10..*)) + list3: List (size(0..-10)) + list4: List (size(*..100)) } service Foo { @@ -328,7 +331,10 @@ class SemanticModelTest { """.trimIndent() parseAndCheck( source to listOf( - "Error: Expected size constraint argument to be a whole number or wildcard", + "Error: Expected size constraint argument to be a positive whole number or wildcard", + "Error: Size constraint lower bound must be greater than or equal to 0", + "Error: Size constraint upper bound must be greater than or equal to 0", + "Warning: Size constraint lower bound should be '0' instead of '*' to avoid confusion", "Error: Size constraint lower bound must be lower than or equal to the upper bound", ) ) diff --git a/specification/examples/todo-service/todo-service.samt b/specification/examples/todo-service/todo-service.samt index 0a50cd25..631bdec3 100644 --- a/specification/examples/todo-service/todo-service.samt +++ b/specification/examples/todo-service/todo-service.samt @@ -7,7 +7,7 @@ package tools.samt.examples.todo record TodoItem { id: UUID title: String ( size(1..*) ) - description: String ( size(*..1000) ) + description: String ( size(0..1000) ) completed: Boolean } @@ -26,7 +26,7 @@ service TodoManager { updateTodo( id: UUID, newTitle: String? ( size(1..*) ), - newDescription: String? ( size(*..1000) ) + newDescription: String? ( size(0..1000) ) ) raises NotFoundFault, MissingPermissionsFault deleteTodo(id: UUID) raises NotFoundFault, MissingPermissionsFault markAsCompleted(id: UUID) raises NotFoundFault, MissingPermissionsFault