diff --git a/create-plugin/build.gradle.kts b/create-plugin/build.gradle.kts index 7833503..7e0cf0b 100644 --- a/create-plugin/build.gradle.kts +++ b/create-plugin/build.gradle.kts @@ -21,6 +21,7 @@ dependencies { implementation(libs.serialization) implementation(libs.serialization.json) implementation(libs.bundles.ktor) + implementation(libs.kotlinx.datetime) testImplementation(projects.common) testImplementation(libs.classGraph) @@ -29,6 +30,7 @@ dependencies { testImplementation(libs.assertJ) testImplementation(platform(libs.junit)) testImplementation(libs.junitJupiter) + testImplementation(libs.kotlinx.datetime) } tasks.test { diff --git a/create-plugin/src/main/kotlin/io/github/tabilzad/ktor/k2/ExpressionsVisitorK2.kt b/create-plugin/src/main/kotlin/io/github/tabilzad/ktor/k2/ExpressionsVisitorK2.kt index b1d8751..8be9ef2 100644 --- a/create-plugin/src/main/kotlin/io/github/tabilzad/ktor/k2/ExpressionsVisitorK2.kt +++ b/create-plugin/src/main/kotlin/io/github/tabilzad/ktor/k2/ExpressionsVisitorK2.kt @@ -383,6 +383,12 @@ internal class ExpressionsVisitorK2( OpenApiSpec.SchemaType( type = kotlinType.toString().toSwaggerType() ) + } else if (kotlinType?.lookupTagIfAny?.name?.asString() == "Instant" || + kotlinType?.lookupTagIfAny?.name?.asString() == "LocalDateTime") { + OpenApiSpec.SchemaType( + type = "string", + format = "date-time" + ) } else { val typeRef = response.type?.generateTypeAndVisitMemberDescriptors() OpenApiSpec.SchemaType( diff --git a/create-plugin/src/main/kotlin/io/github/tabilzad/ktor/k2/K2Utils.kt b/create-plugin/src/main/kotlin/io/github/tabilzad/ktor/k2/K2Utils.kt index dfb7a51..98e87fe 100644 --- a/create-plugin/src/main/kotlin/io/github/tabilzad/ktor/k2/K2Utils.kt +++ b/create-plugin/src/main/kotlin/io/github/tabilzad/ktor/k2/K2Utils.kt @@ -206,6 +206,13 @@ private fun ConeKotlinType.isBuiltinType(classId: ClassId, isNullable: Boolean?) return lookupTag.classId == classId && (isNullable == null || isNullableAny == isNullable) } +@Suppress("CyclomaticComplexMethod") +fun isDatetime(fqClassName: String): Boolean { + return fqClassName == "kotlinx.datetime.Instant" || + fqClassName == "kotlinx.datetime.LocalDateTime" || + fqClassName == "java.time.Instant" +} + fun FirRegularClassSymbol.resolveEnumEntries(): List { return declarationSymbols.filterIsInstance().map { it.name.asString() } } diff --git a/create-plugin/src/main/kotlin/io/github/tabilzad/ktor/k2/visitors/ClassDescriptorVisitorK2.kt b/create-plugin/src/main/kotlin/io/github/tabilzad/ktor/k2/visitors/ClassDescriptorVisitorK2.kt index 0ebb0bd..661f69b 100644 --- a/create-plugin/src/main/kotlin/io/github/tabilzad/ktor/k2/visitors/ClassDescriptorVisitorK2.kt +++ b/create-plugin/src/main/kotlin/io/github/tabilzad/ktor/k2/visitors/ClassDescriptorVisitorK2.kt @@ -91,6 +91,10 @@ internal class ClassDescriptorVisitorK2( ObjectType(type = "string", enum = enumValues) } + fqClassName?.let { isDatetime(it) } == true -> { + ObjectType(type = "string", format = "date-time") + } + typeSymbol?.isSealed == true -> { if (!classNames.names.contains(fqClassName)) { diff --git a/create-plugin/src/main/kotlin/io/github/tabilzad/ktor/output/OpenApiSpec.kt b/create-plugin/src/main/kotlin/io/github/tabilzad/ktor/output/OpenApiSpec.kt index 360dd40..dc32bb1 100644 --- a/create-plugin/src/main/kotlin/io/github/tabilzad/ktor/output/OpenApiSpec.kt +++ b/create-plugin/src/main/kotlin/io/github/tabilzad/ktor/output/OpenApiSpec.kt @@ -2,12 +2,10 @@ package io.github.tabilzad.ktor.output import com.fasterxml.jackson.annotation.JsonIgnore import com.fasterxml.jackson.annotation.JsonProperty -import com.sun.security.ntlm.Server import io.github.tabilzad.ktor.ContentType import io.github.tabilzad.ktor.OpenApiSpecParam import io.github.tabilzad.ktor.model.Info import io.github.tabilzad.ktor.model.SecurityScheme -import java.nio.file.Path internal typealias ContentSchema = Map @@ -78,6 +76,7 @@ data class OpenApiSpec( data class SchemaType( val type: String? = null, + val format: String? = null, val items: SchemaRef? = null, @Suppress("ConstructorParameterNaming") val `$ref`: String? = null, diff --git a/create-plugin/src/test/kotlin/io/github/tabilzad/ktor/K2StabilityTest.kt b/create-plugin/src/test/kotlin/io/github/tabilzad/ktor/K2StabilityTest.kt index 1b536ae..9e0452c 100644 --- a/create-plugin/src/test/kotlin/io/github/tabilzad/ktor/K2StabilityTest.kt +++ b/create-plugin/src/test/kotlin/io/github/tabilzad/ktor/K2StabilityTest.kt @@ -334,6 +334,42 @@ class K2StabilityTest { result.assertWith(expected) } + @Test + fun `should correctly resolve kotlinx-datetime using instant class into date-time response annotations`() { + val (source, expected) = loadSourceAndExpected("ResponseBodyKotlinxDatetimeInstant") + generateCompilerTest( + testFile, + source, + PluginConfiguration.createDefault(hideTransients = false, hidePrivateFields = false) + ) + val result = testFile.readText() + result.assertWith(expected) + } + + @Test + fun `should correctly resolve kotlinx-datetime using instant class into date-time response annotations, simple response body`() { + val (source, expected) = loadSourceAndExpected("ResponseBodyKotlinxDatetimeInstant2") + generateCompilerTest( + testFile, + source, + PluginConfiguration.createDefault(hideTransients = false, hidePrivateFields = false) + ) + val result = testFile.readText() + result.assertWith(expected) + } + + @Test + fun `should correctly resolve kotlinx-datetime using localdatetime class into date-time response annotations`() { + val (source, expected) = loadSourceAndExpected("ResponseBodyKotlinxDatetimeLocalDateTime") + generateCompilerTest( + testFile, + source, + PluginConfiguration.createDefault(hideTransients = false, hidePrivateFields = false) + ) + val result = testFile.readText() + result.assertWith(expected) + } + @Test fun `should handle abstract or sealed schema definitions`() { val (source, expected) = loadSourceAndExpected("Abstractions") diff --git a/create-plugin/src/test/kotlin/io/github/tabilzad/ktor/TestUtils.kt b/create-plugin/src/test/kotlin/io/github/tabilzad/ktor/TestUtils.kt index 0c061ef..70e7c78 100644 --- a/create-plugin/src/test/kotlin/io/github/tabilzad/ktor/TestUtils.kt +++ b/create-plugin/src/test/kotlin/io/github/tabilzad/ktor/TestUtils.kt @@ -183,5 +183,6 @@ private val deps = arrayOf( "kotlinx-coroutines-core:1.10.1", "moshi:1.14.0", "kotlinx-serialization-core:2.1.0", + "kotlinx-datetime:0.4.0", "annotations:0.6.7-alpha" ) diff --git a/create-plugin/src/test/resources/expected/ResponseBodyKotlinxDatetimeInstant-expected.json b/create-plugin/src/test/resources/expected/ResponseBodyKotlinxDatetimeInstant-expected.json new file mode 100644 index 0000000..68995f8 --- /dev/null +++ b/create-plugin/src/test/resources/expected/ResponseBodyKotlinxDatetimeInstant-expected.json @@ -0,0 +1,40 @@ +{ + "openapi" : "3.1.0", + "info" : { + "title" : "Open API Specification", + "description" : "test", + "version" : "1.0.0" + }, + "paths" : { + "/temp" : { + "get" : { + "responses" : { + "200" : { + "description" : "Success", + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/sources.Response" + } + } + } + } + } + } + } + }, + "components" : { + "schemas" : { + "sources.Response" : { + "type" : "object", + "properties" : { + "message" : { + "type" : "string", + "format" : "date-time" + } + }, + "required" : [ "message" ] + } + } + } +} \ No newline at end of file diff --git a/create-plugin/src/test/resources/expected/ResponseBodyKotlinxDatetimeInstant2-expected.json b/create-plugin/src/test/resources/expected/ResponseBodyKotlinxDatetimeInstant2-expected.json new file mode 100644 index 0000000..d6c806c --- /dev/null +++ b/create-plugin/src/test/resources/expected/ResponseBodyKotlinxDatetimeInstant2-expected.json @@ -0,0 +1,30 @@ +{ + "openapi" : "3.1.0", + "info" : { + "title" : "Open API Specification", + "description" : "test", + "version" : "1.0.0" + }, + "paths" : { + "/temp" : { + "get" : { + "responses" : { + "200" : { + "description" : "Success", + "content" : { + "application/json" : { + "schema" : { + "type" : "string", + "format" : "date-time" + } + } + } + } + } + } + } + }, + "components" : { + "schemas" : { } + } +} \ No newline at end of file diff --git a/create-plugin/src/test/resources/expected/ResponseBodyKotlinxDatetimeLocalDateTime-expected.json b/create-plugin/src/test/resources/expected/ResponseBodyKotlinxDatetimeLocalDateTime-expected.json new file mode 100644 index 0000000..68995f8 --- /dev/null +++ b/create-plugin/src/test/resources/expected/ResponseBodyKotlinxDatetimeLocalDateTime-expected.json @@ -0,0 +1,40 @@ +{ + "openapi" : "3.1.0", + "info" : { + "title" : "Open API Specification", + "description" : "test", + "version" : "1.0.0" + }, + "paths" : { + "/temp" : { + "get" : { + "responses" : { + "200" : { + "description" : "Success", + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/sources.Response" + } + } + } + } + } + } + } + }, + "components" : { + "schemas" : { + "sources.Response" : { + "type" : "object", + "properties" : { + "message" : { + "type" : "string", + "format" : "date-time" + } + }, + "required" : [ "message" ] + } + } + } +} \ No newline at end of file diff --git a/create-plugin/src/test/resources/sources/ResponseBodyKotlinxDatetimeInstant.kt b/create-plugin/src/test/resources/sources/ResponseBodyKotlinxDatetimeInstant.kt new file mode 100644 index 0000000..d98bb18 --- /dev/null +++ b/create-plugin/src/test/resources/sources/ResponseBodyKotlinxDatetimeInstant.kt @@ -0,0 +1,31 @@ +package sources + +import io.github.tabilzad.ktor.annotations.GenerateOpenApi +import io.github.tabilzad.ktor.annotations.KtorResponds +import io.github.tabilzad.ktor.annotations.ResponseEntry +import io.ktor.server.application.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant +import kotlinx.serialization.Serializable + + +@Serializable +data class Response( + val message: Instant = Clock.System.now() +) + +@GenerateOpenApi +fun Application.responseBodySample() { + routing { + @KtorResponds( + mapping = [ + ResponseEntry("200", Response::class, description = "Success"), + ] + ) + get("temp") { + call.respond(Response()) + } + } +} \ No newline at end of file diff --git a/create-plugin/src/test/resources/sources/ResponseBodyKotlinxDatetimeInstant2.kt b/create-plugin/src/test/resources/sources/ResponseBodyKotlinxDatetimeInstant2.kt new file mode 100644 index 0000000..f940ca1 --- /dev/null +++ b/create-plugin/src/test/resources/sources/ResponseBodyKotlinxDatetimeInstant2.kt @@ -0,0 +1,26 @@ +package sources + +import io.github.tabilzad.ktor.annotations.GenerateOpenApi +import io.github.tabilzad.ktor.annotations.KtorResponds +import io.github.tabilzad.ktor.annotations.ResponseEntry +import io.ktor.server.application.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant +import kotlinx.serialization.Serializable + + +@GenerateOpenApi +fun Application.responseBodySample() { + routing { + @KtorResponds( + mapping = [ + ResponseEntry("200", kotlinx.datetime.Instant::class, description = "Success"), + ] + ) + get("temp") { + call.respond(Clock.System.now()) + } + } +} \ No newline at end of file diff --git a/create-plugin/src/test/resources/sources/ResponseBodyKotlinxDatetimeLocalDateTime.kt b/create-plugin/src/test/resources/sources/ResponseBodyKotlinxDatetimeLocalDateTime.kt new file mode 100644 index 0000000..d4c84ae --- /dev/null +++ b/create-plugin/src/test/resources/sources/ResponseBodyKotlinxDatetimeLocalDateTime.kt @@ -0,0 +1,31 @@ +package sources + +import io.github.tabilzad.ktor.annotations.GenerateOpenApi +import io.github.tabilzad.ktor.annotations.KtorResponds +import io.github.tabilzad.ktor.annotations.ResponseEntry +import io.ktor.server.application.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.toKotlinLocalDateTime +import kotlinx.serialization.Serializable + + +@Serializable +data class Response( + val message: LocalDateTime = java.time.LocalDateTime.now().toKotlinLocalDateTime() +) + +@GenerateOpenApi +fun Application.responseBodySample() { + routing { + @KtorResponds( + mapping = [ + ResponseEntry("200", Response::class, description = "Success"), + ] + ) + get("temp") { + call.respond(Response()) + } + } +} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index aa4a37a..158628c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -43,4 +43,4 @@ kotlinReflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = moshi = { module = "com.squareup.moshi:moshi", version = "1.14.0" } serialization = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinxSerialization" } serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerialization" } - +kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version = "0.4.0" } diff --git a/ktor-docs-plugin-gradle/build.gradle.kts b/ktor-docs-plugin-gradle/build.gradle.kts index e6f1d4d..a455eb0 100644 --- a/ktor-docs-plugin-gradle/build.gradle.kts +++ b/ktor-docs-plugin-gradle/build.gradle.kts @@ -15,6 +15,7 @@ dependencies { shadow(projects.common) implementation(libs.serialization.json) implementation(libs.serialization) + implementation(libs.kotlinx.datetime) } gradlePlugin {