diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2034045 --- /dev/null +++ b/.gitignore @@ -0,0 +1,62 @@ +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf +.idea/**/artifacts + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ +build/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser +/store-api/application.pid + +.gradle/ +logs +cache +gradle-local.properties +local.env +.idea/codeStyles \ No newline at end of file diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/.name b/.idea/.name new file mode 100644 index 0000000..78e4519 --- /dev/null +++ b/.idea/.name @@ -0,0 +1 @@ +ktor-gradle-template \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml new file mode 100644 index 0000000..61a9130 --- /dev/null +++ b/.idea/compiler.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/jarRepositories.xml b/.idea/jarRepositories.xml new file mode 100644 index 0000000..fce7e59 --- /dev/null +++ b/.idea/jarRepositories.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..2266f6b --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.idea/runConfigurations/run_Service.xml b/.idea/runConfigurations/run_Service.xml new file mode 100644 index 0000000..c792a0a --- /dev/null +++ b/.idea/runConfigurations/run_Service.xml @@ -0,0 +1,26 @@ + + + + + + + \ No newline at end of file diff --git a/.mia-template/README.md b/.mia-template/README.md new file mode 100644 index 0000000..a408bab --- /dev/null +++ b/.mia-template/README.md @@ -0,0 +1,30 @@ +# Tag Project + +## Run script + +### Tag new project version + +Note that this is a multi-module repository that means that the tag will be the same for all the modules. +It uses gradle, so we have implemented a gradle task to tag your version. + +You need to be in the root folder of the project and run the following command: +``` +./gradlew version --tag=[version] +``` + +### Push changes + +Don't forget to push commit and tag: + +```shell +git push +git push --tags +``` + +### Examples + +Assuming your current version is `1.2.3` + +|command | result | +|---|---| +|`./gradlew version --tag=1.2.3` |`v1.2.3` | \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..5d044c4 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## Unreleased + +* [TASK-ID](https://makeitapp.atlassian.net/browse/task-id): description... diff --git a/README.md b/README.md new file mode 100644 index 0000000..c14fb7e --- /dev/null +++ b/README.md @@ -0,0 +1,54 @@ +# Ktor Template walkthrough + +[![Build Status][github-actions-svg]][github-actions] + +This walkthrough will explain you how to correctly create a microservice from a Ktor Template using our DevOps Console. + +## Create a microservice + +In order to do so, access to [Mia-Platform DevOps Console](https://console.cloud.mia-platform.eu/login), create a new project and go to the **Design** area. +From the Design area of your project select _Microservices_ and then create a new one, you have now reached [Mia-Platform Marketplace](https://docs.mia-platform.eu/development_suite/api-console/api-design/marketplace/)! +In the marketplace you will see a set of Examples and Templates that can be used to set-up microservices with a predefined and tested function. + +For this walkthrough select the following example: **Ktor Template**. +Give your microservice the name you prefer, in this walkthrough we'll refer to it with the following name: **my-ktor-service-name**. Then, fill the other required fields and confirm that you want to create a microservice. +A more detailed description on how to create a Microservice can be found in [Microservice from template - Get started](https://docs.mia-platform.eu/development_suite/api-console/api-design/custom_microservice_get_started/#2-service-creation) section of Mia-Platform documentation. + +## Remove status probes + +In order to run this example correctly, it is necessary to remove the default probes of your microservice. To do so, go to the table *Microservice configuration* of the newly created microservice *my-ktor-service-name* in the section *Probes*. Once here, delete both the default readiness and liveness paths. + +## Expose an endpoint to your microservice + +In order to access to your new microservice it is necessary to create an endpoint that targets it. +In particular, in this walkthrough you will create an endpoint to your microservice *my-ktor-service-name*. To do so, from the Design area of your project select _Endpoints_ and then create a new endpoint. +Now you need to choose a path for your endpoint and to connect this endpoint to your microservice. Give to your endpoint the following path: **/ktor-template**. Then, specify that you want to connect your endpoint to a microservice and, finally, select *my-ktor-service-name*. +Step 3 of [Microservice from template - Get started](https://docs.mia-platform.eu/development_suite/api-console/api-design/custom_microservice_get_started/#3-creating-the-endpoint) section of Mia-Platform documentation will explain in detail how to create an endpoint from the DevOps Console. + +## Save your changes + +After having created an endpoint to your microservice you should save the changes that you have done to your project in the DevOps console. +Remember to choose a meaningful title for your commit (e.g "created service my_ktor_service_name"). After some seconds you will be prompted with a popup message which confirms that you have successfully saved all your changes. +Step 4 of [Microservice from template - Get started](https://docs.mia-platform.eu/development_suite/api-console/api-design/custom_microservice_get_started/#4-save-the-project) section of Mia-Platform documentation will explain how to correctly save the changes you have made on your project in the DevOps console. + +## Deploy + +Once all the changes that you have made are saved, you should deploy your project through the DevOps Console. Go to the **Deploy** area of the DevOps Console. +Once here select the environment and the branch you have worked on and confirm your choices clicking on the *deploy* button. When the deploy process is finished you will receveive a pop-up message that will inform you. +Step 5 of [Microservice from template - Get started](https://docs.mia-platform.eu/development_suite/api-console/api-design/custom_microservice_get_started/#5-deploy-the-project-through-the-api-console) section of Mia-Platform documentation will explain in detail how to correctly deploy your project. + +## Try it + +Now, if you launch the following command on your terminal (remember to replace `` with the real host of your project): + +```shell +curl /hello +``` + +you should see the following message: + +```json +{"helloWorld":"Hello world!"} +``` + +Congratulations! You have successfully learnt how to use our Ktor multi module _Hello World_ Example on the DevOps Console! \ No newline at end of file diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..1567c89 --- /dev/null +++ b/build.gradle @@ -0,0 +1,90 @@ +plugins { + id 'java' + id 'org.jetbrains.kotlin.jvm' version '1.3.72' +} + +allprojects { + + group 'eu.miaplatform' + version '1.0.0' + + repositories { + mavenCentral() + jcenter() + maven { url 'https://www.jitpack.io' } + } + + + apply plugin: 'java' + apply plugin: 'kotlin' + apply plugin: 'org.jetbrains.kotlin.jvm' + + sourceCompatibility = 1.8 + + compileKotlin { + kotlinOptions.jvmTarget = "1.8" + } + compileTestKotlin { + kotlinOptions.jvmTarget = "1.8" + } + + dependencies { + compile "org.jetbrains.kotlin:kotlin-stdlib-jdk8" + + testCompile group: 'junit', name: 'junit', version: '4.12' + testCompile "org.junit.jupiter:junit-jupiter-api:5.4.2" + testCompile "org.junit.jupiter:junit-jupiter-params:5.4.2" + testCompile group: 'com.squareup.okhttp', name: 'mockwebserver', version: '2.7.5' + testCompile "io.ktor:ktor-server-tests:$ktor_version" + testCompile "com.squareup.retrofit2:retrofit-mock:2.2.0" + testCompile "org.powermock:powermock-module-junit4:2.0.2" + testCompile "org.powermock:powermock-api-mockito2:2.0.2" + testCompile 'org.mock-server:mockserver-netty:5.5.4' + } +} + +class UpdateVersion extends DefaultTask { + private String tag + + @Option(option = "tag", description = "Updates the project version with specified tag (when supplying the tag do not include the initial v).") + void setTag(String tag) { + this.tag = tag + } + + @Input + String getTag() { + return tag + } + + @TaskAction + void print() { + getLogger().quiet("Updating files to version $tag") + String oldTag = project.version + + getLogger().quiet("Updating build.gradle file") + String updatedBuildGradle = project.buildFile.getText().replaceFirst("version '$oldTag'", "version '$tag'") + project.buildFile.setText(updatedBuildGradle) + + getLogger().quiet("Updating Changelog and Dockerfile") + runCommand("./scripts/update-changelog.sh", tag) + + getLogger().quiet("Commit and tag creation on local repository") + String tagName = "v${tag}" + runCommand("git", "commit", "-a", "-m", tagName) + runCommand("git", "tag", tagName) + } + + static String runCommand(String... commands) { + Process process = new ProcessBuilder(commands).redirectErrorStream(true).start() + process.waitFor() + String result = '' + process.inputStream.eachLine { result += it + '\n' } + boolean error = process.exitValue() != 0 + if (error) { + println(result) + } + return result + } +} +//to execute the task: ./gradlew version --tag=[version] +task version(type: UpdateVersion) \ No newline at end of file diff --git a/commons/build.gradle b/commons/build.gradle new file mode 100644 index 0000000..93a9658 --- /dev/null +++ b/commons/build.gradle @@ -0,0 +1,44 @@ +dependencies { + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8" + + compile group: 'ch.qos.logback', name: 'logback-classic', version: '1.2.3' + compile group: 'net.logstash.logback', name: 'logstash-logback-encoder', version: '6.0' + + compile 'com.google.guava:guava:28.0-jre' + + compile "com.squareup.retrofit2:retrofit:2.8.1" + compile 'com.squareup.retrofit2:converter-jackson:2.8.1' + compile 'com.squareup.okhttp3:logging-interceptor:4.5.0' + + compile "io.ktor:ktor-server-netty:$ktor_version" + compile "io.ktor:ktor-server-core:$ktor_version" + compile "io.ktor:ktor-client-core-jvm:$ktor_version" + compile "io.ktor:ktor-jackson:$ktor_version" + + compile "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.1" + + compile 'com.fasterxml.jackson.core:jackson-databind:2.10.3' + compile 'com.fasterxml.jackson.module:jackson-module-kotlin:2.10.3' +} + +def generatedVersionDir = "${buildDir}/generated-version" + +sourceSets { + main { + output.dir(generatedVersionDir, builtBy: 'generateVersionProperties') + } +} + +task generateVersionProperties { + doLast { + def propsFile = file "$generatedVersionDir/version.properties" + propsFile.parentFile.mkdirs() + def props = new Properties() + print(rootProject.version.toString()) + props.setProperty("version", rootProject.version.toString()) + propsFile.withWriter { props.store(it, null) } + } +} +processResources.dependsOn generateVersionProperties +build.dependsOn generateVersionProperties + diff --git a/commons/src/main/kotlin/eu/miaplatform/commons/StatusService.kt b/commons/src/main/kotlin/eu/miaplatform/commons/StatusService.kt new file mode 100644 index 0000000..1f8b132 --- /dev/null +++ b/commons/src/main/kotlin/eu/miaplatform/commons/StatusService.kt @@ -0,0 +1,17 @@ +package eu.miaplatform.commons + +import java.util.* + +class StatusService { + private val versionProperties = Properties() + + init { + try { + versionProperties.load(this.javaClass.getResourceAsStream("/version.properties")) + } catch (e: Exception) {} + + } + + fun getVersion() : String = versionProperties.getProperty("version") ?: "no version" + +} \ No newline at end of file diff --git a/commons/src/main/kotlin/eu/miaplatform/commons/client/CrudClientInterface.kt b/commons/src/main/kotlin/eu/miaplatform/commons/client/CrudClientInterface.kt new file mode 100644 index 0000000..126306b --- /dev/null +++ b/commons/src/main/kotlin/eu/miaplatform/commons/client/CrudClientInterface.kt @@ -0,0 +1,10 @@ +package eu.miaplatform.commons.client + +import retrofit2.http.* + +interface CrudClientInterface { + + @GET("v2/books") + suspend fun getBooks(@HeaderMap headers: Map): List + +} \ No newline at end of file diff --git a/commons/src/main/kotlin/eu/miaplatform/commons/client/HeadersToProxy.kt b/commons/src/main/kotlin/eu/miaplatform/commons/client/HeadersToProxy.kt new file mode 100644 index 0000000..900b556 --- /dev/null +++ b/commons/src/main/kotlin/eu/miaplatform/commons/client/HeadersToProxy.kt @@ -0,0 +1,59 @@ +package eu.miaplatform.commons.client + +import io.ktor.application.* + +class HeadersToProxy( + private val additionalHeaderToProxy: String = "" +) { + private val requestIdHeaderKey = "x-request-id" + private val userIdHeaderKey = System.getenv("USERID_HEADER_KEY") ?: "miauserid" + private val groupsHeaderKey = System.getenv("GROUPS_HEADER_KEY") ?: "miausergroups" + private val clientTypeHeaderKey = System.getenv("CLIENTTYPE_HEADER_KEY") ?: "client-type" + private val backofficeHeaderKey = System.getenv("BACKOFFICE_HEADER_KEY") ?: "isbackoffice" + private val userPropertyHeaderKey = System.getenv("USER_PROPERTIES_HEADER_KEY") ?: "miauserproperties" + + fun proxy(applicationCall: ApplicationCall): Map { + + val requestIdHeader = applicationCall.request.headers[requestIdHeaderKey] + val miaUserIdHeader = applicationCall.request.headers[userIdHeaderKey] + val groupsHeader = applicationCall.request.headers[groupsHeaderKey] + val clientTypeHeader = applicationCall.request.headers[clientTypeHeaderKey] + val backofficeHeader = applicationCall.request.headers[backofficeHeaderKey] + val userPropertiesHeaderKey = applicationCall.request.headers[userPropertyHeaderKey] + + val headers = mutableMapOf() + + requestIdHeader?.let { headerValue -> + headers[requestIdHeaderKey] = headerValue + } + + miaUserIdHeader?.let { headerValue -> + headers[userIdHeaderKey] = headerValue + } + + groupsHeader?.let { headerValue -> + headers[groupsHeaderKey] = headerValue + } + + clientTypeHeader?.let { headerValue -> + headers[clientTypeHeaderKey] = headerValue + } + + backofficeHeader?.let { headerValue -> + headers[backofficeHeaderKey] = headerValue + } + + userPropertiesHeaderKey?.let { headerValue -> + headers[userPropertyHeaderKey] = headerValue + } + + additionalHeaderToProxy.split(",").forEach { key -> + val headerValue = applicationCall.request.headers[key] + headerValue?.let { header -> + headers[key] = header + } + } + + return headers + } +} \ No newline at end of file diff --git a/commons/src/main/kotlin/eu/miaplatform/commons/client/RetrofitClient.kt b/commons/src/main/kotlin/eu/miaplatform/commons/client/RetrofitClient.kt new file mode 100644 index 0000000..bcc7385 --- /dev/null +++ b/commons/src/main/kotlin/eu/miaplatform/commons/client/RetrofitClient.kt @@ -0,0 +1,41 @@ +package eu.miaplatform.commons.client + +import com.fasterxml.jackson.annotation.JsonInclude +import com.fasterxml.jackson.databind.ObjectMapper +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Retrofit +import retrofit2.converter.jackson.JacksonConverterFactory +import java.util.concurrent.TimeUnit + +class RetrofitClient (private val basePath: String, private val logLevel: HttpLoggingInterceptor.Level, private val clazz: Class) { + + private var restInterface: T? = null + + fun getRestClient(): T { + if (restInterface == null) { + + val loggingInterceptor = HttpLoggingInterceptor() + loggingInterceptor.level = logLevel + + val client = OkHttpClient.Builder() + .callTimeout(60, TimeUnit.SECONDS) + .readTimeout(60, TimeUnit.SECONDS) + .connectTimeout(60, TimeUnit.SECONDS) + .addInterceptor(loggingInterceptor) + .build() + + val mapper = ObjectMapper().apply { + setSerializationInclusion(JsonInclude.Include.NON_NULL) + } + + restInterface = Retrofit.Builder() + .baseUrl(basePath) + .client(client) + .addConverterFactory(JacksonConverterFactory.create(mapper)) + .build() + .create(clazz) + } + return restInterface!! + } +} \ No newline at end of file diff --git a/commons/src/main/kotlin/eu/miaplatform/commons/model/ErrorExceptions.kt b/commons/src/main/kotlin/eu/miaplatform/commons/model/ErrorExceptions.kt new file mode 100644 index 0000000..1887065 --- /dev/null +++ b/commons/src/main/kotlin/eu/miaplatform/commons/model/ErrorExceptions.kt @@ -0,0 +1,6 @@ +package eu.miaplatform.commons.model + +data class BadRequestException(val code: Int, val errorMessage: String): Exception(errorMessage) +data class InternalServerErrorException(val code: Int, val errorMessage: String): Exception(errorMessage) +data class NotFoundException(val code: Int, val errorMessage: String): Exception(errorMessage) +data class UnauthorizedException(val code: Int, val errorMessage: String): Exception(errorMessage) \ No newline at end of file diff --git a/commons/src/main/kotlin/eu/miaplatform/commons/model/HealthBodyResponse.kt b/commons/src/main/kotlin/eu/miaplatform/commons/model/HealthBodyResponse.kt new file mode 100644 index 0000000..cf4da60 --- /dev/null +++ b/commons/src/main/kotlin/eu/miaplatform/commons/model/HealthBodyResponse.kt @@ -0,0 +1,19 @@ +package eu.miaplatform.commons.model + +import com.fasterxml.jackson.annotation.JsonProperty + +data class HealthBodyResponse ( + @JsonProperty("name") + val name: String, + + @JsonProperty("version") + val version: String, + + @JsonProperty("status") + val status: String +) { + enum class Status(val value: String) { + OK("OK"), + KO("KO") + } +} \ No newline at end of file diff --git a/commons/src/test/kotlin/eu/miaplatform/commons/client/HeadersToProxyTest.kt b/commons/src/test/kotlin/eu/miaplatform/commons/client/HeadersToProxyTest.kt new file mode 100644 index 0000000..dfcc7a2 --- /dev/null +++ b/commons/src/test/kotlin/eu/miaplatform/commons/client/HeadersToProxyTest.kt @@ -0,0 +1,221 @@ +package eu.miaplatform.commons.client + +import io.ktor.http.* +import io.ktor.server.testing.* +import org.junit.Test +import kotlin.test.assertEquals + +class HeadersToProxyTest { + + @Test + fun `Returns empty header map when no header is present`() { + + val headersToProxy = HeadersToProxy() + + withTestApplication { + + handleRequest(HttpMethod.Get, "/proxy-headers"){ + }.apply { + val headers = headersToProxy.proxy(this) + + assertEquals(mapOf(), headers) + } + } + } + + @Test + fun `Returns the correct header map when x-request-id has a value`() { + + val headersToProxy = HeadersToProxy() + + withTestApplication { + + handleRequest(HttpMethod.Get, "/proxy-headers"){ + addHeader("x-request-id", "1234abcd") + }.apply { + val headers = headersToProxy.proxy(this) + + assertEquals(mapOf("x-request-id" to "1234abcd"), headers) + } + } + } + + @Test + fun `Returns the correct header map when miauserid has a value`() { + + val headersToProxy = HeadersToProxy() + + withTestApplication { + + handleRequest(HttpMethod.Get, "/proxy-headers"){ + addHeader("miauserid", "userid") + }.apply { + val headers = headersToProxy.proxy(this) + + assertEquals(mapOf("miauserid" to "userid"), headers) + } + } + } + + @Test + fun `Returns the correct header map when miausergroups has a value`() { + + val headersToProxy = HeadersToProxy() + + withTestApplication { + + handleRequest(HttpMethod.Get, "/proxy-headers"){ + addHeader("miausergroups", "group") + }.apply { + val headers = headersToProxy.proxy(this) + + assertEquals(mapOf("miausergroups" to "group"), headers) + } + } + } + + @Test + fun `Returns the correct header map when client-type has a value`() { + + val headersToProxy = HeadersToProxy() + + withTestApplication { + + handleRequest(HttpMethod.Get, "/proxy-headers"){ + addHeader("client-type", "type") + }.apply { + val headers = headersToProxy.proxy(this) + + assertEquals(mapOf("client-type" to "type"), headers) + } + } + } + + @Test + fun `Returns the correct header map when isbackoffice has a value`() { + + val headersToProxy = HeadersToProxy() + + withTestApplication { + + handleRequest(HttpMethod.Get, "/proxy-headers"){ + addHeader("isbackoffice", "true") + }.apply { + val headers = headersToProxy.proxy(this) + + assertEquals(mapOf("isbackoffice" to "true"), headers) + } + } + } + + @Test + fun `Returns the correct header map when miauserproperties has a value`() { + + val headersToProxy = HeadersToProxy() + + withTestApplication { + + handleRequest(HttpMethod.Get, "/proxy-headers"){ + addHeader("miauserproperties", "property") + }.apply { + val headers = headersToProxy.proxy(this) + + assertEquals(mapOf("miauserproperties" to "property"), headers) + } + } + } + + @Test + fun `Returns the correct header map when all platform headers have value`() { + + val headersToProxy = HeadersToProxy() + + withTestApplication { + + handleRequest(HttpMethod.Get, "/proxy-headers"){ + addHeader("x-request-id", "1234abcd") + addHeader("miauserid", "userid") + addHeader("miausergroups", "group") + addHeader("miauserproperties", "property") + addHeader("client-type", "type") + addHeader("miauserproperties", "property") + }.apply { + val headers = headersToProxy.proxy(this) + + assertEquals( + mapOf( + "x-request-id" to "1234abcd", + "miauserid" to "userid", + "miausergroups" to "group", + "miauserproperties" to "property", + "client-type" to "type", + "miauserproperties" to "property" + ), headers) + } + } + } + + @Test + fun `Returns the header map with only headers to proxy when there are more`() { + + val headersToProxy = HeadersToProxy() + + withTestApplication { + + handleRequest(HttpMethod.Get, "/proxy-headers"){ + addHeader("x-request-id", "1234abcd") + addHeader("miauserid", "userid") + addHeader("miausergroups", "group") + addHeader("miauserproperties", "property") + addHeader("client-type", "type") + addHeader("miauserproperties", "property") + addHeader("some-other-header", "other") + }.apply { + val headers = headersToProxy.proxy(this) + + assertEquals( + mapOf( + "x-request-id" to "1234abcd", + "miauserid" to "userid", + "miausergroups" to "group", + "miauserproperties" to "property", + "client-type" to "type", + "miauserproperties" to "property" + ), headers) + } + } + } + + @Test + fun `Returns the header map with additional headers to proxy if present`() { + + val headersToProxy = HeadersToProxy("some-other-header-to-proxy") + + withTestApplication { + + handleRequest(HttpMethod.Get, "/proxy-headers"){ + addHeader("x-request-id", "1234abcd") + addHeader("miauserid", "userid") + addHeader("miausergroups", "group") + addHeader("miauserproperties", "property") + addHeader("client-type", "type") + addHeader("miauserproperties", "property") + addHeader("some-other-header-to-proxy", "other") + }.apply { + val headers = headersToProxy.proxy(this) + + System.getProperty("ADDITIONAL_HEADERS_TO_PROXY", "some-other-header-to-proxy") + assertEquals( + mapOf( + "x-request-id" to "1234abcd", + "miauserid" to "userid", + "miausergroups" to "group", + "miauserproperties" to "property", + "client-type" to "type", + "miauserproperties" to "property", + "some-other-header-to-proxy" to "other" + ), headers) + } + } + } +} \ No newline at end of file diff --git a/gitlab-ci.yml b/gitlab-ci.yml new file mode 100644 index 0000000..0336b1d --- /dev/null +++ b/gitlab-ci.yml @@ -0,0 +1,13 @@ +include: + - project: 'platform/pipelines-templates' + file: '/build/java/template-java-gradle.yml' + ref: master + - project: 'platform/pipelines-templates' + file: '/build/java/template-java-gradle-test-latest.yml' + ref: master + +default: + image: gradle:6.2.0-jdk8 + +variables: + IMAGE_NAME: %CUSTOM_PLUGIN_IMAGE_NAME% diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..fe08173 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,2 @@ +kotlin.code.style=official +ktor_version=1.3.2 \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..f3d88b1 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..b7c8c5d --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-6.2-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..2fe81a7 --- /dev/null +++ b/gradlew @@ -0,0 +1,183 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=`expr $i + 1` + done + case $i in + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=`save "$@"` + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..9109989 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,103 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windows variants + +if not "%OS%" == "Windows_NT" goto win9xME_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/scripts/update-changelog.sh b/scripts/update-changelog.sh new file mode 100755 index 0000000..0c2c3d7 --- /dev/null +++ b/scripts/update-changelog.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env sh + +set -o errexit +set -o nounset + +umask 077 + +# Automatically get the folder where the source files must be placed. +__DIR=$(dirname "${0}") +SOURCE_DIR="${__DIR}/../" +TAG_VALUE="${1}" + +# Create a variable that contains the current date in UTC +# Different flow if this script is running on Darwin or Linux machines. +if [ "$(uname)" = "Darwin" ]; then + NOW_DATE="$(date -u +%F)" +else + NOW_DATE="$(date -u -I)" +fi + +sed -i.bck "s|## Unreleased|## v${TAG_VALUE} - ${NOW_DATE}|g" "${SOURCE_DIR}/CHANGELOG.md" +rm -fr "${SOURCE_DIR}/CHANGELOG.md.bck" diff --git a/service/Dockerfile b/service/Dockerfile new file mode 100644 index 0000000..00adcb6 --- /dev/null +++ b/service/Dockerfile @@ -0,0 +1,17 @@ +FROM openjdk:8-jdk-alpine + +ARG COMMIT_SHA= +ARG BUILD_FILE_NAME=application.jar + +WORKDIR /home/java + +RUN adduser -D -S -s /sbin/nologin -G root java && \ + echo "custom-plugin: $COMMIT_SHA" >> ./commit.sha +COPY build/libs/${BUILD_FILE_NAME} /home/java/application.jar +COPY src/main/resources/application.conf ./application.conf +COPY logback.xml ./logback.xml +ENV LOG_CONFIG_FILE /home/java/logback.xml +COPY LICENSE ./LICENSE +USER java + +CMD ["java","-jar", "./application.jar", "-config=application.conf"] \ No newline at end of file diff --git a/service/LICENSE b/service/LICENSE new file mode 100644 index 0000000..c4c06b0 --- /dev/null +++ b/service/LICENSE @@ -0,0 +1,5 @@ +Copyright © 2020-present Mia s.r.l. +All rights reserved + +Mia-Platform uses Open Source Software. +Copyright notice are available at https://docs.mia-platform.eu/info/oss/. \ No newline at end of file diff --git a/service/build.gradle b/service/build.gradle new file mode 100644 index 0000000..3f21eee --- /dev/null +++ b/service/build.gradle @@ -0,0 +1,21 @@ +dependencies { + compile project(":commons") + + compile "io.ktor:ktor-server-netty:1.3.2" + compile 'com.github.papsign:Ktor-OpenAPI-Generator:0.2-beta.10' +} + +jar { + manifest { + attributes( + 'Class-Path': configurations.compile.collect { it.getName() }.join(' '), + 'Main-Class': 'eu.miaplatform.service.ServiceApplicationKt' + ) + } + + from { + configurations.compile.collect { it.isDirectory() ? it : zipTree(it) } + } + + archiveFileName = 'application.jar' +} \ No newline at end of file diff --git a/service/default.env b/service/default.env new file mode 100644 index 0000000..e5a9c2c --- /dev/null +++ b/service/default.env @@ -0,0 +1,5 @@ +HTTP_PORT=8080 +LOG_LEVEL=info +HTTP_LOG_LEVEL=body +LOG_CONFIG_FILE=service/logback.xml +ADDITIONAL_HEADERS_TO_PROXY=secret \ No newline at end of file diff --git a/service/logback.xml b/service/logback.xml new file mode 100644 index 0000000..f8fef52 --- /dev/null +++ b/service/logback.xml @@ -0,0 +1,16 @@ + + + + + level + + + + + + + + + + + \ No newline at end of file diff --git a/service/src/main/kotlin/eu/miaplatform/service/CustomJacksonObjectSchemaProvider.kt b/service/src/main/kotlin/eu/miaplatform/service/CustomJacksonObjectSchemaProvider.kt new file mode 100644 index 0000000..edeb03e --- /dev/null +++ b/service/src/main/kotlin/eu/miaplatform/service/CustomJacksonObjectSchemaProvider.kt @@ -0,0 +1,82 @@ +package eu.miaplatform.service + +import com.fasterxml.jackson.annotation.JsonProperty +import com.papsign.ktor.openapigen.* +import com.papsign.ktor.openapigen.model.schema.SchemaModel +import com.papsign.ktor.openapigen.modules.DefaultOpenAPIModule +import com.papsign.ktor.openapigen.modules.ModuleProvider +import com.papsign.ktor.openapigen.modules.ofType +import com.papsign.ktor.openapigen.schema.builder.FinalSchemaBuilder +import com.papsign.ktor.openapigen.schema.builder.SchemaBuilder +import com.papsign.ktor.openapigen.schema.builder.provider.SchemaBuilderProviderModule +import com.papsign.ktor.openapigen.schema.namer.DefaultSchemaNamer +import com.papsign.ktor.openapigen.schema.namer.SchemaNamer +import org.slf4j.LoggerFactory +import kotlin.reflect.KType +import kotlin.reflect.KVisibility +import kotlin.reflect.full.starProjectedType +import kotlin.reflect.full.withNullability +import kotlin.reflect.jvm.jvmErasure +import kotlin.reflect.typeOf + +object CustomJacksonObjectSchemaProvider : SchemaBuilderProviderModule, OpenAPIGenModuleExtension, DefaultOpenAPIModule { + private val log = LoggerFactory.getLogger(CustomJacksonObjectSchemaProvider::class.java) + + override fun provide(apiGen: OpenAPIGen, provider: ModuleProvider<*>): List { + val namer = provider.ofType().let { + val last = it.lastOrNull() ?: DefaultSchemaNamer.also { log.debug("No ${SchemaNamer::class} provided, using ${it::class}") } + if (it.size > 1) log.warn("Multiple ${SchemaNamer::class} provided, choosing last: ${last::class}") + last + } + return listOf( + Builder( + apiGen, + namer + ) + ) + } + + private class Builder(private val apiGen: OpenAPIGen, private val namer: SchemaNamer) : SchemaBuilder { + @ExperimentalStdlibApi + internal inline fun getKType() = typeOf() + @ExperimentalStdlibApi + override val superType: KType = getKType() + + private val refs = HashMap>() + + override fun build(type: KType, builder: FinalSchemaBuilder, finalize: (SchemaModel<*>)-> SchemaModel<*>): SchemaModel<*> { + checkType(type) + val nonNullType = type.withNullability(false) + type.annotations.find { it.annotationClass == KType::class } + return refs[nonNullType] ?: { + val erasure = nonNullType.jvmErasure + val name = namer[nonNullType] + val ref = SchemaModel.SchemaModelRef("#/components/schemas/$name") + refs[nonNullType] = ref // needed to prevent infinite recursion + val new = if (erasure.isSealed) { + SchemaModel.OneSchemaModelOf(erasure.sealedSubclasses.map { builder.build(it.starProjectedType) }) + } else { + val props = type.memberProperties.filter { it.source.visibility == KVisibility.PUBLIC } + SchemaModel.SchemaModelObj( + props.associate { + val annotation = it.source.getter.annotations.find { type -> type is JsonProperty } as? JsonProperty + val propertyName = annotation?.value ?: it.name + Pair(propertyName, builder.build(it.type, it.source.annotations)) + }, + props.filter { + !it.type.isMarkedNullable + }.map { + val annotation = it.source.getter.annotations.find { type -> type is JsonProperty } as? JsonProperty + annotation?.value ?: it.name + } + ) + } + val final = finalize(new) + val existing = apiGen.api.components.schemas[name] + if (existing != null && existing != final) log.error("Schema with name $name already exists, and is not the same as the new one, replacing...") + apiGen.api.components.schemas[name] = final + ref + }() + } + } +} \ No newline at end of file diff --git a/service/src/main/kotlin/eu/miaplatform/service/ServiceApplication.kt b/service/src/main/kotlin/eu/miaplatform/service/ServiceApplication.kt new file mode 100644 index 0000000..0add6af --- /dev/null +++ b/service/src/main/kotlin/eu/miaplatform/service/ServiceApplication.kt @@ -0,0 +1,161 @@ +package eu.miaplatform.service + +import ch.qos.logback.classic.util.ContextInitializer +import com.fasterxml.jackson.annotation.JsonInclude +import com.fasterxml.jackson.databind.exc.InvalidFormatException +import com.fasterxml.jackson.module.kotlin.MissingKotlinParameterException +import com.papsign.ktor.openapigen.OpenAPIGen +import com.papsign.ktor.openapigen.interop.withAPI +import com.papsign.ktor.openapigen.route.apiRouting +import com.papsign.ktor.openapigen.schema.builder.provider.DefaultObjectSchemaProvider +import com.papsign.ktor.openapigen.schema.namer.DefaultSchemaNamer +import com.papsign.ktor.openapigen.schema.namer.SchemaNamer +import eu.miaplatform.commons.StatusService +import eu.miaplatform.commons.client.CrudClientInterface +import eu.miaplatform.commons.client.HeadersToProxy +import eu.miaplatform.commons.client.RetrofitClient +import eu.miaplatform.commons.model.BadRequestException +import eu.miaplatform.commons.model.InternalServerErrorException +import eu.miaplatform.commons.model.NotFoundException +import eu.miaplatform.commons.model.UnauthorizedException +import eu.miaplatform.service.controller.documentation +import eu.miaplatform.service.controller.health +import eu.miaplatform.service.controller.helloWorld +import eu.miaplatform.service.model.ErrorResponse +import io.ktor.application.Application +import io.ktor.features.* +import io.ktor.http.* +import io.ktor.routing.* +import io.ktor.application.install +import io.ktor.jackson.jackson +import io.ktor.request.* +import io.ktor.server.netty.* +import io.ktor.util.KtorExperimentalAPI +import okhttp3.logging.HttpLoggingInterceptor +import org.slf4j.event.Level +import java.lang.reflect.InvocationTargetException +import java.util.* +import kotlin.reflect.KType + +fun main(args: Array) { + System.setProperty(ContextInitializer.CONFIG_FILE_PROPERTY, System.getenv("LOG_CONFIG_FILE")) + val timeZone = System.getenv("TIME_ZONE") + if(timeZone != null) { + TimeZone.setDefault(TimeZone.getTimeZone(timeZone)) + } + EngineMain.main(args) +} + +@KtorExperimentalAPI +fun Application.module() { + + val logLevel = when (environment.config.property("ktor.log.level").getString().toUpperCase()) { + "DEBUG" -> Level.DEBUG + "ERROR" -> Level.ERROR + "TRACE" -> Level.TRACE + "WARN" -> Level.WARN + else -> Level.INFO + } + + val httpLogLevel = when (environment.config.property("ktor.log.httpLogLevel").getString().toUpperCase()) { + "BASIC" -> HttpLoggingInterceptor.Level.BASIC + "BODY" -> HttpLoggingInterceptor.Level.BODY + "HEADERS" -> HttpLoggingInterceptor.Level.HEADERS + else -> HttpLoggingInterceptor.Level.NONE + } + + val additionalHeadersToProxy = System.getenv("ADDITIONAL_HEADERS_TO_PROXY") + val headersToProxy = HeadersToProxy(additionalHeadersToProxy) + + val crudClient = RetrofitClient(basePath = "http://test.mp.trenord.it/v2/", logLevel = httpLogLevel, clazz = CrudClientInterface::class.java) + + module(logLevel, crudClient, headersToProxy) +} + +@KtorExperimentalAPI +fun Application.module( + logLevel: Level, + crudClient: RetrofitClient, + headersToProxy: HeadersToProxy +) { + + install(CallLogging) { + level = logLevel + filter { call -> call.request.path().startsWith("/") } + } + + // Documentation here: https://github.com/papsign/Ktor-OpenAPI-Generator + val api = install(OpenAPIGen) { + info { + version = StatusService().getVersion() + title = "Service name" + description = "The service description" + contact { + name = "Name of the contact" + email = "contact@email.com" + } + } + server("https://test.host/") { + description = "Test environment" + } + server("https://preprod.host/") { + description = "Preproduction environment" + } + server("https://cloud.host/") { + description = "Production environment" + } + replaceModule(DefaultSchemaNamer, object: SchemaNamer { + val regex = Regex("[A-Za-z0-9_.]+") + override fun get(type: KType): String { + return type.toString().replace(regex) { it.value.split(".").last() }.replace(Regex(">|<|, "), "_") + } + }) + replaceModule(DefaultObjectSchemaProvider, CustomJacksonObjectSchemaProvider) + } + + install(StatusPages) { + withAPI(api) { + exception(HttpStatusCode.Unauthorized) { + ErrorResponse(it.code, it.errorMessage) + } + exception(HttpStatusCode.NotFound) { + ErrorResponse(it.code, it.errorMessage) + } + exception(HttpStatusCode.BadRequest) { + ErrorResponse(it.code, it.errorMessage) + } + exception(HttpStatusCode.BadRequest) { + ErrorResponse(1000, it.localizedMessage) + } + exception(HttpStatusCode.BadRequest) { + ErrorResponse(1000, it.targetException.localizedMessage) + } + exception(HttpStatusCode.BadRequest) { + ErrorResponse(1000, it.localizedMessage) + } + exception(HttpStatusCode.InternalServerError) { + ErrorResponse(it.code, it.errorMessage) + } + exception(HttpStatusCode.InternalServerError) { + ErrorResponse(1000, it.localizedMessage ?: "Generic error") + } + + } + } + + install(ContentNegotiation) { + jackson { + this.setSerializationInclusion(JsonInclude.Include.NON_NULL) + } + } + + apiRouting { + //here goes your controller + helloWorld(this@module, crudClient, headersToProxy) + } + + routing { + health() + documentation(this.application) + } +} \ No newline at end of file diff --git a/service/src/main/kotlin/eu/miaplatform/service/controller/Documentation.kt b/service/src/main/kotlin/eu/miaplatform/service/controller/Documentation.kt new file mode 100644 index 0000000..4a0f8cc --- /dev/null +++ b/service/src/main/kotlin/eu/miaplatform/service/controller/Documentation.kt @@ -0,0 +1,17 @@ +package eu.miaplatform.service.controller + +import com.papsign.ktor.openapigen.openAPIGen +import io.ktor.application.* +import io.ktor.response.* +import io.ktor.routing.* + +fun Routing.documentation(application: Application) { + route("/documentation") { + get { + call.respondRedirect("/swagger-ui/index.html?url=/documentation/openapi.json", true) + } + get("/openapi.json") { + call.respond(application.openAPIGen.api.serialize()) + } + } +} diff --git a/service/src/main/kotlin/eu/miaplatform/service/controller/Health.kt b/service/src/main/kotlin/eu/miaplatform/service/controller/Health.kt new file mode 100644 index 0000000..d9384a8 --- /dev/null +++ b/service/src/main/kotlin/eu/miaplatform/service/controller/Health.kt @@ -0,0 +1,28 @@ +package eu.miaplatform.service.controller + +import eu.miaplatform.commons.StatusService +import eu.miaplatform.commons.model.HealthBodyResponse +import io.ktor.application.call +import io.ktor.http.HttpStatusCode +import io.ktor.response.respond +import io.ktor.routing.Routing +import io.ktor.routing.get +import io.ktor.routing.route + +fun Routing.health() { + val version = StatusService().getVersion() + val healthBodyResponse = HealthBodyResponse("service-api", version, HealthBodyResponse.Status.OK.value) + + route("/-") { + get("/healthz") { + call.respond(HttpStatusCode.OK, healthBodyResponse) + } + get("/check-up") { + // Add service dependencies here + call.respond(HttpStatusCode.OK, healthBodyResponse) + } + get("/ready") { + call.respond(HttpStatusCode.OK, healthBodyResponse) + } + } +} diff --git a/service/src/main/kotlin/eu/miaplatform/service/controller/HelloWorld.kt b/service/src/main/kotlin/eu/miaplatform/service/controller/HelloWorld.kt new file mode 100644 index 0000000..f46707f --- /dev/null +++ b/service/src/main/kotlin/eu/miaplatform/service/controller/HelloWorld.kt @@ -0,0 +1,95 @@ +package eu.miaplatform.service.controller + +import com.papsign.ktor.openapigen.route.apiRouting +import com.papsign.ktor.openapigen.route.info +import com.papsign.ktor.openapigen.route.path.normal.get +import com.papsign.ktor.openapigen.route.path.normal.post +import com.papsign.ktor.openapigen.route.response.respond +import com.papsign.ktor.openapigen.route.route +import com.papsign.ktor.openapigen.route.tag +import eu.miaplatform.commons.client.CrudClientInterface +import eu.miaplatform.commons.client.HeadersToProxy +import eu.miaplatform.commons.client.RetrofitClient +import eu.miaplatform.commons.model.InternalServerErrorException +import eu.miaplatform.commons.model.UnauthorizedException +import eu.miaplatform.service.model.ServiceTag +import eu.miaplatform.service.model.request.HelloWorldGetRequest +import eu.miaplatform.service.model.request.HelloWorldPostRequest +import eu.miaplatform.service.model.request.HelloWorldRequestBody +import eu.miaplatform.service.model.response.HelloWorldResponse +import io.ktor.application.Application +import kotlinx.coroutines.async + +fun helloWorld(application: Application, crudClient: RetrofitClient, headersToProxy: HeadersToProxy) { + + application.apiRouting { + route("/hello") { + tag(ServiceTag) { + get( + info("The description of the endpoint") + ) { params -> + + if(params.token == "invalid") { + throw UnauthorizedException(1001, "the user is not authorized") + } + + val response = HelloWorldResponse( + params.token, + null, + params.queryParam, + "Hello world!" + ) + + respond(response) + } + + route("/{pathParam}").post( + info("The description of the endpoint") + ) { params, requestBody -> + + if(params.token == "invalid") { + throw UnauthorizedException(1001, "the user is not authorized") + } + + val response = HelloWorldResponse( + params.token, + params.pathParam, + null, + "Hello world ${requestBody.name} ${requestBody.surname}!" + ) + + respond(response) + } + + route("/with-call").get( + info("The description of the endpoint") + ) { params -> + + if(params.token == "invalid") { + throw UnauthorizedException(1001, "the user is not authorized") + } + + val headers = headersToProxy.proxy(this.pipeline.context) + val booksCall = application.async { + crudClient.getRestClient().getBooks(headers) + } + + val books = try { + booksCall.await() + } catch (e: Exception) { + throw InternalServerErrorException(1002, "books call failed") + } + + val response = HelloWorldResponse( + params.token, + null, + params.queryParam, + "Hello world! Book list: ${books.joinToString()}" + ) + + respond(response) + } + } + } + } +} diff --git a/service/src/main/kotlin/eu/miaplatform/service/model/ErrorResponse.kt b/service/src/main/kotlin/eu/miaplatform/service/model/ErrorResponse.kt new file mode 100644 index 0000000..5fda477 --- /dev/null +++ b/service/src/main/kotlin/eu/miaplatform/service/model/ErrorResponse.kt @@ -0,0 +1,11 @@ +package eu.miaplatform.service.model + +import com.fasterxml.jackson.annotation.JsonProperty + +data class ErrorResponse ( + @JsonProperty("code") + val code: Int, + + @JsonProperty("error") + val error: String +) \ No newline at end of file diff --git a/service/src/main/kotlin/eu/miaplatform/service/model/ServiceTag.kt b/service/src/main/kotlin/eu/miaplatform/service/model/ServiceTag.kt new file mode 100644 index 0000000..5c356d8 --- /dev/null +++ b/service/src/main/kotlin/eu/miaplatform/service/model/ServiceTag.kt @@ -0,0 +1,11 @@ +package eu.miaplatform.service.model + +import com.papsign.ktor.openapigen.APITag + +object ServiceTag: APITag { + override val name: String + get() = "Name" + override val description: String + get() = "Description" + +} \ No newline at end of file diff --git a/service/src/main/kotlin/eu/miaplatform/service/model/request/HelloWorldGetRequest.kt b/service/src/main/kotlin/eu/miaplatform/service/model/request/HelloWorldGetRequest.kt new file mode 100644 index 0000000..8d92305 --- /dev/null +++ b/service/src/main/kotlin/eu/miaplatform/service/model/request/HelloWorldGetRequest.kt @@ -0,0 +1,12 @@ +package eu.miaplatform.service.model.request + +import com.papsign.ktor.openapigen.annotations.parameters.HeaderParam +import com.papsign.ktor.openapigen.annotations.parameters.QueryParam + +data class HelloWorldGetRequest ( + @HeaderParam("Description of the header", explode = false) + val token: String, + + @QueryParam("Description of the query param") + val queryParam: String? +) \ No newline at end of file diff --git a/service/src/main/kotlin/eu/miaplatform/service/model/request/HelloWorldPostRequest.kt b/service/src/main/kotlin/eu/miaplatform/service/model/request/HelloWorldPostRequest.kt new file mode 100644 index 0000000..3b3562a --- /dev/null +++ b/service/src/main/kotlin/eu/miaplatform/service/model/request/HelloWorldPostRequest.kt @@ -0,0 +1,13 @@ +package eu.miaplatform.service.model.request + +import com.papsign.ktor.openapigen.annotations.parameters.HeaderParam +import com.papsign.ktor.openapigen.annotations.parameters.PathParam +import com.papsign.ktor.openapigen.annotations.parameters.QueryParam + +data class HelloWorldPostRequest ( + @HeaderParam("Description of the header", explode = false) + val token: String, + + @PathParam("Description of the param") + val pathParam: String? +) \ No newline at end of file diff --git a/service/src/main/kotlin/eu/miaplatform/service/model/request/HelloWorldRequestBody.kt b/service/src/main/kotlin/eu/miaplatform/service/model/request/HelloWorldRequestBody.kt new file mode 100644 index 0000000..c0dded0 --- /dev/null +++ b/service/src/main/kotlin/eu/miaplatform/service/model/request/HelloWorldRequestBody.kt @@ -0,0 +1,13 @@ +package eu.miaplatform.service.model.request + +import com.fasterxml.jackson.annotation.JsonProperty + +class HelloWorldRequestBody ( + @JsonProperty("name") + @get:JsonProperty("name") + val name: String, + + @JsonProperty("surname") + @get:JsonProperty("surname") + val surname: String +) \ No newline at end of file diff --git a/service/src/main/kotlin/eu/miaplatform/service/model/response/HelloWorldResponse.kt b/service/src/main/kotlin/eu/miaplatform/service/model/response/HelloWorldResponse.kt new file mode 100644 index 0000000..27c3270 --- /dev/null +++ b/service/src/main/kotlin/eu/miaplatform/service/model/response/HelloWorldResponse.kt @@ -0,0 +1,21 @@ +package eu.miaplatform.service.model.response + +import com.fasterxml.jackson.annotation.JsonProperty + +data class HelloWorldResponse ( + @JsonProperty("userTokenSent") + @get:JsonProperty("userToken") + val token: String?, + + @JsonProperty("pathParamSent") + @get:JsonProperty("pathParamSent") + val pathParam: String?, + + @JsonProperty("queryParamSent") + @get:JsonProperty("queryParamSent") + val queryParam: String?, + + @JsonProperty("helloWorld") + @get:JsonProperty("helloWorld") + val helloWorld: String? +) \ No newline at end of file diff --git a/service/src/main/resources/application.conf b/service/src/main/resources/application.conf new file mode 100644 index 0000000..16a07f1 --- /dev/null +++ b/service/src/main/resources/application.conf @@ -0,0 +1,13 @@ +ktor { + deployment { + port = 8080 + port = ${?HTTP_PORT} + } + application { + modules = [ eu.miaplatform.service.ServiceApplicationKt.module ] + } + log { + level = ${?LOG_LEVEL} + httpLogLevel = ${?HTTP_LOG_LEVEL} + } +} \ No newline at end of file diff --git a/service/src/test/kotlin/eu/miaplatform/service/HealthTest.kt b/service/src/test/kotlin/eu/miaplatform/service/HealthTest.kt new file mode 100644 index 0000000..5db4cb8 --- /dev/null +++ b/service/src/test/kotlin/eu/miaplatform/service/HealthTest.kt @@ -0,0 +1,95 @@ +package eu.miaplatform.service + +import com.fasterxml.jackson.databind.ObjectMapper +import eu.miaplatform.commons.StatusService +import eu.miaplatform.commons.client.CrudClientInterface +import eu.miaplatform.commons.client.HeadersToProxy +import eu.miaplatform.commons.client.RetrofitClient +import eu.miaplatform.commons.model.HealthBodyResponse +import io.ktor.http.HttpMethod +import io.ktor.http.HttpStatusCode +import io.ktor.server.testing.handleRequest +import org.junit.Test +import io.ktor.server.testing.withTestApplication +import io.ktor.util.KtorExperimentalAPI +import okhttp3.logging.HttpLoggingInterceptor +import org.slf4j.event.Level +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class HealthTest { + private val objectMapper = ObjectMapper() + + private val crudClient = RetrofitClient("http://crud-url", HttpLoggingInterceptor.Level.NONE, CrudClientInterface::class.java) + + @Test + @KtorExperimentalAPI + fun `Health should return OK`() { + withTestApplication({ + module ( + logLevel = Level.DEBUG, + crudClient = crudClient, + headersToProxy = HeadersToProxy() + ) + }) { + handleRequest(HttpMethod.Get, "/-/healthz") { + + }.apply { + assertTrue { response.status()?.value == HttpStatusCode.OK.value } + + val version = StatusService().getVersion() + val body = objectMapper.readValue(response.content, HealthBodyResponse::class.java) + val expectedRes = HealthBodyResponse("service-api", version, HealthBodyResponse.Status.OK.value) + assertEquals(expectedRes, body) + } + } + } + + @Test + @KtorExperimentalAPI + fun `Ready should return OK`() { + withTestApplication({ + module ( + logLevel = Level.DEBUG, + crudClient = crudClient, + headersToProxy = HeadersToProxy() + ) + }) { + handleRequest(HttpMethod.Get, "/-/ready") { + + }.apply { + assertTrue { response.status()?.value == HttpStatusCode.OK.value } + + val version = StatusService().getVersion() + val body = objectMapper.readValue(response.content, HealthBodyResponse::class.java) + val expectedRes = HealthBodyResponse("service-api", version, HealthBodyResponse.Status.OK.value) + assertEquals(expectedRes, body) + } + } + + } + + @Test + @KtorExperimentalAPI + fun `Check Up should return OK`() { + withTestApplication({ + module ( + logLevel = Level.DEBUG, + crudClient = crudClient, + headersToProxy = HeadersToProxy() + ) + }) { + handleRequest(HttpMethod.Get, "/-/check-up") { + + }.apply { + assertTrue { response.status()?.value == HttpStatusCode.OK.value } + + val version = StatusService().getVersion() + val body = objectMapper.readValue(response.content, HealthBodyResponse::class.java) + val expectedRes = HealthBodyResponse("service-api", version, HealthBodyResponse.Status.OK.value) + assertEquals(expectedRes, body) + } + } + + } +} \ No newline at end of file diff --git a/service/src/test/kotlin/eu/miaplatform/service/controller/HelloWorldTest.kt b/service/src/test/kotlin/eu/miaplatform/service/controller/HelloWorldTest.kt new file mode 100644 index 0000000..24d44ad --- /dev/null +++ b/service/src/test/kotlin/eu/miaplatform/service/controller/HelloWorldTest.kt @@ -0,0 +1,451 @@ +package eu.miaplatform.service.controller + +import com.fasterxml.jackson.annotation.JsonInclude +import com.fasterxml.jackson.databind.ObjectMapper +import eu.miaplatform.commons.client.CrudClientInterface +import eu.miaplatform.commons.client.HeadersToProxy +import eu.miaplatform.commons.client.RetrofitClient +import eu.miaplatform.service.model.ErrorResponse +import eu.miaplatform.service.model.request.HelloWorldRequestBody +import eu.miaplatform.service.model.response.HelloWorldResponse +import eu.miaplatform.service.module +import io.ktor.http.HttpMethod +import io.ktor.http.HttpStatusCode +import io.ktor.server.testing.handleRequest +import io.ktor.server.testing.setBody +import io.ktor.server.testing.withTestApplication +import io.ktor.util.KtorExperimentalAPI +import okhttp3.logging.HttpLoggingInterceptor +import org.junit.Test +import org.mockserver.client.MockServerClient +import org.mockserver.integration.ClientAndServer +import org.mockserver.model.HttpRequest +import org.mockserver.model.HttpResponse +import org.slf4j.event.Level +import kotlin.test.assertEquals +import kotlin.test.assertTrue + + +class HelloWorldTest { + + private val objectMapper = ObjectMapper().apply { + setSerializationInclusion(JsonInclude.Include.NON_NULL) + } + private val host = "localhost" + private val port = 3000 + private var mockServer: MockServerClient = MockServerClient(host, port) + + private val crudClient = RetrofitClient("http://$host:$port", HttpLoggingInterceptor.Level.NONE, CrudClientInterface::class.java) + + @Test + @KtorExperimentalAPI + fun `Get should return success object message`() { + withTestApplication({ + module ( + logLevel = Level.DEBUG, + crudClient = crudClient, + headersToProxy = HeadersToProxy() + ) + }) { + handleRequest(HttpMethod.Get, "/hello") { + addHeader("token", "token") + }.apply { + assertTrue { response.status()?.value == HttpStatusCode.OK.value } + + val expectedBody = objectMapper.writeValueAsString( + HelloWorldResponse( + "token", + null, + null, + "Hello world!" + ) + ) + assertEquals(expectedBody, response.content) + } + } + } + + @Test + @KtorExperimentalAPI + fun `Get should return success object message with query parameter`() { + withTestApplication({ + module ( + logLevel = Level.DEBUG, + crudClient = crudClient, + headersToProxy = HeadersToProxy() + ) + }) { + handleRequest(HttpMethod.Get, "/hello?queryParam=param") { + addHeader("token", "token") + }.apply { + assertTrue { response.status()?.value == HttpStatusCode.OK.value } + + val expectedBody = objectMapper.writeValueAsString( + HelloWorldResponse( + "token", + null, + "param", + "Hello world!" + ) + ) + assertEquals(expectedBody, response.content) + } + } + } + + @Test + @KtorExperimentalAPI + fun `Get should return unauthorized if token is invalid`() { + withTestApplication({ + module ( + logLevel = Level.DEBUG, + crudClient = crudClient, + headersToProxy = HeadersToProxy() + ) + }) { + handleRequest(HttpMethod.Get, "/hello") { + addHeader("token", "invalid") + }.apply { + assertTrue { response.status()?.value == HttpStatusCode.Unauthorized.value } + + val expectedBody = objectMapper.writeValueAsString( + ErrorResponse(1001, "the user is not authorized") + ) + assertEquals(expectedBody, response.content) + } + } + } + + @Test + @KtorExperimentalAPI + fun `Get should return bad request if token is missing`() { + withTestApplication({ + module ( + logLevel = Level.DEBUG, + crudClient = crudClient, + headersToProxy = HeadersToProxy() + ) + }) { + handleRequest(HttpMethod.Get, "/hello") { + }.apply { + assertTrue { response.status()?.value == HttpStatusCode.BadRequest.value } + + val expectedBody = objectMapper.writeValueAsString( + ErrorResponse(1000, "Parameter specified as non-null is null: method eu.miaplatform.service.model.request.HelloWorldGetRequest., parameter token") + ) + assertEquals(expectedBody, response.content) + } + } + } + + @Test + @KtorExperimentalAPI + fun `Post should return success object message with path param`() { + + withTestApplication({ + module ( + logLevel = Level.DEBUG, + crudClient = crudClient, + headersToProxy = HeadersToProxy() + ) + }) { + val body = objectMapper.writeValueAsString( + HelloWorldRequestBody("name", "surname") + ) + + handleRequest(HttpMethod.Post, "/hello/1234") { + addHeader("Content-Type", "application/json") + addHeader("token", "token") + setBody(body) + }.apply { + assertTrue { response.status()?.value == HttpStatusCode.OK.value } + + val expectedBody = objectMapper.writeValueAsString( + HelloWorldResponse( + "token", + "1234", + null, + "Hello world name surname!" + ) + ) + assertEquals(expectedBody, response.content) + } + } + } + + @Test + @KtorExperimentalAPI + fun `Post should return unauthorize if token is invalid`() { + + withTestApplication({ + module ( + logLevel = Level.DEBUG, + crudClient = crudClient, + headersToProxy = HeadersToProxy() + ) + }) { + val body = objectMapper.writeValueAsString( + HelloWorldRequestBody("name", "surname") + ) + + handleRequest(HttpMethod.Post, "/hello/1234") { + addHeader("Content-Type", "application/json") + addHeader("token", "invalid") + setBody(body) + }.apply { + assertTrue { response.status()?.value == HttpStatusCode.Unauthorized.value } + + val expectedBody = objectMapper.writeValueAsString( + ErrorResponse(1001, "the user is not authorized") + ) + assertEquals(expectedBody, response.content) + } + } + } + + @Test + @KtorExperimentalAPI + fun `Post should return bad request if token is missing`() { + + withTestApplication({ + module ( + logLevel = Level.DEBUG, + crudClient = crudClient, + headersToProxy = HeadersToProxy() + ) + }) { + val body = objectMapper.writeValueAsString( + HelloWorldRequestBody("name", "surname") + ) + + handleRequest(HttpMethod.Post, "/hello/1234") { + addHeader("Content-Type", "application/json") + setBody(body) + }.apply { + assertTrue { response.status()?.value == HttpStatusCode.BadRequest.value } + + val expectedBody = objectMapper.writeValueAsString( + ErrorResponse(1000, "Parameter specified as non-null is null: method eu.miaplatform.service.model.request.HelloWorldPostRequest., parameter token") + ) + assertEquals(expectedBody, response.content) + } + } + } + + @Test + @KtorExperimentalAPI + fun `Post should return bad request if body is malformed`() { + + withTestApplication({ + module ( + logLevel = Level.DEBUG, + crudClient = crudClient, + headersToProxy = HeadersToProxy() + ) + }) { + val body = objectMapper.writeValueAsString( + mapOf("name" to "name") + ) + + handleRequest(HttpMethod.Post, "/hello/1234") { + addHeader("Content-Type", "application/json") + addHeader("token", "token") + setBody(body) + }.apply { + assertTrue { response.status()?.value == HttpStatusCode.BadRequest.value } + + val expectedBody = objectMapper.writeValueAsString( + ErrorResponse(1000, "Instantiation of [simple type, class eu.miaplatform.service.model.request.HelloWorldRequestBody] value failed for JSON property surname due to missing (therefore NULL) value for creator parameter surname which is a non-nullable type\n at [Source: (InputStreamReader); line: 1, column: 15] (through reference chain: eu.miaplatform.service.model.request.HelloWorldRequestBody[\"surname\"])") + ) + assertEquals(expectedBody, response.content) + } + } + } + + @Test + @KtorExperimentalAPI + fun `Get with call should return success object message`() { + mockServer = ClientAndServer.startClientAndServer(port) + mockServer.setup( + "GET", + "/v2/books", + 200, + objectMapper.writeValueAsString(listOf("book1", "book2")) + ) + + withTestApplication({ + module ( + logLevel = Level.DEBUG, + crudClient = crudClient, + headersToProxy = HeadersToProxy() + ) + }) { + handleRequest(HttpMethod.Get, "/hello/with-call") { + addHeader("token", "token") + }.apply { + assertTrue { response.status()?.value == HttpStatusCode.OK.value } + + val expectedBody = objectMapper.writeValueAsString( + HelloWorldResponse( + "token", + null, + null, + "Hello world! Book list: book1, book2" + ) + ) + assertEquals(expectedBody, response.content) + } + } + } + + @Test + @KtorExperimentalAPI + fun `Get with call should return error if call fails`() { + mockServer = ClientAndServer.startClientAndServer(port) + mockServer.setup( + "GET", + "/v2/books", + 500, + "{\"error\": \"error\"}" + ) + + withTestApplication({ + module ( + logLevel = Level.DEBUG, + crudClient = crudClient, + headersToProxy = HeadersToProxy() + ) + }) { + handleRequest(HttpMethod.Get, "/hello/with-call") { + addHeader("token", "token") + }.apply { + assertTrue { response.status()?.value == HttpStatusCode.InternalServerError.value } + + val expectedBody = objectMapper.writeValueAsString( + ErrorResponse(1002, "books call failed") + ) + assertEquals(expectedBody, response.content) + } + } + mockServer.close() + } + + @Test + @KtorExperimentalAPI + fun `Get with call should return success object message with query parameter`() { + mockServer = ClientAndServer.startClientAndServer(port) + mockServer.setup( + "GET", + "/v2/books", + 200, + objectMapper.writeValueAsString(listOf("book1", "book2")) + ) + withTestApplication({ + module ( + logLevel = Level.DEBUG, + crudClient = crudClient, + headersToProxy = HeadersToProxy() + ) + }) { + handleRequest(HttpMethod.Get, "/hello/with-call?queryParam=param") { + addHeader("token", "token") + }.apply { + assertTrue { response.status()?.value == HttpStatusCode.OK.value } + + val expectedBody = objectMapper.writeValueAsString( + HelloWorldResponse( + "token", + null, + "param", + "Hello world! Book list: book1, book2" + ) + ) + assertEquals(expectedBody, response.content) + } + } + mockServer.close() + } + + @Test + @KtorExperimentalAPI + fun `Get with call should return unauthorized if token is invalid`() { + mockServer = ClientAndServer.startClientAndServer(port) + mockServer.setup( + "GET", + "/v2/books", + 200, + objectMapper.writeValueAsString(listOf("book1", "book2")) + ) + withTestApplication({ + module ( + logLevel = Level.DEBUG, + crudClient = crudClient, + headersToProxy = HeadersToProxy() + ) + }) { + handleRequest(HttpMethod.Get, "/hello/with-call") { + addHeader("token", "invalid") + }.apply { + assertTrue { response.status()?.value == HttpStatusCode.Unauthorized.value } + + val expectedBody = objectMapper.writeValueAsString( + ErrorResponse(1001, "the user is not authorized") + ) + assertEquals(expectedBody, response.content) + } + } + mockServer.close() + } + + @Test + @KtorExperimentalAPI + fun `Get with call should return bad request if token is missing`() { + mockServer = ClientAndServer.startClientAndServer(port) + mockServer.setup( + "GET", + "/v2/books", + 200, + objectMapper.writeValueAsString(listOf("book1", "book2")) + ) + withTestApplication({ + module ( + logLevel = Level.DEBUG, + crudClient = crudClient, + headersToProxy = HeadersToProxy() + ) + }) { + handleRequest(HttpMethod.Get, "/hello/with-call") { + }.apply { + assertTrue { response.status()?.value == HttpStatusCode.BadRequest.value } + + val expectedBody = objectMapper.writeValueAsString( + ErrorResponse(1000, "Parameter specified as non-null is null: method eu.miaplatform.service.model.request.HelloWorldGetRequest., parameter token") + ) + assertEquals(expectedBody, response.content) + } + } + mockServer.close() + } + + + private fun MockServerClient.setup( + requestMethod:String, + requestPath:String, + responseStatus: Int, + responseBody:String + ) { + + this.`when`( + HttpRequest.request() + .withMethod(requestMethod) + .withPath(requestPath) + + ) + .respond( + HttpResponse.response() + .withStatusCode(responseStatus) + .withBody(responseBody) + ) + } + +} \ No newline at end of file diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..90cff63 --- /dev/null +++ b/settings.gradle @@ -0,0 +1,5 @@ +rootProject.name = 'ktor-gradle-template' + +include('commons') +include('service') +