From 771f471f584a03bd80d19c6313b7d01eb4381872 Mon Sep 17 00:00:00 2001 From: dzikoysk Date: Mon, 8 Jun 2026 02:31:45 +0200 Subject: [PATCH 01/43] feat: proof of concept --- .../build.gradle.kts | 15 + .../openapi/dynamic/hook/OpenApiMetadata.kt | 26 ++ .../dynamic/hook/RegisteredRoutesHook.kt | 46 +++ .../openapi/dynamic/hook/fixtures/User.java | 12 + .../dynamic/hook/DynamicOpenApiHookTest.kt | 83 ++++++ .../openapi/plugin/OpenApiConfiguration.kt | 21 +- .../javalin/openapi/plugin/OpenApiPlugin.kt | 14 +- openapi-dynamic/build.gradle.kts | 11 + .../dynamic/IntrospectionSchemaGenerator.kt | 98 +++++++ .../openapi/dynamic/ReflectionHandle.kt | 16 ++ .../dynamic/ReflectiveTypeIntrospector.kt | 266 ++++++++++++++++++ .../openapi/dynamic/fixtures/Account.java | 55 ++++ .../openapi/dynamic/fixtures/Address.java | 20 ++ .../openapi/dynamic/fixtures/FieldsDto.java | 11 + .../openapi/dynamic/fixtures/NotNull.java | 12 + .../openapi/dynamic/fixtures/Role.java | 8 + .../dynamic/fixtures/SnakeCaseDto.java | 12 + .../IntrospectionSchemaGeneratorTest.kt | 86 ++++++ .../dynamic/ReflectiveTypeIntrospectorTest.kt | 102 +++++++ .../openapi/experimental/TypeIntrospector.kt | 22 ++ settings.gradle.kts | 2 + 21 files changed, 934 insertions(+), 4 deletions(-) create mode 100644 javalin-plugins/javalin-openapi-dynamic-hook/build.gradle.kts create mode 100644 javalin-plugins/javalin-openapi-dynamic-hook/src/main/kotlin/io/javalin/openapi/dynamic/hook/OpenApiMetadata.kt create mode 100644 javalin-plugins/javalin-openapi-dynamic-hook/src/main/kotlin/io/javalin/openapi/dynamic/hook/RegisteredRoutesHook.kt create mode 100644 javalin-plugins/javalin-openapi-dynamic-hook/src/test/java/io/javalin/openapi/dynamic/hook/fixtures/User.java create mode 100644 javalin-plugins/javalin-openapi-dynamic-hook/src/test/kotlin/io/javalin/openapi/dynamic/hook/DynamicOpenApiHookTest.kt create mode 100644 openapi-dynamic/build.gradle.kts create mode 100644 openapi-dynamic/src/main/kotlin/io/javalin/openapi/dynamic/IntrospectionSchemaGenerator.kt create mode 100644 openapi-dynamic/src/main/kotlin/io/javalin/openapi/dynamic/ReflectionHandle.kt create mode 100644 openapi-dynamic/src/main/kotlin/io/javalin/openapi/dynamic/ReflectiveTypeIntrospector.kt create mode 100644 openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/fixtures/Account.java create mode 100644 openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/fixtures/Address.java create mode 100644 openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/fixtures/FieldsDto.java create mode 100644 openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/fixtures/NotNull.java create mode 100644 openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/fixtures/Role.java create mode 100644 openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/fixtures/SnakeCaseDto.java create mode 100644 openapi-dynamic/src/test/kotlin/io/javalin/openapi/dynamic/IntrospectionSchemaGeneratorTest.kt create mode 100644 openapi-dynamic/src/test/kotlin/io/javalin/openapi/dynamic/ReflectiveTypeIntrospectorTest.kt create mode 100644 openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/TypeIntrospector.kt diff --git a/javalin-plugins/javalin-openapi-dynamic-hook/build.gradle.kts b/javalin-plugins/javalin-openapi-dynamic-hook/build.gradle.kts new file mode 100644 index 00000000..4120eda6 --- /dev/null +++ b/javalin-plugins/javalin-openapi-dynamic-hook/build.gradle.kts @@ -0,0 +1,15 @@ +description = "Javalin OpenAPI Dynamic Hook | Runtime OpenAPI generation hook for the OpenApiPlugin (route-based)" + +dependencies { + api(project(":openapi-dynamic")) + api(project(":javalin-plugins:javalin-openapi-plugin")) + compileOnly(libs.javalin) + + testImplementation(libs.javalin) + testImplementation(libs.junit.jupiter.api) + testImplementation(libs.junit.jupiter.engine) + testRuntimeOnly(libs.junit.platform.launcher) + testImplementation(libs.assertj.core) + testImplementation(libs.unirest) + testImplementation(libs.logback.classic) +} diff --git a/javalin-plugins/javalin-openapi-dynamic-hook/src/main/kotlin/io/javalin/openapi/dynamic/hook/OpenApiMetadata.kt b/javalin-plugins/javalin-openapi-dynamic-hook/src/main/kotlin/io/javalin/openapi/dynamic/hook/OpenApiMetadata.kt new file mode 100644 index 00000000..1ad51efe --- /dev/null +++ b/javalin-plugins/javalin-openapi-dynamic-hook/src/main/kotlin/io/javalin/openapi/dynamic/hook/OpenApiMetadata.kt @@ -0,0 +1,26 @@ +package io.javalin.openapi.dynamic.hook + +import io.javalin.openapi.dynamic.IntrospectionSchemaGenerator +import io.javalin.openapi.dynamic.ReflectiveTypeIntrospector +import io.javalin.openapi.schema.MediaTypeBuilder +import io.javalin.openapi.schema.OperationBuilder +import io.javalin.router.EndpointMetadata +import java.util.function.Consumer + +/** Endpoint metadata documenting a route through the OpenApi [OperationBuilder] DSL. */ +class OpenApiMetadata(val configure: OperationBuilder.() -> Unit) : EndpointMetadata { + + companion object { + /** Java-friendly factory. */ + @JvmStatic + fun of(configure: Consumer): OpenApiMetadata = + OpenApiMetadata { configure.accept(this) } + } +} + +internal val dynamicIntrospector = ReflectiveTypeIntrospector() +internal val dynamicSchemaGenerator = IntrospectionSchemaGenerator(dynamicIntrospector) + +/** Resolve a DTO [type] to a schema `$ref` inside the content DSL, reusing the reflection engine. */ +fun MediaTypeBuilder.schema(type: Class<*>): Unit = + schema(dynamicSchemaGenerator.createEmbeddedTypeDescription(dynamicIntrospector.introspect(type))) diff --git a/javalin-plugins/javalin-openapi-dynamic-hook/src/main/kotlin/io/javalin/openapi/dynamic/hook/RegisteredRoutesHook.kt b/javalin-plugins/javalin-openapi-dynamic-hook/src/main/kotlin/io/javalin/openapi/dynamic/hook/RegisteredRoutesHook.kt new file mode 100644 index 00000000..58460bba --- /dev/null +++ b/javalin-plugins/javalin-openapi-dynamic-hook/src/main/kotlin/io/javalin/openapi/dynamic/hook/RegisteredRoutesHook.kt @@ -0,0 +1,46 @@ +package io.javalin.openapi.dynamic.hook + +import io.javalin.openapi.plugin.OpenApiHook +import io.javalin.openapi.plugin.OpenApiHookContext + +/** Autogenerates an OpenAPI operation for every registered Javalin route; [OpenApiMetadata] enriches a route when present. */ +class RegisteredRoutesHook : OpenApiHook { + + override fun apply(context: OpenApiHookContext) { + context.builder.openApiVersion("3.1.0") + + context.state.internalRouter.allHttpHandlers() + .map { it.endpoint } + .filter { it.method.isHttpMethod } + .forEach { endpoint -> + val pathParams = PATH_PARAM.findAll(endpoint.path).map { it.groupValues[1] }.toList() + val metadata = endpoint.metadata(OpenApiMetadata::class.java) + + context.builder.path(toOpenApiPath(endpoint.path)).operation(endpoint.method.name().lowercase()) { + if (pathParams.isNotEmpty()) { + parameters { + pathParams.forEach { name -> + parameter(name = name, location = "path", required = true) { type("string") } + } + } + } + + when (metadata) { + null -> responses { response("200") { description("OK") } } + else -> metadata.configure(this) + } + } + } + + context.builder.resolveComponentReferences { type -> dynamicSchemaGenerator.createTypeSchema(type) } + } + + /** Javalin `` → OpenAPI `{slashParam}`; `{param}` kept as-is. */ + private fun toOpenApiPath(path: String): String = + path.replace(ANGLE_PARAM) { "{${it.groupValues[1]}}" } + + private companion object { + private val PATH_PARAM = Regex("[{<]([^}>]+)[}>]") + private val ANGLE_PARAM = Regex("<([^>]+)>") + } +} diff --git a/javalin-plugins/javalin-openapi-dynamic-hook/src/test/java/io/javalin/openapi/dynamic/hook/fixtures/User.java b/javalin-plugins/javalin-openapi-dynamic-hook/src/test/java/io/javalin/openapi/dynamic/hook/fixtures/User.java new file mode 100644 index 00000000..fbf22a38 --- /dev/null +++ b/javalin-plugins/javalin-openapi-dynamic-hook/src/test/java/io/javalin/openapi/dynamic/hook/fixtures/User.java @@ -0,0 +1,12 @@ +package io.javalin.openapi.dynamic.hook.fixtures; + +public class User { + + public int getId() { + return 0; + } + + public String getName() { + return ""; + } +} diff --git a/javalin-plugins/javalin-openapi-dynamic-hook/src/test/kotlin/io/javalin/openapi/dynamic/hook/DynamicOpenApiHookTest.kt b/javalin-plugins/javalin-openapi-dynamic-hook/src/test/kotlin/io/javalin/openapi/dynamic/hook/DynamicOpenApiHookTest.kt new file mode 100644 index 00000000..99632775 --- /dev/null +++ b/javalin-plugins/javalin-openapi-dynamic-hook/src/test/kotlin/io/javalin/openapi/dynamic/hook/DynamicOpenApiHookTest.kt @@ -0,0 +1,83 @@ +package io.javalin.openapi.dynamic.hook + +import io.javalin.Javalin +import io.javalin.http.HandlerType +import io.javalin.openapi.dynamic.hook.fixtures.User +import io.javalin.openapi.experimental.processor.shared.jsonMapper +import io.javalin.openapi.plugin.OpenApiPlugin +import io.javalin.router.Endpoint +import kong.unirest.Unirest +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +class DynamicOpenApiHookTest { + + @Test + fun `autogenerates docs for all registered Javalin routes`() { + val app = Javalin.create { config -> + config.registerPlugin(OpenApiPlugin { it.withHook(RegisteredRoutesHook()) }) + config.routes.get("/users") { } + config.routes.post("/users") { } + config.routes.get("/users/{id}") { } + } + app.start(0) + + try { + val body = Unirest.get("http://localhost:${app.port()}/openapi").asString().body + val paths = jsonMapper.readTree(body).path("paths") + + assertThat(paths.path("/users").has("get")).isTrue() + assertThat(paths.path("/users").has("post")).isTrue() + + val byId = paths.path("/users/{id}").path("get") + assertThat(byId.path("responses").path("200").path("description").asText()).isEqualTo("OK") + + val idParam = byId.path("parameters")[0] + assertThat(idParam.path("name").asText()).isEqualTo("id") + assertThat(idParam.path("in").asText()).isEqualTo("path") + assertThat(idParam.path("required").asBoolean()).isTrue() + assertThat(idParam.path("schema").path("type").asText()).isEqualTo("string") + } finally { + app.stop() + } + } + + @Test + fun `enriches a route from OpenApiMetadata, reusing the operation builder and schema engine`() { + val app = Javalin.create { config -> + config.registerPlugin(OpenApiPlugin { it.withHook(RegisteredRoutesHook()) }) + config.routes.addEndpoint( + Endpoint.create(HandlerType.GET, "/users/{id}") + .addMetadata(OpenApiMetadata { + summary("Get a user") + responses { + response("200") { + description("The user") + content { mediaType("application/json") { schema(User::class.java) } } + } + } + }) + .handler { } + ) + } + app.start(0) + + try { + val document = jsonMapper.readTree(Unirest.get("http://localhost:${app.port()}/openapi").asString().body) + val get = document.path("paths").path("/users/{id}").path("get") + + assertThat(get.path("summary").asText()).isEqualTo("Get a user") + // Path param skeleton is still auto-added, then the metadata is overlaid. + assertThat(get.path("parameters")[0].path("name").asText()).isEqualTo("id") + + val schema = get.path("responses").path("200").path("content").path("application/json").path("schema") + assertThat(schema.path($$"$ref").asText()).isEqualTo("#/components/schemas/User") + + val user = document.path("components").path("schemas").path("User") + assertThat(user.path("type").asText()).isEqualTo("object") + assertThat(user.path("properties").fieldNames().asSequence().toList()).containsExactlyInAnyOrder("id", "name") + } finally { + app.stop() + } + } +} diff --git a/javalin-plugins/javalin-openapi-plugin/src/main/kotlin/io/javalin/openapi/plugin/OpenApiConfiguration.kt b/javalin-plugins/javalin-openapi-plugin/src/main/kotlin/io/javalin/openapi/plugin/OpenApiConfiguration.kt index a67e1cb6..f3cf0bad 100644 --- a/javalin-plugins/javalin-openapi-plugin/src/main/kotlin/io/javalin/openapi/plugin/OpenApiConfiguration.kt +++ b/javalin-plugins/javalin-openapi-plugin/src/main/kotlin/io/javalin/openapi/plugin/OpenApiConfiguration.kt @@ -3,6 +3,7 @@ package io.javalin.openapi.plugin import com.fasterxml.jackson.databind.node.ObjectNode +import io.javalin.config.JavalinState import io.javalin.openapi.schema.OpenApiSchemaBuilder import io.javalin.security.RouteRole import java.util.function.BiConsumer @@ -12,6 +13,18 @@ fun interface DefinitionProcessor { fun process(content: ObjectNode): String } +/** Context for an [OpenApiHook]: the document [builder] for a [version], plus the live Javalin [state]. */ +class OpenApiHookContext( + val version: String, + val builder: OpenApiSchemaBuilder, + val state: JavalinState, +) + +/** Hook that extends the OpenApi document at runtime, per version. */ +fun interface OpenApiHook { + fun apply(context: OpenApiHookContext) +} + /** Configure OpenApi plugin */ class OpenApiPluginConfiguration @JvmOverloads constructor( @JvmField var documentationPath: String = "/openapi", @@ -19,9 +32,15 @@ class OpenApiPluginConfiguration @JvmOverloads constructor( @JvmField var prettyOutputEnabled: Boolean = true, @JvmField var definitionConfiguration: BiConsumer? = null, @JvmField var definitionProcessor: DefinitionProcessor? = null, - @JvmField var resourceClassLoader: ClassLoader? = null + @JvmField var resourceClassLoader: ClassLoader? = null, + @JvmField var hooks: MutableList = mutableListOf(), ) { + /** Register a hook that extends the document at runtime (e.g. dynamically generated routes) */ + fun withHook(hook: OpenApiHook): OpenApiPluginConfiguration = also { + this.hooks.add(hook) + } + /** Path to host documentation as JSON */ fun withDocumentationPath(path: String): OpenApiPluginConfiguration = also { this.documentationPath = path diff --git a/javalin-plugins/javalin-openapi-plugin/src/main/kotlin/io/javalin/openapi/plugin/OpenApiPlugin.kt b/javalin-plugins/javalin-openapi-plugin/src/main/kotlin/io/javalin/openapi/plugin/OpenApiPlugin.kt index 27a53385..9e1e4b2e 100644 --- a/javalin-plugins/javalin-openapi-plugin/src/main/kotlin/io/javalin/openapi/plugin/OpenApiPlugin.kt +++ b/javalin-plugins/javalin-openapi-plugin/src/main/kotlin/io/javalin/openapi/plugin/OpenApiPlugin.kt @@ -15,19 +15,27 @@ open class OpenApiPlugin(userConfig: Consumer) : Plu override fun onStart(state: JavalinState) { state.routes.get( pluginConfig.documentationPath, - OpenApiHandler(createDocumentation()), + OpenApiHandler(createDocumentation(state)), *pluginConfig.roles ) } - private fun createDocumentation(): Lazy> = + private fun createDocumentation(state: JavalinState): Lazy> = lazy { OpenApiLoader(pluginConfig.resourceClassLoader ?: OpenApiLoader::class.java.classLoader) .loadOpenApiSchemes() .mapValues { (version, rawDocs) -> val builder = OpenApiSchemaBuilder.fromJson(rawDocs) + val context = OpenApiHookContext(version, builder, state) + pluginConfig.hooks.forEach { it.apply(context) } pluginConfig.definitionConfiguration?.accept(version, builder) - val json = if (pluginConfig.prettyOutputEnabled) builder.toJson() else builder.toCompactJson() + + val json = + when { + pluginConfig.prettyOutputEnabled -> builder.toJson() + else -> builder.toCompactJson() + } + when (val processor = pluginConfig.definitionProcessor) { null -> json else -> processor.process(jsonMapper.readTree(json) as ObjectNode) diff --git a/openapi-dynamic/build.gradle.kts b/openapi-dynamic/build.gradle.kts new file mode 100644 index 00000000..06614680 --- /dev/null +++ b/openapi-dynamic/build.gradle.kts @@ -0,0 +1,11 @@ +description = "Javalin OpenAPI Dynamic | Runtime reflection-based OpenAPI introspection (experimental)" + +dependencies { + api(project(":openapi-generator")) + + testImplementation(libs.junit.jupiter.params) + testImplementation(libs.junit.jupiter.api) + testImplementation(libs.junit.jupiter.engine) + testRuntimeOnly(libs.junit.platform.launcher) + testImplementation(libs.assertj.core) +} diff --git a/openapi-dynamic/src/main/kotlin/io/javalin/openapi/dynamic/IntrospectionSchemaGenerator.kt b/openapi-dynamic/src/main/kotlin/io/javalin/openapi/dynamic/IntrospectionSchemaGenerator.kt new file mode 100644 index 00000000..9747d5e2 --- /dev/null +++ b/openapi-dynamic/src/main/kotlin/io/javalin/openapi/dynamic/IntrospectionSchemaGenerator.kt @@ -0,0 +1,98 @@ +package io.javalin.openapi.dynamic + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.node.ObjectNode +import io.javalin.openapi.experimental.ClassDefinition +import io.javalin.openapi.experimental.SimpleType +import io.javalin.openapi.experimental.StructureType.ARRAY +import io.javalin.openapi.experimental.StructureType.DICTIONARY +import io.javalin.openapi.experimental.TypeIntrospector +import io.javalin.openapi.experimental.defaults.createDefaultSimpleTypeMappings +import io.javalin.openapi.experimental.processor.generators.ResultScheme +import io.javalin.openapi.experimental.processor.generators.addExtra +import io.javalin.openapi.experimental.processor.shared.createArrayNode +import io.javalin.openapi.experimental.processor.shared.createObjectNode + +/** Backend-agnostic JSON Schema generator driven by [TypeIntrospector] (+ `simpleTypeMappings`). */ +class IntrospectionSchemaGenerator( + private val introspector: TypeIntrospector, + private val simpleTypeMappings: Map = createDefaultSimpleTypeMappings(), +) { + + /** Full component schema for [type] — the body stored under `components/schemas/{simpleName}`. */ + fun createTypeSchema(type: ClassDefinition): ResultScheme { + val schema = createObjectNode() + val references = mutableSetOf() + + if (introspector.isEnum(type)) { + schema.put("type", "string") + val values = createArrayNode() + introspector.enumConstants(type)!!.forEach { values.add(it) } + schema.set("enum", values) + return ResultScheme(schema, references) + } + + schema.put("type", "object") + val propertiesNode = createObjectNode() + schema.set("properties", propertiesNode) + + val properties = introspector.properties(type) + for (property in properties) { + val result = createEmbeddedTypeDescription(property.type, property.extra) + propertiesNode.set(property.name, result.json) + references.addAll(result.references) + } + + val required = properties.filter { it.required } + if (required.isNotEmpty()) { + val requiredNode = createArrayNode() + required.forEach { requiredNode.add(it.name) } + schema.set("required", requiredNode) + } + + return ResultScheme(schema, references) + } + + /** Inline, structure-aware description for a property/value: `$ref`, array, dictionary, or simple type. */ + fun createEmbeddedTypeDescription(type: ClassDefinition, extra: Map = emptyMap()): ResultScheme { + val scheme = createObjectNode() + val references = mutableSetOf() + + when (type.structureType) { + ARRAY -> + if (type.simpleName == "Byte") { + scheme.put("type", "string") + scheme.put("format", "binary") + } else { + scheme.put("type", "array") + val items = createObjectNode() + addType(items, type, references) + scheme.set("items", items) + } + DICTIONARY -> { + scheme.put("type", "object") + val additionalProperties = createEmbeddedTypeDescription(type.generics[1]) + references.addAll(additionalProperties.references) + scheme.set("additionalProperties", additionalProperties.json) + } + else -> + addType(scheme, type, references) + } + + scheme.addExtra(extra) + return ResultScheme(scheme, references) + } + + private fun addType(scheme: ObjectNode, type: ClassDefinition, references: MutableSet) { + when (val mapped = simpleTypeMappings[type.fullName]) { + null -> { + references.add(type) + scheme.put($$"$ref", "#/components/schemas/${type.simpleName}") + } + else -> { + scheme.put("type", mapped.type) + mapped.format?.also { scheme.put("format", it) } + } + } + } +} diff --git a/openapi-dynamic/src/main/kotlin/io/javalin/openapi/dynamic/ReflectionHandle.kt b/openapi-dynamic/src/main/kotlin/io/javalin/openapi/dynamic/ReflectionHandle.kt new file mode 100644 index 00000000..b138b29f --- /dev/null +++ b/openapi-dynamic/src/main/kotlin/io/javalin/openapi/dynamic/ReflectionHandle.kt @@ -0,0 +1,16 @@ +package io.javalin.openapi.dynamic + +import io.javalin.openapi.experimental.ClassDefinition +import java.lang.reflect.Type + +/** Reflection counterpart of the AP's `ClassDefinitionHandle`, stored in [ClassDefinition.handle]. */ +data class ReflectionHandle( + val erasure: Class<*>, + val type: Type, +) + +val ClassDefinition.reflectedClass: Class<*> + get() = (handle as ReflectionHandle).erasure + +val ClassDefinition.reflectedType: Type + get() = (handle as ReflectionHandle).type diff --git a/openapi-dynamic/src/main/kotlin/io/javalin/openapi/dynamic/ReflectiveTypeIntrospector.kt b/openapi-dynamic/src/main/kotlin/io/javalin/openapi/dynamic/ReflectiveTypeIntrospector.kt new file mode 100644 index 00000000..3507dd0c --- /dev/null +++ b/openapi-dynamic/src/main/kotlin/io/javalin/openapi/dynamic/ReflectiveTypeIntrospector.kt @@ -0,0 +1,266 @@ +package io.javalin.openapi.dynamic + +import io.javalin.openapi.OpenApiByFields +import io.javalin.openapi.OpenApiDescription +import io.javalin.openapi.OpenApiIgnore +import io.javalin.openapi.OpenApiName +import io.javalin.openapi.OpenApiNaming +import io.javalin.openapi.OpenApiNullable +import io.javalin.openapi.OpenApiRequired +import io.javalin.openapi.Visibility +import io.javalin.openapi.experimental.ClassDefinition +import io.javalin.openapi.experimental.StructureType +import io.javalin.openapi.experimental.StructureType.ARRAY +import io.javalin.openapi.experimental.StructureType.DEFAULT +import io.javalin.openapi.experimental.StructureType.DICTIONARY +import io.javalin.openapi.experimental.TypeIntrospector +import io.javalin.openapi.experimental.processor.generators.Property +import io.javalin.openapi.experimental.processor.generators.translatePropertyName +import java.lang.reflect.AnnotatedElement +import java.lang.reflect.Field +import java.lang.reflect.GenericArrayType +import java.lang.reflect.Modifier +import java.lang.reflect.ParameterizedType +import java.lang.reflect.Type +import java.lang.reflect.WildcardType + +/** Runtime-reflection [TypeIntrospector] over [java.lang.reflect.Type]. */ +class ReflectiveTypeIntrospector( + private val requireNonNullsByDefault: Boolean = true, +) : TypeIntrospector { + + override fun introspect(nativeType: Any): ClassDefinition { + require(nativeType is Type) { + "ReflectiveTypeIntrospector expects a java.lang.reflect.Type, got ${nativeType::class.java.name}" + } + return classDefinitionOf(nativeType) + } + + fun introspect(type: Type): ClassDefinition = classDefinitionOf(type) + + private fun classDefinitionOf( + type: Type, + generics: List = emptyList(), + structureType: StructureType = DEFAULT, + ): ClassDefinition = + when (type) { + is GenericArrayType -> classDefinitionOf(type.genericComponentType, generics, ARRAY) + is WildcardType -> classDefinitionOf(type.upperBounds.firstOrNull() ?: Any::class.java, generics, structureType) + is ParameterizedType -> parameterized(type, generics, structureType) + is Class<*> -> raw(type, generics, structureType) + else -> objectDefinition(structureType) + } + + private fun raw(clazz: Class<*>, generics: List, structureType: StructureType): ClassDefinition = + when { + clazz.isArray -> classDefinitionOf(clazz.componentType, generics, ARRAY) + clazz.isPrimitive -> definition(box(clazz), generics, structureType) + Map::class.java.isAssignableFrom(clazz) -> definition(clazz, listOf(objectDefinition(), objectDefinition()), DICTIONARY) + Collection::class.java.isAssignableFrom(clazz) -> objectDefinition(ARRAY) + else -> definition(clazz, generics, structureType) + } + + private fun parameterized(type: ParameterizedType, generics: List, structureType: StructureType): ClassDefinition { + val erasure = type.rawType as Class<*> + val arguments = type.actualTypeArguments + return when { + Map::class.java.isAssignableFrom(erasure) -> + definition( + erasure = erasure, + generics = listOf( + classDefinitionOf(arguments.getOrElse(0) { Any::class.java }), + classDefinitionOf(arguments.getOrElse(1) { Any::class.java }), + ), + structureType = DICTIONARY, + fullType = type, + ) + Collection::class.java.isAssignableFrom(erasure) -> + classDefinitionOf(arguments.getOrElse(0) { Any::class.java }, generics, ARRAY) + else -> + definition( + erasure = erasure, + generics = arguments.map { classDefinitionOf(it) }, + structureType = structureType, + fullType = type, + ) + } + } + + private fun definition( + erasure: Class<*>, + generics: List, + structureType: StructureType, + fullType: Type = erasure, + ): ClassDefinition { + val customName = erasure.getAnnotation(OpenApiName::class.java)?.value + val packageName = erasure.`package`?.name?.takeIf { it.isNotEmpty() } + return ClassDefinition( + simpleName = customName ?: erasure.simpleName.ifEmpty { erasure.name.substringAfterLast('.') }, + fullName = when { + customName != null -> if (packageName == null) customName else "$packageName.$customName" + else -> erasure.canonicalName ?: erasure.name + }, + generics = generics, + structureType = structureType, + handle = ReflectionHandle(erasure, fullType), + ) + } + + private fun objectDefinition(structureType: StructureType = DEFAULT): ClassDefinition = + definition(Any::class.java, emptyList(), structureType) + + override fun properties(type: ClassDefinition): List { + val clazz = type.reflectedClass + val byFields = clazz.getAnnotation(OpenApiByFields::class.java) + val namingStrategy = clazz.getAnnotation(OpenApiNaming::class.java)?.value + + return collectMembers(clazz, byFields).mapNotNull { member -> + if (member.annotation(OpenApiIgnore::class.java) != null) return@mapNotNull null + if (member.isField && Modifier.isTransient(member.modifiers)) return@mapNotNull null + + val customName = member.annotation(OpenApiName::class.java)?.value + val resolvedName = when { + customName != null -> customName + member.isField || member.isRecordComponent -> member.rawName + member.rawName.startsWith("get") -> member.rawName.removePrefix("get").replaceFirstChar { it.lowercase() } + member.rawName.startsWith("is") -> member.rawName.removePrefix("is").replaceFirstChar { it.lowercase() } + else -> return@mapNotNull null + } + val finalName = if (customName == null && namingStrategy != null) translatePropertyName(namingStrategy, resolvedName) else resolvedName + + val isPrimitive = (member.genericType as? Class<*>)?.isPrimitive == true + val isNotNull = when { + member.hasAnnotationNamed("NotNull") -> true + isPrimitive -> true + member.hasAnnotationNamed("Nullable") -> false + else -> false + } + val required = member.annotation(OpenApiRequired::class.java) != null || (requireNonNullsByDefault && isNotNull) + + val openApiNullable = member.annotation(OpenApiNullable::class.java) + val nullable = when { + openApiNullable != null -> openApiNullable.nullable + member.hasAnnotationNamed("Nullable") -> true + else -> false + } + + val extra = buildMap { + member.annotation(OpenApiDescription::class.java)?.let { put("description", it.value) } + } + + Property( + name = finalName, + type = classDefinitionOf(member.genericType), + required = required, + nullable = nullable, + extra = extra, + ) + } + } + + private fun collectMembers(clazz: Class<*>, byFields: OpenApiByFields?): List { + if (clazz.isRecord) { + return clazz.recordComponents.map { component -> + val backingField = runCatching { clazz.getDeclaredField(component.name) }.getOrNull() + Member( + rawName = component.name, + genericType = component.genericType, + modifiers = 0, + sources = listOfNotNull(component.accessor, backingField, component), + isField = false, + isRecordComponent = true, + ) + } + } + + val members = mutableListOf() + + if (byFields?.only != true) { + for (method in clazz.methods) { + if (Modifier.isStatic(method.modifiers) || method.isBridge || method.isSynthetic) continue + if (method.parameterCount != 0 || method.declaringClass == Any::class.java) continue + if (method.name == "getClass") continue + if (!method.name.startsWith("get") && !method.name.startsWith("is")) continue + members += Member(method.name, method.genericReturnType, method.modifiers, listOf(method), isField = false, isRecordComponent = false) + } + } + + if (byFields != null) { + for (field in declaredFieldsHierarchy(clazz)) { + if (Modifier.isStatic(field.modifiers)) continue + if (byFields.value.priority > visibilityOf(field.modifiers).priority) continue + members += Member(field.name, field.genericType, field.modifiers, listOf(field), isField = true, isRecordComponent = false) + } + } + + return members + } + + override fun isEnum(type: ClassDefinition): Boolean = + type.reflectedClass.isEnum + + override fun enumConstants(type: ClassDefinition): List? { + val clazz = type.reflectedClass + if (!clazz.isEnum) return null + val namingStrategy = clazz.getAnnotation(OpenApiNaming::class.java)?.value + return clazz.enumConstants.map { constant -> + val name = (constant as Enum<*>).name + val customName = clazz.getField(name).getAnnotation(OpenApiName::class.java)?.value + when { + customName != null -> customName + namingStrategy != null -> translatePropertyName(namingStrategy, name) + else -> name + } + } + } + + override fun annotation(type: ClassDefinition, annotationType: Class): A? = + type.reflectedClass.getAnnotation(annotationType) + + private companion object { + private fun box(primitive: Class<*>): Class<*> = when (primitive) { + Int::class.javaPrimitiveType -> Int::class.javaObjectType + Long::class.javaPrimitiveType -> Long::class.javaObjectType + Short::class.javaPrimitiveType -> Short::class.javaObjectType + Byte::class.javaPrimitiveType -> Byte::class.javaObjectType + Char::class.javaPrimitiveType -> Char::class.javaObjectType + Boolean::class.javaPrimitiveType -> Boolean::class.javaObjectType + Float::class.javaPrimitiveType -> Float::class.javaObjectType + Double::class.javaPrimitiveType -> Double::class.javaObjectType + Void.TYPE -> Void::class.javaObjectType + else -> primitive + } + + private fun declaredFieldsHierarchy(clazz: Class<*>): List { + val fields = mutableListOf() + var current: Class<*>? = clazz + while (current != null && current != Any::class.java) { + fields += current.declaredFields + current = current.superclass + } + return fields + } + + private fun visibilityOf(modifiers: Int): Visibility = when { + Modifier.isPublic(modifiers) -> Visibility.PUBLIC + Modifier.isProtected(modifiers) -> Visibility.PROTECTED + Modifier.isPrivate(modifiers) -> Visibility.PRIVATE + else -> Visibility.DEFAULT + } + } + + private class Member( + val rawName: String, + val genericType: Type, + val modifiers: Int, + val sources: List, + val isField: Boolean, + val isRecordComponent: Boolean, + ) { + fun annotation(annotationType: Class): A? = + sources.firstNotNullOfOrNull { it.getAnnotation(annotationType) } + + fun hasAnnotationNamed(simpleName: String): Boolean = + sources.any { source -> source.annotations.any { it.annotationClass.simpleName == simpleName } } + } +} diff --git a/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/fixtures/Account.java b/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/fixtures/Account.java new file mode 100644 index 00000000..575dcc61 --- /dev/null +++ b/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/fixtures/Account.java @@ -0,0 +1,55 @@ +package io.javalin.openapi.dynamic.fixtures; + +import io.javalin.openapi.OpenApiDescription; +import io.javalin.openapi.OpenApiIgnore; +import io.javalin.openapi.OpenApiName; + +import java.util.List; +import java.util.Map; + +public class Account { + + @NotNull + public String getId() { + return ""; + } + + public int getAge() { + return 0; + } + + public String getName() { + return ""; + } + + public Role getRole() { + return Role.ADMIN; + } + + public Address getAddress() { + return null; + } + + public List getTags() { + return List.of(); + } + + public Map getMeta() { + return Map.of(); + } + + @OpenApiName("e_mail") + public String getEmail() { + return ""; + } + + @OpenApiIgnore + public String getSecret() { + return ""; + } + + @OpenApiDescription("Human readable label") + public String getLabel() { + return ""; + } +} diff --git a/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/fixtures/Address.java b/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/fixtures/Address.java new file mode 100644 index 00000000..3e53d2b7 --- /dev/null +++ b/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/fixtures/Address.java @@ -0,0 +1,20 @@ +package io.javalin.openapi.dynamic.fixtures; + +public class Address { + + private final String city; + private final String zip; + + public Address(String city, String zip) { + this.city = city; + this.zip = zip; + } + + public String getCity() { + return city; + } + + public String getZip() { + return zip; + } +} diff --git a/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/fixtures/FieldsDto.java b/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/fixtures/FieldsDto.java new file mode 100644 index 00000000..b711834f --- /dev/null +++ b/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/fixtures/FieldsDto.java @@ -0,0 +1,11 @@ +package io.javalin.openapi.dynamic.fixtures; + +import io.javalin.openapi.OpenApiByFields; + +@OpenApiByFields +public class FieldsDto { + + public String publicField = ""; + private String privateField = ""; + public static String STATIC_FIELD = ""; +} diff --git a/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/fixtures/NotNull.java b/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/fixtures/NotNull.java new file mode 100644 index 00000000..13136136 --- /dev/null +++ b/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/fixtures/NotNull.java @@ -0,0 +1,12 @@ +package io.javalin.openapi.dynamic.fixtures; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** Stand-in for a runtime-retained {@code @NotNull}, matched by simple name. */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.METHOD, ElementType.FIELD}) +public @interface NotNull { +} diff --git a/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/fixtures/Role.java b/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/fixtures/Role.java new file mode 100644 index 00000000..b3fdb5be --- /dev/null +++ b/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/fixtures/Role.java @@ -0,0 +1,8 @@ +package io.javalin.openapi.dynamic.fixtures; + +import io.javalin.openapi.OpenApiName; + +public enum Role { + ADMIN, + @OpenApiName("regular_user") USER +} diff --git a/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/fixtures/SnakeCaseDto.java b/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/fixtures/SnakeCaseDto.java new file mode 100644 index 00000000..f2a9534d --- /dev/null +++ b/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/fixtures/SnakeCaseDto.java @@ -0,0 +1,12 @@ +package io.javalin.openapi.dynamic.fixtures; + +import io.javalin.openapi.OpenApiNaming; +import io.javalin.openapi.OpenApiNamingStrategy; + +@OpenApiNaming(OpenApiNamingStrategy.SNAKE_CASE) +public class SnakeCaseDto { + + public String getFirstName() { + return ""; + } +} diff --git a/openapi-dynamic/src/test/kotlin/io/javalin/openapi/dynamic/IntrospectionSchemaGeneratorTest.kt b/openapi-dynamic/src/test/kotlin/io/javalin/openapi/dynamic/IntrospectionSchemaGeneratorTest.kt new file mode 100644 index 00000000..60997cfe --- /dev/null +++ b/openapi-dynamic/src/test/kotlin/io/javalin/openapi/dynamic/IntrospectionSchemaGeneratorTest.kt @@ -0,0 +1,86 @@ +package io.javalin.openapi.dynamic + +import com.fasterxml.jackson.databind.JsonNode +import io.javalin.openapi.dynamic.fixtures.Account +import io.javalin.openapi.experimental.processor.shared.jsonMapper +import io.javalin.openapi.schema.OpenApiSchemaBuilder +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +class IntrospectionSchemaGeneratorTest { + + private val introspector = ReflectiveTypeIntrospector() + private val generator = IntrospectionSchemaGenerator(introspector) + + private fun JsonNode.ref(): String = path($$"$ref").asText() + private fun JsonNode.stringArray(): List = map { it.asText() } + + @Test + fun `renders a full component graph through OpenApiSchemaBuilder via reflection`() { + val account = introspector.introspect(Account::class.java) + + val builder = OpenApiSchemaBuilder().openApiVersion("3.1.0") + builder.path("/account").operation("get") { + responses { + response("200") { + description("OK") + content { + mediaType("application/json") { + schema(generator.createEmbeddedTypeDescription(account)) + } + } + } + } + } + builder.resolveComponentReferences { type -> generator.createTypeSchema(type) } + + val document = jsonMapper.readTree(builder.toJson()) + val schemas = document.path("components").path("schemas") + + // The whole ref graph was discovered and resolved to a fixpoint from a single root type. + assertThat(schemas.fieldNames().asSequence().toList()) + .containsExactlyInAnyOrder("Account", "Address", "Role") + + // Root response is a $ref into the component graph. + assertThat( + document.path("paths").path("/account").path("get") + .path("responses").path("200").path("content") + .path("application/json").path("schema").ref() + ).isEqualTo("#/components/schemas/Account") + + val account_ = schemas.path("Account") + assertThat(account_.path("type").asText()).isEqualTo("object") + assertThat(account_.path("required").stringArray()).containsExactlyInAnyOrder("id", "age") + + val properties = account_.path("properties") + assertThat(properties.path("id").path("type").asText()).isEqualTo("string") + assertThat(properties.path("age").path("type").asText()).isEqualTo("integer") + assertThat(properties.path("age").path("format").asText()).isEqualTo("int32") + assertThat(properties.path("e_mail").path("type").asText()).isEqualTo("string") + assertThat(properties.has("email")).isFalse() + assertThat(properties.has("secret")).isFalse() + assertThat(properties.path("label").path("description").asText()).isEqualTo("Human readable label") + + // Nested object → $ref + assertThat(properties.path("address").ref()).isEqualTo("#/components/schemas/Address") + // Enum → $ref (resolved to a string enum component) + assertThat(properties.path("role").ref()).isEqualTo("#/components/schemas/Role") + + // Collection → array of items + assertThat(properties.path("tags").path("type").asText()).isEqualTo("array") + assertThat(properties.path("tags").path("items").path("type").asText()).isEqualTo("string") + + // Map → object with additionalProperties + assertThat(properties.path("meta").path("type").asText()).isEqualTo("object") + assertThat(properties.path("meta").path("additionalProperties").path("type").asText()).isEqualTo("integer") + + val address = schemas.path("Address") + assertThat(address.path("type").asText()).isEqualTo("object") + assertThat(address.path("properties").fieldNames().asSequence().toList()) + .containsExactlyInAnyOrder("city", "zip") + + val role = schemas.path("Role") + assertThat(role.path("type").asText()).isEqualTo("string") + assertThat(role.path("enum").stringArray()).containsExactly("ADMIN", "regular_user") + } +} diff --git a/openapi-dynamic/src/test/kotlin/io/javalin/openapi/dynamic/ReflectiveTypeIntrospectorTest.kt b/openapi-dynamic/src/test/kotlin/io/javalin/openapi/dynamic/ReflectiveTypeIntrospectorTest.kt new file mode 100644 index 00000000..78e360c1 --- /dev/null +++ b/openapi-dynamic/src/test/kotlin/io/javalin/openapi/dynamic/ReflectiveTypeIntrospectorTest.kt @@ -0,0 +1,102 @@ +package io.javalin.openapi.dynamic + +import io.javalin.openapi.dynamic.fixtures.Account +import io.javalin.openapi.dynamic.fixtures.Address +import io.javalin.openapi.dynamic.fixtures.FieldsDto +import io.javalin.openapi.dynamic.fixtures.Role +import io.javalin.openapi.dynamic.fixtures.SnakeCaseDto +import io.javalin.openapi.experimental.StructureType +import io.javalin.openapi.experimental.processor.generators.Property +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows + +class ReflectiveTypeIntrospectorTest { + + private val introspector = ReflectiveTypeIntrospector() + + private fun accountProperties(): Map = + introspector.properties(introspector.introspect(Account::class.java)).associateBy { it.name } + + @Test + fun `resolves a class into the shared ClassDefinition model`() { + val account = introspector.introspect(Account::class.java) + + assertThat(account.simpleName).isEqualTo("Account") + assertThat(account.fullName).isEqualTo("io.javalin.openapi.dynamic.fixtures.Account") + assertThat(account.structureType).isEqualTo(StructureType.DEFAULT) + } + + @Test + fun `lists scalar properties with required flags, renames, and ignores`() { + val props = accountProperties() + + assertThat(props.keys).contains("id", "age", "name", "e_mail") + assertThat(props.keys).doesNotContain("email", "secret", "class") + + assertThat(props.getValue("id").type.fullName).isEqualTo("java.lang.String") + assertThat(props.getValue("id").required).isTrue() // @NotNull + + assertThat(props.getValue("age").type.fullName).isEqualTo("java.lang.Integer") // boxed primitive + assertThat(props.getValue("age").required).isTrue() // primitive ⇒ non-null + + assertThat(props.getValue("name").required).isFalse() + } + + @Test + fun `resolves collections and maps into structure types`() { + val props = accountProperties() + + val tags = props.getValue("tags").type + assertThat(tags.structureType).isEqualTo(StructureType.ARRAY) + assertThat(tags.fullName).isEqualTo("java.lang.String") + + val meta = props.getValue("meta").type + assertThat(meta.structureType).isEqualTo(StructureType.DICTIONARY) + assertThat(meta.generics.map { it.fullName }).containsExactly("java.lang.String", "java.lang.Integer") + } + + @Test + fun `descends into nested object types`() { + val address = accountProperties().getValue("address").type + assertThat(address.fullName).isEqualTo(Address::class.java.name) + + val addressProperties = introspector.properties(address).associateBy { it.name } + assertThat(addressProperties.keys).containsExactlyInAnyOrder("city", "zip") + } + + @Test + fun `reads enum constants with renames`() { + val role = accountProperties().getValue("role").type + + assertThat(introspector.isEnum(role)).isTrue() + assertThat(introspector.enumConstants(role)).containsExactly("ADMIN", "regular_user") + assertThat(introspector.enumConstants(introspector.introspect(Role::class.java))) + .containsExactly("ADMIN", "regular_user") + } + + @Test + fun `captures OpenApiDescription into property extra`() { + assertThat(accountProperties().getValue("label").extra["description"]) + .isEqualTo("Human readable label") + } + + @Test + fun `applies the configured naming strategy`() { + val props = introspector.properties(introspector.introspect(SnakeCaseDto::class.java)).associateBy { it.name } + assertThat(props.keys).contains("first_name") + } + + @Test + fun `reads fields and honors visibility when OpenApiByFields is present`() { + val props = introspector.properties(introspector.introspect(FieldsDto::class.java)).associateBy { it.name } + assertThat(props.keys).containsExactly("publicField") + } + + @Test + fun `rejects non-reflection native tokens`() { + assertThrows { + introspector.introspect("not a type" as Any) + } + } +} diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/TypeIntrospector.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/TypeIntrospector.kt new file mode 100644 index 00000000..074ce742 --- /dev/null +++ b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/TypeIntrospector.kt @@ -0,0 +1,22 @@ +package io.javalin.openapi.experimental + +import io.javalin.openapi.experimental.processor.generators.Property + +/** + * Backend-agnostic contract for turning a native type token (`TypeMirror`, `KSType`, [java.lang.reflect.Type], ...) into the shared [ClassDefinition] / [Property] model, + * so schema generation stays decoupled from any single type system (kapt, KSP, runtime reflection). + */ +interface TypeIntrospector { + + /** Resolve a backend-native type token into a [ClassDefinition] (array/map/generic resolution included). */ + fun introspect(nativeType: Any): ClassDefinition + + fun isEnum(type: ClassDefinition): Boolean + + /** Enum constant names, or `null` if [type] is not an enum. */ + fun enumConstants(type: ClassDefinition): List? + + fun properties(type: ClassDefinition): List + + fun annotation(type: ClassDefinition, annotationType: Class): A? +} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 244aa2ae..4f40a79b 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -4,10 +4,12 @@ include( "openapi-specification", "openapi-generator", "openapi-annotation-processor", + "openapi-dynamic", "javalin-plugins", "javalin-plugins:javalin-openapi-plugin", "javalin-plugins:javalin-swagger-plugin", "javalin-plugins:javalin-redoc-plugin", + "javalin-plugins:javalin-openapi-dynamic-hook", "examples", "examples:javalin-gradle-kotlin" ) \ No newline at end of file From 9bdb4a65cc4eabffaf04e636b8b9a7072d549c7e Mon Sep 17 00:00:00 2001 From: dzikoysk Date: Sun, 14 Jun 2026 18:54:07 +0200 Subject: [PATCH 02/43] chore: move introspection related code to a separate module --- .../introspection-api/build.gradle.kts | 8 + .../io/javalin/introspection/Annotations.kt | 16 ++ .../javalin/introspection/ClassDefinition.kt | 27 +++ .../io/javalin/introspection/PropertyView.kt | 12 ++ .../javalin/introspection/TypeIntrospector.kt | 9 + .../introspection-jap/build.gradle.kts | 10 + .../introspection/jap/JapTypeIntrospector.kt | 187 ++++++++++++++++++ introspection/introspection-ksp/.gitkeep | 1 + .../introspection-runtime/build.gradle.kts | 10 + .../runtime/ReflectionTypeIntrospector.kt | 181 +++++++++++++++++ .../runtime/ReflectionTypeIntrospectorTest.kt | 124 ++++++++++++ .../introspection-test/build.gradle.kts | 10 + .../test/IntrospectionParityTest.kt | 60 ++++++ .../javalin/introspection/test/JapHarness.kt | 52 +++++ .../io/javalin/introspection/test/Snapshot.kt | 39 ++++ .../introspection/test/fixtures/Fixtures.kt | 31 +++ .../openapi/dynamic/ReflectionHandle.kt | 9 +- .../dynamic/ReflectiveTypeIntrospector.kt | 25 +-- settings.gradle.kts | 5 + 19 files changed, 786 insertions(+), 30 deletions(-) create mode 100644 introspection/introspection-api/build.gradle.kts create mode 100644 introspection/introspection-api/src/main/kotlin/io/javalin/introspection/Annotations.kt create mode 100644 introspection/introspection-api/src/main/kotlin/io/javalin/introspection/ClassDefinition.kt create mode 100644 introspection/introspection-api/src/main/kotlin/io/javalin/introspection/PropertyView.kt create mode 100644 introspection/introspection-api/src/main/kotlin/io/javalin/introspection/TypeIntrospector.kt create mode 100644 introspection/introspection-jap/build.gradle.kts create mode 100644 introspection/introspection-jap/src/main/kotlin/io/javalin/introspection/jap/JapTypeIntrospector.kt create mode 100644 introspection/introspection-ksp/.gitkeep create mode 100644 introspection/introspection-runtime/build.gradle.kts create mode 100644 introspection/introspection-runtime/src/main/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospector.kt create mode 100644 introspection/introspection-runtime/src/test/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospectorTest.kt create mode 100644 introspection/introspection-test/build.gradle.kts create mode 100644 introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/IntrospectionParityTest.kt create mode 100644 introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/JapHarness.kt create mode 100644 introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/Snapshot.kt create mode 100644 introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/fixtures/Fixtures.kt diff --git a/introspection/introspection-api/build.gradle.kts b/introspection/introspection-api/build.gradle.kts new file mode 100644 index 00000000..845f6a3c --- /dev/null +++ b/introspection/introspection-api/build.gradle.kts @@ -0,0 +1,8 @@ +description = "Introspection API | Backend-agnostic type & symbol introspection model" + +dependencies { + testImplementation(libs.junit.jupiter.api) + testImplementation(libs.junit.jupiter.engine) + testRuntimeOnly(libs.junit.platform.launcher) + testImplementation(libs.assertj.core) +} diff --git a/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/Annotations.kt b/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/Annotations.kt new file mode 100644 index 00000000..135415b4 --- /dev/null +++ b/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/Annotations.kt @@ -0,0 +1,16 @@ +package io.javalin.introspection + +import kotlin.reflect.KClass + +interface Annotations { + fun find(annotationType: Class): A? + fun hasBySimpleName(simpleName: String): Boolean + + /** Resolve a `KClass`-valued member into a [ClassDefinition], hiding the compile-time `MirroredTypeException`. */ + fun classValue(annotationType: Class, member: A.() -> KClass<*>): ClassDefinition? + + fun classValues(annotationType: Class, member: A.() -> Array>): List + + /** Members of [annotationType] as a neutral map: `Class`→[ClassDefinition], enum→name, array→list. */ + fun values(annotationType: Class): Map? +} diff --git a/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/ClassDefinition.kt b/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/ClassDefinition.kt new file mode 100644 index 00000000..7be6439d --- /dev/null +++ b/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/ClassDefinition.kt @@ -0,0 +1,27 @@ +package io.javalin.introspection + +/** [handle] carries the backend's native token (Class / TypeMirror / KSType). */ +class ClassDefinition( + val simpleName: String, + val fullName: String, + val generics: List = emptyList(), + val structureType: StructureType = StructureType.DEFAULT, + @JvmField val handle: Any? = null, +) { + + override fun equals(other: Any?): Boolean = + this === other || (other is ClassDefinition && + fullName == other.fullName && generics == other.generics && structureType == other.structureType) + + override fun hashCode(): Int { + var result = fullName.hashCode() + result = 31 * result + generics.hashCode() + result = 31 * result + structureType.hashCode() + return result + } + + override fun toString(): String = + if (generics.isEmpty()) fullName else "$fullName<${generics.joinToString(", ")}>" +} + +enum class StructureType { DEFAULT, ARRAY, DICTIONARY } diff --git a/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/PropertyView.kt b/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/PropertyView.kt new file mode 100644 index 00000000..3476718b --- /dev/null +++ b/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/PropertyView.kt @@ -0,0 +1,12 @@ +package io.javalin.introspection + +/** A raw member (getter, field, or record component) — no naming/ignore policy applied. */ +class PropertyView( + val name: String, + val type: ClassDefinition, + val accessor: Accessor, + val nullable: Boolean, + val annotations: Annotations, +) + +enum class Accessor { FIELD, GETTER, RECORD_COMPONENT } diff --git a/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/TypeIntrospector.kt b/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/TypeIntrospector.kt new file mode 100644 index 00000000..db7f7b47 --- /dev/null +++ b/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/TypeIntrospector.kt @@ -0,0 +1,9 @@ +package io.javalin.introspection + +interface TypeIntrospector { + fun introspect(source: Any): ClassDefinition + fun isEnum(type: ClassDefinition): Boolean + fun enumConstants(type: ClassDefinition): List? + fun properties(type: ClassDefinition): List + fun annotations(type: ClassDefinition): Annotations +} diff --git a/introspection/introspection-jap/build.gradle.kts b/introspection/introspection-jap/build.gradle.kts new file mode 100644 index 00000000..2b5494c4 --- /dev/null +++ b/introspection/introspection-jap/build.gradle.kts @@ -0,0 +1,10 @@ +description = "Introspection JAP | javax.lang.model (Java annotation processing) TypeIntrospector backend" + +dependencies { + api(project(":introspection:introspection-api")) + + testImplementation(libs.junit.jupiter.api) + testImplementation(libs.junit.jupiter.engine) + testRuntimeOnly(libs.junit.platform.launcher) + testImplementation(libs.assertj.core) +} diff --git a/introspection/introspection-jap/src/main/kotlin/io/javalin/introspection/jap/JapTypeIntrospector.kt b/introspection/introspection-jap/src/main/kotlin/io/javalin/introspection/jap/JapTypeIntrospector.kt new file mode 100644 index 00000000..167fabb2 --- /dev/null +++ b/introspection/introspection-jap/src/main/kotlin/io/javalin/introspection/jap/JapTypeIntrospector.kt @@ -0,0 +1,187 @@ +package io.javalin.introspection.jap + +import io.javalin.introspection.Accessor +import io.javalin.introspection.Annotations +import io.javalin.introspection.ClassDefinition +import io.javalin.introspection.PropertyView +import io.javalin.introspection.StructureType +import io.javalin.introspection.StructureType.ARRAY +import io.javalin.introspection.StructureType.DEFAULT +import io.javalin.introspection.StructureType.DICTIONARY +import io.javalin.introspection.TypeIntrospector +import javax.lang.model.element.AnnotationValue +import javax.lang.model.element.Element +import javax.lang.model.element.ElementKind +import javax.lang.model.element.ExecutableElement +import javax.lang.model.element.Modifier +import javax.lang.model.element.TypeElement +import javax.lang.model.element.VariableElement +import javax.lang.model.type.ArrayType +import javax.lang.model.type.DeclaredType +import javax.lang.model.type.MirroredTypeException +import javax.lang.model.type.MirroredTypesException +import javax.lang.model.type.PrimitiveType +import javax.lang.model.type.TypeMirror +import javax.lang.model.type.TypeVariable +import javax.lang.model.util.Elements +import javax.lang.model.util.SimpleAnnotationValueVisitor8 +import javax.lang.model.util.Types +import kotlin.reflect.KClass + +/** [TypeIntrospector] backed by `javax.lang.model` (Java annotation processing). */ +class JapTypeIntrospector( + private val types: Types, + private val elements: Elements, +) : TypeIntrospector { + + override fun introspect(source: Any): ClassDefinition { + require(source is TypeMirror) { "JapTypeIntrospector expects a javax.lang.model.type.TypeMirror, got ${source::class.java.name}" } + return classDefinitionOf(source) + } + + fun introspect(mirror: TypeMirror): ClassDefinition = classDefinitionOf(mirror) + + override fun isEnum(type: ClassDefinition): Boolean = + typeElementOf(type)?.kind == ElementKind.ENUM + + override fun enumConstants(type: ClassDefinition): List? = + typeElementOf(type) + ?.takeIf { it.kind == ElementKind.ENUM } + ?.enclosedElements + ?.filter { it.kind == ElementKind.ENUM_CONSTANT } + ?.map { it.simpleName.toString() } + + override fun annotations(type: ClassDefinition): Annotations = + annotationsOf(typeElementOf(type)) + + override fun properties(type: ClassDefinition): List { + val element = typeElementOf(type) ?: return emptyList() + + if (element.kind == ElementKind.RECORD) { + return element.recordComponents.map { component -> + PropertyView(component.simpleName.toString(), classDefinitionOf(component.asType()), Accessor.RECORD_COMPONENT, component.asType().nullable(), annotationsOf(component)) + } + } + + return elements.getAllMembers(element).mapNotNull { member -> + when { + member.isGetter() -> + PropertyView(member.simpleName.toString(), classDefinitionOf((member as ExecutableElement).returnType), Accessor.GETTER, member.returnType.nullable(), annotationsOf(member)) + member.isInstanceField() -> + PropertyView(member.simpleName.toString(), classDefinitionOf((member as VariableElement).asType()), Accessor.FIELD, member.asType().nullable(), annotationsOf(member)) + else -> null + } + } + } + + private fun annotationsOf(element: Element?): Annotations = + JapAnnotations(element, elements) { classDefinitionOf(it) } + + private fun classDefinitionOf(mirror: TypeMirror, generics: List = emptyList(), structureType: StructureType = DEFAULT): ClassDefinition = + when (mirror) { + is TypeVariable -> (mirror.upperBound ?: mirror.lowerBound)?.let { classDefinitionOf(it, generics, structureType) } ?: objectDefinition(structureType) + is ArrayType -> classDefinitionOf(mirror.componentType, generics, ARRAY) + is PrimitiveType -> definition(types.boxedClass(mirror).asType(), generics, structureType) + is DeclaredType -> declared(mirror, generics, structureType) + else -> types.asElement(mirror)?.asType()?.takeIf { it != mirror }?.let { classDefinitionOf(it, generics, structureType) } ?: objectDefinition(structureType) + } + + private fun declared(mirror: DeclaredType, generics: List, structureType: StructureType): ClassDefinition { + val erasure = types.erasure(mirror) + return when { + types.isAssignable(erasure, erasureOf("java.util.Map")) -> + definition( + mirror = mirror, + generics = listOf( + classDefinitionOf(mirror.typeArguments.getOrElse(0) { objectMirror() }), + classDefinitionOf(mirror.typeArguments.getOrElse(1) { objectMirror() }), + ), + structureType = DICTIONARY, + ) + types.isAssignable(erasure, erasureOf("java.util.Collection")) -> + classDefinitionOf(mirror.typeArguments.getOrElse(0) { objectMirror() }, generics, ARRAY) + else -> + definition(mirror, mirror.typeArguments.map { classDefinitionOf(it) }, structureType) + } + } + + private fun definition(mirror: TypeMirror, generics: List, structureType: StructureType): ClassDefinition { + val element = types.asElement(mirror) as? TypeElement + val fullName = element?.qualifiedName?.toString() ?: mirror.toString().substringBefore("<") + return ClassDefinition( + simpleName = element?.simpleName?.toString() ?: fullName.substringAfterLast('.'), + fullName = fullName, + generics = generics, + structureType = structureType, + handle = mirror, + ) + } + + private fun objectDefinition(structureType: StructureType = DEFAULT): ClassDefinition = + definition(objectMirror(), emptyList(), structureType) + + private fun typeElementOf(type: ClassDefinition): TypeElement? = + types.asElement(type.handle as TypeMirror) as? TypeElement + + private fun objectMirror(): TypeMirror = elements.getTypeElement("java.lang.Object").asType() + private fun erasureOf(name: String): TypeMirror = types.erasure(elements.getTypeElement(name).asType()) + + private fun TypeMirror.nullable(): Boolean = !kind.isPrimitive + + private fun Element.isGetter(): Boolean { + if (kind != ElementKind.METHOD || this !is ExecutableElement) return false + if (Modifier.PUBLIC !in modifiers || Modifier.STATIC in modifiers) return false + if (parameters.isNotEmpty() || enclosingElement?.toString() == "java.lang.Object") return false + val name = simpleName.toString() + return name != "getClass" && (name.startsWith("get") || name.startsWith("is")) + } + + private fun Element.isInstanceField(): Boolean = + kind == ElementKind.FIELD && this is VariableElement && Modifier.STATIC !in modifiers +} + +internal class JapAnnotations( + private val element: Element?, + private val elements: Elements, + private val resolve: (TypeMirror) -> ClassDefinition, +) : Annotations { + override fun find(annotationType: Class): A? = + element?.getAnnotation(annotationType) + + override fun hasBySimpleName(simpleName: String): Boolean = + element?.annotationMirrors?.any { it.annotationType.asElement().simpleName.contentEquals(simpleName) } == true + + override fun classValue(annotationType: Class, member: A.() -> KClass<*>): ClassDefinition? { + val annotation = find(annotationType) ?: return null + return try { + elements.getTypeElement(annotation.member().java.name)?.asType()?.let(resolve) + } catch (mirrored: MirroredTypeException) { + resolve(mirrored.typeMirror) + } + } + + override fun classValues(annotationType: Class, member: A.() -> Array>): List { + val annotation = find(annotationType) ?: return emptyList() + return try { + annotation.member().mapNotNull { elements.getTypeElement(it.java.name)?.asType()?.let(resolve) } + } catch (mirrored: MirroredTypesException) { + mirrored.typeMirrors.map(resolve) + } + } + + override fun values(annotationType: Class): Map? { + val mirror = element?.annotationMirrors?.firstOrNull { + (it.annotationType.asElement() as? TypeElement)?.qualifiedName?.contentEquals(annotationType.name) == true + } ?: return null + + val visitor = object : SimpleAnnotationValueVisitor8() { + override fun defaultAction(value: Any?, p: Nothing?): Any? = value + override fun visitType(t: TypeMirror, p: Nothing?): Any = resolve(t) + override fun visitEnumConstant(constant: VariableElement, p: Nothing?): Any = constant.simpleName.toString() + override fun visitArray(values: MutableList, p: Nothing?): Any = values.map { it.accept(this, null) } + } + + return elements.getElementValuesWithDefaults(mirror).entries + .associate { (executable, value) -> executable.simpleName.toString() to value.accept(visitor, null) } + } +} diff --git a/introspection/introspection-ksp/.gitkeep b/introspection/introspection-ksp/.gitkeep new file mode 100644 index 00000000..f818ce70 --- /dev/null +++ b/introspection/introspection-ksp/.gitkeep @@ -0,0 +1 @@ +Placeholder for the future KSP TypeIntrospector backend (io.javalin.introspection.ksp). diff --git a/introspection/introspection-runtime/build.gradle.kts b/introspection/introspection-runtime/build.gradle.kts new file mode 100644 index 00000000..26db5667 --- /dev/null +++ b/introspection/introspection-runtime/build.gradle.kts @@ -0,0 +1,10 @@ +description = "Introspection Runtime | Reflection-based TypeIntrospector backend" + +dependencies { + api(project(":introspection:introspection-api")) + + testImplementation(libs.junit.jupiter.api) + testImplementation(libs.junit.jupiter.engine) + testRuntimeOnly(libs.junit.platform.launcher) + testImplementation(libs.assertj.core) +} diff --git a/introspection/introspection-runtime/src/main/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospector.kt b/introspection/introspection-runtime/src/main/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospector.kt new file mode 100644 index 00000000..4de5a241 --- /dev/null +++ b/introspection/introspection-runtime/src/main/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospector.kt @@ -0,0 +1,181 @@ +package io.javalin.introspection.runtime + +import io.javalin.introspection.Accessor +import io.javalin.introspection.Annotations +import io.javalin.introspection.ClassDefinition +import io.javalin.introspection.PropertyView +import io.javalin.introspection.StructureType +import io.javalin.introspection.StructureType.ARRAY +import io.javalin.introspection.StructureType.DEFAULT +import io.javalin.introspection.StructureType.DICTIONARY +import io.javalin.introspection.TypeIntrospector +import java.lang.reflect.AnnotatedElement +import java.lang.reflect.Field +import java.lang.reflect.GenericArrayType +import java.lang.reflect.Modifier +import java.lang.reflect.ParameterizedType +import java.lang.reflect.Type +import java.lang.reflect.WildcardType +import kotlin.reflect.KClass + +/** [TypeIntrospector] backed by reflection ([java.lang.reflect.Type]). */ +class ReflectionTypeIntrospector : TypeIntrospector { + + override fun introspect(source: Any): ClassDefinition { + require(source is Type) { "ReflectionTypeIntrospector expects a java.lang.reflect.Type, got ${source::class.java.name}" } + return classDefinitionOf(source) + } + + fun introspect(type: Type): ClassDefinition = classDefinitionOf(type) + + override fun isEnum(type: ClassDefinition): Boolean = type.erasure.isEnum + + override fun enumConstants(type: ClassDefinition): List? = + type.erasure.takeIf { it.isEnum }?.enumConstants?.map { (it as Enum<*>).name } + + override fun annotations(type: ClassDefinition): Annotations = + ReflectionAnnotations(listOf(type.erasure)) { classDefinitionOf(it) } + + override fun properties(type: ClassDefinition): List = + collectMembers(type.erasure).map { member -> + PropertyView( + name = member.rawName, + type = classDefinitionOf(member.genericType), + accessor = member.accessor, + nullable = (member.genericType as? Class<*>)?.isPrimitive != true, + annotations = ReflectionAnnotations(member.sources) { classDefinitionOf(it) }, + ) + } + + private fun classDefinitionOf( + type: Type, + generics: List = emptyList(), + structureType: StructureType = DEFAULT, + ): ClassDefinition = + when (type) { + is GenericArrayType -> classDefinitionOf(type.genericComponentType, generics, ARRAY) + is WildcardType -> classDefinitionOf(type.upperBounds.firstOrNull() ?: Any::class.java, generics, structureType) + is ParameterizedType -> parameterized(type, generics, structureType) + is Class<*> -> raw(type, generics, structureType) + else -> definition(Any::class.java, emptyList(), structureType) + } + + private fun raw(clazz: Class<*>, generics: List, structureType: StructureType): ClassDefinition = + when { + clazz.isArray -> classDefinitionOf(clazz.componentType, generics, ARRAY) + clazz.isPrimitive -> definition(clazz.kotlin.javaObjectType, generics, structureType) + Map::class.java.isAssignableFrom(clazz) -> definition(clazz, listOf(objectDefinition(), objectDefinition()), DICTIONARY) + Collection::class.java.isAssignableFrom(clazz) -> objectDefinition(ARRAY) + else -> definition(clazz, generics, structureType) + } + + private fun parameterized(type: ParameterizedType, generics: List, structureType: StructureType): ClassDefinition { + val erasure = type.rawType as Class<*> + val arguments = type.actualTypeArguments + return when { + Map::class.java.isAssignableFrom(erasure) -> + definition( + erasure = erasure, + generics = listOf( + classDefinitionOf(arguments.getOrElse(0) { Any::class.java }), + classDefinitionOf(arguments.getOrElse(1) { Any::class.java }), + ), + structureType = DICTIONARY, + ) + Collection::class.java.isAssignableFrom(erasure) -> + classDefinitionOf(arguments.getOrElse(0) { Any::class.java }, generics, ARRAY) + else -> + definition(erasure, arguments.map { classDefinitionOf(it) }, structureType) + } + } + + private fun definition(erasure: Class<*>, generics: List, structureType: StructureType): ClassDefinition = + ClassDefinition( + simpleName = erasure.simpleName.ifEmpty { erasure.name.substringAfterLast('.') }, + fullName = erasure.canonicalName ?: erasure.name, + generics = generics, + structureType = structureType, + handle = erasure, + ) + + private fun objectDefinition(structureType: StructureType = DEFAULT): ClassDefinition = + definition(Any::class.java, emptyList(), structureType) + + private fun collectMembers(clazz: Class<*>): List { + if (clazz.isRecord) { + return clazz.recordComponents.map { component -> + val backingField = runCatching { clazz.getDeclaredField(component.name) }.getOrNull() + Member(component.name, component.genericType, Accessor.RECORD_COMPONENT, listOfNotNull(component.accessor, backingField, component)) + } + } + + val members = mutableListOf() + + for (method in clazz.methods) { + if (Modifier.isStatic(method.modifiers) || method.isBridge || method.isSynthetic) continue + if (method.parameterCount != 0 || method.declaringClass == Any::class.java) continue + if (method.name == "getClass") continue + if (!method.name.startsWith("get") && !method.name.startsWith("is")) continue + members += Member(method.name, method.genericReturnType, Accessor.GETTER, listOf(method)) + } + + for (field in declaredFieldsHierarchy(clazz)) { + if (Modifier.isStatic(field.modifiers)) continue + members += Member(field.name, field.genericType, Accessor.FIELD, listOf(field)) + } + + return members + } + + private companion object { + private fun declaredFieldsHierarchy(clazz: Class<*>): List { + val fields = mutableListOf() + var current: Class<*>? = clazz + while (current != null && current != Any::class.java) { + // private fields aren't inherited members, so include them only for the leaf class (matches getAllMembers) + current.declaredFields.filterTo(fields) { current == clazz || !Modifier.isPrivate(it.modifiers) } + current = current.superclass + } + return fields + } + } + + private class Member( + val rawName: String, + val genericType: Type, + val accessor: Accessor, + val sources: List, + ) +} + +private val ClassDefinition.erasure: Class<*> + get() = handle as Class<*> + +internal class ReflectionAnnotations( + private val sources: List, + private val resolve: (Type) -> ClassDefinition, +) : Annotations { + override fun find(annotationType: Class): A? = + sources.firstNotNullOfOrNull { it.getAnnotation(annotationType) } + + override fun hasBySimpleName(simpleName: String): Boolean = + sources.any { source -> source.annotations.any { it.annotationClass.simpleName == simpleName } } + + override fun classValue(annotationType: Class, member: A.() -> KClass<*>): ClassDefinition? = + find(annotationType)?.let { resolve(it.member().java) } + + override fun classValues(annotationType: Class, member: A.() -> Array>): List = + find(annotationType)?.member()?.map { resolve(it.java) } ?: emptyList() + + override fun values(annotationType: Class): Map? { + val annotation = find(annotationType) ?: return null + return annotationType.declaredMethods.associate { it.name to normalize(it.invoke(annotation)) } + } + + private fun normalize(value: Any?): Any? = when (value) { + is Class<*> -> resolve(value) + is Enum<*> -> value.name + is Array<*> -> value.map { normalize(it) } + else -> value + } +} diff --git a/introspection/introspection-runtime/src/test/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospectorTest.kt b/introspection/introspection-runtime/src/test/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospectorTest.kt new file mode 100644 index 00000000..1d84c269 --- /dev/null +++ b/introspection/introspection-runtime/src/test/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospectorTest.kt @@ -0,0 +1,124 @@ +package io.javalin.introspection.runtime + +import io.javalin.introspection.Accessor +import io.javalin.introspection.PropertyView +import io.javalin.introspection.StructureType +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import kotlin.reflect.KClass + +class ReflectionTypeIntrospectorTest { + + private val introspector = ReflectionTypeIntrospector() + + private fun props(type: Class<*>): Map = + introspector.properties(introspector.introspect(type)).associateBy { it.name } + + @Test + fun `resolves a class into the shared model`() { + val account = introspector.introspect(Account::class.java) + assertThat(account.simpleName).isEqualTo("Account") + assertThat(account.fullName).isEqualTo("io.javalin.introspection.runtime.Account") + assertThat(account.structureType).isEqualTo(StructureType.DEFAULT) + } + + @Test + fun `exposes raw getter members with boxed primitives and structural nullability`() { + val props = props(Account::class.java) + + assertThat(props.keys).contains("getId", "getAge", "getName", "getColor", "getAddress", "getTags", "getMeta") + assertThat(props.getValue("getId").accessor).isEqualTo(Accessor.GETTER) + + assertThat(props.getValue("getAge").type.fullName).isEqualTo("java.lang.Integer") + assertThat(props.getValue("getAge").nullable).isFalse() + assertThat(props.getValue("getName").nullable).isTrue() + } + + @Test + fun `resolves collections, maps and nested types`() { + val props = props(Account::class.java) + + assertThat(props.getValue("getTags").type.structureType).isEqualTo(StructureType.ARRAY) + assertThat(props.getValue("getTags").type.fullName).isEqualTo("java.lang.String") + + val meta = props.getValue("getMeta").type + assertThat(meta.structureType).isEqualTo(StructureType.DICTIONARY) + assertThat(meta.generics.map { it.fullName }).containsExactly("java.lang.String", "java.lang.Integer") + + assertThat(props.getValue("getAddress").type.fullName).isEqualTo(Address::class.java.name) + } + + @Test + fun `exposes annotations without applying policy`() { + val id = props(Account::class.java).getValue("getId") + assertThat(id.annotations.find(Nn::class.java)).isNotNull() + assertThat(id.annotations.hasBySimpleName("Nn")).isTrue() + assertThat(props(Account::class.java).getValue("getName").annotations.find(Nn::class.java)).isNull() + } + + @Test + fun `reads enum constants raw`() { + val color = introspector.introspect(Color::class.java) + assertThat(introspector.isEnum(color)).isTrue() + assertThat(introspector.enumConstants(color)).containsExactly("RED", "GREEN") + assertThat(introspector.enumConstants(introspector.introspect(Account::class.java))).isNull() + } + + @Test + fun `returns both getters and fields tagged by accessor`() { + class FieldBag { + @JvmField val tag: String = "" + fun getName(): String = "" + } + + val props = props(FieldBag::class.java) + assertThat(props.getValue("getName").accessor).isEqualTo(Accessor.GETTER) + assertThat(props.getValue("tag").accessor).isEqualTo(Accessor.FIELD) + } + + @Test + fun `resolves Class-valued annotation members into ClassDefinitions`() { + val annotations = introspector.annotations(introspector.introspect(Holder::class.java)) + + assertThat(annotations.classValue(Ref::class.java) { value }?.fullName).isEqualTo(Address::class.java.name) + assertThat(annotations.classValues(Refs::class.java) { value }.map { it.fullName }) + .containsExactly(Address::class.java.name, Color::class.java.name) + } + + @Test + fun `reads all annotation members into a neutral value map`() { + val annotations = introspector.annotations(introspector.introspect(Holder::class.java)) + + val values = annotations.values(Mixed::class.java)!! + assertThat(values["name"]).isEqualTo("x") + assertThat(values["count"]).isEqualTo(3) + assertThat((values["type"] as io.javalin.introspection.ClassDefinition).fullName).isEqualTo(Address::class.java.name) + assertThat(values["shade"]).isEqualTo("RED") + assertThat(annotations.values(java.lang.Deprecated::class.java)).isNull() + } +} + +private class Address + +private enum class Color { RED, GREEN } + +private annotation class Nn + +private class Account { + @Nn fun getId(): String = "" + fun getAge(): Int = 0 + fun getName(): String = "" + fun getColor(): Color = Color.RED + fun getAddress(): Address? = null + fun getTags(): List = emptyList() + fun getMeta(): Map = emptyMap() +} + +private annotation class Ref(val value: KClass<*>) +private annotation class Refs(vararg val value: KClass<*>) +private annotation class Mixed(val name: String, val count: Int, val type: KClass<*>, val shade: Color) + +@Ref(Address::class) +@Refs(Address::class, Color::class) +@Mixed(name = "x", count = 3, type = Address::class, shade = Color.RED) +private class Holder diff --git a/introspection/introspection-test/build.gradle.kts b/introspection/introspection-test/build.gradle.kts new file mode 100644 index 00000000..116685f3 --- /dev/null +++ b/introspection/introspection-test/build.gradle.kts @@ -0,0 +1,10 @@ +dependencies { + testImplementation(project(":introspection:introspection-api")) + testImplementation(project(":introspection:introspection-runtime")) + testImplementation(project(":introspection:introspection-jap")) + + testImplementation(libs.junit.jupiter.api) + testImplementation(libs.junit.jupiter.engine) + testRuntimeOnly(libs.junit.platform.launcher) + testImplementation(libs.assertj.core) +} diff --git a/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/IntrospectionParityTest.kt b/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/IntrospectionParityTest.kt new file mode 100644 index 00000000..969cf28e --- /dev/null +++ b/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/IntrospectionParityTest.kt @@ -0,0 +1,60 @@ +package io.javalin.introspection.test + +import io.javalin.introspection.runtime.ReflectionTypeIntrospector +import io.javalin.introspection.test.fixtures.Account +import io.javalin.introspection.test.fixtures.Address +import io.javalin.introspection.test.fixtures.Color +import io.javalin.introspection.test.fixtures.Derived +import io.javalin.introspection.test.fixtures.Holder +import io.javalin.introspection.test.fixtures.Ref +import io.javalin.introspection.test.fixtures.Refs +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import kotlin.reflect.KClass + +class IntrospectionParityTest { + + private val runtime = ReflectionTypeIntrospector() + + private fun assertParity(type: KClass<*>) { + val fqn = type.qualifiedName!! + val runtimeSnapshot = snapshot(runtime, runtime.introspect(type.java)) + val japSnapshot = withJap { jap, elements -> snapshot(jap, jap.introspect(elements.getTypeElement(fqn).asType())) } + assertThat(japSnapshot).isEqualTo(runtimeSnapshot) + } + + @Test fun `scalar, collection, map and nested members match across backends`() = assertParity(Account::class) + + @Test fun `plain object members match across backends`() = assertParity(Address::class) + + @Test fun `enum constants match across backends`() = assertParity(Color::class) + + @Test fun `inherited public getters and private superclass fields match across backends`() = assertParity(Derived::class) + + @Test + fun `Class-valued annotation members resolve identically across backends`() { + val runtimeAnnotations = runtime.annotations(runtime.introspect(Holder::class.java)) + val runtimeRef = runtimeAnnotations.classValue(Ref::class.java) { value }?.fullName + val runtimeRefs = runtimeAnnotations.classValues(Refs::class.java) { value }.map { it.fullName } + + val (japRef, japRefs) = withJap { jap, elements -> + val annotations = jap.annotations(jap.introspect(elements.getTypeElement(Holder::class.qualifiedName).asType())) + annotations.classValue(Ref::class.java) { value }?.fullName to annotations.classValues(Refs::class.java) { value }.map { it.fullName } + } + + assertThat(japRef).isEqualTo(runtimeRef).isEqualTo(Address::class.java.name) + assertThat(japRefs).isEqualTo(runtimeRefs).containsExactly(Address::class.java.name, Color::class.java.name) + } + + @Test + fun `annotation value maps match across backends`() { + val runtimeValues = runtime.annotations(runtime.introspect(Holder::class.java)).values(Ref::class.java) + val japValues = withJap { jap, elements -> + jap.annotations(jap.introspect(elements.getTypeElement(Holder::class.qualifiedName).asType())).values(Ref::class.java) + } + + val runtimeRefType = (runtimeValues!!.getValue("value") as io.javalin.introspection.ClassDefinition).fullName + val japRefType = (japValues!!.getValue("value") as io.javalin.introspection.ClassDefinition).fullName + assertThat(japRefType).isEqualTo(runtimeRefType).isEqualTo(Address::class.java.name) + } +} diff --git a/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/JapHarness.kt b/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/JapHarness.kt new file mode 100644 index 00000000..5668cfe1 --- /dev/null +++ b/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/JapHarness.kt @@ -0,0 +1,52 @@ +package io.javalin.introspection.test + +import io.javalin.introspection.jap.JapTypeIntrospector +import java.net.URI +import javax.annotation.processing.AbstractProcessor +import javax.annotation.processing.RoundEnvironment +import javax.lang.model.SourceVersion +import javax.lang.model.element.TypeElement +import javax.lang.model.util.Elements +import javax.tools.JavaFileObject +import javax.tools.SimpleJavaFileObject +import javax.tools.ToolProvider + +/** + * Runs [block] inside a real annotation-processing round (in-process `javac`, classpath = the test classpath), + * giving it a [JapTypeIntrospector] + [Elements] so JAP can be exercised exactly like the runtime backend. + */ +fun withJap(block: (JapTypeIntrospector, Elements) -> R): R { + val compiler = requireNotNull(ToolProvider.getSystemJavaCompiler()) { "A JDK is required (no system Java compiler)" } + val fileManager = compiler.getStandardFileManager(null, null, null) + try { + val trigger = object : SimpleJavaFileObject(URI.create("string:///Trigger.java"), JavaFileObject.Kind.SOURCE) { + override fun getCharContent(ignoreEncodingErrors: Boolean): CharSequence = "class Trigger {}" + } + val processor = BlockProcessor(block) + val options = listOf("-proc:only", "-classpath", System.getProperty("java.class.path")) + val task = compiler.getTask(null, fileManager, null, options, null, listOf(trigger)) + task.setProcessors(listOf(processor)) + check(task.call()) { "annotation processing round failed" } + check(processor.done) { "processor did not run" } + @Suppress("UNCHECKED_CAST") + return processor.result as R + } finally { + fileManager.close() + } +} + +private class BlockProcessor(private val block: (JapTypeIntrospector, Elements) -> R) : AbstractProcessor() { + var done = false + var result: R? = null + + override fun getSupportedAnnotationTypes(): Set = setOf("*") + override fun getSupportedSourceVersion(): SourceVersion = SourceVersion.latestSupported() + + override fun process(annotations: Set, roundEnv: RoundEnvironment): Boolean { + if (!done) { + result = block(JapTypeIntrospector(processingEnv.typeUtils, processingEnv.elementUtils), processingEnv.elementUtils) + done = true + } + return false + } +} diff --git a/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/Snapshot.kt b/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/Snapshot.kt new file mode 100644 index 00000000..1e0c5d47 --- /dev/null +++ b/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/Snapshot.kt @@ -0,0 +1,39 @@ +package io.javalin.introspection.test + +import io.javalin.introspection.Accessor +import io.javalin.introspection.ClassDefinition +import io.javalin.introspection.StructureType +import io.javalin.introspection.TypeIntrospector + +/** A backend-neutral, order-independent snapshot of one type's introspection, for comparing backends. */ +data class TypeSnapshot( + val fullName: String, + val simpleName: String, + val structure: StructureType, + val isEnum: Boolean, + val enumConstants: List?, + val properties: List, +) + +data class PropertySnapshot( + val name: String, + val typeFullName: String, + val typeStructure: StructureType, + val typeGenerics: List, + val accessor: Accessor, + val nullable: Boolean, +) + +fun snapshot(introspector: TypeIntrospector, type: ClassDefinition): TypeSnapshot { + val enum = introspector.isEnum(type) + return TypeSnapshot( + fullName = type.fullName, + simpleName = type.simpleName, + structure = type.structureType, + isEnum = enum, + enumConstants = introspector.enumConstants(type)?.sorted(), + properties = if (enum) emptyList() else introspector.properties(type) + .map { PropertySnapshot(it.name, it.type.fullName, it.type.structureType, it.type.generics.map { g -> g.fullName }, it.accessor, it.nullable) } + .sortedBy { "${it.accessor}:${it.name}" }, + ) +} diff --git a/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/fixtures/Fixtures.kt b/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/fixtures/Fixtures.kt new file mode 100644 index 00000000..1a2739d7 --- /dev/null +++ b/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/fixtures/Fixtures.kt @@ -0,0 +1,31 @@ +package io.javalin.introspection.test.fixtures + +import kotlin.reflect.KClass + +class Address(val city: String, val zip: String) + +enum class Color { RED, GREEN } + +class Account( + val id: String, + val age: Int, + val color: Color, + val address: Address?, + val tags: List, + val meta: Map, +) + +open class Base(val baseField: String) { + val computed: String get() = "" + protected val secret: String get() = "" +} + +class Derived(val own: String) : Base("") + +annotation class Ref(val value: KClass<*>) + +annotation class Refs(vararg val value: KClass<*>) + +@Ref(Address::class) +@Refs(Address::class, Color::class) +class Holder diff --git a/openapi-dynamic/src/main/kotlin/io/javalin/openapi/dynamic/ReflectionHandle.kt b/openapi-dynamic/src/main/kotlin/io/javalin/openapi/dynamic/ReflectionHandle.kt index b138b29f..ab987555 100644 --- a/openapi-dynamic/src/main/kotlin/io/javalin/openapi/dynamic/ReflectionHandle.kt +++ b/openapi-dynamic/src/main/kotlin/io/javalin/openapi/dynamic/ReflectionHandle.kt @@ -1,16 +1,9 @@ package io.javalin.openapi.dynamic import io.javalin.openapi.experimental.ClassDefinition -import java.lang.reflect.Type /** Reflection counterpart of the AP's `ClassDefinitionHandle`, stored in [ClassDefinition.handle]. */ -data class ReflectionHandle( - val erasure: Class<*>, - val type: Type, -) +data class ReflectionHandle(val erasure: Class<*>) val ClassDefinition.reflectedClass: Class<*> get() = (handle as ReflectionHandle).erasure - -val ClassDefinition.reflectedType: Type - get() = (handle as ReflectionHandle).type diff --git a/openapi-dynamic/src/main/kotlin/io/javalin/openapi/dynamic/ReflectiveTypeIntrospector.kt b/openapi-dynamic/src/main/kotlin/io/javalin/openapi/dynamic/ReflectiveTypeIntrospector.kt index 3507dd0c..188a8102 100644 --- a/openapi-dynamic/src/main/kotlin/io/javalin/openapi/dynamic/ReflectiveTypeIntrospector.kt +++ b/openapi-dynamic/src/main/kotlin/io/javalin/openapi/dynamic/ReflectiveTypeIntrospector.kt @@ -72,7 +72,6 @@ class ReflectiveTypeIntrospector( classDefinitionOf(arguments.getOrElse(1) { Any::class.java }), ), structureType = DICTIONARY, - fullType = type, ) Collection::class.java.isAssignableFrom(erasure) -> classDefinitionOf(arguments.getOrElse(0) { Any::class.java }, generics, ARRAY) @@ -81,7 +80,6 @@ class ReflectiveTypeIntrospector( erasure = erasure, generics = arguments.map { classDefinitionOf(it) }, structureType = structureType, - fullType = type, ) } } @@ -90,7 +88,6 @@ class ReflectiveTypeIntrospector( erasure: Class<*>, generics: List, structureType: StructureType, - fullType: Type = erasure, ): ClassDefinition { val customName = erasure.getAnnotation(OpenApiName::class.java)?.value val packageName = erasure.`package`?.name?.takeIf { it.isNotEmpty() } @@ -102,7 +99,7 @@ class ReflectiveTypeIntrospector( }, generics = generics, structureType = structureType, - handle = ReflectionHandle(erasure, fullType), + handle = ReflectionHandle(erasure), ) } @@ -129,12 +126,7 @@ class ReflectiveTypeIntrospector( val finalName = if (customName == null && namingStrategy != null) translatePropertyName(namingStrategy, resolvedName) else resolvedName val isPrimitive = (member.genericType as? Class<*>)?.isPrimitive == true - val isNotNull = when { - member.hasAnnotationNamed("NotNull") -> true - isPrimitive -> true - member.hasAnnotationNamed("Nullable") -> false - else -> false - } + val isNotNull = member.hasAnnotationNamed("NotNull") || isPrimitive val required = member.annotation(OpenApiRequired::class.java) != null || (requireNonNullsByDefault && isNotNull) val openApiNullable = member.annotation(OpenApiNullable::class.java) @@ -218,18 +210,7 @@ class ReflectiveTypeIntrospector( type.reflectedClass.getAnnotation(annotationType) private companion object { - private fun box(primitive: Class<*>): Class<*> = when (primitive) { - Int::class.javaPrimitiveType -> Int::class.javaObjectType - Long::class.javaPrimitiveType -> Long::class.javaObjectType - Short::class.javaPrimitiveType -> Short::class.javaObjectType - Byte::class.javaPrimitiveType -> Byte::class.javaObjectType - Char::class.javaPrimitiveType -> Char::class.javaObjectType - Boolean::class.javaPrimitiveType -> Boolean::class.javaObjectType - Float::class.javaPrimitiveType -> Float::class.javaObjectType - Double::class.javaPrimitiveType -> Double::class.javaObjectType - Void.TYPE -> Void::class.javaObjectType - else -> primitive - } + private fun box(primitive: Class<*>): Class<*> = primitive.kotlin.javaObjectType private fun declaredFieldsHierarchy(clazz: Class<*>): List { val fields = mutableListOf() diff --git a/settings.gradle.kts b/settings.gradle.kts index 4f40a79b..1cd0ea9d 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -5,6 +5,11 @@ include( "openapi-generator", "openapi-annotation-processor", "openapi-dynamic", + "introspection", + "introspection:introspection-api", + "introspection:introspection-runtime", + "introspection:introspection-jap", + "introspection:introspection-test", "javalin-plugins", "javalin-plugins:javalin-openapi-plugin", "javalin-plugins:javalin-swagger-plugin", From 1717cec40359e417c061c5cdc4160045abcc265e Mon Sep 17 00:00:00 2001 From: dzikoysk Date: Sun, 14 Jun 2026 19:48:49 +0200 Subject: [PATCH 03/43] chore: cleanup --- .../io/javalin/introspection/Annotations.kt | 14 +- .../javalin/introspection/ClassDefinition.kt | 20 +- .../javalin/introspection/TypeIntrospector.kt | 7 +- .../introspection/jap/JapTypeIntrospector.kt | 176 ++++++++----- .../runtime/ReflectionTypeIntrospector.kt | 239 +++++++++--------- .../runtime/ReflectionTypeIntrospectorTest.kt | 22 +- .../test/IntrospectionParityTest.kt | 18 +- .../io/javalin/introspection/test/Snapshot.kt | 9 +- 8 files changed, 269 insertions(+), 236 deletions(-) diff --git a/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/Annotations.kt b/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/Annotations.kt index 135415b4..fe15b269 100644 --- a/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/Annotations.kt +++ b/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/Annotations.kt @@ -4,13 +4,15 @@ import kotlin.reflect.KClass interface Annotations { fun find(annotationType: Class): A? - fun hasBySimpleName(simpleName: String): Boolean - /** Resolve a `KClass`-valued member into a [ClassDefinition], hiding the compile-time `MirroredTypeException`. */ - fun classValue(annotationType: Class, member: A.() -> KClass<*>): ClassDefinition? + /** True if an annotation with this simple name is present (lets callers match e.g. `@NotNull` without depending on it). */ + fun hasNamed(simpleName: String): Boolean - fun classValues(annotationType: Class, member: A.() -> Array>): List + /** Resolve a class-valued member (e.g. `value = Foo::class`) to a [ClassDefinition]. */ + fun resolveType(annotationType: Class, member: A.() -> KClass<*>): ClassDefinition? - /** Members of [annotationType] as a neutral map: `Class`→[ClassDefinition], enum→name, array→list. */ - fun values(annotationType: Class): Map? + fun resolveTypes(annotationType: Class, member: A.() -> Array>): List + + /** Members of [annotationType] as a name→value map (class members resolved to [ClassDefinition], enums to their name). */ + fun memberValues(annotationType: Class): Map? } diff --git a/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/ClassDefinition.kt b/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/ClassDefinition.kt index 7be6439d..62f20c10 100644 --- a/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/ClassDefinition.kt +++ b/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/ClassDefinition.kt @@ -1,24 +1,20 @@ package io.javalin.introspection -/** [handle] carries the backend's native token (Class / TypeMirror / KSType). */ -class ClassDefinition( +/** A type resolved by a backend; navigation ([getProperties], [isEnum], …) is implemented per backend. */ +abstract class ClassDefinition( val simpleName: String, val fullName: String, val generics: List = emptyList(), val structureType: StructureType = StructureType.DEFAULT, - @JvmField val handle: Any? = null, ) { - override fun equals(other: Any?): Boolean = - this === other || (other is ClassDefinition && - fullName == other.fullName && generics == other.generics && structureType == other.structureType) + abstract fun isEnum(): Boolean - override fun hashCode(): Int { - var result = fullName.hashCode() - result = 31 * result + generics.hashCode() - result = 31 * result + structureType.hashCode() - return result - } + abstract fun getEnumConstants(): List? + + abstract fun getProperties(): List + + abstract fun getAnnotations(): Annotations override fun toString(): String = if (generics.isEmpty()) fullName else "$fullName<${generics.joinToString(", ")}>" diff --git a/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/TypeIntrospector.kt b/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/TypeIntrospector.kt index db7f7b47..128ac447 100644 --- a/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/TypeIntrospector.kt +++ b/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/TypeIntrospector.kt @@ -1,9 +1,6 @@ package io.javalin.introspection -interface TypeIntrospector { +/** Resolves a backend-native type token (Class, TypeMirror, KSType) into a [ClassDefinition]. */ +fun interface TypeIntrospector { fun introspect(source: Any): ClassDefinition - fun isEnum(type: ClassDefinition): Boolean - fun enumConstants(type: ClassDefinition): List? - fun properties(type: ClassDefinition): List - fun annotations(type: ClassDefinition): Annotations } diff --git a/introspection/introspection-jap/src/main/kotlin/io/javalin/introspection/jap/JapTypeIntrospector.kt b/introspection/introspection-jap/src/main/kotlin/io/javalin/introspection/jap/JapTypeIntrospector.kt index 167fabb2..5cbc01fa 100644 --- a/introspection/introspection-jap/src/main/kotlin/io/javalin/introspection/jap/JapTypeIntrospector.kt +++ b/introspection/introspection-jap/src/main/kotlin/io/javalin/introspection/jap/JapTypeIntrospector.kt @@ -29,63 +29,34 @@ import javax.lang.model.util.Types import kotlin.reflect.KClass /** [TypeIntrospector] backed by `javax.lang.model` (Java annotation processing). */ -class JapTypeIntrospector( - private val types: Types, - private val elements: Elements, -) : TypeIntrospector { +class JapTypeIntrospector(types: Types, elements: Elements) : TypeIntrospector { + + private val env = JapEnv(types, elements) override fun introspect(source: Any): ClassDefinition { require(source is TypeMirror) { "JapTypeIntrospector expects a javax.lang.model.type.TypeMirror, got ${source::class.java.name}" } - return classDefinitionOf(source) + return env.resolve(source) } - fun introspect(mirror: TypeMirror): ClassDefinition = classDefinitionOf(mirror) - - override fun isEnum(type: ClassDefinition): Boolean = - typeElementOf(type)?.kind == ElementKind.ENUM - - override fun enumConstants(type: ClassDefinition): List? = - typeElementOf(type) - ?.takeIf { it.kind == ElementKind.ENUM } - ?.enclosedElements - ?.filter { it.kind == ElementKind.ENUM_CONSTANT } - ?.map { it.simpleName.toString() } - - override fun annotations(type: ClassDefinition): Annotations = - annotationsOf(typeElementOf(type)) - - override fun properties(type: ClassDefinition): List { - val element = typeElementOf(type) ?: return emptyList() - - if (element.kind == ElementKind.RECORD) { - return element.recordComponents.map { component -> - PropertyView(component.simpleName.toString(), classDefinitionOf(component.asType()), Accessor.RECORD_COMPONENT, component.asType().nullable(), annotationsOf(component)) - } - } - - return elements.getAllMembers(element).mapNotNull { member -> - when { - member.isGetter() -> - PropertyView(member.simpleName.toString(), classDefinitionOf((member as ExecutableElement).returnType), Accessor.GETTER, member.returnType.nullable(), annotationsOf(member)) - member.isInstanceField() -> - PropertyView(member.simpleName.toString(), classDefinitionOf((member as VariableElement).asType()), Accessor.FIELD, member.asType().nullable(), annotationsOf(member)) - else -> null - } - } - } + fun introspect(mirror: TypeMirror): ClassDefinition = + env.resolve(mirror) +} - private fun annotationsOf(element: Element?): Annotations = - JapAnnotations(element, elements) { classDefinitionOf(it) } +/** The `javax.lang.model` context + the type-resolution factory shared by the JAP [ClassDefinition]s. */ +internal class JapEnv(val types: Types, val elements: Elements) { - private fun classDefinitionOf(mirror: TypeMirror, generics: List = emptyList(), structureType: StructureType = DEFAULT): ClassDefinition = + fun resolve(mirror: TypeMirror, generics: List = emptyList(), structureType: StructureType = DEFAULT): ClassDefinition = when (mirror) { - is TypeVariable -> (mirror.upperBound ?: mirror.lowerBound)?.let { classDefinitionOf(it, generics, structureType) } ?: objectDefinition(structureType) - is ArrayType -> classDefinitionOf(mirror.componentType, generics, ARRAY) + is TypeVariable -> (mirror.upperBound ?: mirror.lowerBound)?.let { resolve(it, generics, structureType) } ?: objectDefinition(structureType) + is ArrayType -> resolve(mirror.componentType, generics, ARRAY) is PrimitiveType -> definition(types.boxedClass(mirror).asType(), generics, structureType) is DeclaredType -> declared(mirror, generics, structureType) - else -> types.asElement(mirror)?.asType()?.takeIf { it != mirror }?.let { classDefinitionOf(it, generics, structureType) } ?: objectDefinition(structureType) + else -> types.asElement(mirror)?.asType()?.takeIf { it != mirror }?.let { resolve(it, generics, structureType) } ?: objectDefinition(structureType) } + fun objectMirror(): TypeMirror = + elements.getTypeElement("java.lang.Object").asType() + private fun declared(mirror: DeclaredType, generics: List, structureType: StructureType): ClassDefinition { val erasure = types.erasure(mirror) return when { @@ -93,40 +64,107 @@ class JapTypeIntrospector( definition( mirror = mirror, generics = listOf( - classDefinitionOf(mirror.typeArguments.getOrElse(0) { objectMirror() }), - classDefinitionOf(mirror.typeArguments.getOrElse(1) { objectMirror() }), + resolve(mirror.typeArguments.getOrElse(0) { objectMirror() }), + resolve(mirror.typeArguments.getOrElse(1) { objectMirror() }), ), structureType = DICTIONARY, ) types.isAssignable(erasure, erasureOf("java.util.Collection")) -> - classDefinitionOf(mirror.typeArguments.getOrElse(0) { objectMirror() }, generics, ARRAY) + resolve(mirror.typeArguments.getOrElse(0) { objectMirror() }, generics, ARRAY) else -> - definition(mirror, mirror.typeArguments.map { classDefinitionOf(it) }, structureType) + definition(mirror, mirror.typeArguments.map { resolve(it) }, structureType) } } private fun definition(mirror: TypeMirror, generics: List, structureType: StructureType): ClassDefinition { val element = types.asElement(mirror) as? TypeElement val fullName = element?.qualifiedName?.toString() ?: mirror.toString().substringBefore("<") - return ClassDefinition( + return JapClassDefinition( simpleName = element?.simpleName?.toString() ?: fullName.substringAfterLast('.'), fullName = fullName, generics = generics, structureType = structureType, - handle = mirror, + mirror = mirror, + env = this, ) } private fun objectDefinition(structureType: StructureType = DEFAULT): ClassDefinition = definition(objectMirror(), emptyList(), structureType) - private fun typeElementOf(type: ClassDefinition): TypeElement? = - types.asElement(type.handle as TypeMirror) as? TypeElement + private fun erasureOf(name: String): TypeMirror = + types.erasure(elements.getTypeElement(name).asType()) +} - private fun objectMirror(): TypeMirror = elements.getTypeElement("java.lang.Object").asType() - private fun erasureOf(name: String): TypeMirror = types.erasure(elements.getTypeElement(name).asType()) +private class JapClassDefinition( + simpleName: String, + fullName: String, + generics: List, + structureType: StructureType, + private val mirror: TypeMirror, + private val env: JapEnv, +) : ClassDefinition(simpleName, fullName, generics, structureType) { - private fun TypeMirror.nullable(): Boolean = !kind.isPrimitive + override fun isEnum(): Boolean = + element()?.kind == ElementKind.ENUM + + override fun getEnumConstants(): List? = + element() + ?.takeIf { it.kind == ElementKind.ENUM } + ?.enclosedElements + ?.filter { it.kind == ElementKind.ENUM_CONSTANT } + ?.map { it.simpleName.toString() } + + override fun getAnnotations(): Annotations = + JapAnnotations(element(), env) + + override fun getProperties(): List { + val element = element() ?: return emptyList() + + if (element.kind == ElementKind.RECORD) { + return element.recordComponents.map { component -> + PropertyView( + name = component.simpleName.toString(), + type = env.resolve(component.asType()), + accessor = Accessor.RECORD_COMPONENT, + nullable = component.asType().nullable(), + annotations = JapAnnotations(component, env), + ) + } + } + + return env.elements.getAllMembers(element).mapNotNull { member -> + when { + member.isGetter() -> { + val getter = member as ExecutableElement + PropertyView( + name = getter.simpleName.toString(), + type = env.resolve(getter.returnType), + accessor = Accessor.GETTER, + nullable = getter.returnType.nullable(), + annotations = JapAnnotations(getter, env), + ) + } + member.isInstanceField() -> { + val field = member as VariableElement + PropertyView( + name = field.simpleName.toString(), + type = env.resolve(field.asType()), + accessor = Accessor.FIELD, + nullable = field.asType().nullable(), + annotations = JapAnnotations(field, env), + ) + } + else -> null + } + } + } + + private fun element(): TypeElement? = + env.types.asElement(mirror) as? TypeElement + + private fun TypeMirror.nullable(): Boolean = + !kind.isPrimitive private fun Element.isGetter(): Boolean { if (kind != ElementKind.METHOD || this !is ExecutableElement) return false @@ -140,48 +178,48 @@ class JapTypeIntrospector( kind == ElementKind.FIELD && this is VariableElement && Modifier.STATIC !in modifiers } -internal class JapAnnotations( +private class JapAnnotations( private val element: Element?, - private val elements: Elements, - private val resolve: (TypeMirror) -> ClassDefinition, + private val env: JapEnv, ) : Annotations { + override fun find(annotationType: Class): A? = element?.getAnnotation(annotationType) - override fun hasBySimpleName(simpleName: String): Boolean = + override fun hasNamed(simpleName: String): Boolean = element?.annotationMirrors?.any { it.annotationType.asElement().simpleName.contentEquals(simpleName) } == true - override fun classValue(annotationType: Class, member: A.() -> KClass<*>): ClassDefinition? { + override fun resolveType(annotationType: Class, member: A.() -> KClass<*>): ClassDefinition? { val annotation = find(annotationType) ?: return null return try { - elements.getTypeElement(annotation.member().java.name)?.asType()?.let(resolve) + env.elements.getTypeElement(annotation.member().java.name)?.asType()?.let { env.resolve(it) } } catch (mirrored: MirroredTypeException) { - resolve(mirrored.typeMirror) + env.resolve(mirrored.typeMirror) } } - override fun classValues(annotationType: Class, member: A.() -> Array>): List { + override fun resolveTypes(annotationType: Class, member: A.() -> Array>): List { val annotation = find(annotationType) ?: return emptyList() return try { - annotation.member().mapNotNull { elements.getTypeElement(it.java.name)?.asType()?.let(resolve) } + annotation.member().mapNotNull { kClass -> env.elements.getTypeElement(kClass.java.name)?.asType()?.let { env.resolve(it) } } } catch (mirrored: MirroredTypesException) { - mirrored.typeMirrors.map(resolve) + mirrored.typeMirrors.map { env.resolve(it) } } } - override fun values(annotationType: Class): Map? { + override fun memberValues(annotationType: Class): Map? { val mirror = element?.annotationMirrors?.firstOrNull { (it.annotationType.asElement() as? TypeElement)?.qualifiedName?.contentEquals(annotationType.name) == true } ?: return null val visitor = object : SimpleAnnotationValueVisitor8() { override fun defaultAction(value: Any?, p: Nothing?): Any? = value - override fun visitType(t: TypeMirror, p: Nothing?): Any = resolve(t) + override fun visitType(t: TypeMirror, p: Nothing?): Any = env.resolve(t) override fun visitEnumConstant(constant: VariableElement, p: Nothing?): Any = constant.simpleName.toString() override fun visitArray(values: MutableList, p: Nothing?): Any = values.map { it.accept(this, null) } } - return elements.getElementValuesWithDefaults(mirror).entries + return env.elements.getElementValuesWithDefaults(mirror).entries .associate { (executable, value) -> executable.simpleName.toString() to value.accept(visitor, null) } } } diff --git a/introspection/introspection-runtime/src/main/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospector.kt b/introspection/introspection-runtime/src/main/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospector.kt index 4de5a241..d092cdf7 100644 --- a/introspection/introspection-runtime/src/main/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospector.kt +++ b/introspection/introspection-runtime/src/main/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospector.kt @@ -23,159 +23,160 @@ class ReflectionTypeIntrospector : TypeIntrospector { override fun introspect(source: Any): ClassDefinition { require(source is Type) { "ReflectionTypeIntrospector expects a java.lang.reflect.Type, got ${source::class.java.name}" } - return classDefinitionOf(source) + return reflect(source) } - fun introspect(type: Type): ClassDefinition = classDefinitionOf(type) + fun introspect(type: Type): ClassDefinition = + reflect(type) +} + +private fun reflect(type: Type, generics: List = emptyList(), structureType: StructureType = DEFAULT): ClassDefinition = + when (type) { + is GenericArrayType -> reflect(type.genericComponentType, generics, ARRAY) + is WildcardType -> reflect(type.upperBounds.firstOrNull() ?: Any::class.java, generics, structureType) + is ParameterizedType -> parameterized(type, generics, structureType) + is Class<*> -> raw(type, generics, structureType) + else -> definition(Any::class.java, emptyList(), structureType) + } - override fun isEnum(type: ClassDefinition): Boolean = type.erasure.isEnum +private fun raw(clazz: Class<*>, generics: List, structureType: StructureType): ClassDefinition = + when { + clazz.isArray -> reflect(clazz.componentType, generics, ARRAY) + clazz.isPrimitive -> definition(clazz.kotlin.javaObjectType, generics, structureType) + Map::class.java.isAssignableFrom(clazz) -> definition(clazz, listOf(objectDefinition(), objectDefinition()), DICTIONARY) + Collection::class.java.isAssignableFrom(clazz) -> objectDefinition(ARRAY) + else -> definition(clazz, generics, structureType) + } - override fun enumConstants(type: ClassDefinition): List? = - type.erasure.takeIf { it.isEnum }?.enumConstants?.map { (it as Enum<*>).name } +private fun parameterized(type: ParameterizedType, generics: List, structureType: StructureType): ClassDefinition { + val erasure = type.rawType as Class<*> + val arguments = type.actualTypeArguments + return when { + Map::class.java.isAssignableFrom(erasure) -> + definition( + erasure = erasure, + generics = listOf( + reflect(arguments.getOrElse(0) { Any::class.java }), + reflect(arguments.getOrElse(1) { Any::class.java }), + ), + structureType = DICTIONARY, + ) + Collection::class.java.isAssignableFrom(erasure) -> + reflect(arguments.getOrElse(0) { Any::class.java }, generics, ARRAY) + else -> + definition(erasure, arguments.map { reflect(it) }, structureType) + } +} - override fun annotations(type: ClassDefinition): Annotations = - ReflectionAnnotations(listOf(type.erasure)) { classDefinitionOf(it) } +private fun definition(erasure: Class<*>, generics: List, structureType: StructureType): ClassDefinition = + ReflectionClassDefinition( + simpleName = erasure.simpleName.ifEmpty { erasure.name.substringAfterLast('.') }, + fullName = erasure.canonicalName ?: erasure.name, + generics = generics, + structureType = structureType, + erasure = erasure, + ) - override fun properties(type: ClassDefinition): List = - collectMembers(type.erasure).map { member -> +private fun objectDefinition(structureType: StructureType = DEFAULT): ClassDefinition = + definition(Any::class.java, emptyList(), structureType) + +private class ReflectionClassDefinition( + simpleName: String, + fullName: String, + generics: List, + structureType: StructureType, + private val erasure: Class<*>, +) : ClassDefinition(simpleName, fullName, generics, structureType) { + + override fun isEnum(): Boolean = + erasure.isEnum + + override fun getEnumConstants(): List? = + erasure.takeIf { it.isEnum }?.enumConstants?.map { (it as Enum<*>).name } + + override fun getProperties(): List = + collectMembers(erasure).map { member -> PropertyView( name = member.rawName, - type = classDefinitionOf(member.genericType), + type = reflect(member.genericType), accessor = member.accessor, nullable = (member.genericType as? Class<*>)?.isPrimitive != true, - annotations = ReflectionAnnotations(member.sources) { classDefinitionOf(it) }, + annotations = ReflectionAnnotations(member.sources), ) } - private fun classDefinitionOf( - type: Type, - generics: List = emptyList(), - structureType: StructureType = DEFAULT, - ): ClassDefinition = - when (type) { - is GenericArrayType -> classDefinitionOf(type.genericComponentType, generics, ARRAY) - is WildcardType -> classDefinitionOf(type.upperBounds.firstOrNull() ?: Any::class.java, generics, structureType) - is ParameterizedType -> parameterized(type, generics, structureType) - is Class<*> -> raw(type, generics, structureType) - else -> definition(Any::class.java, emptyList(), structureType) - } - - private fun raw(clazz: Class<*>, generics: List, structureType: StructureType): ClassDefinition = - when { - clazz.isArray -> classDefinitionOf(clazz.componentType, generics, ARRAY) - clazz.isPrimitive -> definition(clazz.kotlin.javaObjectType, generics, structureType) - Map::class.java.isAssignableFrom(clazz) -> definition(clazz, listOf(objectDefinition(), objectDefinition()), DICTIONARY) - Collection::class.java.isAssignableFrom(clazz) -> objectDefinition(ARRAY) - else -> definition(clazz, generics, structureType) - } + override fun getAnnotations(): Annotations = + ReflectionAnnotations(listOf(erasure)) +} - private fun parameterized(type: ParameterizedType, generics: List, structureType: StructureType): ClassDefinition { - val erasure = type.rawType as Class<*> - val arguments = type.actualTypeArguments - return when { - Map::class.java.isAssignableFrom(erasure) -> - definition( - erasure = erasure, - generics = listOf( - classDefinitionOf(arguments.getOrElse(0) { Any::class.java }), - classDefinitionOf(arguments.getOrElse(1) { Any::class.java }), - ), - structureType = DICTIONARY, - ) - Collection::class.java.isAssignableFrom(erasure) -> - classDefinitionOf(arguments.getOrElse(0) { Any::class.java }, generics, ARRAY) - else -> - definition(erasure, arguments.map { classDefinitionOf(it) }, structureType) +private fun collectMembers(clazz: Class<*>): List { + if (clazz.isRecord) { + return clazz.recordComponents.map { component -> + val backingField = runCatching { clazz.getDeclaredField(component.name) }.getOrNull() + Member(component.name, component.genericType, Accessor.RECORD_COMPONENT, listOfNotNull(component.accessor, backingField, component)) } } - private fun definition(erasure: Class<*>, generics: List, structureType: StructureType): ClassDefinition = - ClassDefinition( - simpleName = erasure.simpleName.ifEmpty { erasure.name.substringAfterLast('.') }, - fullName = erasure.canonicalName ?: erasure.name, - generics = generics, - structureType = structureType, - handle = erasure, - ) - - private fun objectDefinition(structureType: StructureType = DEFAULT): ClassDefinition = - definition(Any::class.java, emptyList(), structureType) - - private fun collectMembers(clazz: Class<*>): List { - if (clazz.isRecord) { - return clazz.recordComponents.map { component -> - val backingField = runCatching { clazz.getDeclaredField(component.name) }.getOrNull() - Member(component.name, component.genericType, Accessor.RECORD_COMPONENT, listOfNotNull(component.accessor, backingField, component)) - } - } - - val members = mutableListOf() - - for (method in clazz.methods) { - if (Modifier.isStatic(method.modifiers) || method.isBridge || method.isSynthetic) continue - if (method.parameterCount != 0 || method.declaringClass == Any::class.java) continue - if (method.name == "getClass") continue - if (!method.name.startsWith("get") && !method.name.startsWith("is")) continue - members += Member(method.name, method.genericReturnType, Accessor.GETTER, listOf(method)) - } - - for (field in declaredFieldsHierarchy(clazz)) { - if (Modifier.isStatic(field.modifiers)) continue - members += Member(field.name, field.genericType, Accessor.FIELD, listOf(field)) - } + val members = mutableListOf() - return members + for (method in clazz.methods) { + if (Modifier.isStatic(method.modifiers) || method.isBridge || method.isSynthetic) continue + if (method.parameterCount != 0 || method.declaringClass == Any::class.java) continue + if (method.name == "getClass") continue + if (!method.name.startsWith("get") && !method.name.startsWith("is")) continue + members += Member(method.name, method.genericReturnType, Accessor.GETTER, listOf(method)) } - private companion object { - private fun declaredFieldsHierarchy(clazz: Class<*>): List { - val fields = mutableListOf() - var current: Class<*>? = clazz - while (current != null && current != Any::class.java) { - // private fields aren't inherited members, so include them only for the leaf class (matches getAllMembers) - current.declaredFields.filterTo(fields) { current == clazz || !Modifier.isPrivate(it.modifiers) } - current = current.superclass - } - return fields - } + for (field in declaredFieldsHierarchy(clazz)) { + if (Modifier.isStatic(field.modifiers)) continue + members += Member(field.name, field.genericType, Accessor.FIELD, listOf(field)) } - private class Member( - val rawName: String, - val genericType: Type, - val accessor: Accessor, - val sources: List, - ) + return members +} + +private fun declaredFieldsHierarchy(clazz: Class<*>): List { + val fields = mutableListOf() + var current: Class<*>? = clazz + while (current != null && current != Any::class.java) { + // private fields aren't inherited members, so include them only for the leaf class (matches getAllMembers) + current.declaredFields.filterTo(fields) { current == clazz || !Modifier.isPrivate(it.modifiers) } + current = current.superclass + } + return fields } -private val ClassDefinition.erasure: Class<*> - get() = handle as Class<*> +private class Member( + val rawName: String, + val genericType: Type, + val accessor: Accessor, + val sources: List, +) + +private class ReflectionAnnotations(private val sources: List) : Annotations { -internal class ReflectionAnnotations( - private val sources: List, - private val resolve: (Type) -> ClassDefinition, -) : Annotations { override fun find(annotationType: Class): A? = sources.firstNotNullOfOrNull { it.getAnnotation(annotationType) } - override fun hasBySimpleName(simpleName: String): Boolean = + override fun hasNamed(simpleName: String): Boolean = sources.any { source -> source.annotations.any { it.annotationClass.simpleName == simpleName } } - override fun classValue(annotationType: Class, member: A.() -> KClass<*>): ClassDefinition? = - find(annotationType)?.let { resolve(it.member().java) } + override fun resolveType(annotationType: Class, member: A.() -> KClass<*>): ClassDefinition? = + find(annotationType)?.let { reflect(it.member().java) } - override fun classValues(annotationType: Class, member: A.() -> Array>): List = - find(annotationType)?.member()?.map { resolve(it.java) } ?: emptyList() + override fun resolveTypes(annotationType: Class, member: A.() -> Array>): List = + find(annotationType)?.member()?.map { reflect(it.java) } ?: emptyList() - override fun values(annotationType: Class): Map? { + override fun memberValues(annotationType: Class): Map? { val annotation = find(annotationType) ?: return null return annotationType.declaredMethods.associate { it.name to normalize(it.invoke(annotation)) } } - private fun normalize(value: Any?): Any? = when (value) { - is Class<*> -> resolve(value) - is Enum<*> -> value.name - is Array<*> -> value.map { normalize(it) } - else -> value - } + private fun normalize(value: Any?): Any? = + when (value) { + is Class<*> -> reflect(value) + is Enum<*> -> value.name + is Array<*> -> value.map { normalize(it) } + else -> value + } } diff --git a/introspection/introspection-runtime/src/test/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospectorTest.kt b/introspection/introspection-runtime/src/test/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospectorTest.kt index 1d84c269..102658a7 100644 --- a/introspection/introspection-runtime/src/test/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospectorTest.kt +++ b/introspection/introspection-runtime/src/test/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospectorTest.kt @@ -12,7 +12,7 @@ class ReflectionTypeIntrospectorTest { private val introspector = ReflectionTypeIntrospector() private fun props(type: Class<*>): Map = - introspector.properties(introspector.introspect(type)).associateBy { it.name } + introspector.introspect(type).getProperties().associateBy { it.name } @Test fun `resolves a class into the shared model`() { @@ -52,16 +52,16 @@ class ReflectionTypeIntrospectorTest { fun `exposes annotations without applying policy`() { val id = props(Account::class.java).getValue("getId") assertThat(id.annotations.find(Nn::class.java)).isNotNull() - assertThat(id.annotations.hasBySimpleName("Nn")).isTrue() + assertThat(id.annotations.hasNamed("Nn")).isTrue() assertThat(props(Account::class.java).getValue("getName").annotations.find(Nn::class.java)).isNull() } @Test fun `reads enum constants raw`() { val color = introspector.introspect(Color::class.java) - assertThat(introspector.isEnum(color)).isTrue() - assertThat(introspector.enumConstants(color)).containsExactly("RED", "GREEN") - assertThat(introspector.enumConstants(introspector.introspect(Account::class.java))).isNull() + assertThat(color.isEnum()).isTrue() + assertThat(color.getEnumConstants()).containsExactly("RED", "GREEN") + assertThat(introspector.introspect(Account::class.java).getEnumConstants()).isNull() } @Test @@ -78,23 +78,23 @@ class ReflectionTypeIntrospectorTest { @Test fun `resolves Class-valued annotation members into ClassDefinitions`() { - val annotations = introspector.annotations(introspector.introspect(Holder::class.java)) + val annotations = introspector.introspect(Holder::class.java).getAnnotations() - assertThat(annotations.classValue(Ref::class.java) { value }?.fullName).isEqualTo(Address::class.java.name) - assertThat(annotations.classValues(Refs::class.java) { value }.map { it.fullName }) + assertThat(annotations.resolveType(Ref::class.java) { value }?.fullName).isEqualTo(Address::class.java.name) + assertThat(annotations.resolveTypes(Refs::class.java) { value }.map { it.fullName }) .containsExactly(Address::class.java.name, Color::class.java.name) } @Test fun `reads all annotation members into a neutral value map`() { - val annotations = introspector.annotations(introspector.introspect(Holder::class.java)) + val annotations = introspector.introspect(Holder::class.java).getAnnotations() - val values = annotations.values(Mixed::class.java)!! + val values = annotations.memberValues(Mixed::class.java)!! assertThat(values["name"]).isEqualTo("x") assertThat(values["count"]).isEqualTo(3) assertThat((values["type"] as io.javalin.introspection.ClassDefinition).fullName).isEqualTo(Address::class.java.name) assertThat(values["shade"]).isEqualTo("RED") - assertThat(annotations.values(java.lang.Deprecated::class.java)).isNull() + assertThat(annotations.memberValues(java.lang.Deprecated::class.java)).isNull() } } diff --git a/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/IntrospectionParityTest.kt b/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/IntrospectionParityTest.kt index 969cf28e..1d4246d3 100644 --- a/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/IntrospectionParityTest.kt +++ b/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/IntrospectionParityTest.kt @@ -18,8 +18,8 @@ class IntrospectionParityTest { private fun assertParity(type: KClass<*>) { val fqn = type.qualifiedName!! - val runtimeSnapshot = snapshot(runtime, runtime.introspect(type.java)) - val japSnapshot = withJap { jap, elements -> snapshot(jap, jap.introspect(elements.getTypeElement(fqn).asType())) } + val runtimeSnapshot = snapshot(runtime.introspect(type.java)) + val japSnapshot = withJap { jap, elements -> snapshot(jap.introspect(elements.getTypeElement(fqn).asType())) } assertThat(japSnapshot).isEqualTo(runtimeSnapshot) } @@ -33,13 +33,13 @@ class IntrospectionParityTest { @Test fun `Class-valued annotation members resolve identically across backends`() { - val runtimeAnnotations = runtime.annotations(runtime.introspect(Holder::class.java)) - val runtimeRef = runtimeAnnotations.classValue(Ref::class.java) { value }?.fullName - val runtimeRefs = runtimeAnnotations.classValues(Refs::class.java) { value }.map { it.fullName } + val runtimeAnnotations = runtime.introspect(Holder::class.java).getAnnotations() + val runtimeRef = runtimeAnnotations.resolveType(Ref::class.java) { value }?.fullName + val runtimeRefs = runtimeAnnotations.resolveTypes(Refs::class.java) { value }.map { it.fullName } val (japRef, japRefs) = withJap { jap, elements -> - val annotations = jap.annotations(jap.introspect(elements.getTypeElement(Holder::class.qualifiedName).asType())) - annotations.classValue(Ref::class.java) { value }?.fullName to annotations.classValues(Refs::class.java) { value }.map { it.fullName } + val annotations = jap.introspect(elements.getTypeElement(Holder::class.qualifiedName).asType()).getAnnotations() + annotations.resolveType(Ref::class.java) { value }?.fullName to annotations.resolveTypes(Refs::class.java) { value }.map { it.fullName } } assertThat(japRef).isEqualTo(runtimeRef).isEqualTo(Address::class.java.name) @@ -48,9 +48,9 @@ class IntrospectionParityTest { @Test fun `annotation value maps match across backends`() { - val runtimeValues = runtime.annotations(runtime.introspect(Holder::class.java)).values(Ref::class.java) + val runtimeValues = runtime.introspect(Holder::class.java).getAnnotations().memberValues(Ref::class.java) val japValues = withJap { jap, elements -> - jap.annotations(jap.introspect(elements.getTypeElement(Holder::class.qualifiedName).asType())).values(Ref::class.java) + jap.introspect(elements.getTypeElement(Holder::class.qualifiedName).asType()).getAnnotations().memberValues(Ref::class.java) } val runtimeRefType = (runtimeValues!!.getValue("value") as io.javalin.introspection.ClassDefinition).fullName diff --git a/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/Snapshot.kt b/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/Snapshot.kt index 1e0c5d47..7a4f2371 100644 --- a/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/Snapshot.kt +++ b/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/Snapshot.kt @@ -3,7 +3,6 @@ package io.javalin.introspection.test import io.javalin.introspection.Accessor import io.javalin.introspection.ClassDefinition import io.javalin.introspection.StructureType -import io.javalin.introspection.TypeIntrospector /** A backend-neutral, order-independent snapshot of one type's introspection, for comparing backends. */ data class TypeSnapshot( @@ -24,15 +23,15 @@ data class PropertySnapshot( val nullable: Boolean, ) -fun snapshot(introspector: TypeIntrospector, type: ClassDefinition): TypeSnapshot { - val enum = introspector.isEnum(type) +fun snapshot(type: ClassDefinition): TypeSnapshot { + val enum = type.isEnum() return TypeSnapshot( fullName = type.fullName, simpleName = type.simpleName, structure = type.structureType, isEnum = enum, - enumConstants = introspector.enumConstants(type)?.sorted(), - properties = if (enum) emptyList() else introspector.properties(type) + enumConstants = type.getEnumConstants()?.sorted(), + properties = if (enum) emptyList() else type.getProperties() .map { PropertySnapshot(it.name, it.type.fullName, it.type.structureType, it.type.generics.map { g -> g.fullName }, it.accessor, it.nullable) } .sortedBy { "${it.accessor}:${it.name}" }, ) From e248c0da777e2e3e3665224b4ca0ad509cec75d2 Mon Sep 17 00:00:00 2001 From: dzikoysk Date: Mon, 15 Jun 2026 01:13:43 +0200 Subject: [PATCH 04/43] chore: migrate dynamic module to new introspection api --- .../io/javalin/introspection/Annotations.kt | 2 +- .../javalin/introspection/ClassDefinition.kt | 5 +- .../io/javalin/introspection/PropertyView.kt | 8 + .../introspection/jap/JapTypeIntrospector.kt | 253 +++++++++--------- .../runtime/ReflectionTypeIntrospector.kt | 65 +++-- .../test/sub/PackagePrivateBase.java | 7 + .../test/AnnotationProcessing.kt | 51 ++++ .../test/IntrospectionParityTest.kt | 108 ++++++-- .../javalin/introspection/test/JapHarness.kt | 52 ---- .../io/javalin/introspection/test/Snapshot.kt | 38 --- .../introspection/test/fixtures/Fixtures.kt | 32 +++ openapi-dynamic/build.gradle.kts | 1 + .../openapi/dynamic/ReflectionHandle.kt | 9 - .../dynamic/ReflectiveTypeIntrospector.kt | 233 +++++----------- 14 files changed, 438 insertions(+), 426 deletions(-) create mode 100644 introspection/introspection-test/src/test/java/io/javalin/introspection/test/sub/PackagePrivateBase.java create mode 100644 introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/AnnotationProcessing.kt delete mode 100644 introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/JapHarness.kt delete mode 100644 introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/Snapshot.kt delete mode 100644 openapi-dynamic/src/main/kotlin/io/javalin/openapi/dynamic/ReflectionHandle.kt diff --git a/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/Annotations.kt b/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/Annotations.kt index fe15b269..b02f791a 100644 --- a/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/Annotations.kt +++ b/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/Annotations.kt @@ -5,7 +5,7 @@ import kotlin.reflect.KClass interface Annotations { fun find(annotationType: Class): A? - /** True if an annotation with this simple name is present (lets callers match e.g. `@NotNull` without depending on it). */ + /** True if an annotation with this simple name is present (match e.g. `@NotNull` without depending on it). */ fun hasNamed(simpleName: String): Boolean /** Resolve a class-valued member (e.g. `value = Foo::class`) to a [ClassDefinition]. */ diff --git a/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/ClassDefinition.kt b/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/ClassDefinition.kt index 62f20c10..a737bb51 100644 --- a/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/ClassDefinition.kt +++ b/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/ClassDefinition.kt @@ -1,6 +1,6 @@ package io.javalin.introspection -/** A type resolved by a backend; navigation ([getProperties], [isEnum], …) is implemented per backend. */ +/** A type resolved by a backend; navigation is implemented per backend. */ abstract class ClassDefinition( val simpleName: String, val fullName: String, @@ -8,6 +8,9 @@ abstract class ClassDefinition( val structureType: StructureType = StructureType.DEFAULT, ) { + /** Backend-native token this was resolved from (`Class`, `TypeMirror`, ...), for consumers needing backend specifics. */ + abstract val source: Any + abstract fun isEnum(): Boolean abstract fun getEnumConstants(): List? diff --git a/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/PropertyView.kt b/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/PropertyView.kt index 3476718b..d09dbd16 100644 --- a/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/PropertyView.kt +++ b/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/PropertyView.kt @@ -6,7 +6,15 @@ class PropertyView( val type: ClassDefinition, val accessor: Accessor, val nullable: Boolean, + val visibility: Visibility, val annotations: Annotations, ) enum class Accessor { FIELD, GETTER, RECORD_COMPONENT } + +enum class Visibility { PUBLIC, PROTECTED, PACKAGE_PRIVATE, PRIVATE } + +/** JavaBean getter name: `getX` / `isX` with an uppercase char right after the prefix (excludes e.g. `issue`, `getaway`). */ +fun isGetterName(name: String): Boolean = + (name.startsWith("get") && name.length > 3 && name[3].isUpperCase()) || + (name.startsWith("is") && name.length > 2 && name[2].isUpperCase()) diff --git a/introspection/introspection-jap/src/main/kotlin/io/javalin/introspection/jap/JapTypeIntrospector.kt b/introspection/introspection-jap/src/main/kotlin/io/javalin/introspection/jap/JapTypeIntrospector.kt index 5cbc01fa..6041e484 100644 --- a/introspection/introspection-jap/src/main/kotlin/io/javalin/introspection/jap/JapTypeIntrospector.kt +++ b/introspection/introspection-jap/src/main/kotlin/io/javalin/introspection/jap/JapTypeIntrospector.kt @@ -9,6 +9,9 @@ import io.javalin.introspection.StructureType.ARRAY import io.javalin.introspection.StructureType.DEFAULT import io.javalin.introspection.StructureType.DICTIONARY import io.javalin.introspection.TypeIntrospector +import io.javalin.introspection.Visibility +import io.javalin.introspection.isGetterName +import javax.lang.model.element.AnnotationMirror import javax.lang.model.element.AnnotationValue import javax.lang.model.element.Element import javax.lang.model.element.ElementKind @@ -21,42 +24,33 @@ import javax.lang.model.type.DeclaredType import javax.lang.model.type.MirroredTypeException import javax.lang.model.type.MirroredTypesException import javax.lang.model.type.PrimitiveType +import javax.lang.model.type.TypeKind import javax.lang.model.type.TypeMirror import javax.lang.model.type.TypeVariable +import javax.lang.model.type.WildcardType import javax.lang.model.util.Elements import javax.lang.model.util.SimpleAnnotationValueVisitor8 import javax.lang.model.util.Types import kotlin.reflect.KClass /** [TypeIntrospector] backed by `javax.lang.model` (Java annotation processing). */ -class JapTypeIntrospector(types: Types, elements: Elements) : TypeIntrospector { - - private val env = JapEnv(types, elements) +class JapTypeIntrospector(private val types: Types, private val elements: Elements) : TypeIntrospector { override fun introspect(source: Any): ClassDefinition { require(source is TypeMirror) { "JapTypeIntrospector expects a javax.lang.model.type.TypeMirror, got ${source::class.java.name}" } - return env.resolve(source) + return resolve(source) } - fun introspect(mirror: TypeMirror): ClassDefinition = - env.resolve(mirror) -} - -/** The `javax.lang.model` context + the type-resolution factory shared by the JAP [ClassDefinition]s. */ -internal class JapEnv(val types: Types, val elements: Elements) { - - fun resolve(mirror: TypeMirror, generics: List = emptyList(), structureType: StructureType = DEFAULT): ClassDefinition = + private fun resolve(mirror: TypeMirror, generics: List = emptyList(), structureType: StructureType = DEFAULT): ClassDefinition = when (mirror) { is TypeVariable -> (mirror.upperBound ?: mirror.lowerBound)?.let { resolve(it, generics, structureType) } ?: objectDefinition(structureType) + is WildcardType -> resolve(mirror.extendsBound ?: objectMirror(), generics, structureType) is ArrayType -> resolve(mirror.componentType, generics, ARRAY) is PrimitiveType -> definition(types.boxedClass(mirror).asType(), generics, structureType) is DeclaredType -> declared(mirror, generics, structureType) else -> types.asElement(mirror)?.asType()?.takeIf { it != mirror }?.let { resolve(it, generics, structureType) } ?: objectDefinition(structureType) } - fun objectMirror(): TypeMirror = - elements.getTypeElement("java.lang.Object").asType() - private fun declared(mirror: DeclaredType, generics: List, structureType: StructureType): ClassDefinition { val erasure = types.erasure(mirror) return when { @@ -79,147 +73,162 @@ internal class JapEnv(val types: Types, val elements: Elements) { private fun definition(mirror: TypeMirror, generics: List, structureType: StructureType): ClassDefinition { val element = types.asElement(mirror) as? TypeElement val fullName = element?.qualifiedName?.toString() ?: mirror.toString().substringBefore("<") - return JapClassDefinition( + return Definition( simpleName = element?.simpleName?.toString() ?: fullName.substringAfterLast('.'), fullName = fullName, generics = generics, structureType = structureType, mirror = mirror, - env = this, ) } private fun objectDefinition(structureType: StructureType = DEFAULT): ClassDefinition = definition(objectMirror(), emptyList(), structureType) + private fun objectMirror(): TypeMirror = + elements.getTypeElement("java.lang.Object").asType() + private fun erasureOf(name: String): TypeMirror = types.erasure(elements.getTypeElement(name).asType()) -} -private class JapClassDefinition( - simpleName: String, - fullName: String, - generics: List, - structureType: StructureType, - private val mirror: TypeMirror, - private val env: JapEnv, -) : ClassDefinition(simpleName, fullName, generics, structureType) { - - override fun isEnum(): Boolean = - element()?.kind == ElementKind.ENUM - - override fun getEnumConstants(): List? = - element() - ?.takeIf { it.kind == ElementKind.ENUM } - ?.enclosedElements - ?.filter { it.kind == ElementKind.ENUM_CONSTANT } - ?.map { it.simpleName.toString() } - - override fun getAnnotations(): Annotations = - JapAnnotations(element(), env) - - override fun getProperties(): List { - val element = element() ?: return emptyList() - - if (element.kind == ElementKind.RECORD) { - return element.recordComponents.map { component -> - PropertyView( - name = component.simpleName.toString(), - type = env.resolve(component.asType()), - accessor = Accessor.RECORD_COMPONENT, - nullable = component.asType().nullable(), - annotations = JapAnnotations(component, env), - ) - } - } + private inner class Definition( + simpleName: String, + fullName: String, + generics: List, + structureType: StructureType, + private val mirror: TypeMirror, + ) : ClassDefinition(simpleName, fullName, generics, structureType) { - return env.elements.getAllMembers(element).mapNotNull { member -> - when { - member.isGetter() -> { - val getter = member as ExecutableElement + override val source: Any + get() = mirror + + override fun isEnum(): Boolean = + typeElement()?.kind == ElementKind.ENUM + + override fun getEnumConstants(): List? = + typeElement() + ?.takeIf { it.kind == ElementKind.ENUM } + ?.enclosedElements + ?.filter { it.kind == ElementKind.ENUM_CONSTANT } + ?.map { it.simpleName.toString() } + + override fun getAnnotations(): Annotations = + AnnotationsView(typeElement()) + + override fun getProperties(): List { + val element = typeElement() ?: return emptyList() + + if (element.kind == ElementKind.RECORD) { + return element.recordComponents.map { component -> PropertyView( - name = getter.simpleName.toString(), - type = env.resolve(getter.returnType), - accessor = Accessor.GETTER, - nullable = getter.returnType.nullable(), - annotations = JapAnnotations(getter, env), + name = component.simpleName.toString(), + type = resolve(component.asType()), + accessor = Accessor.RECORD_COMPONENT, + nullable = component.asType().nullable(), + visibility = Visibility.PUBLIC, + annotations = AnnotationsView(component), ) } - member.isInstanceField() -> { - val field = member as VariableElement - PropertyView( - name = field.simpleName.toString(), - type = env.resolve(field.asType()), - accessor = Accessor.FIELD, - nullable = field.asType().nullable(), - annotations = JapAnnotations(field, env), - ) + } + + return elements.getAllMembers(element).mapNotNull { member -> + when { + member.isGetter() -> { + val getter = member as ExecutableElement + PropertyView( + name = getter.simpleName.toString(), + type = resolve(getter.returnType), + accessor = Accessor.GETTER, + nullable = getter.returnType.nullable(), + visibility = getter.visibility(), + annotations = AnnotationsView(getter), + ) + } + member.isInstanceField() -> { + val field = member as VariableElement + PropertyView( + name = field.simpleName.toString(), + type = resolve(field.asType()), + accessor = Accessor.FIELD, + nullable = field.asType().nullable(), + visibility = field.visibility(), + annotations = AnnotationsView(field), + ) + } + else -> null } - else -> null } } - } - private fun element(): TypeElement? = - env.types.asElement(mirror) as? TypeElement + private fun typeElement(): TypeElement? = + types.asElement(mirror) as? TypeElement - private fun TypeMirror.nullable(): Boolean = - !kind.isPrimitive + private fun TypeMirror.nullable(): Boolean = + !kind.isPrimitive - private fun Element.isGetter(): Boolean { - if (kind != ElementKind.METHOD || this !is ExecutableElement) return false - if (Modifier.PUBLIC !in modifiers || Modifier.STATIC in modifiers) return false - if (parameters.isNotEmpty() || enclosingElement?.toString() == "java.lang.Object") return false - val name = simpleName.toString() - return name != "getClass" && (name.startsWith("get") || name.startsWith("is")) - } + private fun Element.isGetter(): Boolean { + if (kind != ElementKind.METHOD || this !is ExecutableElement) return false + if (Modifier.PUBLIC !in modifiers || Modifier.STATIC in modifiers) return false + if (parameters.isNotEmpty() || enclosingElement?.toString() == "java.lang.Object") return false + if (returnType.kind == TypeKind.VOID) return false + return isGetterName(simpleName.toString()) + } - private fun Element.isInstanceField(): Boolean = - kind == ElementKind.FIELD && this is VariableElement && Modifier.STATIC !in modifiers -} + private fun Element.isInstanceField(): Boolean = + kind == ElementKind.FIELD && this is VariableElement && Modifier.STATIC !in modifiers + + private fun Element.visibility(): Visibility = + when { + Modifier.PUBLIC in modifiers -> Visibility.PUBLIC + Modifier.PROTECTED in modifiers -> Visibility.PROTECTED + Modifier.PRIVATE in modifiers -> Visibility.PRIVATE + else -> Visibility.PACKAGE_PRIVATE + } + } -private class JapAnnotations( - private val element: Element?, - private val env: JapEnv, -) : Annotations { + private inner class AnnotationsView(private val element: Element?) : Annotations { - override fun find(annotationType: Class): A? = - element?.getAnnotation(annotationType) + override fun find(annotationType: Class): A? = + element?.getAnnotation(annotationType) - override fun hasNamed(simpleName: String): Boolean = - element?.annotationMirrors?.any { it.annotationType.asElement().simpleName.contentEquals(simpleName) } == true + override fun hasNamed(simpleName: String): Boolean = + element?.annotationMirrors?.any { it.annotationType.asElement().simpleName.contentEquals(simpleName) } == true - override fun resolveType(annotationType: Class, member: A.() -> KClass<*>): ClassDefinition? { - val annotation = find(annotationType) ?: return null - return try { - env.elements.getTypeElement(annotation.member().java.name)?.asType()?.let { env.resolve(it) } - } catch (mirrored: MirroredTypeException) { - env.resolve(mirrored.typeMirror) + override fun resolveType(annotationType: Class, member: A.() -> KClass<*>): ClassDefinition? { + val annotation = find(annotationType) ?: return null + return try { + elements.getTypeElement(annotation.member().java.name)?.asType()?.let { resolve(it) } + } catch (mirrored: MirroredTypeException) { + resolve(mirrored.typeMirror) + } } - } - override fun resolveTypes(annotationType: Class, member: A.() -> Array>): List { - val annotation = find(annotationType) ?: return emptyList() - return try { - annotation.member().mapNotNull { kClass -> env.elements.getTypeElement(kClass.java.name)?.asType()?.let { env.resolve(it) } } - } catch (mirrored: MirroredTypesException) { - mirrored.typeMirrors.map { env.resolve(it) } + override fun resolveTypes(annotationType: Class, member: A.() -> Array>): List { + val annotation = find(annotationType) ?: return emptyList() + return try { + annotation.member().mapNotNull { kClass -> elements.getTypeElement(kClass.java.name)?.asType()?.let { resolve(it) } } + } catch (mirrored: MirroredTypesException) { + mirrored.typeMirrors.map { resolve(it) } + } } - } - override fun memberValues(annotationType: Class): Map? { - val mirror = element?.annotationMirrors?.firstOrNull { - (it.annotationType.asElement() as? TypeElement)?.qualifiedName?.contentEquals(annotationType.name) == true - } ?: return null + override fun memberValues(annotationType: Class): Map? { + val mirror = element?.annotationMirrors?.firstOrNull { + (it.annotationType.asElement() as? TypeElement)?.qualifiedName?.contentEquals(annotationType.name) == true + } ?: return null + + val visitor = object : SimpleAnnotationValueVisitor8() { + override fun defaultAction(value: Any?, p: Nothing?): Any? = value + override fun visitType(t: TypeMirror, p: Nothing?): Any = resolve(t) + override fun visitEnumConstant(constant: VariableElement, p: Nothing?): Any = constant.simpleName.toString() + override fun visitArray(values: MutableList, p: Nothing?): Any = values.map { it.accept(this, null) } + override fun visitAnnotation(nested: AnnotationMirror, p: Nothing?): Any = + elements.getElementValuesWithDefaults(nested).entries + .associate { (member, value) -> member.simpleName.toString() to value.accept(this, null) } + } - val visitor = object : SimpleAnnotationValueVisitor8() { - override fun defaultAction(value: Any?, p: Nothing?): Any? = value - override fun visitType(t: TypeMirror, p: Nothing?): Any = env.resolve(t) - override fun visitEnumConstant(constant: VariableElement, p: Nothing?): Any = constant.simpleName.toString() - override fun visitArray(values: MutableList, p: Nothing?): Any = values.map { it.accept(this, null) } + return elements.getElementValuesWithDefaults(mirror).entries + .associate { (executable, value) -> executable.simpleName.toString() to value.accept(visitor, null) } } - - return env.elements.getElementValuesWithDefaults(mirror).entries - .associate { (executable, value) -> executable.simpleName.toString() to value.accept(visitor, null) } } } diff --git a/introspection/introspection-runtime/src/main/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospector.kt b/introspection/introspection-runtime/src/main/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospector.kt index d092cdf7..3ed796b5 100644 --- a/introspection/introspection-runtime/src/main/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospector.kt +++ b/introspection/introspection-runtime/src/main/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospector.kt @@ -9,7 +9,10 @@ import io.javalin.introspection.StructureType.ARRAY import io.javalin.introspection.StructureType.DEFAULT import io.javalin.introspection.StructureType.DICTIONARY import io.javalin.introspection.TypeIntrospector +import io.javalin.introspection.Visibility +import io.javalin.introspection.isGetterName import java.lang.reflect.AnnotatedElement +import java.lang.reflect.Array as JavaArray import java.lang.reflect.Field import java.lang.reflect.GenericArrayType import java.lang.reflect.Modifier @@ -25,9 +28,6 @@ class ReflectionTypeIntrospector : TypeIntrospector { require(source is Type) { "ReflectionTypeIntrospector expects a java.lang.reflect.Type, got ${source::class.java.name}" } return reflect(source) } - - fun introspect(type: Type): ClassDefinition = - reflect(type) } private fun reflect(type: Type, generics: List = emptyList(), structureType: StructureType = DEFAULT): ClassDefinition = @@ -88,6 +88,9 @@ private class ReflectionClassDefinition( private val erasure: Class<*>, ) : ClassDefinition(simpleName, fullName, generics, structureType) { + override val source: Any + get() = erasure + override fun isEnum(): Boolean = erasure.isEnum @@ -97,10 +100,11 @@ private class ReflectionClassDefinition( override fun getProperties(): List = collectMembers(erasure).map { member -> PropertyView( - name = member.rawName, + name = member.name, type = reflect(member.genericType), accessor = member.accessor, nullable = (member.genericType as? Class<*>)?.isPrimitive != true, + visibility = member.visibility, annotations = ReflectionAnnotations(member.sources), ) } @@ -113,7 +117,7 @@ private fun collectMembers(clazz: Class<*>): List { if (clazz.isRecord) { return clazz.recordComponents.map { component -> val backingField = runCatching { clazz.getDeclaredField(component.name) }.getOrNull() - Member(component.name, component.genericType, Accessor.RECORD_COMPONENT, listOfNotNull(component.accessor, backingField, component)) + Member(component.name, component.genericType, Accessor.RECORD_COMPONENT, Visibility.PUBLIC, listOfNotNull(component.accessor, backingField, component)) } } @@ -122,34 +126,51 @@ private fun collectMembers(clazz: Class<*>): List { for (method in clazz.methods) { if (Modifier.isStatic(method.modifiers) || method.isBridge || method.isSynthetic) continue if (method.parameterCount != 0 || method.declaringClass == Any::class.java) continue - if (method.name == "getClass") continue - if (!method.name.startsWith("get") && !method.name.startsWith("is")) continue - members += Member(method.name, method.genericReturnType, Accessor.GETTER, listOf(method)) + if (method.returnType == Void.TYPE || !isGetterName(method.name)) continue + members += Member(method.name, method.genericReturnType, Accessor.GETTER, visibilityOf(method.modifiers), listOf(method)) } for (field in declaredFieldsHierarchy(clazz)) { if (Modifier.isStatic(field.modifiers)) continue - members += Member(field.name, field.genericType, Accessor.FIELD, listOf(field)) + members += Member(field.name, field.genericType, Accessor.FIELD, visibilityOf(field.modifiers), listOf(field)) } return members } +private fun visibilityOf(modifiers: Int): Visibility = + when { + Modifier.isPublic(modifiers) -> Visibility.PUBLIC + Modifier.isProtected(modifiers) -> Visibility.PROTECTED + Modifier.isPrivate(modifiers) -> Visibility.PRIVATE + else -> Visibility.PACKAGE_PRIVATE + } + private fun declaredFieldsHierarchy(clazz: Class<*>): List { val fields = mutableListOf() var current: Class<*>? = clazz while (current != null && current != Any::class.java) { - // private fields aren't inherited members, so include them only for the leaf class (matches getAllMembers) - current.declaredFields.filterTo(fields) { current == clazz || !Modifier.isPrivate(it.modifiers) } + // only members [clazz] actually inherits, matching JAP's getAllMembers: private stays in the leaf, + // package-private is inherited only within the same package + current.declaredFields.filterTo(fields) { field -> + val modifiers = field.modifiers + when { + current == clazz -> true + Modifier.isPrivate(modifiers) -> false + Modifier.isPublic(modifiers) || Modifier.isProtected(modifiers) -> true + else -> field.declaringClass.packageName == clazz.packageName + } + } current = current.superclass } return fields } private class Member( - val rawName: String, + val name: String, val genericType: Type, val accessor: Accessor, + val visibility: Visibility, val sources: List, ) @@ -167,16 +188,20 @@ private class ReflectionAnnotations(private val sources: List) override fun resolveTypes(annotationType: Class, member: A.() -> Array>): List = find(annotationType)?.member()?.map { reflect(it.java) } ?: emptyList() - override fun memberValues(annotationType: Class): Map? { - val annotation = find(annotationType) ?: return null - return annotationType.declaredMethods.associate { it.name to normalize(it.invoke(annotation)) } - } + override fun memberValues(annotationType: Class): Map? = + find(annotationType)?.let { annotationToMap(it) } + + private fun annotationToMap(annotation: Annotation): Map = + annotation.annotationClass.java.declaredMethods.associate { it.name to normalize(it.invoke(annotation)) } private fun normalize(value: Any?): Any? = - when (value) { - is Class<*> -> reflect(value) - is Enum<*> -> value.name - is Array<*> -> value.map { normalize(it) } + when { + value is Class<*> -> reflect(value) + value is Enum<*> -> value.name + value is Annotation -> annotationToMap(value) + value is Array<*> -> value.map { normalize(it) } + value != null && value::class.java.isArray -> + (0 until JavaArray.getLength(value)).map { normalize(JavaArray.get(value, it)) } else -> value } } diff --git a/introspection/introspection-test/src/test/java/io/javalin/introspection/test/sub/PackagePrivateBase.java b/introspection/introspection-test/src/test/java/io/javalin/introspection/test/sub/PackagePrivateBase.java new file mode 100644 index 00000000..31507eda --- /dev/null +++ b/introspection/introspection-test/src/test/java/io/javalin/introspection/test/sub/PackagePrivateBase.java @@ -0,0 +1,7 @@ +package io.javalin.introspection.test.sub; + +/** Has a package-private field (not inherited across packages) and a public one (inherited everywhere). */ +public class PackagePrivateBase { + String packagePrivateField = ""; + public String publicField = ""; +} diff --git a/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/AnnotationProcessing.kt b/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/AnnotationProcessing.kt new file mode 100644 index 00000000..3e6a6969 --- /dev/null +++ b/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/AnnotationProcessing.kt @@ -0,0 +1,51 @@ +package io.javalin.introspection.test + +import io.javalin.introspection.ClassDefinition +import io.javalin.introspection.jap.JapTypeIntrospector +import java.net.URI +import javax.annotation.processing.AbstractProcessor +import javax.annotation.processing.RoundEnvironment +import javax.lang.model.SourceVersion +import javax.lang.model.element.TypeElement +import javax.tools.JavaFileObject +import javax.tools.SimpleJavaFileObject +import javax.tools.ToolProvider +import kotlin.reflect.KClass + +/** + * Runs the annotation-processing backend inside a real in-process `javac` run, so it can be tested like reflection. + * + * The backend reads types via `javax.lang.model`, which the compiler only exposes to a running processor — there is + * no such environment at plain runtime. So [introspect] compiles a throwaway source with a processor attached, + * resolves [type] from inside it, and passes the result to [block]. The compiler discards its model once the run + * ends, so [block] must finish all introspection (e.g. [ClassDefinition.getProperties]) before returning. + */ +object AnnotationProcessing { + + fun introspect(type: KClass<*>, block: (ClassDefinition) -> R): R { + val compiler = requireNotNull(ToolProvider.getSystemJavaCompiler()) { "A JDK is required (no system Java compiler)" } + val trigger = object : SimpleJavaFileObject(URI.create("string:///Trigger.java"), JavaFileObject.Kind.SOURCE) { + override fun getCharContent(ignoreEncodingErrors: Boolean): CharSequence = "class Trigger {}" + } + + var result: Result? = null + val processor = object : AbstractProcessor() { + override fun getSupportedAnnotationTypes(): Set = setOf("*") + override fun getSupportedSourceVersion(): SourceVersion = SourceVersion.latestSupported() + override fun process(annotations: Set, round: RoundEnvironment): Boolean { + if (result == null) { + val backend = JapTypeIntrospector(processingEnv.typeUtils, processingEnv.elementUtils) + val mirror = processingEnv.elementUtils.getTypeElement(type.qualifiedName).asType() + result = runCatching { block(backend.introspect(mirror)) } + } + return false + } + } + + val options = listOf("-proc:only", "-classpath", System.getProperty("java.class.path")) + val task = compiler.getTask(null, null, null, options, null, listOf(trigger)) + task.setProcessors(listOf(processor)) + check(task.call()) { "annotation processing run failed" } + return (result ?: error("processor did not run")).getOrThrow() + } +} diff --git a/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/IntrospectionParityTest.kt b/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/IntrospectionParityTest.kt index 1d4246d3..78e5e63a 100644 --- a/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/IntrospectionParityTest.kt +++ b/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/IntrospectionParityTest.kt @@ -1,13 +1,25 @@ package io.javalin.introspection.test +import io.javalin.introspection.Accessor +import io.javalin.introspection.ClassDefinition +import io.javalin.introspection.StructureType +import io.javalin.introspection.Visibility import io.javalin.introspection.runtime.ReflectionTypeIntrospector import io.javalin.introspection.test.fixtures.Account import io.javalin.introspection.test.fixtures.Address +import io.javalin.introspection.test.fixtures.Annotated import io.javalin.introspection.test.fixtures.Color +import io.javalin.introspection.test.fixtures.CrossPackageChild import io.javalin.introspection.test.fixtures.Derived +import io.javalin.introspection.test.fixtures.Flagged +import io.javalin.introspection.test.fixtures.Flags import io.javalin.introspection.test.fixtures.Holder +import io.javalin.introspection.test.fixtures.Outer +import io.javalin.introspection.test.fixtures.Point import io.javalin.introspection.test.fixtures.Ref import io.javalin.introspection.test.fixtures.Refs +import io.javalin.introspection.test.fixtures.Tricky +import io.javalin.introspection.test.fixtures.Wrapped import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test import kotlin.reflect.KClass @@ -17,10 +29,9 @@ class IntrospectionParityTest { private val runtime = ReflectionTypeIntrospector() private fun assertParity(type: KClass<*>) { - val fqn = type.qualifiedName!! - val runtimeSnapshot = snapshot(runtime.introspect(type.java)) - val japSnapshot = withJap { jap, elements -> snapshot(jap.introspect(elements.getTypeElement(fqn).asType())) } - assertThat(japSnapshot).isEqualTo(runtimeSnapshot) + val runtimeShape = runtime.introspect(type.java).toShape() + val processedShape = AnnotationProcessing.introspect(type) { it.toShape() } + assertThat(processedShape).isEqualTo(runtimeShape) } @Test fun `scalar, collection, map and nested members match across backends`() = assertParity(Account::class) @@ -29,7 +40,36 @@ class IntrospectionParityTest { @Test fun `enum constants match across backends`() = assertParity(Color::class) - @Test fun `inherited public getters and private superclass fields match across backends`() = assertParity(Derived::class) + @Test fun `inherited getters and private superclass fields match across backends`() = assertParity(Derived::class) + + @Test fun `record components match across backends`() = assertParity(Point::class) + + @Test fun `cross-package inherited fields match across backends`() = assertParity(CrossPackageChild::class) + + @Test + fun `getter detection excludes get-or-is lookalikes on both backends`() { + assertParity(Tricky::class) + val names = runtime.introspect(Tricky::class.java).getProperties().map { it.name } + assertThat(names).contains("getName").doesNotContain("issue", "getaway", "getResult") + } + + @Test + fun `property-level annotations resolve identically across backends`() { + val runtimeMarked = runtime.introspect(Annotated::class.java).getProperties().associate { it.name to it.annotations.hasNamed("Marker") } + val processedMarked = AnnotationProcessing.introspect(Annotated::class) { + it.getProperties().associate { property -> property.name to property.annotations.hasNamed("Marker") } + } + assertThat(processedMarked).isEqualTo(runtimeMarked) + assertThat(runtimeMarked.getValue("getTagged")).isTrue() + assertThat(runtimeMarked.getValue("getPlain")).isFalse() + } + + @Test + fun `nested annotation members normalize to maps identically across backends`() { + val runtimeMeta = runtime.introspect(Wrapped::class.java).getAnnotations().memberValues(Outer::class.java)!!.getValue("meta") + val processedMeta = AnnotationProcessing.introspect(Wrapped::class) { it.getAnnotations().memberValues(Outer::class.java)!!.getValue("meta") } + assertThat(processedMeta).isEqualTo(runtimeMeta).isEqualTo(mapOf("note" to "x")) + } @Test fun `Class-valued annotation members resolve identically across backends`() { @@ -37,24 +77,60 @@ class IntrospectionParityTest { val runtimeRef = runtimeAnnotations.resolveType(Ref::class.java) { value }?.fullName val runtimeRefs = runtimeAnnotations.resolveTypes(Refs::class.java) { value }.map { it.fullName } - val (japRef, japRefs) = withJap { jap, elements -> - val annotations = jap.introspect(elements.getTypeElement(Holder::class.qualifiedName).asType()).getAnnotations() + val (processedRef, processedRefs) = AnnotationProcessing.introspect(Holder::class) { + val annotations = it.getAnnotations() annotations.resolveType(Ref::class.java) { value }?.fullName to annotations.resolveTypes(Refs::class.java) { value }.map { it.fullName } } - assertThat(japRef).isEqualTo(runtimeRef).isEqualTo(Address::class.java.name) - assertThat(japRefs).isEqualTo(runtimeRefs).containsExactly(Address::class.java.name, Color::class.java.name) + assertThat(processedRef).isEqualTo(runtimeRef).isEqualTo(Address::class.java.name) + assertThat(processedRefs).isEqualTo(runtimeRefs).containsExactly(Address::class.java.name, Color::class.java.name) } @Test fun `annotation value maps match across backends`() { - val runtimeValues = runtime.introspect(Holder::class.java).getAnnotations().memberValues(Ref::class.java) - val japValues = withJap { jap, elements -> - jap.introspect(elements.getTypeElement(Holder::class.qualifiedName).asType()).getAnnotations().memberValues(Ref::class.java) - } + val runtimeValue = runtime.introspect(Holder::class.java).getAnnotations().memberValues(Ref::class.java)!!.getValue("value") + val processedValue = AnnotationProcessing.introspect(Holder::class) { it.getAnnotations().memberValues(Ref::class.java)!!.getValue("value") } + assertThat((processedValue as ClassDefinition).fullName) + .isEqualTo((runtimeValue as ClassDefinition).fullName) + .isEqualTo(Address::class.java.name) + } - val runtimeRefType = (runtimeValues!!.getValue("value") as io.javalin.introspection.ClassDefinition).fullName - val japRefType = (japValues!!.getValue("value") as io.javalin.introspection.ClassDefinition).fullName - assertThat(japRefType).isEqualTo(runtimeRefType).isEqualTo(Address::class.java.name) + @Test + fun `primitive array annotation members normalize identically across backends`() { + val runtimeInts = runtime.introspect(Flagged::class.java).getAnnotations().memberValues(Flags::class.java)!!.getValue("ints") + val processedInts = AnnotationProcessing.introspect(Flagged::class) { it.getAnnotations().memberValues(Flags::class.java)!!.getValue("ints") } + assertThat(processedInts).isEqualTo(runtimeInts).isEqualTo(listOf(1, 2, 3)) } } + +private data class TypeShape( + val fullName: String, + val simpleName: String, + val structure: StructureType, + val isEnum: Boolean, + val enumConstants: List?, + val properties: List, +) + +private data class PropertyShape( + val name: String, + val typeFullName: String, + val typeStructure: StructureType, + val typeGenerics: List, + val accessor: Accessor, + val nullable: Boolean, + val visibility: Visibility, +) + +/** Normalized, order-independent structural view of a type, so the two backends can be compared by value. */ +private fun ClassDefinition.toShape(): TypeShape = + TypeShape( + fullName = fullName, + simpleName = simpleName, + structure = structureType, + isEnum = isEnum(), + enumConstants = getEnumConstants()?.sorted(), + properties = if (isEnum()) emptyList() else getProperties() + .map { PropertyShape(it.name, it.type.fullName, it.type.structureType, it.type.generics.map { g -> g.fullName }, it.accessor, it.nullable, it.visibility) } + .sortedBy { "${it.accessor}:${it.name}" }, + ) diff --git a/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/JapHarness.kt b/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/JapHarness.kt deleted file mode 100644 index 5668cfe1..00000000 --- a/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/JapHarness.kt +++ /dev/null @@ -1,52 +0,0 @@ -package io.javalin.introspection.test - -import io.javalin.introspection.jap.JapTypeIntrospector -import java.net.URI -import javax.annotation.processing.AbstractProcessor -import javax.annotation.processing.RoundEnvironment -import javax.lang.model.SourceVersion -import javax.lang.model.element.TypeElement -import javax.lang.model.util.Elements -import javax.tools.JavaFileObject -import javax.tools.SimpleJavaFileObject -import javax.tools.ToolProvider - -/** - * Runs [block] inside a real annotation-processing round (in-process `javac`, classpath = the test classpath), - * giving it a [JapTypeIntrospector] + [Elements] so JAP can be exercised exactly like the runtime backend. - */ -fun withJap(block: (JapTypeIntrospector, Elements) -> R): R { - val compiler = requireNotNull(ToolProvider.getSystemJavaCompiler()) { "A JDK is required (no system Java compiler)" } - val fileManager = compiler.getStandardFileManager(null, null, null) - try { - val trigger = object : SimpleJavaFileObject(URI.create("string:///Trigger.java"), JavaFileObject.Kind.SOURCE) { - override fun getCharContent(ignoreEncodingErrors: Boolean): CharSequence = "class Trigger {}" - } - val processor = BlockProcessor(block) - val options = listOf("-proc:only", "-classpath", System.getProperty("java.class.path")) - val task = compiler.getTask(null, fileManager, null, options, null, listOf(trigger)) - task.setProcessors(listOf(processor)) - check(task.call()) { "annotation processing round failed" } - check(processor.done) { "processor did not run" } - @Suppress("UNCHECKED_CAST") - return processor.result as R - } finally { - fileManager.close() - } -} - -private class BlockProcessor(private val block: (JapTypeIntrospector, Elements) -> R) : AbstractProcessor() { - var done = false - var result: R? = null - - override fun getSupportedAnnotationTypes(): Set = setOf("*") - override fun getSupportedSourceVersion(): SourceVersion = SourceVersion.latestSupported() - - override fun process(annotations: Set, roundEnv: RoundEnvironment): Boolean { - if (!done) { - result = block(JapTypeIntrospector(processingEnv.typeUtils, processingEnv.elementUtils), processingEnv.elementUtils) - done = true - } - return false - } -} diff --git a/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/Snapshot.kt b/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/Snapshot.kt deleted file mode 100644 index 7a4f2371..00000000 --- a/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/Snapshot.kt +++ /dev/null @@ -1,38 +0,0 @@ -package io.javalin.introspection.test - -import io.javalin.introspection.Accessor -import io.javalin.introspection.ClassDefinition -import io.javalin.introspection.StructureType - -/** A backend-neutral, order-independent snapshot of one type's introspection, for comparing backends. */ -data class TypeSnapshot( - val fullName: String, - val simpleName: String, - val structure: StructureType, - val isEnum: Boolean, - val enumConstants: List?, - val properties: List, -) - -data class PropertySnapshot( - val name: String, - val typeFullName: String, - val typeStructure: StructureType, - val typeGenerics: List, - val accessor: Accessor, - val nullable: Boolean, -) - -fun snapshot(type: ClassDefinition): TypeSnapshot { - val enum = type.isEnum() - return TypeSnapshot( - fullName = type.fullName, - simpleName = type.simpleName, - structure = type.structureType, - isEnum = enum, - enumConstants = type.getEnumConstants()?.sorted(), - properties = if (enum) emptyList() else type.getProperties() - .map { PropertySnapshot(it.name, it.type.fullName, it.type.structureType, it.type.generics.map { g -> g.fullName }, it.accessor, it.nullable) } - .sortedBy { "${it.accessor}:${it.name}" }, - ) -} diff --git a/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/fixtures/Fixtures.kt b/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/fixtures/Fixtures.kt index 1a2739d7..02684a03 100644 --- a/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/fixtures/Fixtures.kt +++ b/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/fixtures/Fixtures.kt @@ -6,6 +6,11 @@ class Address(val city: String, val zip: String) enum class Color { RED, GREEN } +class Box(val value: T) + +@JvmRecord +data class Point(val x: Int, val label: String, val tags: List) + class Account( val id: String, val age: Int, @@ -13,6 +18,7 @@ class Account( val address: Address?, val tags: List, val meta: Map, + val bounded: Box, ) open class Base(val baseField: String) { @@ -22,6 +28,32 @@ open class Base(val baseField: String) { class Derived(val own: String) : Base("") +/** Extends a Java base in another package that has a package-private field, to exercise cross-package field inheritance. */ +class CrossPackageChild : io.javalin.introspection.test.sub.PackagePrivateBase() + +class Tricky { + fun getName(): String = "" + fun issue(): String = "" + fun getaway(): String = "" + fun getResult() {} +} + +annotation class Marker + +class Annotated(@get:Marker val tagged: String, val plain: String) + +annotation class Meta(val note: String) + +annotation class Outer(val meta: Meta) + +@Outer(Meta("x")) +class Wrapped + +annotation class Flags(val ints: IntArray) + +@Flags(ints = [1, 2, 3]) +class Flagged + annotation class Ref(val value: KClass<*>) annotation class Refs(vararg val value: KClass<*>) diff --git a/openapi-dynamic/build.gradle.kts b/openapi-dynamic/build.gradle.kts index 06614680..7b60cce5 100644 --- a/openapi-dynamic/build.gradle.kts +++ b/openapi-dynamic/build.gradle.kts @@ -2,6 +2,7 @@ description = "Javalin OpenAPI Dynamic | Runtime reflection-based OpenAPI intros dependencies { api(project(":openapi-generator")) + api(project(":introspection:introspection-runtime")) testImplementation(libs.junit.jupiter.params) testImplementation(libs.junit.jupiter.api) diff --git a/openapi-dynamic/src/main/kotlin/io/javalin/openapi/dynamic/ReflectionHandle.kt b/openapi-dynamic/src/main/kotlin/io/javalin/openapi/dynamic/ReflectionHandle.kt deleted file mode 100644 index ab987555..00000000 --- a/openapi-dynamic/src/main/kotlin/io/javalin/openapi/dynamic/ReflectionHandle.kt +++ /dev/null @@ -1,9 +0,0 @@ -package io.javalin.openapi.dynamic - -import io.javalin.openapi.experimental.ClassDefinition - -/** Reflection counterpart of the AP's `ClassDefinitionHandle`, stored in [ClassDefinition.handle]. */ -data class ReflectionHandle(val erasure: Class<*>) - -val ClassDefinition.reflectedClass: Class<*> - get() = (handle as ReflectionHandle).erasure diff --git a/openapi-dynamic/src/main/kotlin/io/javalin/openapi/dynamic/ReflectiveTypeIntrospector.kt b/openapi-dynamic/src/main/kotlin/io/javalin/openapi/dynamic/ReflectiveTypeIntrospector.kt index 188a8102..78bc3f2c 100644 --- a/openapi-dynamic/src/main/kotlin/io/javalin/openapi/dynamic/ReflectiveTypeIntrospector.kt +++ b/openapi-dynamic/src/main/kotlin/io/javalin/openapi/dynamic/ReflectiveTypeIntrospector.kt @@ -1,5 +1,7 @@ package io.javalin.openapi.dynamic +import io.javalin.introspection.Accessor +import io.javalin.introspection.runtime.ReflectionTypeIntrospector import io.javalin.openapi.OpenApiByFields import io.javalin.openapi.OpenApiDescription import io.javalin.openapi.OpenApiIgnore @@ -10,139 +12,84 @@ import io.javalin.openapi.OpenApiRequired import io.javalin.openapi.Visibility import io.javalin.openapi.experimental.ClassDefinition import io.javalin.openapi.experimental.StructureType -import io.javalin.openapi.experimental.StructureType.ARRAY -import io.javalin.openapi.experimental.StructureType.DEFAULT -import io.javalin.openapi.experimental.StructureType.DICTIONARY import io.javalin.openapi.experimental.TypeIntrospector import io.javalin.openapi.experimental.processor.generators.Property import io.javalin.openapi.experimental.processor.generators.translatePropertyName -import java.lang.reflect.AnnotatedElement -import java.lang.reflect.Field -import java.lang.reflect.GenericArrayType -import java.lang.reflect.Modifier -import java.lang.reflect.ParameterizedType import java.lang.reflect.Type -import java.lang.reflect.WildcardType +import io.javalin.introspection.ClassDefinition as RawType +import io.javalin.introspection.PropertyView as RawProperty +import io.javalin.introspection.Visibility as RawVisibility -/** Runtime-reflection [TypeIntrospector] over [java.lang.reflect.Type]. */ +/** Runtime-reflection [TypeIntrospector]: layers OpenAPI policy over the shared [ReflectionTypeIntrospector]. */ class ReflectiveTypeIntrospector( private val requireNonNullsByDefault: Boolean = true, ) : TypeIntrospector { + private val runtime = ReflectionTypeIntrospector() + override fun introspect(nativeType: Any): ClassDefinition { require(nativeType is Type) { "ReflectiveTypeIntrospector expects a java.lang.reflect.Type, got ${nativeType::class.java.name}" } - return classDefinitionOf(nativeType) + return introspect(nativeType) } - fun introspect(type: Type): ClassDefinition = classDefinitionOf(type) - - private fun classDefinitionOf( - type: Type, - generics: List = emptyList(), - structureType: StructureType = DEFAULT, - ): ClassDefinition = - when (type) { - is GenericArrayType -> classDefinitionOf(type.genericComponentType, generics, ARRAY) - is WildcardType -> classDefinitionOf(type.upperBounds.firstOrNull() ?: Any::class.java, generics, structureType) - is ParameterizedType -> parameterized(type, generics, structureType) - is Class<*> -> raw(type, generics, structureType) - else -> objectDefinition(structureType) - } - - private fun raw(clazz: Class<*>, generics: List, structureType: StructureType): ClassDefinition = - when { - clazz.isArray -> classDefinitionOf(clazz.componentType, generics, ARRAY) - clazz.isPrimitive -> definition(box(clazz), generics, structureType) - Map::class.java.isAssignableFrom(clazz) -> definition(clazz, listOf(objectDefinition(), objectDefinition()), DICTIONARY) - Collection::class.java.isAssignableFrom(clazz) -> objectDefinition(ARRAY) - else -> definition(clazz, generics, structureType) - } - - private fun parameterized(type: ParameterizedType, generics: List, structureType: StructureType): ClassDefinition { - val erasure = type.rawType as Class<*> - val arguments = type.actualTypeArguments - return when { - Map::class.java.isAssignableFrom(erasure) -> - definition( - erasure = erasure, - generics = listOf( - classDefinitionOf(arguments.getOrElse(0) { Any::class.java }), - classDefinitionOf(arguments.getOrElse(1) { Any::class.java }), - ), - structureType = DICTIONARY, - ) - Collection::class.java.isAssignableFrom(erasure) -> - classDefinitionOf(arguments.getOrElse(0) { Any::class.java }, generics, ARRAY) - else -> - definition( - erasure = erasure, - generics = arguments.map { classDefinitionOf(it) }, - structureType = structureType, - ) - } - } + fun introspect(type: Type): ClassDefinition = + toExperimental(runtime.introspect(type)) - private fun definition( - erasure: Class<*>, - generics: List, - structureType: StructureType, - ): ClassDefinition { + /** Map the policy-free [RawType] tree into the OpenAPI [ClassDefinition] model, applying `@OpenApiName`. */ + private fun toExperimental(raw: RawType): ClassDefinition { + val erasure = raw.source as Class<*> val customName = erasure.getAnnotation(OpenApiName::class.java)?.value val packageName = erasure.`package`?.name?.takeIf { it.isNotEmpty() } return ClassDefinition( - simpleName = customName ?: erasure.simpleName.ifEmpty { erasure.name.substringAfterLast('.') }, + simpleName = customName ?: raw.simpleName, fullName = when { customName != null -> if (packageName == null) customName else "$packageName.$customName" - else -> erasure.canonicalName ?: erasure.name + else -> raw.fullName }, - generics = generics, - structureType = structureType, - handle = ReflectionHandle(erasure), + generics = raw.generics.map { toExperimental(it) }, + structureType = StructureType.valueOf(raw.structureType.name), + handle = raw, ) } - private fun objectDefinition(structureType: StructureType = DEFAULT): ClassDefinition = - definition(Any::class.java, emptyList(), structureType) - override fun properties(type: ClassDefinition): List { - val clazz = type.reflectedClass - val byFields = clazz.getAnnotation(OpenApiByFields::class.java) - val namingStrategy = clazz.getAnnotation(OpenApiNaming::class.java)?.value + val raw = type.raw + val byFields = raw.getAnnotations().find(OpenApiByFields::class.java) + val namingStrategy = raw.getAnnotations().find(OpenApiNaming::class.java)?.value - return collectMembers(clazz, byFields).mapNotNull { member -> - if (member.annotation(OpenApiIgnore::class.java) != null) return@mapNotNull null - if (member.isField && Modifier.isTransient(member.modifiers)) return@mapNotNull null + return raw.getProperties().mapNotNull { property -> + if (!property.includedBy(byFields)) return@mapNotNull null + val annotations = property.annotations + if (annotations.find(OpenApiIgnore::class.java) != null) return@mapNotNull null - val customName = member.annotation(OpenApiName::class.java)?.value + val customName = annotations.find(OpenApiName::class.java)?.value val resolvedName = when { customName != null -> customName - member.isField || member.isRecordComponent -> member.rawName - member.rawName.startsWith("get") -> member.rawName.removePrefix("get").replaceFirstChar { it.lowercase() } - member.rawName.startsWith("is") -> member.rawName.removePrefix("is").replaceFirstChar { it.lowercase() } - else -> return@mapNotNull null + property.accessor == Accessor.GETTER -> property.name.stripGetterPrefix() + else -> property.name } val finalName = if (customName == null && namingStrategy != null) translatePropertyName(namingStrategy, resolvedName) else resolvedName - val isPrimitive = (member.genericType as? Class<*>)?.isPrimitive == true - val isNotNull = member.hasAnnotationNamed("NotNull") || isPrimitive - val required = member.annotation(OpenApiRequired::class.java) != null || (requireNonNullsByDefault && isNotNull) + val isPrimitive = !property.nullable + val required = annotations.find(OpenApiRequired::class.java) != null || + (requireNonNullsByDefault && (annotations.hasNamed("NotNull") || isPrimitive)) - val openApiNullable = member.annotation(OpenApiNullable::class.java) + val openApiNullable = annotations.find(OpenApiNullable::class.java) val nullable = when { openApiNullable != null -> openApiNullable.nullable - member.hasAnnotationNamed("Nullable") -> true + annotations.hasNamed("Nullable") -> true else -> false } val extra = buildMap { - member.annotation(OpenApiDescription::class.java)?.let { put("description", it.value) } + annotations.find(OpenApiDescription::class.java)?.let { put("description", it.value) } } Property( name = finalName, - type = classDefinitionOf(member.genericType), + type = toExperimental(property.type), required = required, nullable = nullable, extra = extra, @@ -150,54 +97,16 @@ class ReflectiveTypeIntrospector( } } - private fun collectMembers(clazz: Class<*>, byFields: OpenApiByFields?): List { - if (clazz.isRecord) { - return clazz.recordComponents.map { component -> - val backingField = runCatching { clazz.getDeclaredField(component.name) }.getOrNull() - Member( - rawName = component.name, - genericType = component.genericType, - modifiers = 0, - sources = listOfNotNull(component.accessor, backingField, component), - isField = false, - isRecordComponent = true, - ) - } - } - - val members = mutableListOf() - - if (byFields?.only != true) { - for (method in clazz.methods) { - if (Modifier.isStatic(method.modifiers) || method.isBridge || method.isSynthetic) continue - if (method.parameterCount != 0 || method.declaringClass == Any::class.java) continue - if (method.name == "getClass") continue - if (!method.name.startsWith("get") && !method.name.startsWith("is")) continue - members += Member(method.name, method.genericReturnType, method.modifiers, listOf(method), isField = false, isRecordComponent = false) - } - } - - if (byFields != null) { - for (field in declaredFieldsHierarchy(clazz)) { - if (Modifier.isStatic(field.modifiers)) continue - if (byFields.value.priority > visibilityOf(field.modifiers).priority) continue - members += Member(field.name, field.genericType, field.modifiers, listOf(field), isField = true, isRecordComponent = false) - } - } - - return members - } - override fun isEnum(type: ClassDefinition): Boolean = - type.reflectedClass.isEnum + type.raw.isEnum() override fun enumConstants(type: ClassDefinition): List? { - val clazz = type.reflectedClass - if (!clazz.isEnum) return null - val namingStrategy = clazz.getAnnotation(OpenApiNaming::class.java)?.value - return clazz.enumConstants.map { constant -> - val name = (constant as Enum<*>).name - val customName = clazz.getField(name).getAnnotation(OpenApiName::class.java)?.value + val raw = type.raw + if (!raw.isEnum()) return null + val erasure = raw.source as Class<*> + val namingStrategy = raw.getAnnotations().find(OpenApiNaming::class.java)?.value + return raw.getEnumConstants()!!.map { name -> + val customName = erasure.getField(name).getAnnotation(OpenApiName::class.java)?.value when { customName != null -> customName namingStrategy != null -> translatePropertyName(namingStrategy, name) @@ -207,41 +116,31 @@ class ReflectiveTypeIntrospector( } override fun annotation(type: ClassDefinition, annotationType: Class): A? = - type.reflectedClass.getAnnotation(annotationType) - - private companion object { - private fun box(primitive: Class<*>): Class<*> = primitive.kotlin.javaObjectType + type.raw.getAnnotations().find(annotationType) +} - private fun declaredFieldsHierarchy(clazz: Class<*>): List { - val fields = mutableListOf() - var current: Class<*>? = clazz - while (current != null && current != Any::class.java) { - fields += current.declaredFields - current = current.superclass - } - return fields - } +/** `@OpenApiByFields` selection: getters unless `only`, fields at/above the configured visibility, record components always. */ +private fun RawProperty.includedBy(byFields: OpenApiByFields?): Boolean = + when (accessor) { + Accessor.RECORD_COMPONENT -> true + Accessor.GETTER -> byFields?.only != true + Accessor.FIELD -> byFields != null && byFields.value.priority <= visibility.toOpenApi().priority + } - private fun visibilityOf(modifiers: Int): Visibility = when { - Modifier.isPublic(modifiers) -> Visibility.PUBLIC - Modifier.isProtected(modifiers) -> Visibility.PROTECTED - Modifier.isPrivate(modifiers) -> Visibility.PRIVATE - else -> Visibility.DEFAULT - } +private fun String.stripGetterPrefix(): String = + when { + startsWith("get") -> removePrefix("get").replaceFirstChar { it.lowercase() } + startsWith("is") -> removePrefix("is").replaceFirstChar { it.lowercase() } + else -> this } - private class Member( - val rawName: String, - val genericType: Type, - val modifiers: Int, - val sources: List, - val isField: Boolean, - val isRecordComponent: Boolean, - ) { - fun annotation(annotationType: Class): A? = - sources.firstNotNullOfOrNull { it.getAnnotation(annotationType) } - - fun hasAnnotationNamed(simpleName: String): Boolean = - sources.any { source -> source.annotations.any { it.annotationClass.simpleName == simpleName } } +private fun RawVisibility.toOpenApi(): Visibility = + when (this) { + RawVisibility.PUBLIC -> Visibility.PUBLIC + RawVisibility.PROTECTED -> Visibility.PROTECTED + RawVisibility.PRIVATE -> Visibility.PRIVATE + RawVisibility.PACKAGE_PRIVATE -> Visibility.DEFAULT } -} + +private val ClassDefinition.raw: RawType + get() = handle as RawType From 96935e1cb7c6f7f1f9af9fba88282d1e11f241ed Mon Sep 17 00:00:00 2001 From: dzikoysk Date: Mon, 15 Jun 2026 01:51:12 +0200 Subject: [PATCH 05/43] feat: support transient --- .../kotlin/io/javalin/introspection/PropertyView.kt | 1 + .../javalin/introspection/jap/JapTypeIntrospector.kt | 3 +++ .../runtime/ReflectionTypeIntrospector.kt | 8 +++++--- .../introspection/test/IntrospectionParityTest.kt | 12 +++++++++++- .../javalin/introspection/test/fixtures/Fixtures.kt | 2 ++ .../openapi/dynamic/ReflectiveTypeIntrospector.kt | 2 +- .../openapi/dynamic/fixtures/TransientDto.java | 9 +++++++++ .../dynamic/ReflectiveTypeIntrospectorTest.kt | 7 +++++++ 8 files changed, 39 insertions(+), 5 deletions(-) create mode 100644 openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/fixtures/TransientDto.java diff --git a/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/PropertyView.kt b/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/PropertyView.kt index d09dbd16..ca29d6a7 100644 --- a/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/PropertyView.kt +++ b/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/PropertyView.kt @@ -7,6 +7,7 @@ class PropertyView( val accessor: Accessor, val nullable: Boolean, val visibility: Visibility, + val transient: Boolean, val annotations: Annotations, ) diff --git a/introspection/introspection-jap/src/main/kotlin/io/javalin/introspection/jap/JapTypeIntrospector.kt b/introspection/introspection-jap/src/main/kotlin/io/javalin/introspection/jap/JapTypeIntrospector.kt index 6041e484..60a7ba88 100644 --- a/introspection/introspection-jap/src/main/kotlin/io/javalin/introspection/jap/JapTypeIntrospector.kt +++ b/introspection/introspection-jap/src/main/kotlin/io/javalin/introspection/jap/JapTypeIntrospector.kt @@ -126,6 +126,7 @@ class JapTypeIntrospector(private val types: Types, private val elements: Elemen accessor = Accessor.RECORD_COMPONENT, nullable = component.asType().nullable(), visibility = Visibility.PUBLIC, + transient = false, annotations = AnnotationsView(component), ) } @@ -141,6 +142,7 @@ class JapTypeIntrospector(private val types: Types, private val elements: Elemen accessor = Accessor.GETTER, nullable = getter.returnType.nullable(), visibility = getter.visibility(), + transient = false, annotations = AnnotationsView(getter), ) } @@ -152,6 +154,7 @@ class JapTypeIntrospector(private val types: Types, private val elements: Elemen accessor = Accessor.FIELD, nullable = field.asType().nullable(), visibility = field.visibility(), + transient = Modifier.TRANSIENT in field.modifiers, annotations = AnnotationsView(field), ) } diff --git a/introspection/introspection-runtime/src/main/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospector.kt b/introspection/introspection-runtime/src/main/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospector.kt index 3ed796b5..9c24e746 100644 --- a/introspection/introspection-runtime/src/main/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospector.kt +++ b/introspection/introspection-runtime/src/main/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospector.kt @@ -105,6 +105,7 @@ private class ReflectionClassDefinition( accessor = member.accessor, nullable = (member.genericType as? Class<*>)?.isPrimitive != true, visibility = member.visibility, + transient = member.transient, annotations = ReflectionAnnotations(member.sources), ) } @@ -117,7 +118,7 @@ private fun collectMembers(clazz: Class<*>): List { if (clazz.isRecord) { return clazz.recordComponents.map { component -> val backingField = runCatching { clazz.getDeclaredField(component.name) }.getOrNull() - Member(component.name, component.genericType, Accessor.RECORD_COMPONENT, Visibility.PUBLIC, listOfNotNull(component.accessor, backingField, component)) + Member(component.name, component.genericType, Accessor.RECORD_COMPONENT, Visibility.PUBLIC, transient = false, listOfNotNull(component.accessor, backingField, component)) } } @@ -127,12 +128,12 @@ private fun collectMembers(clazz: Class<*>): List { if (Modifier.isStatic(method.modifiers) || method.isBridge || method.isSynthetic) continue if (method.parameterCount != 0 || method.declaringClass == Any::class.java) continue if (method.returnType == Void.TYPE || !isGetterName(method.name)) continue - members += Member(method.name, method.genericReturnType, Accessor.GETTER, visibilityOf(method.modifiers), listOf(method)) + members += Member(method.name, method.genericReturnType, Accessor.GETTER, visibilityOf(method.modifiers), transient = false, listOf(method)) } for (field in declaredFieldsHierarchy(clazz)) { if (Modifier.isStatic(field.modifiers)) continue - members += Member(field.name, field.genericType, Accessor.FIELD, visibilityOf(field.modifiers), listOf(field)) + members += Member(field.name, field.genericType, Accessor.FIELD, visibilityOf(field.modifiers), Modifier.isTransient(field.modifiers), listOf(field)) } return members @@ -171,6 +172,7 @@ private class Member( val genericType: Type, val accessor: Accessor, val visibility: Visibility, + val transient: Boolean, val sources: List, ) diff --git a/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/IntrospectionParityTest.kt b/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/IntrospectionParityTest.kt index 78e5e63a..cd156f62 100644 --- a/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/IntrospectionParityTest.kt +++ b/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/IntrospectionParityTest.kt @@ -19,6 +19,7 @@ import io.javalin.introspection.test.fixtures.Point import io.javalin.introspection.test.fixtures.Ref import io.javalin.introspection.test.fixtures.Refs import io.javalin.introspection.test.fixtures.Tricky +import io.javalin.introspection.test.fixtures.WithTransient import io.javalin.introspection.test.fixtures.Wrapped import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test @@ -46,6 +47,14 @@ class IntrospectionParityTest { @Test fun `cross-package inherited fields match across backends`() = assertParity(CrossPackageChild::class) + @Test + fun `transient fields are flagged on both backends`() { + assertParity(WithTransient::class) + val transientByName = runtime.introspect(WithTransient::class.java).getProperties().associate { it.name to it.transient } + assertThat(transientByName.getValue("skipped")).isTrue() + assertThat(transientByName.getValue("kept")).isFalse() + } + @Test fun `getter detection excludes get-or-is lookalikes on both backends`() { assertParity(Tricky::class) @@ -120,6 +129,7 @@ private data class PropertyShape( val accessor: Accessor, val nullable: Boolean, val visibility: Visibility, + val transient: Boolean, ) /** Normalized, order-independent structural view of a type, so the two backends can be compared by value. */ @@ -131,6 +141,6 @@ private fun ClassDefinition.toShape(): TypeShape = isEnum = isEnum(), enumConstants = getEnumConstants()?.sorted(), properties = if (isEnum()) emptyList() else getProperties() - .map { PropertyShape(it.name, it.type.fullName, it.type.structureType, it.type.generics.map { g -> g.fullName }, it.accessor, it.nullable, it.visibility) } + .map { PropertyShape(it.name, it.type.fullName, it.type.structureType, it.type.generics.map { g -> g.fullName }, it.accessor, it.nullable, it.visibility, it.transient) } .sortedBy { "${it.accessor}:${it.name}" }, ) diff --git a/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/fixtures/Fixtures.kt b/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/fixtures/Fixtures.kt index 02684a03..d76a735e 100644 --- a/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/fixtures/Fixtures.kt +++ b/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/fixtures/Fixtures.kt @@ -31,6 +31,8 @@ class Derived(val own: String) : Base("") /** Extends a Java base in another package that has a package-private field, to exercise cross-package field inheritance. */ class CrossPackageChild : io.javalin.introspection.test.sub.PackagePrivateBase() +class WithTransient(@Transient val skipped: String, val kept: String) + class Tricky { fun getName(): String = "" fun issue(): String = "" diff --git a/openapi-dynamic/src/main/kotlin/io/javalin/openapi/dynamic/ReflectiveTypeIntrospector.kt b/openapi-dynamic/src/main/kotlin/io/javalin/openapi/dynamic/ReflectiveTypeIntrospector.kt index 78bc3f2c..a1233163 100644 --- a/openapi-dynamic/src/main/kotlin/io/javalin/openapi/dynamic/ReflectiveTypeIntrospector.kt +++ b/openapi-dynamic/src/main/kotlin/io/javalin/openapi/dynamic/ReflectiveTypeIntrospector.kt @@ -60,7 +60,7 @@ class ReflectiveTypeIntrospector( val namingStrategy = raw.getAnnotations().find(OpenApiNaming::class.java)?.value return raw.getProperties().mapNotNull { property -> - if (!property.includedBy(byFields)) return@mapNotNull null + if (!property.includedBy(byFields) || property.transient) return@mapNotNull null val annotations = property.annotations if (annotations.find(OpenApiIgnore::class.java) != null) return@mapNotNull null diff --git a/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/fixtures/TransientDto.java b/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/fixtures/TransientDto.java new file mode 100644 index 00000000..918798a9 --- /dev/null +++ b/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/fixtures/TransientDto.java @@ -0,0 +1,9 @@ +package io.javalin.openapi.dynamic.fixtures; + +import io.javalin.openapi.OpenApiByFields; + +@OpenApiByFields +public class TransientDto { + public String kept = ""; + public transient String skipped = ""; +} diff --git a/openapi-dynamic/src/test/kotlin/io/javalin/openapi/dynamic/ReflectiveTypeIntrospectorTest.kt b/openapi-dynamic/src/test/kotlin/io/javalin/openapi/dynamic/ReflectiveTypeIntrospectorTest.kt index 78e360c1..4f4c69b5 100644 --- a/openapi-dynamic/src/test/kotlin/io/javalin/openapi/dynamic/ReflectiveTypeIntrospectorTest.kt +++ b/openapi-dynamic/src/test/kotlin/io/javalin/openapi/dynamic/ReflectiveTypeIntrospectorTest.kt @@ -5,6 +5,7 @@ import io.javalin.openapi.dynamic.fixtures.Address import io.javalin.openapi.dynamic.fixtures.FieldsDto import io.javalin.openapi.dynamic.fixtures.Role import io.javalin.openapi.dynamic.fixtures.SnakeCaseDto +import io.javalin.openapi.dynamic.fixtures.TransientDto import io.javalin.openapi.experimental.StructureType import io.javalin.openapi.experimental.processor.generators.Property import org.assertj.core.api.Assertions.assertThat @@ -93,6 +94,12 @@ class ReflectiveTypeIntrospectorTest { assertThat(props.keys).containsExactly("publicField") } + @Test + fun `skips transient fields under OpenApiByFields`() { + val props = introspector.properties(introspector.introspect(TransientDto::class.java)).associateBy { it.name } + assertThat(props.keys).containsExactly("kept") + } + @Test fun `rejects non-reflection native tokens`() { assertThrows { From dcb1ce53b9f0975461967f3afa3aee83b4a57e67 Mon Sep 17 00:00:00 2001 From: dzikoysk Date: Wed, 17 Jun 2026 18:31:34 +0200 Subject: [PATCH 06/43] chore: phase 1 transition --- openapi-annotation-processor/build.gradle.kts | 1 + .../AnnotationProcessorContext.kt | 11 +-- .../experimental/ClassDefinitionFactory.kt | 81 ++++++------------- 3 files changed, 28 insertions(+), 65 deletions(-) diff --git a/openapi-annotation-processor/build.gradle.kts b/openapi-annotation-processor/build.gradle.kts index b6198c96..2f6f26c8 100644 --- a/openapi-annotation-processor/build.gradle.kts +++ b/openapi-annotation-processor/build.gradle.kts @@ -8,6 +8,7 @@ plugins { dependencies { api(project(":openapi-generator")) + implementation(project(":introspection:introspection-jap")) kaptTest(project(":openapi-annotation-processor")) testImplementation(project(":openapi-annotation-processor")) diff --git a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/AnnotationProcessorContext.kt b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/AnnotationProcessorContext.kt index 95e6a975..1376cb24 100644 --- a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/AnnotationProcessorContext.kt +++ b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/AnnotationProcessorContext.kt @@ -2,7 +2,6 @@ package io.javalin.openapi.experimental import com.sun.source.util.Trees import io.javalin.openapi.OpenApiName -import io.javalin.openapi.experimental.StructureType.DEFAULT import io.javalin.openapi.experimental.processor.generators.TypeSchemaGenerator import io.javalin.openapi.experimental.processor.shared.getTypeMirror import io.javalin.openapi.experimental.processor.shared.getTypeMirrors @@ -36,8 +35,8 @@ class AnnotationProcessorContext( } } - fun getClassDefinition(mirror: TypeMirror, generics: List = emptyList(), type: StructureType = DEFAULT): ClassDefinition = - classDefinitionFrom(this, mirror, generics, type) + fun getClassDefinition(mirror: TypeMirror): ClassDefinition = + classDefinitionFrom(this, mirror) fun getClassDefinitions(mirrors: Set): Set = mirrors.map { getClassDefinition(it) }.toSet() @@ -69,10 +68,8 @@ class AnnotationProcessorContext( /* Extension methods, should be replaced by context receivers in the future */ - fun TypeMirror.toClassDefinition( - generics: List = emptyList(), - type: StructureType = DEFAULT - ): ClassDefinition = getClassDefinition(this, generics, type) + fun TypeMirror.toClassDefinition(): ClassDefinition = + getClassDefinition(this) fun TypeMirror.getSimpleName(): String = getFullName().substringAfterLast(".") diff --git a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/ClassDefinitionFactory.kt b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/ClassDefinitionFactory.kt index a07e01af..a1ce0efb 100644 --- a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/ClassDefinitionFactory.kt +++ b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/ClassDefinitionFactory.kt @@ -1,65 +1,30 @@ package io.javalin.openapi.experimental -import io.javalin.openapi.experimental.StructureType.ARRAY -import io.javalin.openapi.experimental.StructureType.DEFAULT -import io.javalin.openapi.experimental.StructureType.DICTIONARY -import io.javalin.openapi.experimental.processor.shared.collectionType +import io.javalin.introspection.jap.JapTypeIntrospector import io.javalin.openapi.experimental.processor.shared.mapType import io.javalin.openapi.experimental.processor.shared.objectType -import javax.lang.model.type.ArrayType -import javax.lang.model.type.DeclaredType -import javax.lang.model.type.PrimitiveType import javax.lang.model.type.TypeMirror -import javax.lang.model.type.TypeVariable +import io.javalin.introspection.ClassDefinition as RawType +import io.javalin.introspection.StructureType as RawStructureType -fun classDefinitionFrom( - context: AnnotationProcessorContext, - mirror: TypeMirror, - generics: List = emptyList(), - type: StructureType = DEFAULT -): ClassDefinition = - with(context) { - when (mirror) { - is TypeVariable -> - mirror.upperBound?.toClassDefinition(generics, type) ?: mirror.lowerBound?.toClassDefinition(generics, type) - is ArrayType -> - mirror.componentType.toClassDefinition(generics, type = ARRAY) - is PrimitiveType -> { - val boxedMirror = types.boxedClass(mirror).asType() - val boxedElement = types.boxedClass(mirror) - ClassDefinition( - simpleName = context.inContext { boxedMirror.getSimpleName() }, - fullName = context.inContext { boxedMirror.getFullName() }, - generics = generics, - structureType = type, - handle = ClassDefinitionHandle(boxedMirror, boxedElement) - ) - } - is DeclaredType -> - when { - types.isAssignable(types.erasure(mirror), mapType().asType()) -> - ClassDefinition( - simpleName = context.inContext { mirror.getSimpleName() }, - fullName = context.inContext { mirror.getFullName() }, - generics = listOfNotNull( - mirror.typeArguments.getOrElse(0) { objectType().asType() }.toClassDefinition(), - mirror.typeArguments.getOrElse(1) { objectType().asType() }.toClassDefinition() - ), - structureType = DICTIONARY, - handle = ClassDefinitionHandle(mirror, mapType()) - ) - types.isAssignable(types.erasure(mirror), collectionType().asType()) -> - mirror.typeArguments.getOrElse(0) { objectType().asType() }.toClassDefinition(generics, ARRAY) - else -> - ClassDefinition( - simpleName = context.inContext { mirror.getSimpleName() }, - fullName = context.inContext { mirror.getFullName() }, - generics = mirror.typeArguments.mapNotNull { it.toClassDefinition() }, - structureType = type, - handle = ClassDefinitionHandle(mirror, mirror.asElement()) - ) - } - else -> - types.asElement(mirror)?.asType()?.toClassDefinition(generics, type) - } ?: objectType().asType().toClassDefinition(type = type) +/** + * Resolves a [TypeMirror] into the OpenAPI [ClassDefinition] model. Structure resolution (array/map/collection/ + * generics/primitive/typevar) is delegated to the shared [JapTypeIntrospector]; names + handle stay AP-specific. + */ +fun classDefinitionFrom(context: AnnotationProcessorContext, mirror: TypeMirror): ClassDefinition = + context.toExperimental(JapTypeIntrospector(context.types, context.env.elementUtils).introspect(mirror)) + +private fun AnnotationProcessorContext.toExperimental(raw: RawType): ClassDefinition = + inContext { + val mirror = raw.source as TypeMirror + ClassDefinition( + simpleName = mirror.getSimpleName(), + fullName = mirror.getFullName(), + generics = raw.generics.map { toExperimental(it) }, + structureType = StructureType.valueOf(raw.structureType.name), + handle = ClassDefinitionHandle( + mirror = mirror, + source = if (raw.structureType == RawStructureType.DICTIONARY) mapType() else (types.asElement(mirror) ?: objectType()) + ) + ) } From 66336cbf36be64182fec58331a65394432cc1239 Mon Sep 17 00:00:00 2001 From: dzikoysk Date: Wed, 17 Jun 2026 20:14:30 +0200 Subject: [PATCH 07/43] test: prototype --- gradle/libs.versions.toml | 7 + introspection/introspection-ksp/.gitkeep | 1 - .../introspection-ksp/build.gradle.kts | 14 ++ .../introspection/ksp/KspTypeIntrospector.kt | 186 ++++++++++++++++++ .../ksp/KspTypeIntrospectorTest.kt | 86 ++++++++ .../introspection/ksp/fixtures/Fixtures.kt | 19 ++ settings.gradle.kts | 1 + 7 files changed, 313 insertions(+), 1 deletion(-) delete mode 100644 introspection/introspection-ksp/.gitkeep create mode 100644 introspection/introspection-ksp/build.gradle.kts create mode 100644 introspection/introspection-ksp/src/main/kotlin/io/javalin/introspection/ksp/KspTypeIntrospector.kt create mode 100644 introspection/introspection-ksp/src/test/kotlin/io/javalin/introspection/ksp/KspTypeIntrospectorTest.kt create mode 100644 introspection/introspection-ksp/src/test/kotlin/io/javalin/introspection/ksp/fixtures/Fixtures.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 47c3ccac..8952d2a9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -22,6 +22,8 @@ assertj = "3.27.6" lombok = "1.18.42" json-unit = "4.1.1" unirest = "3.14.5" +ksp = "2.3.9" +kctfork = "0.12.1" [libraries] # Javalin @@ -40,6 +42,11 @@ jackson-module-kotlin = { module = "com.fasterxml.jackson.module:jackson-module- jackson-dataformat-yaml = { module = "com.fasterxml.jackson.dataformat:jackson-dataformat-yaml", version.ref = "jackson" } jackson-datatype-jsr310 = { module = "com.fasterxml.jackson.datatype:jackson-datatype-jsr310", version.ref = "jackson" } +# KSP (introspection-ksp backend) +ksp-symbol-processing-api = { module = "com.google.devtools.ksp:symbol-processing-api", version.ref = "ksp" } +kctfork-core = { module = "dev.zacsweers.kctfork:core", version.ref = "kctfork" } +kctfork-ksp = { module = "dev.zacsweers.kctfork:ksp", version.ref = "kctfork" } + # Other groovy = { module = "org.apache.groovy:groovy", version.ref = "groovy" } lombok = { module = "org.projectlombok:lombok", version.ref = "lombok" } diff --git a/introspection/introspection-ksp/.gitkeep b/introspection/introspection-ksp/.gitkeep deleted file mode 100644 index f818ce70..00000000 --- a/introspection/introspection-ksp/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -Placeholder for the future KSP TypeIntrospector backend (io.javalin.introspection.ksp). diff --git a/introspection/introspection-ksp/build.gradle.kts b/introspection/introspection-ksp/build.gradle.kts new file mode 100644 index 00000000..569de30d --- /dev/null +++ b/introspection/introspection-ksp/build.gradle.kts @@ -0,0 +1,14 @@ +description = "Introspection KSP | Kotlin Symbol Processing TypeIntrospector backend" + +dependencies { + api(project(":introspection:introspection-api")) + implementation(libs.ksp.symbol.processing.api) + + testImplementation(project(":introspection:introspection-runtime")) + testImplementation(libs.kctfork.core) + testImplementation(libs.kctfork.ksp) + testImplementation(libs.junit.jupiter.api) + testImplementation(libs.junit.jupiter.engine) + testRuntimeOnly(libs.junit.platform.launcher) + testImplementation(libs.assertj.core) +} diff --git a/introspection/introspection-ksp/src/main/kotlin/io/javalin/introspection/ksp/KspTypeIntrospector.kt b/introspection/introspection-ksp/src/main/kotlin/io/javalin/introspection/ksp/KspTypeIntrospector.kt new file mode 100644 index 00000000..4c2eef3b --- /dev/null +++ b/introspection/introspection-ksp/src/main/kotlin/io/javalin/introspection/ksp/KspTypeIntrospector.kt @@ -0,0 +1,186 @@ +package io.javalin.introspection.ksp + +import com.google.devtools.ksp.getVisibility +import com.google.devtools.ksp.processing.Resolver +import com.google.devtools.ksp.symbol.ClassKind +import com.google.devtools.ksp.symbol.KSAnnotated +import com.google.devtools.ksp.symbol.KSAnnotation +import com.google.devtools.ksp.symbol.KSClassDeclaration +import com.google.devtools.ksp.symbol.KSDeclaration +import com.google.devtools.ksp.symbol.KSType +import com.google.devtools.ksp.symbol.KSTypeParameter +import com.google.devtools.ksp.symbol.Modifier +import io.javalin.introspection.Accessor +import io.javalin.introspection.Annotations +import io.javalin.introspection.ClassDefinition +import io.javalin.introspection.PropertyView +import io.javalin.introspection.StructureType +import io.javalin.introspection.StructureType.ARRAY +import io.javalin.introspection.StructureType.DEFAULT +import io.javalin.introspection.StructureType.DICTIONARY +import io.javalin.introspection.TypeIntrospector +import io.javalin.introspection.Visibility +import kotlin.reflect.KClass +import com.google.devtools.ksp.symbol.Visibility as KspVisibility + +/** + * Prototype [TypeIntrospector] backed by Kotlin Symbol Processing (KSP). + * + * KSP works on the *source/symbol* model, so it surfaces a Kotlin `val x` as one logical property (not a private + * field + `getX()` like the compiled-bytecode backends), names builtins with Kotlin FQNs (`kotlin.Int`), and cannot + * materialize JVM annotation instances — so annotation access here is purely name/value based. + */ +class KspTypeIntrospector(private val resolver: Resolver) : TypeIntrospector { + + private val mapType = builtin("kotlin.collections.Map") + private val collectionType = builtin("kotlin.collections.Collection") + + override fun introspect(source: Any): ClassDefinition { + require(source is KSType) { "KspTypeIntrospector expects a com.google.devtools.ksp.symbol.KSType, got ${source::class.java.name}" } + return resolve(source) + } + + /** Convenience for tests/harness: resolve a type by fully-qualified name. */ + fun introspect(qualifiedName: String): ClassDefinition { + val declaration = resolver.getClassDeclarationByName(resolver.getKSNameFromString(qualifiedName)) + ?: error("KSP cannot resolve $qualifiedName") + return resolve(declaration.asStarProjectedType()) + } + + private fun resolve(type: KSType, structureType: StructureType = DEFAULT): ClassDefinition { + val declaration = type.declaration + if (declaration is KSTypeParameter) { + val bound = declaration.bounds.firstOrNull()?.resolve() + return if (bound != null) resolve(bound, structureType) else objectDefinition(structureType) + } + val qualifiedName = declaration.qualifiedName?.asString() ?: return objectDefinition(structureType) + return when { + mapType != null && mapType.isAssignableFrom(type.starProjection()) -> + definition(type, DICTIONARY, listOf(resolve(argument(type, 0)), resolve(argument(type, 1)))) + collectionType != null && collectionType.isAssignableFrom(type.starProjection()) -> + resolve(argument(type, 0), ARRAY) + qualifiedName == "kotlin.Array" -> + resolve(argument(type, 0), ARRAY) + else -> + definition(type, structureType, type.arguments.mapNotNull { it.type?.resolve() }.map { resolve(it) }) + } + } + + private fun definition(type: KSType, structureType: StructureType, generics: List): ClassDefinition { + val declaration = type.declaration + val fullName = declaration.qualifiedName?.asString() ?: declaration.simpleName.asString() + return Definition( + simpleName = declaration.simpleName.asString(), + fullName = fullName, + generics = generics, + structureType = structureType, + type = type, + ) + } + + private fun objectDefinition(structureType: StructureType = DEFAULT): ClassDefinition = + definition(builtin("kotlin.Any")!!, structureType, emptyList()) + + private fun argument(type: KSType, index: Int): KSType = + type.arguments.getOrNull(index)?.type?.resolve() ?: builtin("kotlin.Any")!! + + private fun builtin(qualifiedName: String): KSType? = + resolver.getClassDeclarationByName(resolver.getKSNameFromString(qualifiedName))?.asStarProjectedType() + + private inner class Definition( + simpleName: String, + fullName: String, + generics: List, + structureType: StructureType, + private val type: KSType, + ) : ClassDefinition(simpleName, fullName, generics, structureType) { + + private val declaration: KSClassDeclaration? + get() = type.declaration as? KSClassDeclaration + + override val source: Any + get() = type + + override fun isEnum(): Boolean = + declaration?.classKind == ClassKind.ENUM_CLASS + + override fun getEnumConstants(): List? = + declaration + ?.takeIf { it.classKind == ClassKind.ENUM_CLASS } + ?.declarations + ?.filterIsInstance() + ?.filter { it.classKind == ClassKind.ENUM_ENTRY } + ?.map { it.simpleName.asString() } + ?.toList() + + override fun getAnnotations(): Annotations = + KspAnnotations(declaration) + + override fun getProperties(): List { + val declaration = declaration ?: return emptyList() + return declaration.getAllProperties().map { property -> + val propertyType = property.type.resolve() + PropertyView( + name = property.simpleName.asString(), + type = resolve(propertyType), + accessor = Accessor.GETTER, + nullable = propertyType.isMarkedNullable, + visibility = visibilityOf(property.getVisibility()), + transient = Modifier.JAVA_TRANSIENT in property.modifiers, + annotations = KspAnnotations(property), + ) + }.toList() + } + } + + private inner class KspAnnotations(private val element: KSAnnotated?) : Annotations { + + override fun find(annotationType: Class): A? = + throw UnsupportedOperationException("KSP cannot materialize JVM annotation instances; use name/value access") + + override fun hasNamed(simpleName: String): Boolean = + element?.annotations?.any { it.shortName.asString() == simpleName } == true + + override fun resolveType(annotationType: Class, member: A.() -> KClass<*>): ClassDefinition? = + throw UnsupportedOperationException("KSP cannot invoke a member lambda on an instance; use name-based resolution") + + override fun resolveTypes(annotationType: Class, member: A.() -> Array>): List = + throw UnsupportedOperationException("KSP cannot invoke a member lambda on an instance; use name-based resolution") + + override fun memberValues(annotationType: Class): Map? { + val annotation = annotationOf(annotationType.name) ?: return null + return annotation.arguments.associate { it.name!!.asString() to normalize(it.value) } + } + + /** Name-based class-member resolution — the portable replacement for the lambda forms above. */ + fun resolveClass(annotationName: String, member: String): ClassDefinition? = + (annotationOf(annotationName)?.arguments?.firstOrNull { it.name?.asString() == member }?.value as? KSType) + ?.let { resolve(it) } + + fun resolveClasses(annotationName: String, member: String): List { + val value = annotationOf(annotationName)?.arguments?.firstOrNull { it.name?.asString() == member }?.value + @Suppress("UNCHECKED_CAST") + return (value as? List).orEmpty().map { resolve(it) } + } + + private fun annotationOf(qualifiedName: String): KSAnnotation? = + element?.annotations?.firstOrNull { it.annotationType.resolve().declaration.qualifiedName?.asString() == qualifiedName } + + private fun normalize(value: Any?): Any? = + when (value) { + is KSType -> resolve(value) + is KSAnnotation -> value.arguments.associate { it.name!!.asString() to normalize(it.value) } + is List<*> -> value.map { normalize(it) } + is KSDeclaration -> value.simpleName.asString() + else -> value + } + } + + private fun visibilityOf(visibility: KspVisibility): Visibility = + when (visibility) { + KspVisibility.PUBLIC, KspVisibility.INTERNAL -> Visibility.PUBLIC + KspVisibility.PROTECTED -> Visibility.PROTECTED + KspVisibility.PRIVATE, KspVisibility.LOCAL -> Visibility.PRIVATE + else -> Visibility.PACKAGE_PRIVATE + } +} diff --git a/introspection/introspection-ksp/src/test/kotlin/io/javalin/introspection/ksp/KspTypeIntrospectorTest.kt b/introspection/introspection-ksp/src/test/kotlin/io/javalin/introspection/ksp/KspTypeIntrospectorTest.kt new file mode 100644 index 00000000..6428bf53 --- /dev/null +++ b/introspection/introspection-ksp/src/test/kotlin/io/javalin/introspection/ksp/KspTypeIntrospectorTest.kt @@ -0,0 +1,86 @@ +package io.javalin.introspection.ksp + +import com.google.devtools.ksp.processing.Resolver +import com.google.devtools.ksp.processing.SymbolProcessor +import com.google.devtools.ksp.processing.SymbolProcessorEnvironment +import com.google.devtools.ksp.processing.SymbolProcessorProvider +import com.google.devtools.ksp.symbol.KSAnnotated +import com.tschuchort.compiletesting.KotlinCompilation +import com.tschuchort.compiletesting.SourceFile +import com.tschuchort.compiletesting.symbolProcessorProviders +import com.tschuchort.compiletesting.useKsp2 +import io.javalin.introspection.ClassDefinition +import io.javalin.introspection.ksp.fixtures.Ref +import org.assertj.core.api.Assertions.assertThat +import org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi +import org.junit.jupiter.api.Test + +@OptIn(ExperimentalCompilerApi::class) +class KspTypeIntrospectorTest { + + /** Runs KSP in-process over a trivial trigger; the introspector resolves the classpath fixtures by name. */ + private fun withKsp(block: (KspTypeIntrospector) -> R): R { + var result: Result? = null + val provider = object : SymbolProcessorProvider { + override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor = + object : SymbolProcessor { + override fun process(resolver: Resolver): List { + if (result == null) result = runCatching { block(KspTypeIntrospector(resolver)) } + return emptyList() + } + } + } + val compilation = KotlinCompilation().apply { + useKsp2() + sources = listOf(SourceFile.kotlin("Trigger.kt", "package trigger\nclass Trigger")) + symbolProcessorProviders = mutableListOf(provider) + inheritClassPath = true + messageOutputStream = System.out + } + val compiled = compilation.compile() + check(compiled.exitCode == KotlinCompilation.ExitCode.OK) { "KSP compilation failed: ${compiled.messages}" } + return (result ?: error("KSP processor did not run")).getOrThrow() + } + + @Test + fun `KSP backend resolves structure, enums and name-based annotations`() { + data class Snapshot( + val properties: Map, + val isEnum: Boolean, + val enumConstants: List?, + val hasRef: Boolean, + val refValue: String?, + ) + + val snapshot = withKsp { ksp -> + val fixtures = "io.javalin.introspection.ksp.fixtures" + val account = ksp.introspect("$fixtures.Account") + val color = ksp.introspect("$fixtures.Color") + Snapshot( + properties = account.getProperties().associate { it.name to "${it.type.fullName}:${it.type.structureType}" }, + isEnum = color.isEnum(), + enumConstants = color.getEnumConstants(), + hasRef = account.getAnnotations().hasNamed("Ref"), + refValue = (account.getAnnotations().memberValues(Ref::class.java)?.getValue("value") as? ClassDefinition)?.fullName, + ) + } + + // Structure + collection/map/nullability resolve; NOTE KSP yields Kotlin FQNs (kotlin.String/Int), not java.lang.* + assertThat(snapshot.properties.keys).containsExactlyInAnyOrder("id", "age", "color", "address", "tags", "meta") + assertThat(snapshot.properties["tags"]).endsWith(":ARRAY") + assertThat(snapshot.properties["meta"]).endsWith(":DICTIONARY") + assertThat(snapshot.properties["color"]).isEqualTo("$REF_PKG.Color:DEFAULT") + + // Enum + assertThat(snapshot.isEnum).isTrue() + assertThat(snapshot.enumConstants).containsExactly("RED", "GREEN") + + // Name-based annotation access (the only kind KSP can do): presence + class-valued member resolved to a ClassDefinition + assertThat(snapshot.hasRef).isTrue() + assertThat(snapshot.refValue).isEqualTo("$REF_PKG.Address") + } + + private companion object { + const val REF_PKG = "io.javalin.introspection.ksp.fixtures" + } +} diff --git a/introspection/introspection-ksp/src/test/kotlin/io/javalin/introspection/ksp/fixtures/Fixtures.kt b/introspection/introspection-ksp/src/test/kotlin/io/javalin/introspection/ksp/fixtures/Fixtures.kt new file mode 100644 index 00000000..48579ba2 --- /dev/null +++ b/introspection/introspection-ksp/src/test/kotlin/io/javalin/introspection/ksp/fixtures/Fixtures.kt @@ -0,0 +1,19 @@ +package io.javalin.introspection.ksp.fixtures + +import kotlin.reflect.KClass + +enum class Color { RED, GREEN } + +annotation class Ref(val value: KClass<*>) + +class Address(val city: String, val zip: String) + +@Ref(Address::class) +class Account( + val id: String, + val age: Int, + val color: Color, + val address: Address?, + val tags: List, + val meta: Map, +) diff --git a/settings.gradle.kts b/settings.gradle.kts index 1cd0ea9d..bd23642f 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -9,6 +9,7 @@ include( "introspection:introspection-api", "introspection:introspection-runtime", "introspection:introspection-jap", + "introspection:introspection-ksp", "introspection:introspection-test", "javalin-plugins", "javalin-plugins:javalin-openapi-plugin", From 9371903995b637a8841db2f9cadbcdb1833846e6 Mon Sep 17 00:00:00 2001 From: dzikoysk Date: Wed, 17 Jun 2026 20:52:02 +0200 Subject: [PATCH 08/43] chore: finding 1 --- .../io/javalin/introspection/Annotations.kt | 28 +++++++++++++------ .../introspection/jap/JapTypeIntrospector.kt | 24 ---------------- .../introspection/ksp/KspTypeIntrospector.kt | 21 -------------- .../runtime/ReflectionTypeIntrospector.kt | 12 +------- .../runtime/ReflectionTypeIntrospectorTest.kt | 8 +++--- .../test/IntrospectionParityTest.kt | 6 ++-- .../dynamic/ReflectiveTypeIntrospector.kt | 21 +++++++------- 7 files changed, 39 insertions(+), 81 deletions(-) diff --git a/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/Annotations.kt b/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/Annotations.kt index b02f791a..3990374c 100644 --- a/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/Annotations.kt +++ b/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/Annotations.kt @@ -1,18 +1,30 @@ package io.javalin.introspection -import kotlin.reflect.KClass - +/** + * Backend-agnostic, name/value-based access to annotations. Deliberately avoids handing back typed annotation + * instances or member lambdas — those can't be honored on KSP (no JVM instances). Class-valued members are read + * through [memberValues] (resolved to [ClassDefinition]), so [resolveClass]/[resolveClasses] are derived for free. + */ interface Annotations { - fun find(annotationType: Class): A? /** True if an annotation with this simple name is present (match e.g. `@NotNull` without depending on it). */ fun hasNamed(simpleName: String): Boolean - /** Resolve a class-valued member (e.g. `value = Foo::class`) to a [ClassDefinition]. */ - fun resolveType(annotationType: Class, member: A.() -> KClass<*>): ClassDefinition? + /** + * Members of [annotationType] (with defaults) as a name→value map, or null if absent. + * Class members → [ClassDefinition], enum members → constant name, nested annotations → name→value map, arrays → list. + */ + fun memberValues(annotationType: Class): Map? + + /** True if an annotation of [annotationType] is present. */ + fun has(annotationType: Class): Boolean = + memberValues(annotationType) != null - fun resolveTypes(annotationType: Class, member: A.() -> Array>): List + /** Resolve a class-valued member to a [ClassDefinition]. */ + fun resolveClass(annotationType: Class, member: String): ClassDefinition? = + memberValues(annotationType)?.get(member) as? ClassDefinition - /** Members of [annotationType] as a name→value map (class members resolved to [ClassDefinition], enums to their name). */ - fun memberValues(annotationType: Class): Map? + /** Resolve a class-array-valued member to [ClassDefinition]s. */ + fun resolveClasses(annotationType: Class, member: String): List = + (memberValues(annotationType)?.get(member) as? List<*>)?.filterIsInstance() ?: emptyList() } diff --git a/introspection/introspection-jap/src/main/kotlin/io/javalin/introspection/jap/JapTypeIntrospector.kt b/introspection/introspection-jap/src/main/kotlin/io/javalin/introspection/jap/JapTypeIntrospector.kt index 60a7ba88..8b1b20c8 100644 --- a/introspection/introspection-jap/src/main/kotlin/io/javalin/introspection/jap/JapTypeIntrospector.kt +++ b/introspection/introspection-jap/src/main/kotlin/io/javalin/introspection/jap/JapTypeIntrospector.kt @@ -21,8 +21,6 @@ import javax.lang.model.element.TypeElement import javax.lang.model.element.VariableElement import javax.lang.model.type.ArrayType import javax.lang.model.type.DeclaredType -import javax.lang.model.type.MirroredTypeException -import javax.lang.model.type.MirroredTypesException import javax.lang.model.type.PrimitiveType import javax.lang.model.type.TypeKind import javax.lang.model.type.TypeMirror @@ -31,7 +29,6 @@ import javax.lang.model.type.WildcardType import javax.lang.model.util.Elements import javax.lang.model.util.SimpleAnnotationValueVisitor8 import javax.lang.model.util.Types -import kotlin.reflect.KClass /** [TypeIntrospector] backed by `javax.lang.model` (Java annotation processing). */ class JapTypeIntrospector(private val types: Types, private val elements: Elements) : TypeIntrospector { @@ -191,30 +188,9 @@ class JapTypeIntrospector(private val types: Types, private val elements: Elemen private inner class AnnotationsView(private val element: Element?) : Annotations { - override fun find(annotationType: Class): A? = - element?.getAnnotation(annotationType) - override fun hasNamed(simpleName: String): Boolean = element?.annotationMirrors?.any { it.annotationType.asElement().simpleName.contentEquals(simpleName) } == true - override fun resolveType(annotationType: Class, member: A.() -> KClass<*>): ClassDefinition? { - val annotation = find(annotationType) ?: return null - return try { - elements.getTypeElement(annotation.member().java.name)?.asType()?.let { resolve(it) } - } catch (mirrored: MirroredTypeException) { - resolve(mirrored.typeMirror) - } - } - - override fun resolveTypes(annotationType: Class, member: A.() -> Array>): List { - val annotation = find(annotationType) ?: return emptyList() - return try { - annotation.member().mapNotNull { kClass -> elements.getTypeElement(kClass.java.name)?.asType()?.let { resolve(it) } } - } catch (mirrored: MirroredTypesException) { - mirrored.typeMirrors.map { resolve(it) } - } - } - override fun memberValues(annotationType: Class): Map? { val mirror = element?.annotationMirrors?.firstOrNull { (it.annotationType.asElement() as? TypeElement)?.qualifiedName?.contentEquals(annotationType.name) == true diff --git a/introspection/introspection-ksp/src/main/kotlin/io/javalin/introspection/ksp/KspTypeIntrospector.kt b/introspection/introspection-ksp/src/main/kotlin/io/javalin/introspection/ksp/KspTypeIntrospector.kt index 4c2eef3b..28766e31 100644 --- a/introspection/introspection-ksp/src/main/kotlin/io/javalin/introspection/ksp/KspTypeIntrospector.kt +++ b/introspection/introspection-ksp/src/main/kotlin/io/javalin/introspection/ksp/KspTypeIntrospector.kt @@ -20,7 +20,6 @@ import io.javalin.introspection.StructureType.DEFAULT import io.javalin.introspection.StructureType.DICTIONARY import io.javalin.introspection.TypeIntrospector import io.javalin.introspection.Visibility -import kotlin.reflect.KClass import com.google.devtools.ksp.symbol.Visibility as KspVisibility /** @@ -135,34 +134,14 @@ class KspTypeIntrospector(private val resolver: Resolver) : TypeIntrospector { private inner class KspAnnotations(private val element: KSAnnotated?) : Annotations { - override fun find(annotationType: Class): A? = - throw UnsupportedOperationException("KSP cannot materialize JVM annotation instances; use name/value access") - override fun hasNamed(simpleName: String): Boolean = element?.annotations?.any { it.shortName.asString() == simpleName } == true - override fun resolveType(annotationType: Class, member: A.() -> KClass<*>): ClassDefinition? = - throw UnsupportedOperationException("KSP cannot invoke a member lambda on an instance; use name-based resolution") - - override fun resolveTypes(annotationType: Class, member: A.() -> Array>): List = - throw UnsupportedOperationException("KSP cannot invoke a member lambda on an instance; use name-based resolution") - override fun memberValues(annotationType: Class): Map? { val annotation = annotationOf(annotationType.name) ?: return null return annotation.arguments.associate { it.name!!.asString() to normalize(it.value) } } - /** Name-based class-member resolution — the portable replacement for the lambda forms above. */ - fun resolveClass(annotationName: String, member: String): ClassDefinition? = - (annotationOf(annotationName)?.arguments?.firstOrNull { it.name?.asString() == member }?.value as? KSType) - ?.let { resolve(it) } - - fun resolveClasses(annotationName: String, member: String): List { - val value = annotationOf(annotationName)?.arguments?.firstOrNull { it.name?.asString() == member }?.value - @Suppress("UNCHECKED_CAST") - return (value as? List).orEmpty().map { resolve(it) } - } - private fun annotationOf(qualifiedName: String): KSAnnotation? = element?.annotations?.firstOrNull { it.annotationType.resolve().declaration.qualifiedName?.asString() == qualifiedName } diff --git a/introspection/introspection-runtime/src/main/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospector.kt b/introspection/introspection-runtime/src/main/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospector.kt index 9c24e746..26644be8 100644 --- a/introspection/introspection-runtime/src/main/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospector.kt +++ b/introspection/introspection-runtime/src/main/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospector.kt @@ -19,7 +19,6 @@ import java.lang.reflect.Modifier import java.lang.reflect.ParameterizedType import java.lang.reflect.Type import java.lang.reflect.WildcardType -import kotlin.reflect.KClass /** [TypeIntrospector] backed by reflection ([java.lang.reflect.Type]). */ class ReflectionTypeIntrospector : TypeIntrospector { @@ -178,20 +177,11 @@ private class Member( private class ReflectionAnnotations(private val sources: List) : Annotations { - override fun find(annotationType: Class): A? = - sources.firstNotNullOfOrNull { it.getAnnotation(annotationType) } - override fun hasNamed(simpleName: String): Boolean = sources.any { source -> source.annotations.any { it.annotationClass.simpleName == simpleName } } - override fun resolveType(annotationType: Class, member: A.() -> KClass<*>): ClassDefinition? = - find(annotationType)?.let { reflect(it.member().java) } - - override fun resolveTypes(annotationType: Class, member: A.() -> Array>): List = - find(annotationType)?.member()?.map { reflect(it.java) } ?: emptyList() - override fun memberValues(annotationType: Class): Map? = - find(annotationType)?.let { annotationToMap(it) } + sources.firstNotNullOfOrNull { it.getAnnotation(annotationType) }?.let { annotationToMap(it) } private fun annotationToMap(annotation: Annotation): Map = annotation.annotationClass.java.declaredMethods.associate { it.name to normalize(it.invoke(annotation)) } diff --git a/introspection/introspection-runtime/src/test/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospectorTest.kt b/introspection/introspection-runtime/src/test/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospectorTest.kt index 102658a7..141ebb67 100644 --- a/introspection/introspection-runtime/src/test/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospectorTest.kt +++ b/introspection/introspection-runtime/src/test/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospectorTest.kt @@ -51,9 +51,9 @@ class ReflectionTypeIntrospectorTest { @Test fun `exposes annotations without applying policy`() { val id = props(Account::class.java).getValue("getId") - assertThat(id.annotations.find(Nn::class.java)).isNotNull() + assertThat(id.annotations.has(Nn::class.java)).isTrue() assertThat(id.annotations.hasNamed("Nn")).isTrue() - assertThat(props(Account::class.java).getValue("getName").annotations.find(Nn::class.java)).isNull() + assertThat(props(Account::class.java).getValue("getName").annotations.has(Nn::class.java)).isFalse() } @Test @@ -80,8 +80,8 @@ class ReflectionTypeIntrospectorTest { fun `resolves Class-valued annotation members into ClassDefinitions`() { val annotations = introspector.introspect(Holder::class.java).getAnnotations() - assertThat(annotations.resolveType(Ref::class.java) { value }?.fullName).isEqualTo(Address::class.java.name) - assertThat(annotations.resolveTypes(Refs::class.java) { value }.map { it.fullName }) + assertThat(annotations.resolveClass(Ref::class.java, "value")?.fullName).isEqualTo(Address::class.java.name) + assertThat(annotations.resolveClasses(Refs::class.java, "value").map { it.fullName }) .containsExactly(Address::class.java.name, Color::class.java.name) } diff --git a/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/IntrospectionParityTest.kt b/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/IntrospectionParityTest.kt index cd156f62..3ed438a0 100644 --- a/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/IntrospectionParityTest.kt +++ b/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/IntrospectionParityTest.kt @@ -83,12 +83,12 @@ class IntrospectionParityTest { @Test fun `Class-valued annotation members resolve identically across backends`() { val runtimeAnnotations = runtime.introspect(Holder::class.java).getAnnotations() - val runtimeRef = runtimeAnnotations.resolveType(Ref::class.java) { value }?.fullName - val runtimeRefs = runtimeAnnotations.resolveTypes(Refs::class.java) { value }.map { it.fullName } + val runtimeRef = runtimeAnnotations.resolveClass(Ref::class.java, "value")?.fullName + val runtimeRefs = runtimeAnnotations.resolveClasses(Refs::class.java, "value").map { it.fullName } val (processedRef, processedRefs) = AnnotationProcessing.introspect(Holder::class) { val annotations = it.getAnnotations() - annotations.resolveType(Ref::class.java) { value }?.fullName to annotations.resolveTypes(Refs::class.java) { value }.map { it.fullName } + annotations.resolveClass(Ref::class.java, "value")?.fullName to annotations.resolveClasses(Refs::class.java, "value").map { it.fullName } } assertThat(processedRef).isEqualTo(runtimeRef).isEqualTo(Address::class.java.name) diff --git a/openapi-dynamic/src/main/kotlin/io/javalin/openapi/dynamic/ReflectiveTypeIntrospector.kt b/openapi-dynamic/src/main/kotlin/io/javalin/openapi/dynamic/ReflectiveTypeIntrospector.kt index a1233163..183169e6 100644 --- a/openapi-dynamic/src/main/kotlin/io/javalin/openapi/dynamic/ReflectiveTypeIntrospector.kt +++ b/openapi-dynamic/src/main/kotlin/io/javalin/openapi/dynamic/ReflectiveTypeIntrospector.kt @@ -56,15 +56,16 @@ class ReflectiveTypeIntrospector( override fun properties(type: ClassDefinition): List { val raw = type.raw - val byFields = raw.getAnnotations().find(OpenApiByFields::class.java) - val namingStrategy = raw.getAnnotations().find(OpenApiNaming::class.java)?.value + val erasure = raw.source as Class<*> + val byFields = erasure.getAnnotation(OpenApiByFields::class.java) + val namingStrategy = erasure.getAnnotation(OpenApiNaming::class.java)?.value return raw.getProperties().mapNotNull { property -> if (!property.includedBy(byFields) || property.transient) return@mapNotNull null val annotations = property.annotations - if (annotations.find(OpenApiIgnore::class.java) != null) return@mapNotNull null + if (annotations.has(OpenApiIgnore::class.java)) return@mapNotNull null - val customName = annotations.find(OpenApiName::class.java)?.value + val customName = annotations.memberValues(OpenApiName::class.java)?.get("value") as? String val resolvedName = when { customName != null -> customName property.accessor == Accessor.GETTER -> property.name.stripGetterPrefix() @@ -73,18 +74,18 @@ class ReflectiveTypeIntrospector( val finalName = if (customName == null && namingStrategy != null) translatePropertyName(namingStrategy, resolvedName) else resolvedName val isPrimitive = !property.nullable - val required = annotations.find(OpenApiRequired::class.java) != null || + val required = annotations.has(OpenApiRequired::class.java) || (requireNonNullsByDefault && (annotations.hasNamed("NotNull") || isPrimitive)) - val openApiNullable = annotations.find(OpenApiNullable::class.java) + val explicitNullable = annotations.memberValues(OpenApiNullable::class.java)?.get("nullable") as? Boolean val nullable = when { - openApiNullable != null -> openApiNullable.nullable + explicitNullable != null -> explicitNullable annotations.hasNamed("Nullable") -> true else -> false } val extra = buildMap { - annotations.find(OpenApiDescription::class.java)?.let { put("description", it.value) } + (annotations.memberValues(OpenApiDescription::class.java)?.get("value") as? String)?.let { put("description", it) } } Property( @@ -104,7 +105,7 @@ class ReflectiveTypeIntrospector( val raw = type.raw if (!raw.isEnum()) return null val erasure = raw.source as Class<*> - val namingStrategy = raw.getAnnotations().find(OpenApiNaming::class.java)?.value + val namingStrategy = erasure.getAnnotation(OpenApiNaming::class.java)?.value return raw.getEnumConstants()!!.map { name -> val customName = erasure.getField(name).getAnnotation(OpenApiName::class.java)?.value when { @@ -116,7 +117,7 @@ class ReflectiveTypeIntrospector( } override fun annotation(type: ClassDefinition, annotationType: Class): A? = - type.raw.getAnnotations().find(annotationType) + (type.raw.source as Class<*>).getAnnotation(annotationType) } /** `@OpenApiByFields` selection: getters unless `only`, fields at/above the configured visibility, record components always. */ From f0835ae7291e225cc764680f6e3abb01612a8c50 Mon Sep 17 00:00:00 2001 From: dzikoysk Date: Wed, 17 Jun 2026 22:00:20 +0200 Subject: [PATCH 09/43] chore: finding 2 --- .../introspection/ksp/KspTypeIntrospector.kt | 13 ++++++++++--- .../introspection/ksp/KspTypeIntrospectorTest.kt | 16 +++++++++++----- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/introspection/introspection-ksp/src/main/kotlin/io/javalin/introspection/ksp/KspTypeIntrospector.kt b/introspection/introspection-ksp/src/main/kotlin/io/javalin/introspection/ksp/KspTypeIntrospector.kt index 28766e31..610dba68 100644 --- a/introspection/introspection-ksp/src/main/kotlin/io/javalin/introspection/ksp/KspTypeIntrospector.kt +++ b/introspection/introspection-ksp/src/main/kotlin/io/javalin/introspection/ksp/KspTypeIntrospector.kt @@ -1,5 +1,6 @@ package io.javalin.introspection.ksp +import com.google.devtools.ksp.KspExperimental import com.google.devtools.ksp.getVisibility import com.google.devtools.ksp.processing.Resolver import com.google.devtools.ksp.symbol.ClassKind @@ -66,10 +67,9 @@ class KspTypeIntrospector(private val resolver: Resolver) : TypeIntrospector { } private fun definition(type: KSType, structureType: StructureType, generics: List): ClassDefinition { - val declaration = type.declaration - val fullName = declaration.qualifiedName?.asString() ?: declaration.simpleName.asString() + val fullName = canonicalName(type.declaration) return Definition( - simpleName = declaration.simpleName.asString(), + simpleName = fullName.substringAfterLast('.'), fullName = fullName, generics = generics, structureType = structureType, @@ -77,6 +77,13 @@ class KspTypeIntrospector(private val resolver: Resolver) : TypeIntrospector { ) } + /** Normalize Kotlin builtin FQNs (kotlin.Int, kotlin.String, kotlin.collections.Map, ...) to their JVM names, matching jap/reflection. */ + @OptIn(KspExperimental::class) + private fun canonicalName(declaration: KSDeclaration): String { + val kotlinName = declaration.qualifiedName ?: return declaration.simpleName.asString() + return (resolver.mapKotlinNameToJava(kotlinName) ?: kotlinName).asString() + } + private fun objectDefinition(structureType: StructureType = DEFAULT): ClassDefinition = definition(builtin("kotlin.Any")!!, structureType, emptyList()) diff --git a/introspection/introspection-ksp/src/test/kotlin/io/javalin/introspection/ksp/KspTypeIntrospectorTest.kt b/introspection/introspection-ksp/src/test/kotlin/io/javalin/introspection/ksp/KspTypeIntrospectorTest.kt index 6428bf53..bc96760c 100644 --- a/introspection/introspection-ksp/src/test/kotlin/io/javalin/introspection/ksp/KspTypeIntrospectorTest.kt +++ b/introspection/introspection-ksp/src/test/kotlin/io/javalin/introspection/ksp/KspTypeIntrospectorTest.kt @@ -65,11 +65,17 @@ class KspTypeIntrospectorTest { ) } - // Structure + collection/map/nullability resolve; NOTE KSP yields Kotlin FQNs (kotlin.String/Int), not java.lang.* - assertThat(snapshot.properties.keys).containsExactlyInAnyOrder("id", "age", "color", "address", "tags", "meta") - assertThat(snapshot.properties["tags"]).endsWith(":ARRAY") - assertThat(snapshot.properties["meta"]).endsWith(":DICTIONARY") - assertThat(snapshot.properties["color"]).isEqualTo("$REF_PKG.Color:DEFAULT") + // Structure + collection/map/nullability resolve, and Kotlin builtins normalize to JVM names (== jap/reflection) + assertThat(snapshot.properties).containsAllEntriesOf( + mapOf( + "id" to "java.lang.String:DEFAULT", + "age" to "java.lang.Integer:DEFAULT", + "color" to "$REF_PKG.Color:DEFAULT", + "address" to "$REF_PKG.Address:DEFAULT", + "tags" to "java.lang.String:ARRAY", + "meta" to "java.util.Map:DICTIONARY", + ) + ) // Enum assertThat(snapshot.isEnum).isTrue() From 1e0e08b3de7f60bdb5afa182b975ca92dce33509 Mon Sep 17 00:00:00 2001 From: dzikoysk Date: Wed, 17 Jun 2026 22:27:31 +0200 Subject: [PATCH 10/43] chore: finding 3 --- .../io/javalin/introspection/PropertyView.kt | 28 +++++- .../introspection/jap/JapTypeIntrospector.kt | 97 +++++++++++++------ .../introspection/ksp/KspTypeIntrospector.kt | 6 +- .../runtime/ReflectionTypeIntrospector.kt | 91 +++++++++++------ .../runtime/ReflectionTypeIntrospectorTest.kt | 30 +++--- .../test/IntrospectionParityTest.kt | 14 +-- .../dynamic/ReflectiveTypeIntrospector.kt | 26 ++--- 7 files changed, 186 insertions(+), 106 deletions(-) diff --git a/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/PropertyView.kt b/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/PropertyView.kt index ca29d6a7..dbf71b14 100644 --- a/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/PropertyView.kt +++ b/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/PropertyView.kt @@ -1,21 +1,39 @@ package io.javalin.introspection -/** A raw member (getter, field, or record component) — no naming/ignore policy applied. */ +/** A logical property — one per name, merging whatever accessors back it (getter, field, setter). No naming/ignore policy applied. */ class PropertyView( val name: String, val type: ClassDefinition, - val accessor: Accessor, + val accessors: Set, val nullable: Boolean, val visibility: Visibility, val transient: Boolean, val annotations: Annotations, -) +) { + /** How this property projects: backed by a getter (Java bean / Kotlin property / record) → [Projection.PROPERTY]; field-only → [Projection.FIELD]. */ + val projection: Projection + get() = if (Accessor.GETTER in accessors) Projection.PROPERTY else Projection.FIELD +} -enum class Accessor { FIELD, GETTER, RECORD_COMPONENT } +enum class Accessor { GETTER, FIELD, SETTER } + +enum class Projection { PROPERTY, FIELD } enum class Visibility { PUBLIC, PROTECTED, PACKAGE_PRIVATE, PRIVATE } -/** JavaBean getter name: `getX` / `isX` with an uppercase char right after the prefix (excludes e.g. `issue`, `getaway`). */ +/** A getter name: `getX` / `isX` with an uppercase char right after the prefix (excludes e.g. `issue`, `getaway`). */ fun isGetterName(name: String): Boolean = (name.startsWith("get") && name.length > 3 && name[3].isUpperCase()) || (name.startsWith("is") && name.length > 2 && name[2].isUpperCase()) + +/** A setter name: `setX` with an uppercase char right after the prefix. */ +fun isSetterName(name: String): Boolean = + name.startsWith("set") && name.length > 3 && name[3].isUpperCase() + +/** Logical property name of a getter/setter (`getId`/`setId`→`id`, `isActive`→`active`); call only on names that pass [isGetterName]/[isSetterName]. */ +fun propertyName(accessorName: String): String = + when { + accessorName.startsWith("get") -> accessorName.removePrefix("get") + accessorName.startsWith("set") -> accessorName.removePrefix("set") + else -> accessorName.removePrefix("is") + }.replaceFirstChar { it.lowercase() } diff --git a/introspection/introspection-jap/src/main/kotlin/io/javalin/introspection/jap/JapTypeIntrospector.kt b/introspection/introspection-jap/src/main/kotlin/io/javalin/introspection/jap/JapTypeIntrospector.kt index 8b1b20c8..73ac5980 100644 --- a/introspection/introspection-jap/src/main/kotlin/io/javalin/introspection/jap/JapTypeIntrospector.kt +++ b/introspection/introspection-jap/src/main/kotlin/io/javalin/introspection/jap/JapTypeIntrospector.kt @@ -11,6 +11,8 @@ import io.javalin.introspection.StructureType.DICTIONARY import io.javalin.introspection.TypeIntrospector import io.javalin.introspection.Visibility import io.javalin.introspection.isGetterName +import io.javalin.introspection.isSetterName +import io.javalin.introspection.propertyName import javax.lang.model.element.AnnotationMirror import javax.lang.model.element.AnnotationValue import javax.lang.model.element.Element @@ -110,7 +112,7 @@ class JapTypeIntrospector(private val types: Types, private val elements: Elemen ?.map { it.simpleName.toString() } override fun getAnnotations(): Annotations = - AnnotationsView(typeElement()) + AnnotationsView(listOfNotNull(typeElement())) override fun getProperties(): List { val element = typeElement() ?: return emptyList() @@ -120,44 +122,68 @@ class JapTypeIntrospector(private val types: Types, private val elements: Elemen PropertyView( name = component.simpleName.toString(), type = resolve(component.asType()), - accessor = Accessor.RECORD_COMPONENT, + accessors = setOf(Accessor.GETTER, Accessor.FIELD), nullable = component.asType().nullable(), visibility = Visibility.PUBLIC, transient = false, - annotations = AnnotationsView(component), + annotations = AnnotationsView(listOf(component)), ) } } - return elements.getAllMembers(element).mapNotNull { member -> + val builders = LinkedHashMap() + for (member in elements.getAllMembers(element)) { when { - member.isGetter() -> { - val getter = member as ExecutableElement - PropertyView( - name = getter.simpleName.toString(), - type = resolve(getter.returnType), - accessor = Accessor.GETTER, - nullable = getter.returnType.nullable(), - visibility = getter.visibility(), - transient = false, - annotations = AnnotationsView(getter), - ) + member.isGetter() -> (member as ExecutableElement).let { getter -> + builders.getOrPut(propertyName(getter.simpleName.toString())) { Acc() }.apply { + accessors += Accessor.GETTER + getterType = getter.returnType + getterVisibility = getter.visibility() + sources += getter + } } - member.isInstanceField() -> { - val field = member as VariableElement - PropertyView( - name = field.simpleName.toString(), - type = resolve(field.asType()), - accessor = Accessor.FIELD, - nullable = field.asType().nullable(), - visibility = field.visibility(), - transient = Modifier.TRANSIENT in field.modifiers, - annotations = AnnotationsView(field), - ) + member.isSetter() -> (member as ExecutableElement).let { setter -> + builders.getOrPut(propertyName(setter.simpleName.toString())) { Acc() }.apply { + accessors += Accessor.SETTER + setterType = setter.parameters[0].asType() + sources += setter + } + } + member.isInstanceField() -> (member as VariableElement).let { field -> + builders.getOrPut(field.simpleName.toString()) { Acc() }.apply { + accessors += Accessor.FIELD + fieldType = field.asType() + fieldVisibility = field.visibility() + transient = Modifier.TRANSIENT in field.modifiers + sources += field + } } - else -> null } } + + return builders.map { (name, acc) -> + val type = acc.getterType ?: acc.fieldType ?: acc.setterType!! + PropertyView( + name = name, + type = resolve(type), + accessors = acc.accessors, + nullable = type.nullable(), + visibility = acc.fieldVisibility ?: acc.getterVisibility ?: Visibility.PUBLIC, + transient = acc.transient, + annotations = AnnotationsView(acc.sources), + ) + } + } + + private inner class Acc { + val accessors = mutableSetOf() + val sources = mutableListOf() + var getterType: TypeMirror? = null + var fieldType: TypeMirror? = null + var setterType: TypeMirror? = null + var getterVisibility: Visibility? = null + var fieldVisibility: Visibility? = null + var transient = false } private fun typeElement(): TypeElement? = @@ -174,6 +200,13 @@ class JapTypeIntrospector(private val types: Types, private val elements: Elemen return isGetterName(simpleName.toString()) } + private fun Element.isSetter(): Boolean { + if (kind != ElementKind.METHOD || this !is ExecutableElement) return false + if (Modifier.PUBLIC !in modifiers || Modifier.STATIC in modifiers) return false + if (parameters.size != 1 || enclosingElement?.toString() == "java.lang.Object") return false + return isSetterName(simpleName.toString()) + } + private fun Element.isInstanceField(): Boolean = kind == ElementKind.FIELD && this is VariableElement && Modifier.STATIC !in modifiers @@ -186,14 +219,16 @@ class JapTypeIntrospector(private val types: Types, private val elements: Elemen } } - private inner class AnnotationsView(private val element: Element?) : Annotations { + private inner class AnnotationsView(private val sources: List) : Annotations { override fun hasNamed(simpleName: String): Boolean = - element?.annotationMirrors?.any { it.annotationType.asElement().simpleName.contentEquals(simpleName) } == true + sources.any { source -> source.annotationMirrors.any { it.annotationType.asElement().simpleName.contentEquals(simpleName) } } override fun memberValues(annotationType: Class): Map? { - val mirror = element?.annotationMirrors?.firstOrNull { - (it.annotationType.asElement() as? TypeElement)?.qualifiedName?.contentEquals(annotationType.name) == true + val mirror = sources.firstNotNullOfOrNull { source -> + source.annotationMirrors.firstOrNull { + (it.annotationType.asElement() as? TypeElement)?.qualifiedName?.contentEquals(annotationType.name) == true + } } ?: return null val visitor = object : SimpleAnnotationValueVisitor8() { diff --git a/introspection/introspection-ksp/src/main/kotlin/io/javalin/introspection/ksp/KspTypeIntrospector.kt b/introspection/introspection-ksp/src/main/kotlin/io/javalin/introspection/ksp/KspTypeIntrospector.kt index 610dba68..828f200f 100644 --- a/introspection/introspection-ksp/src/main/kotlin/io/javalin/introspection/ksp/KspTypeIntrospector.kt +++ b/introspection/introspection-ksp/src/main/kotlin/io/javalin/introspection/ksp/KspTypeIntrospector.kt @@ -129,7 +129,11 @@ class KspTypeIntrospector(private val resolver: Resolver) : TypeIntrospector { PropertyView( name = property.simpleName.asString(), type = resolve(propertyType), - accessor = Accessor.GETTER, + accessors = buildSet { + add(Accessor.GETTER) + add(Accessor.FIELD) + if (property.isMutable) add(Accessor.SETTER) + }, nullable = propertyType.isMarkedNullable, visibility = visibilityOf(property.getVisibility()), transient = Modifier.JAVA_TRANSIENT in property.modifiers, diff --git a/introspection/introspection-runtime/src/main/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospector.kt b/introspection/introspection-runtime/src/main/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospector.kt index 26644be8..2c76b54c 100644 --- a/introspection/introspection-runtime/src/main/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospector.kt +++ b/introspection/introspection-runtime/src/main/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospector.kt @@ -11,6 +11,8 @@ import io.javalin.introspection.StructureType.DICTIONARY import io.javalin.introspection.TypeIntrospector import io.javalin.introspection.Visibility import io.javalin.introspection.isGetterName +import io.javalin.introspection.isSetterName +import io.javalin.introspection.propertyName import java.lang.reflect.AnnotatedElement import java.lang.reflect.Array as JavaArray import java.lang.reflect.Field @@ -97,45 +99,87 @@ private class ReflectionClassDefinition( erasure.takeIf { it.isEnum }?.enumConstants?.map { (it as Enum<*>).name } override fun getProperties(): List = - collectMembers(erasure).map { member -> - PropertyView( - name = member.name, - type = reflect(member.genericType), - accessor = member.accessor, - nullable = (member.genericType as? Class<*>)?.isPrimitive != true, - visibility = member.visibility, - transient = member.transient, - annotations = ReflectionAnnotations(member.sources), - ) - } + properties(erasure) override fun getAnnotations(): Annotations = ReflectionAnnotations(listOf(erasure)) } -private fun collectMembers(clazz: Class<*>): List { +/** Group a class's getters, setters and fields into one logical [PropertyView] per name. */ +private fun properties(clazz: Class<*>): List { if (clazz.isRecord) { return clazz.recordComponents.map { component -> val backingField = runCatching { clazz.getDeclaredField(component.name) }.getOrNull() - Member(component.name, component.genericType, Accessor.RECORD_COMPONENT, Visibility.PUBLIC, transient = false, listOfNotNull(component.accessor, backingField, component)) + PropertyView( + name = component.name, + type = reflect(component.genericType), + accessors = setOf(Accessor.GETTER, Accessor.FIELD), + nullable = (component.genericType as? Class<*>)?.isPrimitive != true, + visibility = Visibility.PUBLIC, + transient = false, + annotations = ReflectionAnnotations(listOfNotNull(component.accessor, backingField, component)), + ) } } - val members = mutableListOf() + val builders = LinkedHashMap() for (method in clazz.methods) { if (Modifier.isStatic(method.modifiers) || method.isBridge || method.isSynthetic) continue - if (method.parameterCount != 0 || method.declaringClass == Any::class.java) continue - if (method.returnType == Void.TYPE || !isGetterName(method.name)) continue - members += Member(method.name, method.genericReturnType, Accessor.GETTER, visibilityOf(method.modifiers), transient = false, listOf(method)) + if (method.declaringClass == Any::class.java) continue + when { + method.parameterCount == 0 && method.returnType != Void.TYPE && isGetterName(method.name) -> + builders.getOrPut(propertyName(method.name)) { PropertyBuilder() }.apply { + accessors += Accessor.GETTER + getterType = method.genericReturnType + getterVisibility = visibilityOf(method.modifiers) + sources += method + } + method.parameterCount == 1 && isSetterName(method.name) -> + builders.getOrPut(propertyName(method.name)) { PropertyBuilder() }.apply { + accessors += Accessor.SETTER + setterType = method.genericParameterTypes[0] + sources += method + } + } } for (field in declaredFieldsHierarchy(clazz)) { if (Modifier.isStatic(field.modifiers)) continue - members += Member(field.name, field.genericType, Accessor.FIELD, visibilityOf(field.modifiers), Modifier.isTransient(field.modifiers), listOf(field)) + builders.getOrPut(field.name) { PropertyBuilder() }.apply { + accessors += Accessor.FIELD + fieldType = field.genericType + fieldVisibility = visibilityOf(field.modifiers) + transient = Modifier.isTransient(field.modifiers) + sources += field + } } - return members + return builders.map { (name, builder) -> builder.build(name) } +} + +private class PropertyBuilder { + val accessors = mutableSetOf() + val sources = mutableListOf() + var getterType: Type? = null + var fieldType: Type? = null + var setterType: Type? = null + var getterVisibility: Visibility? = null + var fieldVisibility: Visibility? = null + var transient = false + + fun build(name: String): PropertyView { + val type = getterType ?: fieldType ?: setterType!! + return PropertyView( + name = name, + type = reflect(type), + accessors = accessors, + nullable = (type as? Class<*>)?.isPrimitive != true, + visibility = fieldVisibility ?: getterVisibility ?: Visibility.PUBLIC, + transient = transient, + annotations = ReflectionAnnotations(sources), + ) + } } private fun visibilityOf(modifiers: Int): Visibility = @@ -166,15 +210,6 @@ private fun declaredFieldsHierarchy(clazz: Class<*>): List { return fields } -private class Member( - val name: String, - val genericType: Type, - val accessor: Accessor, - val visibility: Visibility, - val transient: Boolean, - val sources: List, -) - private class ReflectionAnnotations(private val sources: List) : Annotations { override fun hasNamed(simpleName: String): Boolean = diff --git a/introspection/introspection-runtime/src/test/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospectorTest.kt b/introspection/introspection-runtime/src/test/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospectorTest.kt index 141ebb67..330cc258 100644 --- a/introspection/introspection-runtime/src/test/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospectorTest.kt +++ b/introspection/introspection-runtime/src/test/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospectorTest.kt @@ -23,37 +23,37 @@ class ReflectionTypeIntrospectorTest { } @Test - fun `exposes raw getter members with boxed primitives and structural nullability`() { + fun `exposes getter members under logical names with boxed primitives and structural nullability`() { val props = props(Account::class.java) - assertThat(props.keys).contains("getId", "getAge", "getName", "getColor", "getAddress", "getTags", "getMeta") - assertThat(props.getValue("getId").accessor).isEqualTo(Accessor.GETTER) + assertThat(props.keys).contains("id", "age", "name", "color", "address", "tags", "meta") + assertThat(props.getValue("id").accessors).containsExactly(Accessor.GETTER) - assertThat(props.getValue("getAge").type.fullName).isEqualTo("java.lang.Integer") - assertThat(props.getValue("getAge").nullable).isFalse() - assertThat(props.getValue("getName").nullable).isTrue() + assertThat(props.getValue("age").type.fullName).isEqualTo("java.lang.Integer") + assertThat(props.getValue("age").nullable).isFalse() + assertThat(props.getValue("name").nullable).isTrue() } @Test fun `resolves collections, maps and nested types`() { val props = props(Account::class.java) - assertThat(props.getValue("getTags").type.structureType).isEqualTo(StructureType.ARRAY) - assertThat(props.getValue("getTags").type.fullName).isEqualTo("java.lang.String") + assertThat(props.getValue("tags").type.structureType).isEqualTo(StructureType.ARRAY) + assertThat(props.getValue("tags").type.fullName).isEqualTo("java.lang.String") - val meta = props.getValue("getMeta").type + val meta = props.getValue("meta").type assertThat(meta.structureType).isEqualTo(StructureType.DICTIONARY) assertThat(meta.generics.map { it.fullName }).containsExactly("java.lang.String", "java.lang.Integer") - assertThat(props.getValue("getAddress").type.fullName).isEqualTo(Address::class.java.name) + assertThat(props.getValue("address").type.fullName).isEqualTo(Address::class.java.name) } @Test fun `exposes annotations without applying policy`() { - val id = props(Account::class.java).getValue("getId") + val id = props(Account::class.java).getValue("id") assertThat(id.annotations.has(Nn::class.java)).isTrue() assertThat(id.annotations.hasNamed("Nn")).isTrue() - assertThat(props(Account::class.java).getValue("getName").annotations.has(Nn::class.java)).isFalse() + assertThat(props(Account::class.java).getValue("name").annotations.has(Nn::class.java)).isFalse() } @Test @@ -65,15 +65,15 @@ class ReflectionTypeIntrospectorTest { } @Test - fun `returns both getters and fields tagged by accessor`() { + fun `tags each property with its backing accessors`() { class FieldBag { @JvmField val tag: String = "" fun getName(): String = "" } val props = props(FieldBag::class.java) - assertThat(props.getValue("getName").accessor).isEqualTo(Accessor.GETTER) - assertThat(props.getValue("tag").accessor).isEqualTo(Accessor.FIELD) + assertThat(props.getValue("name").accessors).containsExactly(Accessor.GETTER) + assertThat(props.getValue("tag").accessors).containsExactly(Accessor.FIELD) } @Test diff --git a/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/IntrospectionParityTest.kt b/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/IntrospectionParityTest.kt index 3ed438a0..579dfe98 100644 --- a/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/IntrospectionParityTest.kt +++ b/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/IntrospectionParityTest.kt @@ -59,18 +59,18 @@ class IntrospectionParityTest { fun `getter detection excludes get-or-is lookalikes on both backends`() { assertParity(Tricky::class) val names = runtime.introspect(Tricky::class.java).getProperties().map { it.name } - assertThat(names).contains("getName").doesNotContain("issue", "getaway", "getResult") + assertThat(names).contains("name").doesNotContain("issue", "getaway", "result") } @Test fun `property-level annotations resolve identically across backends`() { val runtimeMarked = runtime.introspect(Annotated::class.java).getProperties().associate { it.name to it.annotations.hasNamed("Marker") } val processedMarked = AnnotationProcessing.introspect(Annotated::class) { - it.getProperties().associate { property -> property.name to property.annotations.hasNamed("Marker") } + it.getProperties().associate { p -> p.name to p.annotations.hasNamed("Marker") } } assertThat(processedMarked).isEqualTo(runtimeMarked) - assertThat(runtimeMarked.getValue("getTagged")).isTrue() - assertThat(runtimeMarked.getValue("getPlain")).isFalse() + assertThat(runtimeMarked.getValue("tagged")).isTrue() + assertThat(runtimeMarked.getValue("plain")).isFalse() } @Test @@ -126,7 +126,7 @@ private data class PropertyShape( val typeFullName: String, val typeStructure: StructureType, val typeGenerics: List, - val accessor: Accessor, + val accessors: Set, val nullable: Boolean, val visibility: Visibility, val transient: Boolean, @@ -141,6 +141,6 @@ private fun ClassDefinition.toShape(): TypeShape = isEnum = isEnum(), enumConstants = getEnumConstants()?.sorted(), properties = if (isEnum()) emptyList() else getProperties() - .map { PropertyShape(it.name, it.type.fullName, it.type.structureType, it.type.generics.map { g -> g.fullName }, it.accessor, it.nullable, it.visibility, it.transient) } - .sortedBy { "${it.accessor}:${it.name}" }, + .map { PropertyShape(it.name, it.type.fullName, it.type.structureType, it.type.generics.map { g -> g.fullName }, it.accessors, it.nullable, it.visibility, it.transient) } + .sortedBy { it.name }, ) diff --git a/openapi-dynamic/src/main/kotlin/io/javalin/openapi/dynamic/ReflectiveTypeIntrospector.kt b/openapi-dynamic/src/main/kotlin/io/javalin/openapi/dynamic/ReflectiveTypeIntrospector.kt index 183169e6..b6054c22 100644 --- a/openapi-dynamic/src/main/kotlin/io/javalin/openapi/dynamic/ReflectiveTypeIntrospector.kt +++ b/openapi-dynamic/src/main/kotlin/io/javalin/openapi/dynamic/ReflectiveTypeIntrospector.kt @@ -66,11 +66,7 @@ class ReflectiveTypeIntrospector( if (annotations.has(OpenApiIgnore::class.java)) return@mapNotNull null val customName = annotations.memberValues(OpenApiName::class.java)?.get("value") as? String - val resolvedName = when { - customName != null -> customName - property.accessor == Accessor.GETTER -> property.name.stripGetterPrefix() - else -> property.name - } + val resolvedName = customName ?: property.name val finalName = if (customName == null && namingStrategy != null) translatePropertyName(namingStrategy, resolvedName) else resolvedName val isPrimitive = !property.nullable @@ -120,20 +116,12 @@ class ReflectiveTypeIntrospector( (type.raw.source as Class<*>).getAnnotation(annotationType) } -/** `@OpenApiByFields` selection: getters unless `only`, fields at/above the configured visibility, record components always. */ -private fun RawProperty.includedBy(byFields: OpenApiByFields?): Boolean = - when (accessor) { - Accessor.RECORD_COMPONENT -> true - Accessor.GETTER -> byFields?.only != true - Accessor.FIELD -> byFields != null && byFields.value.priority <= visibility.toOpenApi().priority - } - -private fun String.stripGetterPrefix(): String = - when { - startsWith("get") -> removePrefix("get").replaceFirstChar { it.lowercase() } - startsWith("is") -> removePrefix("is").replaceFirstChar { it.lowercase() } - else -> this - } +/** `@OpenApiByFields` selection: getter-backed properties by default; with the annotation, fields at/above the configured visibility (and getters too unless `only`). */ +private fun RawProperty.includedBy(byFields: OpenApiByFields?): Boolean { + if (byFields == null) return Accessor.GETTER in accessors + val fieldVisible = Accessor.FIELD in accessors && byFields.value.priority <= visibility.toOpenApi().priority + return if (byFields.only) fieldVisible else Accessor.GETTER in accessors || fieldVisible +} private fun RawVisibility.toOpenApi(): Visibility = when (this) { From 7e3d895f895a8ee4db353482341c59508c1a0e13 Mon Sep 17 00:00:00 2001 From: dzikoysk Date: Sat, 20 Jun 2026 02:28:21 +0200 Subject: [PATCH 11/43] chore: yet another migration Claude-Session: https://claude.ai/code/session_01NjkDTqWZkjLPL9Wn4AyFCf --- .../io/javalin/introspection/Annotations.kt | 18 + .../javalin/introspection/ClassDefinition.kt | 8 +- .../introspection/InternalIntrospectionApi.kt | 14 + .../io/javalin/introspection/PropertyView.kt | 30 +- .../introspection/jap/JapTypeIntrospector.kt | 153 +++--- .../introspection/ksp/KspTypeIntrospector.kt | 65 ++- .../ksp/KspTypeIntrospectorTest.kt | 7 +- .../introspection/ksp/fixtures/Fixtures.kt | 6 + .../runtime/ReflectionTypeIntrospector.kt | 140 +++--- .../runtime/ReflectionTypeIntrospectorTest.kt | 8 +- .../io/javalin/introspection/test/Note.java | 20 + .../test/IntrospectionParityTest.kt | 41 +- .../introspection/test/fixtures/Fixtures.kt | 8 + .../openapi/dynamic/hook/OpenApiMetadata.kt | 3 +- .../AnnotationProcessorContext.kt | 53 +- .../experimental/ClassDefinitionFactory.kt | 4 +- ...OpenApiAnnotationProcessorConfiguration.kt | 17 - .../generators/CompositionGenerator.kt | 99 ---- .../generators/TypeSchemaGenerator.kt | 468 ------------------ .../processor/shared/ModelExtensions.kt | 7 - .../dynamic/IntrospectionSchemaGenerator.kt | 98 ---- .../dynamic/ReflectiveTypeIntrospector.kt | 127 ++--- ...rTest.kt => DynamicSchemaGeneratorTest.kt} | 4 +- .../dynamic/ReflectiveTypeIntrospectorTest.kt | 86 ++-- openapi-generator/build.gradle.kts | 1 + .../experimental/EmbeddedTypeProcessor.kt | 19 + .../experimental/SchemaGenerationContext.kt | 36 ++ .../openapi/experimental/TypeIntrospector.kt | 22 - .../defaults/ArrayEmbeddedTypeProcessor.kt | 1 - .../CompositionEmbeddedTypeProcessor.kt | 0 .../DictionaryEmbeddedTypeProcessor.kt | 2 +- .../generators/CompositionGenerator.kt | 103 ++++ .../processor/generators/ExampleGenerator.kt | 8 + .../generators/PropertyComposition.kt | 10 +- .../generators/TypeSchemaGenerator.kt | 387 +++++++++++++++ 35 files changed, 991 insertions(+), 1082 deletions(-) create mode 100644 introspection/introspection-api/src/main/kotlin/io/javalin/introspection/InternalIntrospectionApi.kt create mode 100644 introspection/introspection-test/src/test/java/io/javalin/introspection/test/Note.java delete mode 100644 openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/CompositionGenerator.kt delete mode 100644 openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/TypeSchemaGenerator.kt delete mode 100644 openapi-dynamic/src/main/kotlin/io/javalin/openapi/dynamic/IntrospectionSchemaGenerator.kt rename openapi-dynamic/src/test/kotlin/io/javalin/openapi/dynamic/{IntrospectionSchemaGeneratorTest.kt => DynamicSchemaGeneratorTest.kt} (97%) create mode 100644 openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/EmbeddedTypeProcessor.kt create mode 100644 openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/SchemaGenerationContext.kt delete mode 100644 openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/TypeIntrospector.kt rename {openapi-annotation-processor => openapi-generator}/src/main/kotlin/io/javalin/openapi/experimental/defaults/ArrayEmbeddedTypeProcessor.kt (95%) rename {openapi-annotation-processor => openapi-generator}/src/main/kotlin/io/javalin/openapi/experimental/defaults/CompositionEmbeddedTypeProcessor.kt (100%) rename {openapi-annotation-processor => openapi-generator}/src/main/kotlin/io/javalin/openapi/experimental/defaults/DictionaryEmbeddedTypeProcessor.kt (95%) create mode 100644 openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/CompositionGenerator.kt create mode 100644 openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/TypeSchemaGenerator.kt diff --git a/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/Annotations.kt b/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/Annotations.kt index 3990374c..0642ca48 100644 --- a/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/Annotations.kt +++ b/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/Annotations.kt @@ -16,6 +16,12 @@ interface Annotations { */ fun memberValues(annotationType: Class): Map? + /** Each occurrence of a repeatable annotation, as a member value-map. */ + fun memberValuesList(annotationType: Class): List> + + /** Every annotation present, as neutral views — for scanning by meta-annotation without depending on the annotation types. */ + fun all(): List + /** True if an annotation of [annotationType] is present. */ fun has(annotationType: Class): Boolean = memberValues(annotationType) != null @@ -28,3 +34,15 @@ interface Annotations { fun resolveClasses(annotationType: Class, member: String): List = (memberValues(annotationType)?.get(member) as? List<*>)?.filterIsInstance() ?: emptyList() } + +interface AnnotationView { + + val qualifiedName: String + + val simpleName: String + + /** Annotations on this annotation's own type — e.g. to detect `@CustomAnnotation`. */ + val meta: Annotations + + fun values(): Map +} diff --git a/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/ClassDefinition.kt b/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/ClassDefinition.kt index a737bb51..8745acc4 100644 --- a/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/ClassDefinition.kt +++ b/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/ClassDefinition.kt @@ -8,12 +8,13 @@ abstract class ClassDefinition( val structureType: StructureType = StructureType.DEFAULT, ) { - /** Backend-native token this was resolved from (`Class`, `TypeMirror`, ...), for consumers needing backend specifics. */ + /** Backend-native token this was resolved from (`Class`, `TypeMirror`, ...); only the producing backend may cast it. */ + @InternalIntrospectionApi abstract val source: Any abstract fun isEnum(): Boolean - abstract fun getEnumConstants(): List? + abstract fun getEnumConstants(): List? abstract fun getProperties(): List @@ -24,3 +25,6 @@ abstract class ClassDefinition( } enum class StructureType { DEFAULT, ARRAY, DICTIONARY } + +/** An enum constant paired with its declared annotations (e.g. for `@OpenApiName` overrides). */ +class EnumConstantView(val name: String, val annotations: Annotations) diff --git a/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/InternalIntrospectionApi.kt b/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/InternalIntrospectionApi.kt new file mode 100644 index 00000000..7f0713d9 --- /dev/null +++ b/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/InternalIntrospectionApi.kt @@ -0,0 +1,14 @@ +package io.javalin.introspection + +/** + * Opt-in marker for [ClassDefinition.source] / [PropertyView.source] — the backend-native token (`Class`, + * `TypeMirror`, `KSType`, ...). It is only meaningful to the backend that produced it; backend-agnostic code must + * never cast it. Opt in only inside a backend adapter that knows the concrete token type. + */ +@RequiresOptIn( + level = RequiresOptIn.Level.ERROR, + message = "source is a backend-native token; only the producing backend may read/cast it. Agnostic code must not touch it.", +) +@Retention(AnnotationRetention.BINARY) +@Target(AnnotationTarget.PROPERTY) +annotation class InternalIntrospectionApi diff --git a/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/PropertyView.kt b/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/PropertyView.kt index dbf71b14..09a0ac30 100644 --- a/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/PropertyView.kt +++ b/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/PropertyView.kt @@ -1,23 +1,18 @@ package io.javalin.introspection -/** A logical property — one per name, merging whatever accessors back it (getter, field, setter). No naming/ignore policy applied. */ +/** A raw member (getter, field, or record component) — no naming/ignore policy applied. */ class PropertyView( val name: String, val type: ClassDefinition, - val accessors: Set, + val accessor: Accessor, val nullable: Boolean, val visibility: Visibility, val transient: Boolean, + @property:InternalIntrospectionApi val source: Any, val annotations: Annotations, -) { - /** How this property projects: backed by a getter (Java bean / Kotlin property / record) → [Projection.PROPERTY]; field-only → [Projection.FIELD]. */ - val projection: Projection - get() = if (Accessor.GETTER in accessors) Projection.PROPERTY else Projection.FIELD -} +) -enum class Accessor { GETTER, FIELD, SETTER } - -enum class Projection { PROPERTY, FIELD } +enum class Accessor { FIELD, GETTER, RECORD_COMPONENT } enum class Visibility { PUBLIC, PROTECTED, PACKAGE_PRIVATE, PRIVATE } @@ -26,14 +21,7 @@ fun isGetterName(name: String): Boolean = (name.startsWith("get") && name.length > 3 && name[3].isUpperCase()) || (name.startsWith("is") && name.length > 2 && name[2].isUpperCase()) -/** A setter name: `setX` with an uppercase char right after the prefix. */ -fun isSetterName(name: String): Boolean = - name.startsWith("set") && name.length > 3 && name[3].isUpperCase() - -/** Logical property name of a getter/setter (`getId`/`setId`→`id`, `isActive`→`active`); call only on names that pass [isGetterName]/[isSetterName]. */ -fun propertyName(accessorName: String): String = - when { - accessorName.startsWith("get") -> accessorName.removePrefix("get") - accessorName.startsWith("set") -> accessorName.removePrefix("set") - else -> accessorName.removePrefix("is") - }.replaceFirstChar { it.lowercase() } +/** Logical property name of a getter (`getId`→`id`, `isActive`→`active`); call only on names that pass [isGetterName]. */ +fun propertyName(getterName: String): String = + (if (getterName.startsWith("get")) getterName.removePrefix("get") else getterName.removePrefix("is")) + .replaceFirstChar { it.lowercase() } diff --git a/introspection/introspection-jap/src/main/kotlin/io/javalin/introspection/jap/JapTypeIntrospector.kt b/introspection/introspection-jap/src/main/kotlin/io/javalin/introspection/jap/JapTypeIntrospector.kt index 73ac5980..db6e05d0 100644 --- a/introspection/introspection-jap/src/main/kotlin/io/javalin/introspection/jap/JapTypeIntrospector.kt +++ b/introspection/introspection-jap/src/main/kotlin/io/javalin/introspection/jap/JapTypeIntrospector.kt @@ -1,8 +1,11 @@ package io.javalin.introspection.jap import io.javalin.introspection.Accessor +import io.javalin.introspection.AnnotationView import io.javalin.introspection.Annotations import io.javalin.introspection.ClassDefinition +import io.javalin.introspection.EnumConstantView +import io.javalin.introspection.InternalIntrospectionApi import io.javalin.introspection.PropertyView import io.javalin.introspection.StructureType import io.javalin.introspection.StructureType.ARRAY @@ -11,7 +14,6 @@ import io.javalin.introspection.StructureType.DICTIONARY import io.javalin.introspection.TypeIntrospector import io.javalin.introspection.Visibility import io.javalin.introspection.isGetterName -import io.javalin.introspection.isSetterName import io.javalin.introspection.propertyName import javax.lang.model.element.AnnotationMirror import javax.lang.model.element.AnnotationValue @@ -40,6 +42,9 @@ class JapTypeIntrospector(private val types: Types, private val elements: Elemen return resolve(source) } + fun annotationsOf(element: Element): Annotations = + AnnotationsView(listOf(element)) + private fun resolve(mirror: TypeMirror, generics: List = emptyList(), structureType: StructureType = DEFAULT): ClassDefinition = when (mirror) { is TypeVariable -> (mirror.upperBound ?: mirror.lowerBound)?.let { resolve(it, generics, structureType) } ?: objectDefinition(structureType) @@ -90,6 +95,18 @@ class JapTypeIntrospector(private val types: Types, private val elements: Elemen private fun erasureOf(name: String): TypeMirror = types.erasure(elements.getTypeElement(name).asType()) + private fun mirrorValues(mirror: AnnotationMirror): Map { + val visitor = object : SimpleAnnotationValueVisitor8() { + override fun defaultAction(value: Any?, p: Nothing?): Any? = value + override fun visitType(t: TypeMirror, p: Nothing?): Any = resolve(t) + override fun visitEnumConstant(constant: VariableElement, p: Nothing?): Any = constant.simpleName.toString() + override fun visitArray(values: MutableList, p: Nothing?): Any = values.map { it.accept(this, null) } + override fun visitAnnotation(nested: AnnotationMirror, p: Nothing?): Any = mirrorValues(nested) + } + return elements.getElementValuesWithDefaults(mirror).entries + .associate { (member, value) -> member.simpleName.toString() to value.accept(visitor, null) } + } + private inner class Definition( simpleName: String, fullName: String, @@ -98,18 +115,19 @@ class JapTypeIntrospector(private val types: Types, private val elements: Elemen private val mirror: TypeMirror, ) : ClassDefinition(simpleName, fullName, generics, structureType) { + @InternalIntrospectionApi override val source: Any get() = mirror override fun isEnum(): Boolean = typeElement()?.kind == ElementKind.ENUM - override fun getEnumConstants(): List? = + override fun getEnumConstants(): List? = typeElement() ?.takeIf { it.kind == ElementKind.ENUM } ?.enclosedElements ?.filter { it.kind == ElementKind.ENUM_CONSTANT } - ?.map { it.simpleName.toString() } + ?.map { EnumConstantView(it.simpleName.toString(), AnnotationsView(listOf(it))) } override fun getAnnotations(): Annotations = AnnotationsView(listOfNotNull(typeElement())) @@ -122,68 +140,45 @@ class JapTypeIntrospector(private val types: Types, private val elements: Elemen PropertyView( name = component.simpleName.toString(), type = resolve(component.asType()), - accessors = setOf(Accessor.GETTER, Accessor.FIELD), + accessor = Accessor.RECORD_COMPONENT, nullable = component.asType().nullable(), visibility = Visibility.PUBLIC, transient = false, + source = component, annotations = AnnotationsView(listOf(component)), ) } } - val builders = LinkedHashMap() - for (member in elements.getAllMembers(element)) { + return elements.getAllMembers(element).mapNotNull { member -> when { member.isGetter() -> (member as ExecutableElement).let { getter -> - builders.getOrPut(propertyName(getter.simpleName.toString())) { Acc() }.apply { - accessors += Accessor.GETTER - getterType = getter.returnType - getterVisibility = getter.visibility() - sources += getter - } - } - member.isSetter() -> (member as ExecutableElement).let { setter -> - builders.getOrPut(propertyName(setter.simpleName.toString())) { Acc() }.apply { - accessors += Accessor.SETTER - setterType = setter.parameters[0].asType() - sources += setter - } + PropertyView( + name = propertyName(getter.simpleName.toString()), + type = resolve(getter.returnType), + accessor = Accessor.GETTER, + nullable = getter.returnType.nullable(), + visibility = getter.visibility(), + transient = false, + source = getter, + annotations = AnnotationsView(listOf(getter)), + ) } member.isInstanceField() -> (member as VariableElement).let { field -> - builders.getOrPut(field.simpleName.toString()) { Acc() }.apply { - accessors += Accessor.FIELD - fieldType = field.asType() - fieldVisibility = field.visibility() - transient = Modifier.TRANSIENT in field.modifiers - sources += field - } + PropertyView( + name = field.simpleName.toString(), + type = resolve(field.asType()), + accessor = Accessor.FIELD, + nullable = field.asType().nullable(), + visibility = field.visibility(), + transient = Modifier.TRANSIENT in field.modifiers, + source = field, + annotations = AnnotationsView(listOf(field)), + ) } + else -> null } } - - return builders.map { (name, acc) -> - val type = acc.getterType ?: acc.fieldType ?: acc.setterType!! - PropertyView( - name = name, - type = resolve(type), - accessors = acc.accessors, - nullable = type.nullable(), - visibility = acc.fieldVisibility ?: acc.getterVisibility ?: Visibility.PUBLIC, - transient = acc.transient, - annotations = AnnotationsView(acc.sources), - ) - } - } - - private inner class Acc { - val accessors = mutableSetOf() - val sources = mutableListOf() - var getterType: TypeMirror? = null - var fieldType: TypeMirror? = null - var setterType: TypeMirror? = null - var getterVisibility: Visibility? = null - var fieldVisibility: Visibility? = null - var transient = false } private fun typeElement(): TypeElement? = @@ -200,13 +195,6 @@ class JapTypeIntrospector(private val types: Types, private val elements: Elemen return isGetterName(simpleName.toString()) } - private fun Element.isSetter(): Boolean { - if (kind != ElementKind.METHOD || this !is ExecutableElement) return false - if (Modifier.PUBLIC !in modifiers || Modifier.STATIC in modifiers) return false - if (parameters.size != 1 || enclosingElement?.toString() == "java.lang.Object") return false - return isSetterName(simpleName.toString()) - } - private fun Element.isInstanceField(): Boolean = kind == ElementKind.FIELD && this is VariableElement && Modifier.STATIC !in modifiers @@ -224,25 +212,38 @@ class JapTypeIntrospector(private val types: Types, private val elements: Elemen override fun hasNamed(simpleName: String): Boolean = sources.any { source -> source.annotationMirrors.any { it.annotationType.asElement().simpleName.contentEquals(simpleName) } } - override fun memberValues(annotationType: Class): Map? { - val mirror = sources.firstNotNullOfOrNull { source -> - source.annotationMirrors.firstOrNull { - (it.annotationType.asElement() as? TypeElement)?.qualifiedName?.contentEquals(annotationType.name) == true - } - } ?: return null - - val visitor = object : SimpleAnnotationValueVisitor8() { - override fun defaultAction(value: Any?, p: Nothing?): Any? = value - override fun visitType(t: TypeMirror, p: Nothing?): Any = resolve(t) - override fun visitEnumConstant(constant: VariableElement, p: Nothing?): Any = constant.simpleName.toString() - override fun visitArray(values: MutableList, p: Nothing?): Any = values.map { it.accept(this, null) } - override fun visitAnnotation(nested: AnnotationMirror, p: Nothing?): Any = - elements.getElementValuesWithDefaults(nested).entries - .associate { (member, value) -> member.simpleName.toString() to value.accept(this, null) } - } - - return elements.getElementValuesWithDefaults(mirror).entries - .associate { (executable, value) -> executable.simpleName.toString() to value.accept(visitor, null) } + override fun memberValues(annotationType: Class): Map? = + sources.firstNotNullOfOrNull { source -> source.annotationMirrors.firstOrNull { it.named(annotationType.name) } } + ?.let { mirrorValues(it) } + + override fun memberValuesList(annotationType: Class): List> { + val mirrors = sources.flatMap { it.annotationMirrors } + val direct = mirrors.filter { it.named(annotationType.name) }.map { mirrorValues(it) } + // javac wraps repeated annotations in their @Repeatable container; unwrap its `value` array + val containerName = annotationType.getAnnotation(java.lang.annotation.Repeatable::class.java)?.value?.java?.canonicalName + val repeated = containerName + ?.let { name -> mirrors.firstOrNull { it.named(name) } } + ?.let { mirrorValues(it)["value"] as? List<*> } + ?.filterIsInstance>() + .orEmpty() + return direct + repeated } + + override fun all(): List = + sources.flatMap { it.annotationMirrors }.distinctBy { it.annotationType.asElement() }.map { JapAnnotationView(it) } + + private fun AnnotationMirror.named(qualifiedName: String): Boolean = + (annotationType.asElement() as? TypeElement)?.qualifiedName?.contentEquals(qualifiedName) == true + } + + private inner class JapAnnotationView(private val mirror: AnnotationMirror) : AnnotationView { + override val qualifiedName: String + get() = (mirror.annotationType.asElement() as? TypeElement)?.qualifiedName?.toString() ?: simpleName + override val simpleName: String + get() = mirror.annotationType.asElement().simpleName.toString() + override val meta: Annotations + get() = AnnotationsView(listOf(mirror.annotationType.asElement())) + override fun values(): Map = + mirrorValues(mirror) } } diff --git a/introspection/introspection-ksp/src/main/kotlin/io/javalin/introspection/ksp/KspTypeIntrospector.kt b/introspection/introspection-ksp/src/main/kotlin/io/javalin/introspection/ksp/KspTypeIntrospector.kt index 828f200f..c7ecb6ed 100644 --- a/introspection/introspection-ksp/src/main/kotlin/io/javalin/introspection/ksp/KspTypeIntrospector.kt +++ b/introspection/introspection-ksp/src/main/kotlin/io/javalin/introspection/ksp/KspTypeIntrospector.kt @@ -12,8 +12,11 @@ import com.google.devtools.ksp.symbol.KSType import com.google.devtools.ksp.symbol.KSTypeParameter import com.google.devtools.ksp.symbol.Modifier import io.javalin.introspection.Accessor +import io.javalin.introspection.AnnotationView import io.javalin.introspection.Annotations import io.javalin.introspection.ClassDefinition +import io.javalin.introspection.EnumConstantView +import io.javalin.introspection.InternalIntrospectionApi import io.javalin.introspection.PropertyView import io.javalin.introspection.StructureType import io.javalin.introspection.StructureType.ARRAY @@ -104,19 +107,20 @@ class KspTypeIntrospector(private val resolver: Resolver) : TypeIntrospector { private val declaration: KSClassDeclaration? get() = type.declaration as? KSClassDeclaration + @InternalIntrospectionApi override val source: Any get() = type override fun isEnum(): Boolean = declaration?.classKind == ClassKind.ENUM_CLASS - override fun getEnumConstants(): List? = + override fun getEnumConstants(): List? = declaration ?.takeIf { it.classKind == ClassKind.ENUM_CLASS } ?.declarations ?.filterIsInstance() ?.filter { it.classKind == ClassKind.ENUM_ENTRY } - ?.map { it.simpleName.asString() } + ?.map { EnumConstantView(it.simpleName.asString(), KspAnnotations(it)) } ?.toList() override fun getAnnotations(): Annotations = @@ -129,14 +133,11 @@ class KspTypeIntrospector(private val resolver: Resolver) : TypeIntrospector { PropertyView( name = property.simpleName.asString(), type = resolve(propertyType), - accessors = buildSet { - add(Accessor.GETTER) - add(Accessor.FIELD) - if (property.isMutable) add(Accessor.SETTER) - }, + accessor = Accessor.GETTER, nullable = propertyType.isMarkedNullable, visibility = visibilityOf(property.getVisibility()), transient = Modifier.JAVA_TRANSIENT in property.modifiers, + source = property, annotations = KspAnnotations(property), ) }.toList() @@ -148,24 +149,44 @@ class KspTypeIntrospector(private val resolver: Resolver) : TypeIntrospector { override fun hasNamed(simpleName: String): Boolean = element?.annotations?.any { it.shortName.asString() == simpleName } == true - override fun memberValues(annotationType: Class): Map? { - val annotation = annotationOf(annotationType.name) ?: return null - return annotation.arguments.associate { it.name!!.asString() to normalize(it.value) } - } + override fun memberValues(annotationType: Class): Map? = + element?.annotations + ?.firstOrNull { it.annotationType.resolve().declaration.qualifiedName?.asString() == annotationType.name } + ?.let { argumentValues(it) } + + override fun memberValuesList(annotationType: Class): List> = + element?.annotations + ?.filter { it.annotationType.resolve().declaration.qualifiedName?.asString() == annotationType.name } + ?.map { argumentValues(it) } + ?.toList() ?: emptyList() + + override fun all(): List = + element?.annotations?.map { KspAnnotationView(it) }?.toList() ?: emptyList() + } - private fun annotationOf(qualifiedName: String): KSAnnotation? = - element?.annotations?.firstOrNull { it.annotationType.resolve().declaration.qualifiedName?.asString() == qualifiedName } - - private fun normalize(value: Any?): Any? = - when (value) { - is KSType -> resolve(value) - is KSAnnotation -> value.arguments.associate { it.name!!.asString() to normalize(it.value) } - is List<*> -> value.map { normalize(it) } - is KSDeclaration -> value.simpleName.asString() - else -> value - } + private inner class KspAnnotationView(private val annotation: KSAnnotation) : AnnotationView { + override val qualifiedName: String + get() = annotation.annotationType.resolve().declaration.qualifiedName?.asString() ?: simpleName + override val simpleName: String + get() = annotation.shortName.asString() + override val meta: Annotations + get() = KspAnnotations(annotation.annotationType.resolve().declaration) + override fun values(): Map = + argumentValues(annotation) } + private fun argumentValues(annotation: KSAnnotation): Map = + annotation.arguments.associate { it.name!!.asString() to normalize(it.value) } + + private fun normalize(value: Any?): Any? = + when (value) { + is KSType -> resolve(value) + is KSAnnotation -> argumentValues(value) + is List<*> -> value.map { normalize(it) } + is KSDeclaration -> value.simpleName.asString() + else -> value + } + private fun visibilityOf(visibility: KspVisibility): Visibility = when (visibility) { KspVisibility.PUBLIC, KspVisibility.INTERNAL -> Visibility.PUBLIC diff --git a/introspection/introspection-ksp/src/test/kotlin/io/javalin/introspection/ksp/KspTypeIntrospectorTest.kt b/introspection/introspection-ksp/src/test/kotlin/io/javalin/introspection/ksp/KspTypeIntrospectorTest.kt index bc96760c..08242ee3 100644 --- a/introspection/introspection-ksp/src/test/kotlin/io/javalin/introspection/ksp/KspTypeIntrospectorTest.kt +++ b/introspection/introspection-ksp/src/test/kotlin/io/javalin/introspection/ksp/KspTypeIntrospectorTest.kt @@ -50,6 +50,7 @@ class KspTypeIntrospectorTest { val enumConstants: List?, val hasRef: Boolean, val refValue: String?, + val scannedLabel: Any?, ) val snapshot = withKsp { ksp -> @@ -59,9 +60,10 @@ class KspTypeIntrospectorTest { Snapshot( properties = account.getProperties().associate { it.name to "${it.type.fullName}:${it.type.structureType}" }, isEnum = color.isEnum(), - enumConstants = color.getEnumConstants(), + enumConstants = color.getEnumConstants()?.map { it.name }, hasRef = account.getAnnotations().hasNamed("Ref"), refValue = (account.getAnnotations().memberValues(Ref::class.java)?.getValue("value") as? ClassDefinition)?.fullName, + scannedLabel = account.getAnnotations().all().first { it.meta.hasNamed("MetaMarker") }.values()["label"], ) } @@ -84,6 +86,9 @@ class KspTypeIntrospectorTest { // Name-based annotation access (the only kind KSP can do): presence + class-valued member resolved to a ClassDefinition assertThat(snapshot.hasRef).isTrue() assertThat(snapshot.refValue).isEqualTo("$REF_PKG.Address") + + // Enumerate-all + meta-annotation scan (the custom-annotation path) works on KSP too + assertThat(snapshot.scannedLabel).isEqualTo("x") } private companion object { diff --git a/introspection/introspection-ksp/src/test/kotlin/io/javalin/introspection/ksp/fixtures/Fixtures.kt b/introspection/introspection-ksp/src/test/kotlin/io/javalin/introspection/ksp/fixtures/Fixtures.kt index 48579ba2..d0bfbe0b 100644 --- a/introspection/introspection-ksp/src/test/kotlin/io/javalin/introspection/ksp/fixtures/Fixtures.kt +++ b/introspection/introspection-ksp/src/test/kotlin/io/javalin/introspection/ksp/fixtures/Fixtures.kt @@ -8,7 +8,13 @@ annotation class Ref(val value: KClass<*>) class Address(val city: String, val zip: String) +annotation class MetaMarker + +@MetaMarker +annotation class Tagged(val label: String) + @Ref(Address::class) +@Tagged("x") class Account( val id: String, val age: Int, diff --git a/introspection/introspection-runtime/src/main/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospector.kt b/introspection/introspection-runtime/src/main/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospector.kt index 2c76b54c..e965786b 100644 --- a/introspection/introspection-runtime/src/main/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospector.kt +++ b/introspection/introspection-runtime/src/main/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospector.kt @@ -1,8 +1,11 @@ package io.javalin.introspection.runtime import io.javalin.introspection.Accessor +import io.javalin.introspection.AnnotationView import io.javalin.introspection.Annotations import io.javalin.introspection.ClassDefinition +import io.javalin.introspection.EnumConstantView +import io.javalin.introspection.InternalIntrospectionApi import io.javalin.introspection.PropertyView import io.javalin.introspection.StructureType import io.javalin.introspection.StructureType.ARRAY @@ -11,7 +14,6 @@ import io.javalin.introspection.StructureType.DICTIONARY import io.javalin.introspection.TypeIntrospector import io.javalin.introspection.Visibility import io.javalin.introspection.isGetterName -import io.javalin.introspection.isSetterName import io.javalin.introspection.propertyName import java.lang.reflect.AnnotatedElement import java.lang.reflect.Array as JavaArray @@ -89,98 +91,71 @@ private class ReflectionClassDefinition( private val erasure: Class<*>, ) : ClassDefinition(simpleName, fullName, generics, structureType) { + @InternalIntrospectionApi override val source: Any get() = erasure override fun isEnum(): Boolean = erasure.isEnum - override fun getEnumConstants(): List? = - erasure.takeIf { it.isEnum }?.enumConstants?.map { (it as Enum<*>).name } + override fun getEnumConstants(): List? = + erasure.takeIf { it.isEnum }?.enumConstants?.map { + val name = (it as Enum<*>).name + EnumConstantView(name, ReflectionAnnotations(listOf(erasure.getField(name)))) + } override fun getProperties(): List = - properties(erasure) + collectMembers(erasure).map { member -> + PropertyView( + name = if (member.accessor == Accessor.GETTER) propertyName(member.name) else member.name, + type = reflect(member.genericType), + accessor = member.accessor, + nullable = (member.genericType as? Class<*>)?.isPrimitive != true, + visibility = member.visibility, + transient = member.transient, + source = member.source, + annotations = ReflectionAnnotations(member.sources), + ) + } override fun getAnnotations(): Annotations = ReflectionAnnotations(listOf(erasure)) } -/** Group a class's getters, setters and fields into one logical [PropertyView] per name. */ -private fun properties(clazz: Class<*>): List { +private fun collectMembers(clazz: Class<*>): List { if (clazz.isRecord) { return clazz.recordComponents.map { component -> val backingField = runCatching { clazz.getDeclaredField(component.name) }.getOrNull() - PropertyView( - name = component.name, - type = reflect(component.genericType), - accessors = setOf(Accessor.GETTER, Accessor.FIELD), - nullable = (component.genericType as? Class<*>)?.isPrimitive != true, - visibility = Visibility.PUBLIC, - transient = false, - annotations = ReflectionAnnotations(listOfNotNull(component.accessor, backingField, component)), - ) + Member(component.name, component.genericType, Accessor.RECORD_COMPONENT, Visibility.PUBLIC, false, component.accessor, listOfNotNull(component.accessor, backingField, component)) } } - val builders = LinkedHashMap() + val members = mutableListOf() for (method in clazz.methods) { if (Modifier.isStatic(method.modifiers) || method.isBridge || method.isSynthetic) continue - if (method.declaringClass == Any::class.java) continue - when { - method.parameterCount == 0 && method.returnType != Void.TYPE && isGetterName(method.name) -> - builders.getOrPut(propertyName(method.name)) { PropertyBuilder() }.apply { - accessors += Accessor.GETTER - getterType = method.genericReturnType - getterVisibility = visibilityOf(method.modifiers) - sources += method - } - method.parameterCount == 1 && isSetterName(method.name) -> - builders.getOrPut(propertyName(method.name)) { PropertyBuilder() }.apply { - accessors += Accessor.SETTER - setterType = method.genericParameterTypes[0] - sources += method - } - } + if (method.parameterCount != 0 || method.declaringClass == Any::class.java) continue + if (method.returnType == Void.TYPE || !isGetterName(method.name)) continue + members += Member(method.name, method.genericReturnType, Accessor.GETTER, visibilityOf(method.modifiers), false, method, listOf(method)) } for (field in declaredFieldsHierarchy(clazz)) { if (Modifier.isStatic(field.modifiers)) continue - builders.getOrPut(field.name) { PropertyBuilder() }.apply { - accessors += Accessor.FIELD - fieldType = field.genericType - fieldVisibility = visibilityOf(field.modifiers) - transient = Modifier.isTransient(field.modifiers) - sources += field - } + members += Member(field.name, field.genericType, Accessor.FIELD, visibilityOf(field.modifiers), Modifier.isTransient(field.modifiers), field, listOf(field)) } - return builders.map { (name, builder) -> builder.build(name) } + return members } -private class PropertyBuilder { - val accessors = mutableSetOf() - val sources = mutableListOf() - var getterType: Type? = null - var fieldType: Type? = null - var setterType: Type? = null - var getterVisibility: Visibility? = null - var fieldVisibility: Visibility? = null - var transient = false - - fun build(name: String): PropertyView { - val type = getterType ?: fieldType ?: setterType!! - return PropertyView( - name = name, - type = reflect(type), - accessors = accessors, - nullable = (type as? Class<*>)?.isPrimitive != true, - visibility = fieldVisibility ?: getterVisibility ?: Visibility.PUBLIC, - transient = transient, - annotations = ReflectionAnnotations(sources), - ) - } -} +private class Member( + val name: String, + val genericType: Type, + val accessor: Accessor, + val visibility: Visibility, + val transient: Boolean, + val source: AnnotatedElement, + val sources: List, +) private fun visibilityOf(modifiers: Int): Visibility = when { @@ -218,17 +193,30 @@ private class ReflectionAnnotations(private val sources: List) override fun memberValues(annotationType: Class): Map? = sources.firstNotNullOfOrNull { it.getAnnotation(annotationType) }?.let { annotationToMap(it) } - private fun annotationToMap(annotation: Annotation): Map = - annotation.annotationClass.java.declaredMethods.associate { it.name to normalize(it.invoke(annotation)) } - - private fun normalize(value: Any?): Any? = - when { - value is Class<*> -> reflect(value) - value is Enum<*> -> value.name - value is Annotation -> annotationToMap(value) - value is Array<*> -> value.map { normalize(it) } - value != null && value::class.java.isArray -> - (0 until JavaArray.getLength(value)).map { normalize(JavaArray.get(value, it)) } - else -> value - } + override fun memberValuesList(annotationType: Class): List> = + sources.flatMap { it.getAnnotationsByType(annotationType).toList() }.map { annotationToMap(it) } + + override fun all(): List = + sources.flatMap { it.annotations.toList() }.distinctBy { it.annotationClass }.map { ReflectionAnnotationView(it) } +} + +private class ReflectionAnnotationView(private val annotation: Annotation) : AnnotationView { + override val qualifiedName: String get() = annotation.annotationClass.java.name + override val simpleName: String get() = annotation.annotationClass.java.simpleName + override val meta: Annotations get() = ReflectionAnnotations(listOf(annotation.annotationClass.java)) + override fun values(): Map = annotationToMap(annotation) } + +private fun annotationToMap(annotation: Annotation): Map = + annotation.annotationClass.java.declaredMethods.associate { it.name to normalize(it.invoke(annotation)) } + +private fun normalize(value: Any?): Any? = + when { + value is Class<*> -> reflect(value) + value is Enum<*> -> value.name + value is Annotation -> annotationToMap(value) + value is Array<*> -> value.map { normalize(it) } + value != null && value::class.java.isArray -> + (0 until JavaArray.getLength(value)).map { normalize(JavaArray.get(value, it)) } + else -> value + } diff --git a/introspection/introspection-runtime/src/test/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospectorTest.kt b/introspection/introspection-runtime/src/test/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospectorTest.kt index 330cc258..c177cf2f 100644 --- a/introspection/introspection-runtime/src/test/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospectorTest.kt +++ b/introspection/introspection-runtime/src/test/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospectorTest.kt @@ -27,7 +27,7 @@ class ReflectionTypeIntrospectorTest { val props = props(Account::class.java) assertThat(props.keys).contains("id", "age", "name", "color", "address", "tags", "meta") - assertThat(props.getValue("id").accessors).containsExactly(Accessor.GETTER) + assertThat(props.getValue("id").accessor).isEqualTo(Accessor.GETTER) assertThat(props.getValue("age").type.fullName).isEqualTo("java.lang.Integer") assertThat(props.getValue("age").nullable).isFalse() @@ -60,7 +60,7 @@ class ReflectionTypeIntrospectorTest { fun `reads enum constants raw`() { val color = introspector.introspect(Color::class.java) assertThat(color.isEnum()).isTrue() - assertThat(color.getEnumConstants()).containsExactly("RED", "GREEN") + assertThat(color.getEnumConstants()?.map { it.name }).containsExactly("RED", "GREEN") assertThat(introspector.introspect(Account::class.java).getEnumConstants()).isNull() } @@ -72,8 +72,8 @@ class ReflectionTypeIntrospectorTest { } val props = props(FieldBag::class.java) - assertThat(props.getValue("name").accessors).containsExactly(Accessor.GETTER) - assertThat(props.getValue("tag").accessors).containsExactly(Accessor.FIELD) + assertThat(props.getValue("name").accessor).isEqualTo(Accessor.GETTER) + assertThat(props.getValue("tag").accessor).isEqualTo(Accessor.FIELD) } @Test diff --git a/introspection/introspection-test/src/test/java/io/javalin/introspection/test/Note.java b/introspection/introspection-test/src/test/java/io/javalin/introspection/test/Note.java new file mode 100644 index 00000000..3a104df6 --- /dev/null +++ b/introspection/introspection-test/src/test/java/io/javalin/introspection/test/Note.java @@ -0,0 +1,20 @@ +package io.javalin.introspection.test; + +import java.lang.annotation.Repeatable; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +@Repeatable(Notes.class) +@Retention(RetentionPolicy.RUNTIME) +public @interface Note { + String value(); +} + +@Retention(RetentionPolicy.RUNTIME) +@interface Notes { + Note[] value(); +} + +@Note("a") +@Note("b") +class Noted {} diff --git a/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/IntrospectionParityTest.kt b/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/IntrospectionParityTest.kt index 579dfe98..002b0ab6 100644 --- a/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/IntrospectionParityTest.kt +++ b/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/IntrospectionParityTest.kt @@ -1,6 +1,7 @@ package io.javalin.introspection.test import io.javalin.introspection.Accessor +import io.javalin.introspection.Annotations import io.javalin.introspection.ClassDefinition import io.javalin.introspection.StructureType import io.javalin.introspection.Visibility @@ -18,6 +19,7 @@ import io.javalin.introspection.test.fixtures.Outer import io.javalin.introspection.test.fixtures.Point import io.javalin.introspection.test.fixtures.Ref import io.javalin.introspection.test.fixtures.Refs +import io.javalin.introspection.test.fixtures.Scanned import io.javalin.introspection.test.fixtures.Tricky import io.javalin.introspection.test.fixtures.WithTransient import io.javalin.introspection.test.fixtures.Wrapped @@ -50,7 +52,9 @@ class IntrospectionParityTest { @Test fun `transient fields are flagged on both backends`() { assertParity(WithTransient::class) - val transientByName = runtime.introspect(WithTransient::class.java).getProperties().associate { it.name to it.transient } + val transientByName = runtime.introspect(WithTransient::class.java).getProperties() + .filter { it.accessor == Accessor.FIELD } + .associate { it.name to it.transient } assertThat(transientByName.getValue("skipped")).isTrue() assertThat(transientByName.getValue("kept")).isFalse() } @@ -64,15 +68,38 @@ class IntrospectionParityTest { @Test fun `property-level annotations resolve identically across backends`() { - val runtimeMarked = runtime.introspect(Annotated::class.java).getProperties().associate { it.name to it.annotations.hasNamed("Marker") } + val runtimeMarked = runtime.introspect(Annotated::class.java).getProperties() + .filter { it.accessor == Accessor.GETTER } + .associate { it.name to it.annotations.hasNamed("Marker") } val processedMarked = AnnotationProcessing.introspect(Annotated::class) { - it.getProperties().associate { p -> p.name to p.annotations.hasNamed("Marker") } + it.getProperties().filter { p -> p.accessor == Accessor.GETTER } + .associate { p -> p.name to p.annotations.hasNamed("Marker") } } assertThat(processedMarked).isEqualTo(runtimeMarked) assertThat(runtimeMarked.getValue("tagged")).isTrue() assertThat(runtimeMarked.getValue("plain")).isFalse() } + @Test + fun `annotation enumeration with meta-annotations matches across backends`() { + fun scan(annotations: Annotations): Pair { + val tagged = annotations.all().first { it.meta.hasNamed("MetaMarker") } + return tagged.simpleName to tagged.values()["label"] + } + val runtimeScan = scan(runtime.introspect(Scanned::class.java).getAnnotations()) + val processedScan = AnnotationProcessing.introspect(Scanned::class) { scan(it.getAnnotations()) } + assertThat(processedScan).isEqualTo(runtimeScan).isEqualTo("Tagged" to "x") + } + + @Test + fun `repeatable annotations enumerate identically across backends`() { + fun notes(annotations: Annotations) = annotations.memberValuesList(Note::class.java).map { it["value"] } + val runtimeNotes = notes(runtime.introspect(Noted::class.java).getAnnotations()) + val processedNotes = AnnotationProcessing.introspect(Noted::class) { notes(it.getAnnotations()) } + assertThat(processedNotes).isEqualTo(runtimeNotes) + assertThat(runtimeNotes).containsExactlyInAnyOrder("a", "b") + } + @Test fun `nested annotation members normalize to maps identically across backends`() { val runtimeMeta = runtime.introspect(Wrapped::class.java).getAnnotations().memberValues(Outer::class.java)!!.getValue("meta") @@ -126,7 +153,7 @@ private data class PropertyShape( val typeFullName: String, val typeStructure: StructureType, val typeGenerics: List, - val accessors: Set, + val accessor: Accessor, val nullable: Boolean, val visibility: Visibility, val transient: Boolean, @@ -139,8 +166,8 @@ private fun ClassDefinition.toShape(): TypeShape = simpleName = simpleName, structure = structureType, isEnum = isEnum(), - enumConstants = getEnumConstants()?.sorted(), + enumConstants = getEnumConstants()?.map { it.name }?.sorted(), properties = if (isEnum()) emptyList() else getProperties() - .map { PropertyShape(it.name, it.type.fullName, it.type.structureType, it.type.generics.map { g -> g.fullName }, it.accessors, it.nullable, it.visibility, it.transient) } - .sortedBy { it.name }, + .map { PropertyShape(it.name, it.type.fullName, it.type.structureType, it.type.generics.map { g -> g.fullName }, it.accessor, it.nullable, it.visibility, it.transient) } + .sortedBy { "${it.accessor}:${it.name}" }, ) diff --git a/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/fixtures/Fixtures.kt b/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/fixtures/Fixtures.kt index d76a735e..378261ff 100644 --- a/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/fixtures/Fixtures.kt +++ b/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/fixtures/Fixtures.kt @@ -51,6 +51,14 @@ annotation class Outer(val meta: Meta) @Outer(Meta("x")) class Wrapped +annotation class MetaMarker + +@MetaMarker +annotation class Tagged(val label: String) + +@Tagged("x") +class Scanned + annotation class Flags(val ints: IntArray) @Flags(ints = [1, 2, 3]) diff --git a/javalin-plugins/javalin-openapi-dynamic-hook/src/main/kotlin/io/javalin/openapi/dynamic/hook/OpenApiMetadata.kt b/javalin-plugins/javalin-openapi-dynamic-hook/src/main/kotlin/io/javalin/openapi/dynamic/hook/OpenApiMetadata.kt index 1ad51efe..70639471 100644 --- a/javalin-plugins/javalin-openapi-dynamic-hook/src/main/kotlin/io/javalin/openapi/dynamic/hook/OpenApiMetadata.kt +++ b/javalin-plugins/javalin-openapi-dynamic-hook/src/main/kotlin/io/javalin/openapi/dynamic/hook/OpenApiMetadata.kt @@ -1,6 +1,5 @@ package io.javalin.openapi.dynamic.hook -import io.javalin.openapi.dynamic.IntrospectionSchemaGenerator import io.javalin.openapi.dynamic.ReflectiveTypeIntrospector import io.javalin.openapi.schema.MediaTypeBuilder import io.javalin.openapi.schema.OperationBuilder @@ -19,7 +18,7 @@ class OpenApiMetadata(val configure: OperationBuilder.() -> Unit) : EndpointMeta } internal val dynamicIntrospector = ReflectiveTypeIntrospector() -internal val dynamicSchemaGenerator = IntrospectionSchemaGenerator(dynamicIntrospector) +internal val dynamicSchemaGenerator = dynamicIntrospector.typeSchemaGenerator /** Resolve a DTO [type] to a schema `$ref` inside the content DSL, reusing the reflection engine. */ fun MediaTypeBuilder.schema(type: Class<*>): Unit = diff --git a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/AnnotationProcessorContext.kt b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/AnnotationProcessorContext.kt index 1376cb24..788b8ce7 100644 --- a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/AnnotationProcessorContext.kt +++ b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/AnnotationProcessorContext.kt @@ -1,6 +1,12 @@ package io.javalin.openapi.experimental import com.sun.source.util.Trees +import io.javalin.introspection.Annotations +import io.javalin.introspection.EnumConstantView +import io.javalin.introspection.InternalIntrospectionApi +import io.javalin.introspection.PropertyView +import io.javalin.introspection.jap.JapTypeIntrospector +import io.javalin.openapi.DiscriminatorMappingName import io.javalin.openapi.OpenApiName import io.javalin.openapi.experimental.processor.generators.TypeSchemaGenerator import io.javalin.openapi.experimental.processor.shared.getTypeMirror @@ -9,23 +15,66 @@ import javax.annotation.processing.Messager import javax.annotation.processing.ProcessingEnvironment import javax.annotation.processing.RoundEnvironment import javax.lang.model.element.Element +import javax.lang.model.element.ElementKind import javax.lang.model.element.ExecutableElement import javax.lang.model.element.TypeElement import javax.lang.model.type.TypeMirror import javax.lang.model.util.Types import kotlin.reflect.KClass +import io.javalin.introspection.ClassDefinition as RawType class AnnotationProcessorContext( val parameters: OpenApiAnnotationProcessorParameters, val configuration: OpenApiAnnotationProcessorConfiguration, val env: ProcessingEnvironment, val trees: Trees?, -) { +) : SchemaGenerationContext { val types: Types = env.typeUtils - val typeSchemaGenerator: TypeSchemaGenerator = TypeSchemaGenerator(this) + override val typeSchemaGenerator: TypeSchemaGenerator = TypeSchemaGenerator(this) var roundEnv: RoundEnvironment? = null + override val simpleTypeMappings: Map get() = configuration.simpleTypeMappings + override val embeddedTypeProcessors: List get() = configuration.embeddedTypeProcessors + + private val japIntrospector: JapTypeIntrospector by lazy { JapTypeIntrospector(types, env.elementUtils) } + + fun annotationsOf(element: Element): Annotations = + japIntrospector.annotationsOf(element) + + fun propertiesOf(mirror: TypeMirror): List = + japIntrospector.introspect(mirror).getProperties() + + fun enumConstantsOf(mirror: TypeMirror): List = + japIntrospector.introspect(mirror).getEnumConstants() ?: emptyList() + + override fun isEnum(type: ClassDefinition): Boolean = + type.source.kind == ElementKind.ENUM + + override fun annotationsOf(type: ClassDefinition): Annotations = + annotationsOf(type.source) + + override fun propertiesOf(type: ClassDefinition): List = + propertiesOf(type.mirror) + + override fun enumConstantsOf(type: ClassDefinition): List = + enumConstantsOf(type.mirror) + + override fun toClassDefinition(raw: RawType): ClassDefinition = + toExperimental(raw) + + @OptIn(InternalIntrospectionApi::class) + override fun acceptsProperty(type: ClassDefinition, property: PropertyView): Boolean = + configuration.propertyInSchemeFilter?.filter(this, type, property.source as Element) != false + + override fun discriminatorSubtypes(type: ClassDefinition): List> = + roundEnv!!.getElementsAnnotatedWith(DiscriminatorMappingName::class.java) + .asSequence() + .filterIsInstance() + .map { it.getAnnotation(DiscriminatorMappingName::class.java).value to getClassDefinition(it.asType()) } + .filter { (_, subtype) -> isAssignable(subtype.mirror, type.mirror) } + .toList() + fun inContext(body: AnnotationProcessorContext.() -> R): R = body() diff --git a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/ClassDefinitionFactory.kt b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/ClassDefinitionFactory.kt index a1ce0efb..64ab2f36 100644 --- a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/ClassDefinitionFactory.kt +++ b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/ClassDefinitionFactory.kt @@ -1,5 +1,6 @@ package io.javalin.openapi.experimental +import io.javalin.introspection.InternalIntrospectionApi import io.javalin.introspection.jap.JapTypeIntrospector import io.javalin.openapi.experimental.processor.shared.mapType import io.javalin.openapi.experimental.processor.shared.objectType @@ -14,7 +15,8 @@ import io.javalin.introspection.StructureType as RawStructureType fun classDefinitionFrom(context: AnnotationProcessorContext, mirror: TypeMirror): ClassDefinition = context.toExperimental(JapTypeIntrospector(context.types, context.env.elementUtils).introspect(mirror)) -private fun AnnotationProcessorContext.toExperimental(raw: RawType): ClassDefinition = +@OptIn(InternalIntrospectionApi::class) +internal fun AnnotationProcessorContext.toExperimental(raw: RawType): ClassDefinition = inContext { val mirror = raw.source as TypeMirror ClassDefinition( diff --git a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/OpenApiAnnotationProcessorConfiguration.kt b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/OpenApiAnnotationProcessorConfiguration.kt index 8c81b81a..c0e4e842 100644 --- a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/OpenApiAnnotationProcessorConfiguration.kt +++ b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/OpenApiAnnotationProcessorConfiguration.kt @@ -1,11 +1,9 @@ package io.javalin.openapi.experimental -import com.fasterxml.jackson.databind.node.ObjectNode import io.javalin.openapi.experimental.defaults.ArrayEmbeddedTypeProcessor import io.javalin.openapi.experimental.defaults.CompositionEmbeddedTypeProcessor import io.javalin.openapi.experimental.defaults.DictionaryEmbeddedTypeProcessor import io.javalin.openapi.experimental.defaults.createDefaultSimpleTypeMappings -import io.javalin.openapi.experimental.processor.generators.PropertyComposition import javax.lang.model.element.Element @ExperimentalCompileOpenApiConfiguration @@ -33,18 +31,3 @@ class OpenApiAnnotationProcessorConfiguration { fun interface PropertyInSchemeFilter { fun filter(context: AnnotationProcessorContext, type: ClassDefinition, property: Element): Boolean } - -data class EmbeddedTypeProcessorContext( - val parentContext: AnnotationProcessorContext, - val scheme: ObjectNode, - val references: MutableSet, - val type: ClassDefinition, - val inlineRefs: Boolean = false, - val requiresNonNulls: Boolean = true, - val composition: PropertyComposition? = null, - val extra: Map = emptyMap() -) - -fun interface EmbeddedTypeProcessor { - fun process(context: EmbeddedTypeProcessorContext): Boolean -} diff --git a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/CompositionGenerator.kt b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/CompositionGenerator.kt deleted file mode 100644 index 4760b597..00000000 --- a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/CompositionGenerator.kt +++ /dev/null @@ -1,99 +0,0 @@ -package io.javalin.openapi.experimental.processor.generators - -import com.fasterxml.jackson.databind.JsonNode -import com.fasterxml.jackson.databind.node.ObjectNode -import io.javalin.openapi.AllOf -import io.javalin.openapi.AnyOf -import io.javalin.openapi.Composition.ALL_OF -import io.javalin.openapi.Composition.ANY_OF -import io.javalin.openapi.Composition.ONE_OF -import io.javalin.openapi.DiscriminatorMappingName -import io.javalin.openapi.NULL_STRING -import io.javalin.openapi.OneOf -import io.javalin.openapi.experimental.AnnotationProcessorContext -import io.javalin.openapi.experimental.ClassDefinition -import io.javalin.openapi.experimental.CustomProperty -import io.javalin.openapi.experimental.mirror -import io.javalin.openapi.experimental.processor.shared.createJsonObjectOf -import io.javalin.openapi.experimental.processor.shared.createObjectNode -import io.javalin.openapi.experimental.processor.shared.toJsonArray -import io.javalin.openapi.experimental.processor.shared.toJsonObject -import javax.lang.model.element.Element -import javax.lang.model.element.TypeElement - -fun findCompositionInElement(context: AnnotationProcessorContext, element: Element): PropertyComposition? = - with (context) { - element.getAnnotation(OneOf::class.java)?.let { PropertyComposition(ONE_OF, it.getClassDefinitions { value }, it.discriminator) } - ?: element.getAnnotation(AnyOf::class.java)?.let { PropertyComposition(ANY_OF, it.getClassDefinitions { value }, it.discriminator) } - ?: element.getAnnotation(AllOf::class.java)?.let { PropertyComposition(ALL_OF, it.getClassDefinitions { value }, it.discriminator) } - } - -fun ObjectNode.createComposition( - context: AnnotationProcessorContext, - classDefinition: ClassDefinition, - propertyComposition: PropertyComposition, - references: MutableSet, - inlineRefs: Boolean = false, - requiresNonNulls: Boolean = true, -) { - with (context) { - val subtypes by lazy { - context.roundEnv!!.getElementsAnnotatedWith(DiscriminatorMappingName::class.java) - .asSequence() - .filterIsInstance() - .map { it.getAnnotation(DiscriminatorMappingName::class.java).value to context.getClassDefinition(it.asType()) } - .filter { (_, type) -> context.isAssignable(type.mirror, classDefinition.mirror) } - .toList() - } - - val refs = propertyComposition.references.ifEmpty { subtypes.map { it.second } } - - when (inlineRefs) { - true -> - refs - .map { context.typeSchemaGenerator.createTypeSchema(type = it, inlineRefs = true, requireNonNullsByDefault = requiresNonNulls) } - .onEach { (_, refs) -> references.addAll(refs) } - .map { (scheme, _) -> scheme } - .toJsonArray { add(it) } - .let { set(propertyComposition.type.propertyName, it) } - - false -> - refs - .onEach { references.add(it) } - .map { createJsonObjectOf($$"$ref", "#/components/schemas/${it.simpleName}") } - .toJsonArray { add(it) } - .let { set(propertyComposition.type.propertyName, it) } - } - - propertyComposition.discriminator - .takeIf { it.property.name != NULL_STRING } - ?.also { discriminator -> - val discriminatorObject = createObjectNode() - set("discriminator", discriminatorObject) - - val discriminatorProperty = discriminator.property - discriminatorObject.put("propertyName", discriminatorProperty.name) - - val mapping = discriminator.mapping - .map { it.name to it.getClassDefinition { value } } - .ifEmpty { subtypes } - - if (discriminatorProperty.injectInMappings) { - val customProperty = CustomProperty( - name = discriminatorProperty.name, - type = discriminatorProperty.getClassDefinition { type } - ) - - mapping.forEach { (_, mappedClass) -> - mappedClass.extra.add(customProperty) - } - } - - mapping - .onEach { (_, mappedClass) -> references.add(mappedClass) } - .associate { (name, mappedClass) -> name to "#/components/schemas/${mappedClass.simpleName}" } - .takeIf { it.isNotEmpty() } - ?.also { discriminatorObject.set("mapping", it.toJsonObject()) } - } - } -} diff --git a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/TypeSchemaGenerator.kt b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/TypeSchemaGenerator.kt deleted file mode 100644 index 4954bb0f..00000000 --- a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/TypeSchemaGenerator.kt +++ /dev/null @@ -1,468 +0,0 @@ -package io.javalin.openapi.experimental.processor.generators - -import com.fasterxml.jackson.databind.JsonNode -import com.fasterxml.jackson.databind.node.ArrayNode -import com.fasterxml.jackson.databind.node.ObjectNode -import io.javalin.openapi.* -import io.javalin.openapi.experimental.* -import io.javalin.openapi.experimental.processor.shared.* -import javax.lang.model.element.* -import javax.lang.model.element.ElementKind.* -import javax.lang.model.type.TypeMirror - -class TypeSchemaGenerator(val context: AnnotationProcessorContext) { - - // The cache helps to avoid processing the same property multiple times & prevent infinite recursion - // ~ https://github.com/javalin/javalin-openapi/issues/230 - private val processedProperties = mutableMapOf() - - fun createTypeSchema( - type: ClassDefinition, - inlineRefs: Boolean = false, - requireNonNullsByDefault: Boolean = true - ): ResultScheme = with (context) { - val source = type.source - val definedBy = source.getAnnotation(OpenApiPropertyType::class.java)?.getClassDefinition { definedBy } - - if (definedBy != null && source.kind != ENUM) { - return createTypeSchema(definedBy, inlineRefs, requireNonNullsByDefault) - } - - val schema = createObjectNode() - val references = mutableSetOf() - val composition = findCompositionInElement(context, source) - - when { - composition != null -> { - schema.createComposition(context, type, composition, references, inlineRefs, requireNonNullsByDefault) - } - source.kind == ENUM -> { - val enumType = definedBy - ?.let { context.configuration.simpleTypeMappings[it.fullName] } - - val namingStrategy = source.getAnnotation(OpenApiNaming::class.java)?.value - val values = createArrayNode() - - source.enclosedElements - .filterIsInstance() - .filter { it.modifiers.contains(Modifier.STATIC) } - .filter { context.isAssignable(it.asType(), type.mirror) } - .map { element -> - val customName = element.getAnnotation(OpenApiName::class.java) - when { - customName != null -> customName.value - namingStrategy != null -> translatePropertyName(namingStrategy, element.toSimpleName()) - else -> element.toSimpleName() - } - } - .forEach { name -> - if (enumType != null && enumType.type != "string") { - values.add(jsonMapper.readTree(name)) - } else { - values.add(name) - } - } - - schema.put("type", enumType?.type ?: "string") - enumType?.format?.also { schema.put("format", it) } - schema.set("enum", values) - - val extra = source.findExtra(context) - schema.addExtra(extra) - } - else -> { - schema.put("type", "object") - - val extra = source.findExtra(context) - schema.addExtra(extra) - - val propertiesObject = createObjectNode() - schema.set("properties", propertiesObject) - - val requireNonNulls = source.getAnnotation(JsonSchema::class.java) - ?.requireNonNulls - ?: requireNonNullsByDefault - - val properties = context.findAllProperties(type, requireNonNulls) - - properties.forEach { property -> - val result = - when { - processedProperties.contains(property) -> - processedProperties[property]!! - else -> - createEmbeddedTypeDescription( - type = property.type, - inlineRefs = inlineRefs, - requiresNonNulls = requireNonNulls, - composition = property.composition, - extra = property.extra, - nullable = property.nullable, - ).also { - processedProperties[property] = it - } - } - propertiesObject.set(property.name, result.json) - references.addAll(result.references) - } - - if (properties.any { it.required }) { - val required = createArrayNode() - properties.filter { it.required }.forEach { required.add(it.name) } - schema.set("required", required) - } - } - } - - return ResultScheme(schema, references) - } - - fun createEmbeddedTypeDescription( - type: ClassDefinition, - inlineRefs: Boolean = false, - requiresNonNulls: Boolean = true, - composition: PropertyComposition? = null, - extra: Map = emptyMap(), - nullable: Boolean = false, - ): ResultScheme = context.inContext { - val definedBy = type.source.getAnnotation(OpenApiPropertyType::class.java)?.getClassDefinition { definedBy } - - if (definedBy != null && type.source.kind != ENUM) { - return@inContext createEmbeddedTypeDescription(definedBy, inlineRefs, requiresNonNulls, composition, extra, nullable) - } - - val scheme = createObjectNode() - val references = mutableSetOf() - - val handledByCustomProcessor = - context.configuration.embeddedTypeProcessors.firstOrNull { - it.process( - EmbeddedTypeProcessorContext( - parentContext = context, - scheme = scheme, - references = references, - type = type, - inlineRefs = inlineRefs, - requiresNonNulls = requiresNonNulls, - composition = composition, - extra = extra - ) - ) - } - - if (handledByCustomProcessor == null) { - // Unwrap Optional as nullable T - if (type.fullName == "java.util.Optional" && type.generics.size == 1) { - return@inContext createEmbeddedTypeDescription(type.generics.first(), inlineRefs, requiresNonNulls, composition, extra, nullable = true) - } - - addType(scheme, type, inlineRefs, references, requiresNonNulls) - } - - scheme.addExtra(extra) - - if (nullable) { - val currentType = scheme.get("type")?.takeIf { it.isTextual }?.asText() - val currentRef = scheme.get($$"$ref")?.asText() - val compositionKey = listOf("oneOf", "anyOf", "allOf").firstOrNull { scheme.has(it) } - when { - currentType != null -> { - scheme.remove("type") - scheme.set("type", createArrayNode().add(currentType).add("null")) - } - currentRef != null -> { - scheme.remove($$"$ref") - val anyOf = createArrayNode() - anyOf.add(createObjectNode().put($$"$ref", currentRef)) - anyOf.add(createObjectNode().put("type", "null")) - scheme.set("anyOf", anyOf) - } - compositionKey == "allOf" -> { - val allOfArray = scheme.remove("allOf") - val discriminator = scheme.remove("discriminator") - val inner = createObjectNode() - inner.set("allOf", allOfArray) - if (discriminator != null) inner.set("discriminator", discriminator) - val anyOf = createArrayNode() - anyOf.add(inner) - anyOf.add(createObjectNode().put("type", "null")) - scheme.set("anyOf", anyOf) - } - compositionKey != null -> { - (scheme.get(compositionKey) as? ArrayNode)?.add(createObjectNode().put("type", "null")) - } - } - } - - ResultScheme(scheme, references) - } - - fun addType( - scheme: ObjectNode, - type: ClassDefinition, - inlineRefs: Boolean, - references: MutableSet, - requiresNonNulls: Boolean - ) { - when (val nonRefType = context.configuration.simpleTypeMappings[type.fullName]) { - null -> { - if (inlineRefs) { - val (subScheme, subReferences) = createTypeSchema(type, true, requiresNonNulls) - subScheme.properties().forEach { (key, value) -> scheme.set(key, value) } - references.addAll(subReferences) - } else { - references.add(type) - scheme.put($$"$ref", "#/components/schemas/${type.simpleName}") - } - } - else -> { - scheme.put("type", nonRefType.type) - nonRefType.format?.also { scheme.put("format", it) } - } - } - } - -} - -internal fun AnnotationProcessorContext.findAllProperties(type: ClassDefinition, requireNonNulls: Boolean): Collection = inContext { - val source = type.source - val openApiByFields: OpenApiByFields? = source.getAnnotation(OpenApiByFields::class.java) - - val isRecord = when (recordType()) { - null -> false - else -> isAssignable(type.mirror, recordType()!!.asType()) - } - - inDebug { it.info("TypeSchemaGenerator#findAllProperties | Enclosed elements of ${type.mirror}: ${source.enclosedElements}") } - val properties = mutableListOf() - - for (property in env.elementUtils.getAllMembers(forTypeElement(type.mirror))) { - if (property is Element) { - if (configuration.propertyInSchemeFilter?.filter(this@findAllProperties, type, property) == false) { - continue - } - - if (property.modifiers.contains(Modifier.STATIC)) { - continue - } - - when { - property.kind != METHOD && openApiByFields == null -> continue - property.kind == METHOD && openApiByFields?.only == true -> continue - } - - val acceptFields = openApiByFields?.value - - if (acceptFields != null) { - val modifiers = property.modifiers - - val fieldVisibility = when { - modifiers.contains(Modifier.PRIVATE) -> Visibility.PRIVATE - modifiers.contains(Modifier.PROTECTED) -> Visibility.PROTECTED - modifiers.contains(Modifier.DEFAULT) -> Visibility.DEFAULT - modifiers.contains(Modifier.PUBLIC) -> Visibility.PUBLIC - else -> Visibility.DEFAULT - } - - if (acceptFields.priority > fieldVisibility.priority) { - continue - } - } - - if (property.getAnnotation(OpenApiIgnore::class.java) != null || property.modifiers.contains(Modifier.TRANSIENT)) { - continue - } - - if (objectType().enclosedElements.any { it.toSimpleName() == property.toSimpleName() }) { - continue - } - - val simpleName = property.toSimpleName() - val customName = property.getAnnotation(OpenApiName::class.java) - val namingStrategy = source.getAnnotation(OpenApiNaming::class.java)?.value - - val name = when { - customName != null -> customName.value - isRecord || property.kind == FIELD -> simpleName - simpleName.startsWith("get") -> simpleName.replaceFirst("get", "").replaceFirstChar { it.lowercase() } - simpleName.startsWith("is") -> simpleName.replaceFirst("is", "").replaceFirstChar { it.lowercase() } - else -> continue - } - - val finalName = if (customName == null && namingStrategy != null) { - translatePropertyName(namingStrategy, name) - } else { - name - } - - val customType = property.getAnnotation(OpenApiPropertyType::class.java) - - val propertyType = customType?.getTypeMirror { definedBy } - ?: (property as? ExecutableElement)?.returnType - ?: (property as? VariableElement)?.asType() - ?: continue - - val isNotNull = when { - customType?.nullability == Nullability.NOT_NULL -> true - customType?.nullability == Nullability.NULLABLE -> false - property.hasAnnotation("NotNull") -> true - propertyType.isPrimitive() -> true - property.hasAnnotation("Nullable") -> false - else -> false - } - - val required = when { - property.getAnnotation(OpenApiRequired::class.java) != null -> true - else -> requireNonNulls && isNotNull - } - - val openApiNullable = property.getAnnotation(OpenApiNullable::class.java) - - val isExplicitlyNullable = when { - openApiNullable != null -> openApiNullable.nullable - customType?.nullability == Nullability.NULLABLE -> true - property.hasAnnotation("Nullable") -> true - else -> false - } - - properties.add( - Property( - name = finalName, - type = propertyType.toClassDefinition(), - composition = findCompositionInElement(this@findAllProperties, property), - required = required, - nullable = isExplicitlyNullable, - extra = property.findExtra(this@findAllProperties) - ) - ) - } - } - - type.extra - .filterIsInstance() - .forEach { extraProperty -> - properties.add( - Property( - name = extraProperty.name, - type = extraProperty.type, - required = requireNonNulls - ) - ) - } - - properties -} - -private fun Element.findExtra(context: AnnotationProcessorContext): Map = context.inContext { - val extra = mutableMapOf( - "description" to getAnnotation(OpenApiDescription::class.java)?.value - ) - - getAnnotationsByType(OpenApiExample::class.java).forEach { example -> - when { - example.value != NULL_STRING -> { - extra["example"] = example.value - } - example.raw != NULL_STRING -> { - extra["example"] = jsonMapper.readTree(example.raw) - } - example.objects.isNotEmpty() -> { - val result = ExampleGenerator.generateFromExamples(example.objects.map { it.toExampleProperty() }) - extra["example"] = result.jsonElement ?: result.simpleValue - } - } - } - - getAnnotationsByType(OpenApiNumberValidation::class.java).forEach { validation -> - extra["minimum"] = validation.minimum.takeIf { it != NULL_STRING }?.toBigDecimal() - extra["maximum"] = validation.maximum.takeIf { it != NULL_STRING }?.toBigDecimal() - extra["exclusiveMinimum"] = validation.exclusiveMinimum.takeIf { it != NULL_STRING }?.toBigDecimal() - extra["exclusiveMaximum"] = validation.exclusiveMaximum.takeIf { it != NULL_STRING }?.toBigDecimal() - extra["multipleOf"] = validation.multipleOf.takeIf { it != NULL_STRING }?.toBigDecimal() - } - - getAnnotationsByType(OpenApiStringValidation::class.java).forEach { validation -> - extra["minLength"] = validation.minLength.takeIf { it != NULL_STRING }?.toInt() - extra["maxLength"] = validation.maxLength.takeIf { it != NULL_STRING }?.toInt() - extra["format"] = validation.format.takeIf { it != NULL_STRING } - extra["pattern"] = validation.pattern.takeIf { it != NULL_STRING } - } - - getAnnotationsByType(OpenApiArrayValidation::class.java).forEach { validation -> - extra["minItems"] = validation.minItems.takeIf { it != NULL_STRING }?.toInt() - extra["maxItems"] = validation.maxItems.takeIf { it != NULL_STRING }?.toInt() - extra["uniqueItems"] = validation.uniqueItems.takeIf { it } - } - - getAnnotationsByType(OpenApiObjectValidation::class.java).forEach { validation -> - extra["minProperties"] = validation.minProperties.takeIf { it != NULL_STRING }?.toInt() - extra["maxProperties"] = validation.maxProperties.takeIf { it != NULL_STRING }?.toInt() - } - - getAnnotationsByType(Custom::class.java).forEach { custom -> - extra[custom.name] = custom.value - } - - context.env.elementUtils.getAllAnnotationMirrors(this@findExtra) - .filterNot { it.annotationType.getFullName() == Metadata::class.qualifiedName } - .onEach { annotation -> inDebug { it.info("TypeSchemaGenerator#findExtra | Annotation: ${annotation.annotationType}") } } - .filter { annotation -> - val isCustom = annotation.annotationType.asElement()?.getAnnotation(CustomAnnotation::class.java) != null - - if (!isCustom) { - inDebug { - it.info("TypeSchemaGenerator#findExtra | Usage: $annotation") - it.info("TypeSchemaGenerator#findExtra | Implementation:") - context.env.elementUtils.printElements( - MessagerWriter(context), - annotation.annotationType.asElement() - ) - } - } - - isCustom - } - .flatMap { customAnnotation -> - inDebug { it.info("TypeSchemaGenerator#findExtra | Custom annotation: $customAnnotation") } - val elements = context.env.elementUtils.getElementValuesWithDefaults(customAnnotation) - inDebug { it.info("TypeSchemaGenerator#findExtra | Element values with defaults: $elements") } - elements.asSequence() - } - .forEach { (element, value) -> - extra[element.toSimpleName()] = value.accept(object : AnnotationValueVisitor { - override fun visit(av: AnnotationValue, p: Nothing?) = av.value.toString() - override fun visitBoolean(boolean: Boolean, p: Nothing?) = boolean - override fun visitByte(byte: Byte, p: Nothing?) = byte - override fun visitChar(char: Char, p: Nothing?) = char - override fun visitDouble(double: Double, p: Nothing?) = double - override fun visitFloat(float: Float, p: Nothing?) = float - override fun visitInt(int: Int, p: Nothing?) = int - override fun visitLong(long: Long, p: Nothing?) = long - override fun visitShort(short: Short, p: Nothing?) = short - override fun visitString(string: String, p: Nothing?) = string.trimIndent() - override fun visitType(type: TypeMirror, p: Nothing?) = type.getFullName() - override fun visitEnumConstant(variable: VariableElement, p: Nothing?) = variable.toSimpleName() - override fun visitArray(values: MutableList, p: Nothing?): ArrayNode = createArrayNode().also { array -> - values.forEach { - when (val result = it.accept(this, null)) { - is Boolean -> array.add(result) - is Int -> array.add(result) - is Long -> array.add(result) - is Double -> array.add(result) - is Float -> array.add(result) - is Short -> array.add(result.toInt()) - is Byte -> array.add(result.toInt()) - is String -> array.add(result) - is JsonNode -> array.add(result) - else -> throw UnsupportedOperationException("[CustomAnnotation] Unsupported array value: $it") - } - } - } - override fun visitAnnotation(annotationMirror: AnnotationMirror?, p: Nothing?) = throw UnsupportedOperationException("[CustomAnnotation] Unsupported nested annotations") - override fun visitUnknown(av: AnnotationValue?, p: Nothing?) = throw UnsupportedOperationException("[CustomAnnotation] Unknown value $av") - }, null) - inDebug { it.info("TypeSchemaGenerator#findExtra | Visited entry ($element, $value) mapped to ${extra[element.toSimpleName()]}") } - } - - extra -} diff --git a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/processor/shared/ModelExtensions.kt b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/processor/shared/ModelExtensions.kt index 9a131c85..555ee7ef 100644 --- a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/processor/shared/ModelExtensions.kt +++ b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/processor/shared/ModelExtensions.kt @@ -3,7 +3,6 @@ package io.javalin.openapi.experimental.processor.shared import io.javalin.openapi.experimental.AnnotationProcessorContext import javax.lang.model.element.Element import javax.lang.model.element.TypeElement -import javax.lang.model.element.VariableElement import javax.lang.model.type.MirroredTypeException import javax.lang.model.type.MirroredTypesException import javax.lang.model.type.TypeMirror @@ -23,12 +22,6 @@ fun Element.hasAnnotation(simpleName: String): Boolean = fun Element.getFullName(): String = toString() -fun Element.toSimpleName(): String = - simpleName.toString() - -fun VariableElement.toSimpleName(): String = - simpleName.toString() - fun A.getTypeMirrors(supplier: A.() -> Array>): Set = try { throw Error(supplier().toString()) // always throws MirroredTypesException, because we cannot get Class instance from annotation at compile-time diff --git a/openapi-dynamic/src/main/kotlin/io/javalin/openapi/dynamic/IntrospectionSchemaGenerator.kt b/openapi-dynamic/src/main/kotlin/io/javalin/openapi/dynamic/IntrospectionSchemaGenerator.kt deleted file mode 100644 index 9747d5e2..00000000 --- a/openapi-dynamic/src/main/kotlin/io/javalin/openapi/dynamic/IntrospectionSchemaGenerator.kt +++ /dev/null @@ -1,98 +0,0 @@ -package io.javalin.openapi.dynamic - -import com.fasterxml.jackson.databind.JsonNode -import com.fasterxml.jackson.databind.node.ObjectNode -import io.javalin.openapi.experimental.ClassDefinition -import io.javalin.openapi.experimental.SimpleType -import io.javalin.openapi.experimental.StructureType.ARRAY -import io.javalin.openapi.experimental.StructureType.DICTIONARY -import io.javalin.openapi.experimental.TypeIntrospector -import io.javalin.openapi.experimental.defaults.createDefaultSimpleTypeMappings -import io.javalin.openapi.experimental.processor.generators.ResultScheme -import io.javalin.openapi.experimental.processor.generators.addExtra -import io.javalin.openapi.experimental.processor.shared.createArrayNode -import io.javalin.openapi.experimental.processor.shared.createObjectNode - -/** Backend-agnostic JSON Schema generator driven by [TypeIntrospector] (+ `simpleTypeMappings`). */ -class IntrospectionSchemaGenerator( - private val introspector: TypeIntrospector, - private val simpleTypeMappings: Map = createDefaultSimpleTypeMappings(), -) { - - /** Full component schema for [type] — the body stored under `components/schemas/{simpleName}`. */ - fun createTypeSchema(type: ClassDefinition): ResultScheme { - val schema = createObjectNode() - val references = mutableSetOf() - - if (introspector.isEnum(type)) { - schema.put("type", "string") - val values = createArrayNode() - introspector.enumConstants(type)!!.forEach { values.add(it) } - schema.set("enum", values) - return ResultScheme(schema, references) - } - - schema.put("type", "object") - val propertiesNode = createObjectNode() - schema.set("properties", propertiesNode) - - val properties = introspector.properties(type) - for (property in properties) { - val result = createEmbeddedTypeDescription(property.type, property.extra) - propertiesNode.set(property.name, result.json) - references.addAll(result.references) - } - - val required = properties.filter { it.required } - if (required.isNotEmpty()) { - val requiredNode = createArrayNode() - required.forEach { requiredNode.add(it.name) } - schema.set("required", requiredNode) - } - - return ResultScheme(schema, references) - } - - /** Inline, structure-aware description for a property/value: `$ref`, array, dictionary, or simple type. */ - fun createEmbeddedTypeDescription(type: ClassDefinition, extra: Map = emptyMap()): ResultScheme { - val scheme = createObjectNode() - val references = mutableSetOf() - - when (type.structureType) { - ARRAY -> - if (type.simpleName == "Byte") { - scheme.put("type", "string") - scheme.put("format", "binary") - } else { - scheme.put("type", "array") - val items = createObjectNode() - addType(items, type, references) - scheme.set("items", items) - } - DICTIONARY -> { - scheme.put("type", "object") - val additionalProperties = createEmbeddedTypeDescription(type.generics[1]) - references.addAll(additionalProperties.references) - scheme.set("additionalProperties", additionalProperties.json) - } - else -> - addType(scheme, type, references) - } - - scheme.addExtra(extra) - return ResultScheme(scheme, references) - } - - private fun addType(scheme: ObjectNode, type: ClassDefinition, references: MutableSet) { - when (val mapped = simpleTypeMappings[type.fullName]) { - null -> { - references.add(type) - scheme.put($$"$ref", "#/components/schemas/${type.simpleName}") - } - else -> { - scheme.put("type", mapped.type) - mapped.format?.also { scheme.put("format", it) } - } - } - } -} diff --git a/openapi-dynamic/src/main/kotlin/io/javalin/openapi/dynamic/ReflectiveTypeIntrospector.kt b/openapi-dynamic/src/main/kotlin/io/javalin/openapi/dynamic/ReflectiveTypeIntrospector.kt index b6054c22..59fdb32f 100644 --- a/openapi-dynamic/src/main/kotlin/io/javalin/openapi/dynamic/ReflectiveTypeIntrospector.kt +++ b/openapi-dynamic/src/main/kotlin/io/javalin/openapi/dynamic/ReflectiveTypeIntrospector.kt @@ -1,44 +1,44 @@ package io.javalin.openapi.dynamic -import io.javalin.introspection.Accessor +import io.javalin.introspection.Annotations +import io.javalin.introspection.EnumConstantView +import io.javalin.introspection.InternalIntrospectionApi +import io.javalin.introspection.PropertyView import io.javalin.introspection.runtime.ReflectionTypeIntrospector -import io.javalin.openapi.OpenApiByFields -import io.javalin.openapi.OpenApiDescription -import io.javalin.openapi.OpenApiIgnore import io.javalin.openapi.OpenApiName -import io.javalin.openapi.OpenApiNaming -import io.javalin.openapi.OpenApiNullable -import io.javalin.openapi.OpenApiRequired -import io.javalin.openapi.Visibility import io.javalin.openapi.experimental.ClassDefinition +import io.javalin.openapi.experimental.EmbeddedTypeProcessor +import io.javalin.openapi.experimental.SchemaGenerationContext +import io.javalin.openapi.experimental.SimpleType import io.javalin.openapi.experimental.StructureType -import io.javalin.openapi.experimental.TypeIntrospector -import io.javalin.openapi.experimental.processor.generators.Property -import io.javalin.openapi.experimental.processor.generators.translatePropertyName +import io.javalin.openapi.experimental.defaults.ArrayEmbeddedTypeProcessor +import io.javalin.openapi.experimental.defaults.CompositionEmbeddedTypeProcessor +import io.javalin.openapi.experimental.defaults.DictionaryEmbeddedTypeProcessor +import io.javalin.openapi.experimental.defaults.createDefaultSimpleTypeMappings +import io.javalin.openapi.experimental.processor.generators.TypeSchemaGenerator import java.lang.reflect.Type import io.javalin.introspection.ClassDefinition as RawType -import io.javalin.introspection.PropertyView as RawProperty -import io.javalin.introspection.Visibility as RawVisibility -/** Runtime-reflection [TypeIntrospector]: layers OpenAPI policy over the shared [ReflectionTypeIntrospector]. */ +/** Runtime-reflection [SchemaGenerationContext]: drives the shared [TypeSchemaGenerator] over [ReflectionTypeIntrospector]. */ class ReflectiveTypeIntrospector( - private val requireNonNullsByDefault: Boolean = true, -) : TypeIntrospector { + override val simpleTypeMappings: Map = createDefaultSimpleTypeMappings(), +) : SchemaGenerationContext { private val runtime = ReflectionTypeIntrospector() - override fun introspect(nativeType: Any): ClassDefinition { - require(nativeType is Type) { - "ReflectiveTypeIntrospector expects a java.lang.reflect.Type, got ${nativeType::class.java.name}" - } - return introspect(nativeType) - } + override val typeSchemaGenerator: TypeSchemaGenerator = TypeSchemaGenerator(this) + override val embeddedTypeProcessors: List = listOf( + CompositionEmbeddedTypeProcessor(), + ArrayEmbeddedTypeProcessor(), + DictionaryEmbeddedTypeProcessor() + ) fun introspect(type: Type): ClassDefinition = - toExperimental(runtime.introspect(type)) + toClassDefinition(runtime.introspect(type)) /** Map the policy-free [RawType] tree into the OpenAPI [ClassDefinition] model, applying `@OpenApiName`. */ - private fun toExperimental(raw: RawType): ClassDefinition { + @OptIn(InternalIntrospectionApi::class) + override fun toClassDefinition(raw: RawType): ClassDefinition { val erasure = raw.source as Class<*> val customName = erasure.getAnnotation(OpenApiName::class.java)?.value val packageName = erasure.`package`?.name?.takeIf { it.isNotEmpty() } @@ -48,88 +48,25 @@ class ReflectiveTypeIntrospector( customName != null -> if (packageName == null) customName else "$packageName.$customName" else -> raw.fullName }, - generics = raw.generics.map { toExperimental(it) }, + generics = raw.generics.map { toClassDefinition(it) }, structureType = StructureType.valueOf(raw.structureType.name), handle = raw, ) } - override fun properties(type: ClassDefinition): List { - val raw = type.raw - val erasure = raw.source as Class<*> - val byFields = erasure.getAnnotation(OpenApiByFields::class.java) - val namingStrategy = erasure.getAnnotation(OpenApiNaming::class.java)?.value - - return raw.getProperties().mapNotNull { property -> - if (!property.includedBy(byFields) || property.transient) return@mapNotNull null - val annotations = property.annotations - if (annotations.has(OpenApiIgnore::class.java)) return@mapNotNull null - - val customName = annotations.memberValues(OpenApiName::class.java)?.get("value") as? String - val resolvedName = customName ?: property.name - val finalName = if (customName == null && namingStrategy != null) translatePropertyName(namingStrategy, resolvedName) else resolvedName - - val isPrimitive = !property.nullable - val required = annotations.has(OpenApiRequired::class.java) || - (requireNonNullsByDefault && (annotations.hasNamed("NotNull") || isPrimitive)) - - val explicitNullable = annotations.memberValues(OpenApiNullable::class.java)?.get("nullable") as? Boolean - val nullable = when { - explicitNullable != null -> explicitNullable - annotations.hasNamed("Nullable") -> true - else -> false - } - - val extra = buildMap { - (annotations.memberValues(OpenApiDescription::class.java)?.get("value") as? String)?.let { put("description", it) } - } - - Property( - name = finalName, - type = toExperimental(property.type), - required = required, - nullable = nullable, - extra = extra, - ) - } - } - override fun isEnum(type: ClassDefinition): Boolean = type.raw.isEnum() - override fun enumConstants(type: ClassDefinition): List? { - val raw = type.raw - if (!raw.isEnum()) return null - val erasure = raw.source as Class<*> - val namingStrategy = erasure.getAnnotation(OpenApiNaming::class.java)?.value - return raw.getEnumConstants()!!.map { name -> - val customName = erasure.getField(name).getAnnotation(OpenApiName::class.java)?.value - when { - customName != null -> customName - namingStrategy != null -> translatePropertyName(namingStrategy, name) - else -> name - } - } - } + override fun annotationsOf(type: ClassDefinition): Annotations = + type.raw.getAnnotations() - override fun annotation(type: ClassDefinition, annotationType: Class): A? = - (type.raw.source as Class<*>).getAnnotation(annotationType) -} + override fun propertiesOf(type: ClassDefinition): List = + type.raw.getProperties() -/** `@OpenApiByFields` selection: getter-backed properties by default; with the annotation, fields at/above the configured visibility (and getters too unless `only`). */ -private fun RawProperty.includedBy(byFields: OpenApiByFields?): Boolean { - if (byFields == null) return Accessor.GETTER in accessors - val fieldVisible = Accessor.FIELD in accessors && byFields.value.priority <= visibility.toOpenApi().priority - return if (byFields.only) fieldVisible else Accessor.GETTER in accessors || fieldVisible -} + override fun enumConstantsOf(type: ClassDefinition): List = + type.raw.getEnumConstants() ?: emptyList() -private fun RawVisibility.toOpenApi(): Visibility = - when (this) { - RawVisibility.PUBLIC -> Visibility.PUBLIC - RawVisibility.PROTECTED -> Visibility.PROTECTED - RawVisibility.PRIVATE -> Visibility.PRIVATE - RawVisibility.PACKAGE_PRIVATE -> Visibility.DEFAULT - } +} private val ClassDefinition.raw: RawType get() = handle as RawType diff --git a/openapi-dynamic/src/test/kotlin/io/javalin/openapi/dynamic/IntrospectionSchemaGeneratorTest.kt b/openapi-dynamic/src/test/kotlin/io/javalin/openapi/dynamic/DynamicSchemaGeneratorTest.kt similarity index 97% rename from openapi-dynamic/src/test/kotlin/io/javalin/openapi/dynamic/IntrospectionSchemaGeneratorTest.kt rename to openapi-dynamic/src/test/kotlin/io/javalin/openapi/dynamic/DynamicSchemaGeneratorTest.kt index 60997cfe..cc0cfbe9 100644 --- a/openapi-dynamic/src/test/kotlin/io/javalin/openapi/dynamic/IntrospectionSchemaGeneratorTest.kt +++ b/openapi-dynamic/src/test/kotlin/io/javalin/openapi/dynamic/DynamicSchemaGeneratorTest.kt @@ -7,10 +7,10 @@ import io.javalin.openapi.schema.OpenApiSchemaBuilder import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test -class IntrospectionSchemaGeneratorTest { +class DynamicSchemaGeneratorTest { private val introspector = ReflectiveTypeIntrospector() - private val generator = IntrospectionSchemaGenerator(introspector) + private val generator = introspector.typeSchemaGenerator private fun JsonNode.ref(): String = path($$"$ref").asText() private fun JsonNode.stringArray(): List = map { it.asText() } diff --git a/openapi-dynamic/src/test/kotlin/io/javalin/openapi/dynamic/ReflectiveTypeIntrospectorTest.kt b/openapi-dynamic/src/test/kotlin/io/javalin/openapi/dynamic/ReflectiveTypeIntrospectorTest.kt index 4f4c69b5..cc0bb3dc 100644 --- a/openapi-dynamic/src/test/kotlin/io/javalin/openapi/dynamic/ReflectiveTypeIntrospectorTest.kt +++ b/openapi-dynamic/src/test/kotlin/io/javalin/openapi/dynamic/ReflectiveTypeIntrospectorTest.kt @@ -1,5 +1,6 @@ package io.javalin.openapi.dynamic +import com.fasterxml.jackson.databind.JsonNode import io.javalin.openapi.dynamic.fixtures.Account import io.javalin.openapi.dynamic.fixtures.Address import io.javalin.openapi.dynamic.fixtures.FieldsDto @@ -7,17 +8,18 @@ import io.javalin.openapi.dynamic.fixtures.Role import io.javalin.openapi.dynamic.fixtures.SnakeCaseDto import io.javalin.openapi.dynamic.fixtures.TransientDto import io.javalin.openapi.experimental.StructureType -import io.javalin.openapi.experimental.processor.generators.Property import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test -import org.junit.jupiter.api.assertThrows class ReflectiveTypeIntrospectorTest { private val introspector = ReflectiveTypeIntrospector() - private fun accountProperties(): Map = - introspector.properties(introspector.introspect(Account::class.java)).associateBy { it.name } + private fun schemaOf(type: Class<*>): JsonNode = + introspector.typeSchemaGenerator.createTypeSchema(introspector.introspect(type)).json + + private fun propertyNames(type: Class<*>): List = + schemaOf(type).path("properties").fieldNames().asSequence().toList() @Test fun `resolves a class into the shared ClassDefinition model`() { @@ -29,81 +31,53 @@ class ReflectiveTypeIntrospectorTest { } @Test - fun `lists scalar properties with required flags, renames, and ignores`() { - val props = accountProperties() - - assertThat(props.keys).contains("id", "age", "name", "e_mail") - assertThat(props.keys).doesNotContain("email", "secret", "class") + fun `applies renames, ignores, required flags and descriptions through the shared generator`() { + val account = schemaOf(Account::class.java) + val properties = account.path("properties") - assertThat(props.getValue("id").type.fullName).isEqualTo("java.lang.String") - assertThat(props.getValue("id").required).isTrue() // @NotNull + assertThat(properties.fieldNames().asSequence().toList()).contains("id", "age", "name", "e_mail") + assertThat(properties.has("email")).isFalse() + assertThat(properties.has("secret")).isFalse() + assertThat(properties.has("class")).isFalse() - assertThat(props.getValue("age").type.fullName).isEqualTo("java.lang.Integer") // boxed primitive - assertThat(props.getValue("age").required).isTrue() // primitive ⇒ non-null - - assertThat(props.getValue("name").required).isFalse() + assertThat(account.path("required").map { it.asText() }).containsExactlyInAnyOrder("id", "age") + assertThat(properties.path("label").path("description").asText()).isEqualTo("Human readable label") } @Test - fun `resolves collections and maps into structure types`() { - val props = accountProperties() - - val tags = props.getValue("tags").type - assertThat(tags.structureType).isEqualTo(StructureType.ARRAY) - assertThat(tags.fullName).isEqualTo("java.lang.String") + fun `resolves collections, maps and nested objects`() { + val properties = schemaOf(Account::class.java).path("properties") - val meta = props.getValue("meta").type - assertThat(meta.structureType).isEqualTo(StructureType.DICTIONARY) - assertThat(meta.generics.map { it.fullName }).containsExactly("java.lang.String", "java.lang.Integer") - } - - @Test - fun `descends into nested object types`() { - val address = accountProperties().getValue("address").type - assertThat(address.fullName).isEqualTo(Address::class.java.name) + assertThat(properties.path("tags").path("type").asText()).isEqualTo("array") + assertThat(properties.path("tags").path("items").path("type").asText()).isEqualTo("string") + assertThat(properties.path("meta").path("type").asText()).isEqualTo("object") + assertThat(properties.path("meta").path("additionalProperties").path("type").asText()).isEqualTo("integer") + assertThat(properties.path("address").path($$"$ref").asText()).isEqualTo("#/components/schemas/Address") - val addressProperties = introspector.properties(address).associateBy { it.name } - assertThat(addressProperties.keys).containsExactlyInAnyOrder("city", "zip") + assertThat(propertyNames(Address::class.java)).containsExactlyInAnyOrder("city", "zip") } @Test fun `reads enum constants with renames`() { - val role = accountProperties().getValue("role").type + assertThat(introspector.isEnum(introspector.introspect(Role::class.java))).isTrue() - assertThat(introspector.isEnum(role)).isTrue() - assertThat(introspector.enumConstants(role)).containsExactly("ADMIN", "regular_user") - assertThat(introspector.enumConstants(introspector.introspect(Role::class.java))) - .containsExactly("ADMIN", "regular_user") - } - - @Test - fun `captures OpenApiDescription into property extra`() { - assertThat(accountProperties().getValue("label").extra["description"]) - .isEqualTo("Human readable label") + val role = schemaOf(Role::class.java) + assertThat(role.path("type").asText()).isEqualTo("string") + assertThat(role.path("enum").map { it.asText() }).containsExactly("ADMIN", "regular_user") } @Test fun `applies the configured naming strategy`() { - val props = introspector.properties(introspector.introspect(SnakeCaseDto::class.java)).associateBy { it.name } - assertThat(props.keys).contains("first_name") + assertThat(propertyNames(SnakeCaseDto::class.java)).contains("first_name") } @Test fun `reads fields and honors visibility when OpenApiByFields is present`() { - val props = introspector.properties(introspector.introspect(FieldsDto::class.java)).associateBy { it.name } - assertThat(props.keys).containsExactly("publicField") + assertThat(propertyNames(FieldsDto::class.java)).containsExactly("publicField") } @Test fun `skips transient fields under OpenApiByFields`() { - val props = introspector.properties(introspector.introspect(TransientDto::class.java)).associateBy { it.name } - assertThat(props.keys).containsExactly("kept") - } - - @Test - fun `rejects non-reflection native tokens`() { - assertThrows { - introspector.introspect("not a type" as Any) - } + assertThat(propertyNames(TransientDto::class.java)).containsExactly("kept") } } diff --git a/openapi-generator/build.gradle.kts b/openapi-generator/build.gradle.kts index 167ea7c3..74062633 100644 --- a/openapi-generator/build.gradle.kts +++ b/openapi-generator/build.gradle.kts @@ -2,6 +2,7 @@ description = "Javalin OpenAPI Generator | JSON schema generation for OpenAPI do dependencies { api(project(":openapi-specification")) + api(project(":introspection:introspection-api")) api(libs.jackson.databind) api(libs.jackson.module.kotlin) diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/EmbeddedTypeProcessor.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/EmbeddedTypeProcessor.kt new file mode 100644 index 00000000..efe8c53a --- /dev/null +++ b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/EmbeddedTypeProcessor.kt @@ -0,0 +1,19 @@ +package io.javalin.openapi.experimental + +import com.fasterxml.jackson.databind.node.ObjectNode +import io.javalin.openapi.experimental.processor.generators.PropertyComposition + +data class EmbeddedTypeProcessorContext( + val parentContext: SchemaGenerationContext, + val scheme: ObjectNode, + val references: MutableSet, + val type: ClassDefinition, + val inlineRefs: Boolean = false, + val requiresNonNulls: Boolean = true, + val composition: PropertyComposition? = null, + val extra: Map = emptyMap() +) + +fun interface EmbeddedTypeProcessor { + fun process(context: EmbeddedTypeProcessorContext): Boolean +} diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/SchemaGenerationContext.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/SchemaGenerationContext.kt new file mode 100644 index 00000000..810e085f --- /dev/null +++ b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/SchemaGenerationContext.kt @@ -0,0 +1,36 @@ +package io.javalin.openapi.experimental + +import io.javalin.introspection.Annotations +import io.javalin.introspection.EnumConstantView +import io.javalin.introspection.PropertyView +import io.javalin.openapi.experimental.processor.generators.TypeSchemaGenerator +import io.javalin.introspection.ClassDefinition as RawType + +/** + * Neutral platform seam the shared schema generator runs against. Each backend (annotation processing, runtime + * reflection, ...) supplies introspection, class conversion, property filtering, and discriminator subtypes; the + * generator itself stays free of any single type system. + */ +interface SchemaGenerationContext { + + val typeSchemaGenerator: TypeSchemaGenerator + val simpleTypeMappings: Map + val embeddedTypeProcessors: List + + fun isEnum(type: ClassDefinition): Boolean + + fun annotationsOf(type: ClassDefinition): Annotations + + fun propertiesOf(type: ClassDefinition): List + + fun enumConstantsOf(type: ClassDefinition): List + + /** Lift a backend-neutral [RawType] into the OpenAPI [ClassDefinition] model (names + handle stay backend-specific). */ + fun toClassDefinition(raw: RawType): ClassDefinition + + /** Whether [property] of [type] should be emitted (platform-specific filtering, e.g. the AP `propertyInSchemeFilter`). */ + fun acceptsProperty(type: ClassDefinition, property: PropertyView): Boolean = true + + /** Discriminator subtypes discovered by the platform (AP round scan); empty when the backend cannot scan. */ + fun discriminatorSubtypes(type: ClassDefinition): List> = emptyList() +} diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/TypeIntrospector.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/TypeIntrospector.kt deleted file mode 100644 index 074ce742..00000000 --- a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/TypeIntrospector.kt +++ /dev/null @@ -1,22 +0,0 @@ -package io.javalin.openapi.experimental - -import io.javalin.openapi.experimental.processor.generators.Property - -/** - * Backend-agnostic contract for turning a native type token (`TypeMirror`, `KSType`, [java.lang.reflect.Type], ...) into the shared [ClassDefinition] / [Property] model, - * so schema generation stays decoupled from any single type system (kapt, KSP, runtime reflection). - */ -interface TypeIntrospector { - - /** Resolve a backend-native type token into a [ClassDefinition] (array/map/generic resolution included). */ - fun introspect(nativeType: Any): ClassDefinition - - fun isEnum(type: ClassDefinition): Boolean - - /** Enum constant names, or `null` if [type] is not an enum. */ - fun enumConstants(type: ClassDefinition): List? - - fun properties(type: ClassDefinition): List - - fun annotation(type: ClassDefinition, annotationType: Class): A? -} \ No newline at end of file diff --git a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/defaults/ArrayEmbeddedTypeProcessor.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/defaults/ArrayEmbeddedTypeProcessor.kt similarity index 95% rename from openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/defaults/ArrayEmbeddedTypeProcessor.kt rename to openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/defaults/ArrayEmbeddedTypeProcessor.kt index b3b018e2..89dbd582 100644 --- a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/defaults/ArrayEmbeddedTypeProcessor.kt +++ b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/defaults/ArrayEmbeddedTypeProcessor.kt @@ -1,6 +1,5 @@ package io.javalin.openapi.experimental.defaults -import com.fasterxml.jackson.databind.JsonNode import io.javalin.openapi.experimental.EmbeddedTypeProcessor import io.javalin.openapi.experimental.EmbeddedTypeProcessorContext import io.javalin.openapi.experimental.StructureType.ARRAY diff --git a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/defaults/CompositionEmbeddedTypeProcessor.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/defaults/CompositionEmbeddedTypeProcessor.kt similarity index 100% rename from openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/defaults/CompositionEmbeddedTypeProcessor.kt rename to openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/defaults/CompositionEmbeddedTypeProcessor.kt diff --git a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/defaults/DictionaryEmbeddedTypeProcessor.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/defaults/DictionaryEmbeddedTypeProcessor.kt similarity index 95% rename from openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/defaults/DictionaryEmbeddedTypeProcessor.kt rename to openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/defaults/DictionaryEmbeddedTypeProcessor.kt index b627e4de..20be083a 100644 --- a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/defaults/DictionaryEmbeddedTypeProcessor.kt +++ b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/defaults/DictionaryEmbeddedTypeProcessor.kt @@ -14,7 +14,7 @@ class DictionaryEmbeddedTypeProcessor : EmbeddedTypeProcessor { val additionalProperties = createObjectNode() val additionalType = context.type.generics[1] - context.parentContext.configuration.embeddedTypeProcessors + context.parentContext.embeddedTypeProcessors .firstOrNull { it.process( context.copy( diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/CompositionGenerator.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/CompositionGenerator.kt new file mode 100644 index 00000000..88b29388 --- /dev/null +++ b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/CompositionGenerator.kt @@ -0,0 +1,103 @@ +package io.javalin.openapi.experimental.processor.generators + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.node.ObjectNode +import io.javalin.introspection.Annotations +import io.javalin.openapi.AllOf +import io.javalin.openapi.AnyOf +import io.javalin.openapi.Composition +import io.javalin.openapi.Composition.ALL_OF +import io.javalin.openapi.Composition.ANY_OF +import io.javalin.openapi.Composition.ONE_OF +import io.javalin.openapi.NULL_STRING +import io.javalin.openapi.OneOf +import io.javalin.openapi.experimental.ClassDefinition +import io.javalin.openapi.experimental.CustomProperty +import io.javalin.openapi.experimental.SchemaGenerationContext +import io.javalin.openapi.experimental.processor.shared.createJsonObjectOf +import io.javalin.openapi.experimental.processor.shared.createObjectNode +import io.javalin.openapi.experimental.processor.shared.toJsonArray +import io.javalin.openapi.experimental.processor.shared.toJsonObject +import io.javalin.introspection.ClassDefinition as RawType + +fun findCompositionInElement(context: SchemaGenerationContext, annotations: Annotations): PropertyComposition? = + compositionOf(context, annotations, OneOf::class.java, ONE_OF) + ?: compositionOf(context, annotations, AnyOf::class.java, ANY_OF) + ?: compositionOf(context, annotations, AllOf::class.java, ALL_OF) + +private fun compositionOf(context: SchemaGenerationContext, annotations: Annotations, annotationType: Class, composition: Composition): PropertyComposition? { + val values = annotations.memberValues(annotationType) ?: return null + val references = annotations.resolveClasses(annotationType, "value").map { context.toClassDefinition(it) }.toSet() + val discriminator = (values["discriminator"] as? Map<*, *>)?.let { discriminatorInfo(context, it) } + return PropertyComposition(composition, references, discriminator) +} + +private fun discriminatorInfo(context: SchemaGenerationContext, discriminator: Map<*, *>): DiscriminatorInfo { + val property = discriminator["property"] as Map<*, *> + val mapping = (discriminator["mapping"] as? List<*>).orEmpty() + .filterIsInstance>() + .map { (it["name"] as String) to context.toClassDefinition(it["value"] as RawType) } + return DiscriminatorInfo( + propertyName = property["name"] as String, + propertyType = context.toClassDefinition(property["type"] as RawType), + injectInMappings = property["injectInMappings"] as Boolean, + mapping = mapping, + ) +} + +fun ObjectNode.createComposition( + context: SchemaGenerationContext, + classDefinition: ClassDefinition, + propertyComposition: PropertyComposition, + references: MutableSet, + inlineRefs: Boolean = false, + requiresNonNulls: Boolean = true, +) { + val subtypes by lazy { context.discriminatorSubtypes(classDefinition) } + + val refs = propertyComposition.references.ifEmpty { subtypes.map { it.second } } + + when (inlineRefs) { + true -> + refs + .map { context.typeSchemaGenerator.createTypeSchema(type = it, inlineRefs = true, requireNonNullsByDefault = requiresNonNulls) } + .onEach { (_, refs) -> references.addAll(refs) } + .map { (scheme, _) -> scheme } + .toJsonArray { add(it) } + .let { set(propertyComposition.type.propertyName, it) } + + false -> + refs + .onEach { references.add(it) } + .map { createJsonObjectOf($$"$ref", "#/components/schemas/${it.simpleName}") } + .toJsonArray { add(it) } + .let { set(propertyComposition.type.propertyName, it) } + } + + propertyComposition.discriminator + ?.takeIf { it.propertyName != NULL_STRING } + ?.also { discriminator -> + val discriminatorObject = createObjectNode() + set("discriminator", discriminatorObject) + discriminatorObject.put("propertyName", discriminator.propertyName) + + val mapping = discriminator.mapping.ifEmpty { subtypes } + + if (discriminator.injectInMappings) { + val customProperty = CustomProperty( + name = discriminator.propertyName, + type = discriminator.propertyType + ) + + mapping.forEach { (_, mappedClass) -> + mappedClass.extra.add(customProperty) + } + } + + mapping + .onEach { (_, mappedClass) -> references.add(mappedClass) } + .associate { (name, mappedClass) -> name to "#/components/schemas/${mappedClass.simpleName}" } + .takeIf { it.isNotEmpty() } + ?.also { discriminatorObject.set("mapping", it.toJsonObject()) } + } +} diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/ExampleGenerator.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/ExampleGenerator.kt index 7b6b97a8..3525c868 100644 --- a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/ExampleGenerator.kt +++ b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/ExampleGenerator.kt @@ -23,6 +23,14 @@ fun OpenApiExampleProperty.toExampleProperty(): ExampleProperty = objects = this.objects.map { it.toExampleProperty() }.takeIf { it.isNotEmpty() }, ) +fun Map.toExampleProperty(): ExampleProperty = + ExampleProperty( + name = (this["name"] as? String)?.takeIf { it != NULL_STRING }, + value = (this["value"] as? String)?.takeIf { it != NULL_STRING }, + raw = (this["raw"] as? String)?.takeIf { it != NULL_STRING }, + objects = (this["objects"] as? List<*>)?.filterIsInstance>()?.map { it.toExampleProperty() }?.takeIf { it.isNotEmpty() }, + ) + object ExampleGenerator { data class GeneratorResult( diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/PropertyComposition.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/PropertyComposition.kt index d82c9514..526350a5 100644 --- a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/PropertyComposition.kt +++ b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/PropertyComposition.kt @@ -1,11 +1,17 @@ package io.javalin.openapi.experimental.processor.generators import io.javalin.openapi.Composition -import io.javalin.openapi.Discriminator import io.javalin.openapi.experimental.ClassDefinition data class PropertyComposition( val type: Composition, val references: Set, - val discriminator: Discriminator + val discriminator: DiscriminatorInfo?, +) + +data class DiscriminatorInfo( + val propertyName: String, + val propertyType: ClassDefinition, + val injectInMappings: Boolean, + val mapping: List>, ) diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/TypeSchemaGenerator.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/TypeSchemaGenerator.kt new file mode 100644 index 00000000..5758363f --- /dev/null +++ b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/TypeSchemaGenerator.kt @@ -0,0 +1,387 @@ +package io.javalin.openapi.experimental.processor.generators + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.node.ArrayNode +import com.fasterxml.jackson.databind.node.ObjectNode +import io.javalin.introspection.Accessor +import io.javalin.introspection.Annotations +import io.javalin.introspection.Visibility as RawVisibility +import io.javalin.openapi.* +import io.javalin.openapi.experimental.* +import io.javalin.openapi.experimental.processor.shared.* + +class TypeSchemaGenerator(val context: SchemaGenerationContext) { + + // The cache helps to avoid processing the same property multiple times & prevent infinite recursion + // ~ https://github.com/javalin/javalin-openapi/issues/230 + private val processedProperties = mutableMapOf() + + fun createTypeSchema( + type: ClassDefinition, + inlineRefs: Boolean = false, + requireNonNullsByDefault: Boolean = true + ): ResultScheme { + val annotations = context.annotationsOf(type) + val isEnum = context.isEnum(type) + val definedBy = annotations.resolveClass(OpenApiPropertyType::class.java, "definedBy")?.let { context.toClassDefinition(it) } + + if (definedBy != null && !isEnum) { + return createTypeSchema(definedBy, inlineRefs, requireNonNullsByDefault) + } + + val schema = createObjectNode() + val references = mutableSetOf() + val composition = findCompositionInElement(context, annotations) + + when { + composition != null -> { + schema.createComposition(context, type, composition, references, inlineRefs, requireNonNullsByDefault) + } + isEnum -> { + val enumType = definedBy + ?.let { context.simpleTypeMappings[it.fullName] } + + val namingStrategy = annotations.namingStrategy() + val values = createArrayNode() + + context.enumConstantsOf(type) + .map { constant -> + val customName = constant.annotations.memberValues(OpenApiName::class.java)?.get("value") as? String + when { + customName != null -> customName + namingStrategy != null -> translatePropertyName(namingStrategy, constant.name) + else -> constant.name + } + } + .forEach { name -> + if (enumType != null && enumType.type != "string") { + values.add(jsonMapper.readTree(name)) + } else { + values.add(name) + } + } + + schema.put("type", enumType?.type ?: "string") + enumType?.format?.also { schema.put("format", it) } + schema.set("enum", values) + + schema.addExtra(annotations.findExtra()) + } + else -> { + schema.put("type", "object") + + schema.addExtra(annotations.findExtra()) + + val propertiesObject = createObjectNode() + schema.set("properties", propertiesObject) + + val requireNonNulls = (annotations.memberValues(JsonSchema::class.java)?.get("requireNonNulls") as? Boolean) + ?: requireNonNullsByDefault + + val properties = context.findAllProperties(type, requireNonNulls) + + properties.forEach { property -> + val result = + when { + processedProperties.contains(property) -> + processedProperties[property]!! + else -> + createEmbeddedTypeDescription( + type = property.type, + inlineRefs = inlineRefs, + requiresNonNulls = requireNonNulls, + composition = property.composition, + extra = property.extra, + nullable = property.nullable, + ).also { + processedProperties[property] = it + } + } + propertiesObject.set(property.name, result.json) + references.addAll(result.references) + } + + if (properties.any { it.required }) { + val required = createArrayNode() + properties.filter { it.required }.forEach { required.add(it.name) } + schema.set("required", required) + } + } + } + + return ResultScheme(schema, references) + } + + fun createEmbeddedTypeDescription( + type: ClassDefinition, + inlineRefs: Boolean = false, + requiresNonNulls: Boolean = true, + composition: PropertyComposition? = null, + extra: Map = emptyMap(), + nullable: Boolean = false, + ): ResultScheme { + val definedBy = context.annotationsOf(type).resolveClass(OpenApiPropertyType::class.java, "definedBy")?.let { context.toClassDefinition(it) } + + if (definedBy != null && !context.isEnum(type)) { + return createEmbeddedTypeDescription(definedBy, inlineRefs, requiresNonNulls, composition, extra, nullable) + } + + val scheme = createObjectNode() + val references = mutableSetOf() + + val handledByCustomProcessor = + context.embeddedTypeProcessors.firstOrNull { + it.process( + EmbeddedTypeProcessorContext( + parentContext = context, + scheme = scheme, + references = references, + type = type, + inlineRefs = inlineRefs, + requiresNonNulls = requiresNonNulls, + composition = composition, + extra = extra + ) + ) + } + + if (handledByCustomProcessor == null) { + // Unwrap Optional as nullable T + if (type.fullName == "java.util.Optional" && type.generics.size == 1) { + return createEmbeddedTypeDescription(type.generics.first(), inlineRefs, requiresNonNulls, composition, extra, nullable = true) + } + + addType(scheme, type, inlineRefs, references, requiresNonNulls) + } + + scheme.addExtra(extra) + + if (nullable) { + val currentType = scheme.get("type")?.takeIf { it.isTextual }?.asText() + val currentRef = scheme.get($$"$ref")?.asText() + val compositionKey = listOf("oneOf", "anyOf", "allOf").firstOrNull { scheme.has(it) } + when { + currentType != null -> { + scheme.remove("type") + scheme.set("type", createArrayNode().add(currentType).add("null")) + } + currentRef != null -> { + scheme.remove($$"$ref") + val anyOf = createArrayNode() + anyOf.add(createObjectNode().put($$"$ref", currentRef)) + anyOf.add(createObjectNode().put("type", "null")) + scheme.set("anyOf", anyOf) + } + compositionKey == "allOf" -> { + val allOfArray = scheme.remove("allOf") + val discriminator = scheme.remove("discriminator") + val inner = createObjectNode() + inner.set("allOf", allOfArray) + if (discriminator != null) inner.set("discriminator", discriminator) + val anyOf = createArrayNode() + anyOf.add(inner) + anyOf.add(createObjectNode().put("type", "null")) + scheme.set("anyOf", anyOf) + } + compositionKey != null -> { + (scheme.get(compositionKey) as? ArrayNode)?.add(createObjectNode().put("type", "null")) + } + } + } + + return ResultScheme(scheme, references) + } + + fun addType( + scheme: ObjectNode, + type: ClassDefinition, + inlineRefs: Boolean, + references: MutableSet, + requiresNonNulls: Boolean + ) { + when (val nonRefType = context.simpleTypeMappings[type.fullName]) { + null -> { + if (inlineRefs) { + val (subScheme, subReferences) = createTypeSchema(type, true, requiresNonNulls) + subScheme.properties().forEach { (key, value) -> scheme.set(key, value) } + references.addAll(subReferences) + } else { + references.add(type) + scheme.put($$"$ref", "#/components/schemas/${type.simpleName}") + } + } + else -> { + scheme.put("type", nonRefType.type) + nonRefType.format?.also { scheme.put("format", it) } + } + } + } + +} + +internal fun SchemaGenerationContext.findAllProperties(type: ClassDefinition, requireNonNulls: Boolean): Collection { + val annotations = annotationsOf(type) + val byFields = annotations.memberValues(OpenApiByFields::class.java) + val byFieldsOnly = byFields?.get("only") == true + val byFieldsVisibility = (byFields?.get("value") as? String)?.let { Visibility.valueOf(it) } + val namingStrategy = annotations.namingStrategy() + + val properties = mutableListOf() + + for (property in propertiesOf(type)) { + if (!acceptsProperty(type, property)) continue + + when (property.accessor) { + Accessor.FIELD -> if (byFields == null) continue + Accessor.GETTER -> if (byFieldsOnly) continue + Accessor.RECORD_COMPONENT -> {} + } + if (byFieldsVisibility != null && byFieldsVisibility.priority > property.visibility.toOpenApi().priority) continue + if (property.annotations.has(OpenApiIgnore::class.java) || property.transient) continue + + val customName = property.annotations.memberValues(OpenApiName::class.java)?.get("value") as? String + val name = customName ?: property.name + val finalName = if (customName == null && namingStrategy != null) translatePropertyName(namingStrategy, name) else name + + val nullability = property.annotations.memberValues(OpenApiPropertyType::class.java)?.get("nullability") as? String + val redirect = property.annotations.resolveClass(OpenApiPropertyType::class.java, "definedBy") + val isPrimitive = redirect == null && !property.nullable + + val isNotNull = when { + nullability == Nullability.NOT_NULL.name -> true + nullability == Nullability.NULLABLE.name -> false + property.annotations.hasNamed("NotNull") -> true + isPrimitive -> true + property.annotations.hasNamed("Nullable") -> false + else -> false + } + val required = property.annotations.has(OpenApiRequired::class.java) || (requireNonNulls && isNotNull) + + val explicitNullable = property.annotations.memberValues(OpenApiNullable::class.java)?.get("nullable") as? Boolean + val isExplicitlyNullable = when { + explicitNullable != null -> explicitNullable + nullability == Nullability.NULLABLE.name -> true + property.annotations.hasNamed("Nullable") -> true + else -> false + } + + properties.add( + Property( + name = finalName, + type = toClassDefinition(redirect ?: property.type), + composition = findCompositionInElement(this, property.annotations), + required = required, + nullable = isExplicitlyNullable, + extra = property.annotations.findExtra(), + ) + ) + } + + type.extra + .filterIsInstance() + .forEach { extraProperty -> + properties.add( + Property( + name = extraProperty.name, + type = extraProperty.type, + required = requireNonNulls + ) + ) + } + + return properties +} + +private fun Annotations.namingStrategy(): OpenApiNamingStrategy? = + (memberValues(OpenApiNaming::class.java)?.get("value") as? String)?.let { OpenApiNamingStrategy.valueOf(it) } + +private fun Annotations.findExtra(): Map { + val extra = mutableMapOf( + "description" to memberValues(OpenApiDescription::class.java)?.get("value") + ) + + memberValues(OpenApiExample::class.java)?.also { example -> + val value = example.notNull("value") + val raw = example.notNull("raw") + val objects = (example["objects"] as? List<*>)?.filterIsInstance>().orEmpty() + when { + value != null -> extra["example"] = value + raw != null -> extra["example"] = jsonMapper.readTree(raw) + objects.isNotEmpty() -> { + val result = ExampleGenerator.generateFromExamples(objects.map { it.toExampleProperty() }) + extra["example"] = result.jsonElement ?: result.simpleValue + } + } + } + + memberValues(OpenApiNumberValidation::class.java)?.also { + extra["minimum"] = it.notNull("minimum")?.toBigDecimal() + extra["maximum"] = it.notNull("maximum")?.toBigDecimal() + extra["exclusiveMinimum"] = it.notNull("exclusiveMinimum")?.toBigDecimal() + extra["exclusiveMaximum"] = it.notNull("exclusiveMaximum")?.toBigDecimal() + extra["multipleOf"] = it.notNull("multipleOf")?.toBigDecimal() + } + + memberValues(OpenApiStringValidation::class.java)?.also { + extra["minLength"] = it.notNull("minLength")?.toInt() + extra["maxLength"] = it.notNull("maxLength")?.toInt() + extra["format"] = it.notNull("format") + extra["pattern"] = it.notNull("pattern") + } + + memberValues(OpenApiArrayValidation::class.java)?.also { + extra["minItems"] = it.notNull("minItems")?.toInt() + extra["maxItems"] = it.notNull("maxItems")?.toInt() + extra["uniqueItems"] = (it["uniqueItems"] as? Boolean)?.takeIf { unique -> unique } + } + + memberValues(OpenApiObjectValidation::class.java)?.also { + extra["minProperties"] = it.notNull("minProperties")?.toInt() + extra["maxProperties"] = it.notNull("maxProperties")?.toInt() + } + + memberValuesList(Custom::class.java).forEach { custom -> + extra[custom["name"] as String] = custom["value"] + } + + all() + .filter { it.meta.has(CustomAnnotation::class.java) } + .flatMap { it.values().entries } + .forEach { (name, value) -> extra[name] = customAnnotationValue(value) } + + return extra +} + +private fun RawVisibility.toOpenApi(): Visibility = + when (this) { + RawVisibility.PUBLIC -> Visibility.PUBLIC + RawVisibility.PROTECTED -> Visibility.PROTECTED + RawVisibility.PRIVATE -> Visibility.PRIVATE + RawVisibility.PACKAGE_PRIVATE -> Visibility.DEFAULT + } + +private fun Map.notNull(key: String): String? = + (this[key] as? String)?.takeIf { it != NULL_STRING } + +private fun customAnnotationValue(value: Any?): Any? = + when (value) { + is String -> value.trimIndent() + is io.javalin.introspection.ClassDefinition -> value.fullName + is List<*> -> createArrayNode().also { array -> + value.forEach { + when (val element = customAnnotationValue(it)) { + is Boolean -> array.add(element) + is Int -> array.add(element) + is Long -> array.add(element) + is Double -> array.add(element) + is Float -> array.add(element) + is Short -> array.add(element.toInt()) + is Byte -> array.add(element.toInt()) + is String -> array.add(element) + is JsonNode -> array.add(element) + else -> throw UnsupportedOperationException("[CustomAnnotation] Unsupported array value: $it") + } + } + } + else -> value + } From be96af3a5f28dc91f80ae947b8a980103d3f198f Mon Sep 17 00:00:00 2001 From: dzikoysk Date: Sat, 20 Jun 2026 21:25:30 +0200 Subject: [PATCH 12/43] chore: cleanup --- .../introspection/jap/JapTypeIntrospector.kt | 2 +- .../runtime/ReflectionTypeIntrospector.kt | 37 ++++++- .../openapi/dynamic/hook/OpenApiMetadata.kt | 9 +- .../dynamic/hook/RegisteredRoutesHook.kt | 4 +- .../AnnotationProcessorContext.kt | 73 ++++++-------- .../experimental/ClassDefinitionFactory.kt | 32 ------ .../experimental/ClassDefinitionHandle.kt | 15 --- ...OpenApiAnnotationProcessorConfiguration.kt | 12 +-- .../openapi/experimental/OpenApiTypeHandle.kt | 18 ++++ .../shared/AnnotationProcessorExtensions.kt | 20 ---- .../processor/shared/ModelExtensions.kt | 12 --- .../generators/JsonSchemaGenerator.kt | 2 +- .../openapi/schema/OpenApiSchemaGenerator.kt | 4 +- .../src/test/compile/openapi.groovy | 10 +- .../processor/OperationAnnotationsTest.kt | 98 +++++++++++++++++++ .../processor/PropertySelectionTest.kt | 56 +++++++++++ .../processor/ValidationAnnotationsTest.kt | 56 +++++++++++ ...ospector.kt => ReflectionSchemaContext.kt} | 53 +++++----- .../javalin/openapi/dynamic/fixtures/Cat.java | 7 ++ .../javalin/openapi/dynamic/fixtures/Dog.java | 7 ++ .../openapi/dynamic/fixtures/Shape.java | 17 ++++ .../openapi/dynamic/fixtures/Validated.java | 17 ++++ .../openapi/dynamic/DynamicCompositionTest.kt | 42 ++++++++ .../dynamic/DynamicSchemaGeneratorTest.kt | 9 +- .../experimental/EmbeddedTypeProcessor.kt | 4 +- .../experimental/SchemaGenerationContext.kt | 16 +-- .../defaults/DefaultEmbeddedTypeProcessors.kt | 10 ++ .../generators/CompositionGenerator.kt | 16 +-- .../generators/PropertyComposition.kt | 8 +- .../processor/generators/ResultScheme.kt | 6 +- .../generators/TypeSchemaGenerator.kt | 20 ++-- .../openapi/schema/OpenApiSchemaBuilder.kt | 34 +++---- .../schema/OpenApiSchemaBuilderTest.kt | 22 ++--- 33 files changed, 513 insertions(+), 235 deletions(-) delete mode 100644 openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/ClassDefinitionFactory.kt delete mode 100644 openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/ClassDefinitionHandle.kt create mode 100644 openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/OpenApiTypeHandle.kt create mode 100644 openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/OperationAnnotationsTest.kt create mode 100644 openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/PropertySelectionTest.kt create mode 100644 openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/ValidationAnnotationsTest.kt rename openapi-dynamic/src/main/kotlin/io/javalin/openapi/dynamic/{ReflectiveTypeIntrospector.kt => ReflectionSchemaContext.kt} (51%) create mode 100644 openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/fixtures/Cat.java create mode 100644 openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/fixtures/Dog.java create mode 100644 openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/fixtures/Shape.java create mode 100644 openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/fixtures/Validated.java create mode 100644 openapi-dynamic/src/test/kotlin/io/javalin/openapi/dynamic/DynamicCompositionTest.kt create mode 100644 openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/defaults/DefaultEmbeddedTypeProcessors.kt diff --git a/introspection/introspection-jap/src/main/kotlin/io/javalin/introspection/jap/JapTypeIntrospector.kt b/introspection/introspection-jap/src/main/kotlin/io/javalin/introspection/jap/JapTypeIntrospector.kt index db6e05d0..6b9c91af 100644 --- a/introspection/introspection-jap/src/main/kotlin/io/javalin/introspection/jap/JapTypeIntrospector.kt +++ b/introspection/introspection-jap/src/main/kotlin/io/javalin/introspection/jap/JapTypeIntrospector.kt @@ -189,7 +189,7 @@ class JapTypeIntrospector(private val types: Types, private val elements: Elemen private fun Element.isGetter(): Boolean { if (kind != ElementKind.METHOD || this !is ExecutableElement) return false - if (Modifier.PUBLIC !in modifiers || Modifier.STATIC in modifiers) return false + if (Modifier.STATIC in modifiers) return false if (parameters.isNotEmpty() || enclosingElement?.toString() == "java.lang.Object") return false if (returnType.kind == TypeKind.VOID) return false return isGetterName(simpleName.toString()) diff --git a/introspection/introspection-runtime/src/main/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospector.kt b/introspection/introspection-runtime/src/main/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospector.kt index e965786b..947aa3b2 100644 --- a/introspection/introspection-runtime/src/main/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospector.kt +++ b/introspection/introspection-runtime/src/main/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospector.kt @@ -19,6 +19,7 @@ import java.lang.reflect.AnnotatedElement import java.lang.reflect.Array as JavaArray import java.lang.reflect.Field import java.lang.reflect.GenericArrayType +import java.lang.reflect.Method import java.lang.reflect.Modifier import java.lang.reflect.ParameterizedType import java.lang.reflect.Type @@ -131,12 +132,24 @@ private fun collectMembers(clazz: Class<*>): List { } val members = mutableListOf() + val getterNames = mutableSetOf() + // public getters (incl. inherited + interface defaults) for (method in clazz.methods) { if (Modifier.isStatic(method.modifiers) || method.isBridge || method.isSynthetic) continue if (method.parameterCount != 0 || method.declaringClass == Any::class.java) continue if (method.returnType == Void.TYPE || !isGetterName(method.name)) continue - members += Member(method.name, method.genericReturnType, Accessor.GETTER, visibilityOf(method.modifiers), false, method, listOf(method)) + if (getterNames.add(method.name)) { + members += Member(method.name, method.genericReturnType, Accessor.GETTER, visibilityOf(method.modifiers), false, method, listOf(method)) + } + } + + // non-public getters declared up the class hierarchy (clazz.methods only returns public ones), + // matching JAP's getAllMembers which also surfaces protected/package-private getters + for (method in nonPublicGettersHierarchy(clazz)) { + if (getterNames.add(method.name)) { + members += Member(method.name, method.genericReturnType, Accessor.GETTER, visibilityOf(method.modifiers), false, method, listOf(method)) + } } for (field in declaredFieldsHierarchy(clazz)) { @@ -185,6 +198,28 @@ private fun declaredFieldsHierarchy(clazz: Class<*>): List { return fields } +/** Non-public getters declared up the class hierarchy (leaf-first), with the same inheritance rules as fields. */ +private fun nonPublicGettersHierarchy(clazz: Class<*>): List { + val getters = mutableListOf() + var current: Class<*>? = clazz + while (current != null && current != Any::class.java) { + for (method in current.declaredMethods) { + val modifiers = method.modifiers + if (Modifier.isStatic(modifiers) || Modifier.isPublic(modifiers) || method.isBridge || method.isSynthetic) continue + if (method.parameterCount != 0 || method.returnType == Void.TYPE || !isGetterName(method.name)) continue + val inherited = when { + current == clazz -> true + Modifier.isPrivate(modifiers) -> false + Modifier.isProtected(modifiers) -> true + else -> method.declaringClass.packageName == clazz.packageName + } + if (inherited) getters += method + } + current = current.superclass + } + return getters +} + private class ReflectionAnnotations(private val sources: List) : Annotations { override fun hasNamed(simpleName: String): Boolean = diff --git a/javalin-plugins/javalin-openapi-dynamic-hook/src/main/kotlin/io/javalin/openapi/dynamic/hook/OpenApiMetadata.kt b/javalin-plugins/javalin-openapi-dynamic-hook/src/main/kotlin/io/javalin/openapi/dynamic/hook/OpenApiMetadata.kt index 70639471..b43d75c0 100644 --- a/javalin-plugins/javalin-openapi-dynamic-hook/src/main/kotlin/io/javalin/openapi/dynamic/hook/OpenApiMetadata.kt +++ b/javalin-plugins/javalin-openapi-dynamic-hook/src/main/kotlin/io/javalin/openapi/dynamic/hook/OpenApiMetadata.kt @@ -1,6 +1,6 @@ package io.javalin.openapi.dynamic.hook -import io.javalin.openapi.dynamic.ReflectiveTypeIntrospector +import io.javalin.openapi.dynamic.ReflectionSchemaContext import io.javalin.openapi.schema.MediaTypeBuilder import io.javalin.openapi.schema.OperationBuilder import io.javalin.router.EndpointMetadata @@ -17,9 +17,6 @@ class OpenApiMetadata(val configure: OperationBuilder.() -> Unit) : EndpointMeta } } -internal val dynamicIntrospector = ReflectiveTypeIntrospector() -internal val dynamicSchemaGenerator = dynamicIntrospector.typeSchemaGenerator - -/** Resolve a DTO [type] to a schema `$ref` inside the content DSL, reusing the reflection engine. */ +/** Resolve a DTO [type] to a schema inside the content DSL via the reflection engine. */ fun MediaTypeBuilder.schema(type: Class<*>): Unit = - schema(dynamicSchemaGenerator.createEmbeddedTypeDescription(dynamicIntrospector.introspect(type))) + schema(ReflectionSchemaContext().inlineSchema(type)) diff --git a/javalin-plugins/javalin-openapi-dynamic-hook/src/main/kotlin/io/javalin/openapi/dynamic/hook/RegisteredRoutesHook.kt b/javalin-plugins/javalin-openapi-dynamic-hook/src/main/kotlin/io/javalin/openapi/dynamic/hook/RegisteredRoutesHook.kt index 58460bba..13e45272 100644 --- a/javalin-plugins/javalin-openapi-dynamic-hook/src/main/kotlin/io/javalin/openapi/dynamic/hook/RegisteredRoutesHook.kt +++ b/javalin-plugins/javalin-openapi-dynamic-hook/src/main/kotlin/io/javalin/openapi/dynamic/hook/RegisteredRoutesHook.kt @@ -1,5 +1,6 @@ package io.javalin.openapi.dynamic.hook +import io.javalin.openapi.dynamic.ReflectionSchemaContext import io.javalin.openapi.plugin.OpenApiHook import io.javalin.openapi.plugin.OpenApiHookContext @@ -7,6 +8,7 @@ import io.javalin.openapi.plugin.OpenApiHookContext class RegisteredRoutesHook : OpenApiHook { override fun apply(context: OpenApiHookContext) { + val schemaContext = ReflectionSchemaContext() // document-scoped: owns the generator + its memo cache context.builder.openApiVersion("3.1.0") context.state.internalRouter.allHttpHandlers() @@ -32,7 +34,7 @@ class RegisteredRoutesHook : OpenApiHook { } } - context.builder.resolveComponentReferences { type -> dynamicSchemaGenerator.createTypeSchema(type) } + context.builder.resolveComponentReferences { type -> schemaContext.componentSchema(type) } } /** Javalin `` → OpenAPI `{slashParam}`; `{param}` kept as-is. */ diff --git a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/AnnotationProcessorContext.kt b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/AnnotationProcessorContext.kt index 788b8ce7..f794bd56 100644 --- a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/AnnotationProcessorContext.kt +++ b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/AnnotationProcessorContext.kt @@ -9,8 +9,8 @@ import io.javalin.introspection.jap.JapTypeIntrospector import io.javalin.openapi.DiscriminatorMappingName import io.javalin.openapi.OpenApiName import io.javalin.openapi.experimental.processor.generators.TypeSchemaGenerator -import io.javalin.openapi.experimental.processor.shared.getTypeMirror -import io.javalin.openapi.experimental.processor.shared.getTypeMirrors +import io.javalin.openapi.experimental.processor.shared.mapType +import io.javalin.openapi.experimental.processor.shared.objectType import javax.annotation.processing.Messager import javax.annotation.processing.ProcessingEnvironment import javax.annotation.processing.RoundEnvironment @@ -20,8 +20,8 @@ import javax.lang.model.element.ExecutableElement import javax.lang.model.element.TypeElement import javax.lang.model.type.TypeMirror import javax.lang.model.util.Types -import kotlin.reflect.KClass import io.javalin.introspection.ClassDefinition as RawType +import io.javalin.introspection.StructureType as RawStructureType class AnnotationProcessorContext( val parameters: OpenApiAnnotationProcessorParameters, @@ -39,39 +39,47 @@ class AnnotationProcessorContext( private val japIntrospector: JapTypeIntrospector by lazy { JapTypeIntrospector(types, env.elementUtils) } - fun annotationsOf(element: Element): Annotations = - japIntrospector.annotationsOf(element) - - fun propertiesOf(mirror: TypeMirror): List = - japIntrospector.introspect(mirror).getProperties() - - fun enumConstantsOf(mirror: TypeMirror): List = - japIntrospector.introspect(mirror).getEnumConstants() ?: emptyList() - - override fun isEnum(type: ClassDefinition): Boolean = + override fun isEnum(type: OpenApiType): Boolean = type.source.kind == ElementKind.ENUM - override fun annotationsOf(type: ClassDefinition): Annotations = - annotationsOf(type.source) + override fun annotationsOf(type: OpenApiType): Annotations = + japIntrospector.annotationsOf(type.source) - override fun propertiesOf(type: ClassDefinition): List = - propertiesOf(type.mirror) + override fun propertiesOf(type: OpenApiType): List = + japIntrospector.introspect(type.mirror).getProperties() - override fun enumConstantsOf(type: ClassDefinition): List = - enumConstantsOf(type.mirror) + override fun enumConstantsOf(type: OpenApiType): List = + japIntrospector.introspect(type.mirror).getEnumConstants() ?: emptyList() + + /** Lift a backend-neutral [RawType] into the OpenAPI [OpenApiType] model; names + handle stay AP-specific. */ + @OptIn(InternalIntrospectionApi::class) + override fun toOpenApiType(raw: RawType): OpenApiType { + val mirror = raw.source as TypeMirror + return OpenApiType( + simpleName = mirror.getSimpleName(), + fullName = mirror.getFullName(), + generics = raw.generics.map { toOpenApiType(it) }, + structureType = StructureType.valueOf(raw.structureType.name), + handle = OpenApiTypeHandle( + mirror = mirror, + source = if (raw.structureType == RawStructureType.DICTIONARY) mapType() else (types.asElement(mirror) ?: objectType()) + ) + ) + } - override fun toClassDefinition(raw: RawType): ClassDefinition = - toExperimental(raw) + /** Resolve a [TypeMirror] into the OpenAPI model via the shared [JapTypeIntrospector]. */ + fun TypeMirror.toOpenApiType(): OpenApiType = + toOpenApiType(japIntrospector.introspect(this)) @OptIn(InternalIntrospectionApi::class) - override fun acceptsProperty(type: ClassDefinition, property: PropertyView): Boolean = + override fun acceptsProperty(type: OpenApiType, property: PropertyView): Boolean = configuration.propertyInSchemeFilter?.filter(this, type, property.source as Element) != false - override fun discriminatorSubtypes(type: ClassDefinition): List> = + override fun discriminatorSubtypes(type: OpenApiType): List> = roundEnv!!.getElementsAnnotatedWith(DiscriminatorMappingName::class.java) .asSequence() .filterIsInstance() - .map { it.getAnnotation(DiscriminatorMappingName::class.java).value to getClassDefinition(it.asType()) } + .map { it.getAnnotation(DiscriminatorMappingName::class.java).value to it.asType().toOpenApiType() } .filter { (_, subtype) -> isAssignable(subtype.mirror, type.mirror) } .toList() @@ -84,12 +92,6 @@ class AnnotationProcessorContext( } } - fun getClassDefinition(mirror: TypeMirror): ClassDefinition = - classDefinitionFrom(this, mirror) - - fun getClassDefinitions(mirrors: Set): Set = - mirrors.map { getClassDefinition(it) }.toSet() - fun forTypeElement(name: String): TypeElement? = env.elementUtils.getTypeElement(name) @@ -117,9 +119,6 @@ class AnnotationProcessorContext( /* Extension methods, should be replaced by context receivers in the future */ - fun TypeMirror.toClassDefinition(): ClassDefinition = - getClassDefinition(this) - fun TypeMirror.getSimpleName(): String = getFullName().substringAfterLast(".") @@ -127,12 +126,4 @@ class AnnotationProcessorContext( fun TypeMirror.getFullName(): String = getFullName(this) - fun A.getClassDefinitions(supplier: A.() -> Array>): Set = - getTypeMirrors(supplier) - .map { it.toClassDefinition() } - .toSet() - - fun A.getClassDefinition(supplier: A.() -> KClass<*>): ClassDefinition = - getTypeMirror(supplier).toClassDefinition() - } diff --git a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/ClassDefinitionFactory.kt b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/ClassDefinitionFactory.kt deleted file mode 100644 index 64ab2f36..00000000 --- a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/ClassDefinitionFactory.kt +++ /dev/null @@ -1,32 +0,0 @@ -package io.javalin.openapi.experimental - -import io.javalin.introspection.InternalIntrospectionApi -import io.javalin.introspection.jap.JapTypeIntrospector -import io.javalin.openapi.experimental.processor.shared.mapType -import io.javalin.openapi.experimental.processor.shared.objectType -import javax.lang.model.type.TypeMirror -import io.javalin.introspection.ClassDefinition as RawType -import io.javalin.introspection.StructureType as RawStructureType - -/** - * Resolves a [TypeMirror] into the OpenAPI [ClassDefinition] model. Structure resolution (array/map/collection/ - * generics/primitive/typevar) is delegated to the shared [JapTypeIntrospector]; names + handle stay AP-specific. - */ -fun classDefinitionFrom(context: AnnotationProcessorContext, mirror: TypeMirror): ClassDefinition = - context.toExperimental(JapTypeIntrospector(context.types, context.env.elementUtils).introspect(mirror)) - -@OptIn(InternalIntrospectionApi::class) -internal fun AnnotationProcessorContext.toExperimental(raw: RawType): ClassDefinition = - inContext { - val mirror = raw.source as TypeMirror - ClassDefinition( - simpleName = mirror.getSimpleName(), - fullName = mirror.getFullName(), - generics = raw.generics.map { toExperimental(it) }, - structureType = StructureType.valueOf(raw.structureType.name), - handle = ClassDefinitionHandle( - mirror = mirror, - source = if (raw.structureType == RawStructureType.DICTIONARY) mapType() else (types.asElement(mirror) ?: objectType()) - ) - ) - } diff --git a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/ClassDefinitionHandle.kt b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/ClassDefinitionHandle.kt deleted file mode 100644 index 25fe3674..00000000 --- a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/ClassDefinitionHandle.kt +++ /dev/null @@ -1,15 +0,0 @@ -package io.javalin.openapi.experimental - -import javax.lang.model.element.Element -import javax.lang.model.type.TypeMirror - -data class ClassDefinitionHandle( - val mirror: TypeMirror, - val source: Element -) - -val ClassDefinition.mirror: TypeMirror - get() = (handle as ClassDefinitionHandle).mirror - -val ClassDefinition.source: Element - get() = (handle as ClassDefinitionHandle).source diff --git a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/OpenApiAnnotationProcessorConfiguration.kt b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/OpenApiAnnotationProcessorConfiguration.kt index c0e4e842..b85c89a2 100644 --- a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/OpenApiAnnotationProcessorConfiguration.kt +++ b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/OpenApiAnnotationProcessorConfiguration.kt @@ -1,8 +1,6 @@ package io.javalin.openapi.experimental -import io.javalin.openapi.experimental.defaults.ArrayEmbeddedTypeProcessor -import io.javalin.openapi.experimental.defaults.CompositionEmbeddedTypeProcessor -import io.javalin.openapi.experimental.defaults.DictionaryEmbeddedTypeProcessor +import io.javalin.openapi.experimental.defaults.createDefaultEmbeddedTypeProcessors import io.javalin.openapi.experimental.defaults.createDefaultSimpleTypeMappings import javax.lang.model.element.Element @@ -16,11 +14,7 @@ class OpenApiAnnotationProcessorConfiguration { var validateWithParser: Boolean = true var propertyInSchemeFilter: PropertyInSchemeFilter? = null val simpleTypeMappings: MutableMap = createDefaultSimpleTypeMappings() - val embeddedTypeProcessors: MutableList = mutableListOf( - CompositionEmbeddedTypeProcessor(), - ArrayEmbeddedTypeProcessor(), - DictionaryEmbeddedTypeProcessor() - ) + val embeddedTypeProcessors: MutableList = createDefaultEmbeddedTypeProcessors() fun insertEmbeddedTypeProcessor(embeddedTypeProcessor: EmbeddedTypeProcessor) { embeddedTypeProcessors.add(0, embeddedTypeProcessor) @@ -29,5 +23,5 @@ class OpenApiAnnotationProcessorConfiguration { } fun interface PropertyInSchemeFilter { - fun filter(context: AnnotationProcessorContext, type: ClassDefinition, property: Element): Boolean + fun filter(context: AnnotationProcessorContext, type: OpenApiType, property: Element): Boolean } diff --git a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/OpenApiTypeHandle.kt b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/OpenApiTypeHandle.kt new file mode 100644 index 00000000..d4d56167 --- /dev/null +++ b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/OpenApiTypeHandle.kt @@ -0,0 +1,18 @@ +package io.javalin.openapi.experimental + +import javax.lang.model.element.Element +import javax.lang.model.type.TypeMirror + +/** The annotation-processing token carried in [OpenApiType.handle]. */ +data class OpenApiTypeHandle( + val mirror: TypeMirror, + val source: Element +) + +@OptIn(InternalOpenApiTypeApi::class) +val OpenApiType.mirror: TypeMirror + get() = (handle as OpenApiTypeHandle).mirror + +@OptIn(InternalOpenApiTypeApi::class) +val OpenApiType.source: Element + get() = (handle as OpenApiTypeHandle).source diff --git a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/processor/shared/AnnotationProcessorExtensions.kt b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/processor/shared/AnnotationProcessorExtensions.kt index 7b922891..efbda4b1 100644 --- a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/processor/shared/AnnotationProcessorExtensions.kt +++ b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/processor/shared/AnnotationProcessorExtensions.kt @@ -1,7 +1,6 @@ package io.javalin.openapi.experimental.processor.shared import io.javalin.openapi.experimental.AnnotationProcessorContext -import java.io.Writer import javax.annotation.processing.Filer import javax.annotation.processing.FilerException import javax.annotation.processing.Messager @@ -47,22 +46,3 @@ fun Messager.printException(kind: Kind, throwable: Throwable) { printException(throwable.cause!!) } } - -class MessagerWriter(val context: AnnotationProcessorContext) : Writer() { - - private val builder = StringBuilder() - - override fun flush() { - context.env.messager.info(builder.toString()) - builder.clear() - } - - override fun write(cbuf: CharArray, off: Int, len: Int) { - builder.append(cbuf, off, len) - } - - override fun close() { - flush() - } - -} diff --git a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/processor/shared/ModelExtensions.kt b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/processor/shared/ModelExtensions.kt index 555ee7ef..ad77e756 100644 --- a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/processor/shared/ModelExtensions.kt +++ b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/processor/shared/ModelExtensions.kt @@ -1,7 +1,6 @@ package io.javalin.openapi.experimental.processor.shared import io.javalin.openapi.experimental.AnnotationProcessorContext -import javax.lang.model.element.Element import javax.lang.model.element.TypeElement import javax.lang.model.type.MirroredTypeException import javax.lang.model.type.MirroredTypesException @@ -9,18 +8,7 @@ import javax.lang.model.type.TypeMirror import kotlin.reflect.KClass fun AnnotationProcessorContext.objectType(): TypeElement = forTypeElement(Object::class.java.name)!! -fun AnnotationProcessorContext.collectionType(): TypeElement = forTypeElement(Collection::class.java.name)!! fun AnnotationProcessorContext.mapType(): TypeElement = forTypeElement(Map::class.java.name)!! -fun AnnotationProcessorContext.recordType(): TypeElement? = forTypeElement("java.lang.Record") - -fun TypeMirror.isPrimitive(): Boolean = - kind.isPrimitive - -fun Element.hasAnnotation(simpleName: String): Boolean = - annotationMirrors.any { it.annotationType.asElement().simpleName.contentEquals(simpleName) } - -fun Element.getFullName(): String = - toString() fun A.getTypeMirrors(supplier: A.() -> Array>): Set = try { diff --git a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/processor/generators/JsonSchemaGenerator.kt b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/processor/generators/JsonSchemaGenerator.kt index 57a9a870..8c6c468e 100644 --- a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/processor/generators/JsonSchemaGenerator.kt +++ b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/processor/generators/JsonSchemaGenerator.kt @@ -17,7 +17,7 @@ class JsonSchemaGenerator { private fun generate(element: Element): String = context.inContext { context.typeSchemaGenerator.createTypeSchema( - type = element.asType().toClassDefinition(), + type = element.asType().toOpenApiType(), inlineRefs = true ).toJsonSchemaString() } diff --git a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/schema/OpenApiSchemaGenerator.kt b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/schema/OpenApiSchemaGenerator.kt index bc0de1f7..9ad94e1b 100644 --- a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/schema/OpenApiSchemaGenerator.kt +++ b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/schema/OpenApiSchemaGenerator.kt @@ -350,7 +350,7 @@ class OpenApiSchemaGenerator( private fun detectContentType(typeMirror: TypeMirror): String = context.inContext { - val model = typeMirror.toClassDefinition() + val model = typeMirror.toOpenApiType() when { (model.structureType == ARRAY && model.simpleName == "Byte") || model.simpleName == "[B" || model.simpleName == "File" -> "application/octet-stream" @@ -362,7 +362,7 @@ class OpenApiSchemaGenerator( private fun createTypeDescriptionWithReferences(type: TypeMirror): ResultScheme = context.inContext { - val model = type.toClassDefinition() + val model = type.toOpenApiType() context.typeSchemaGenerator.createEmbeddedTypeDescription(model) } diff --git a/openapi-annotation-processor/src/test/compile/openapi.groovy b/openapi-annotation-processor/src/test/compile/openapi.groovy index c5e8e1fb..f43e76a3 100644 --- a/openapi-annotation-processor/src/test/compile/openapi.groovy +++ b/openapi-annotation-processor/src/test/compile/openapi.groovy @@ -1,6 +1,6 @@ import io.javalin.openapi.experimental.AnnotationProcessorContext -import io.javalin.openapi.experimental.ClassDefinition -import io.javalin.openapi.experimental.ClassDefinitionHandleKt +import io.javalin.openapi.experimental.OpenApiType +import io.javalin.openapi.experimental.OpenApiTypeHandleKt import io.javalin.openapi.experimental.EmbeddedTypeProcessorContext import io.javalin.openapi.experimental.ExperimentalCompileOpenApiConfiguration import io.javalin.openapi.experimental.OpenApiAnnotationProcessorConfiguration @@ -22,15 +22,15 @@ class OpenApiConfiguration implements OpenApiAnnotationProcessorConfigurer { configuration.simpleTypeMappings['io.javalin.openapi.processor.TypeMappersTest.CustomType'] = new SimpleType("string") // Used by UserCasesTest - configuration.propertyInSchemeFilter = { AnnotationProcessorContext ctx, ClassDefinition type, Element property -> + configuration.propertyInSchemeFilter = { AnnotationProcessorContext ctx, OpenApiType type, Element property -> TypeElement specificRecord = ctx.forTypeElement('io.javalin.openapi.processor.UserCasesTest.SpecificRecord') TypeElement specificRecordBase = ctx.forTypeElement('io.javalin.openapi.processor.UserCasesTest.SpecificRecordBase') - if (ctx.isAssignable(ClassDefinitionHandleKt.getMirror(type), specificRecord.asType()) && ctx.hasElement(specificRecord, property)) { + if (ctx.isAssignable(OpenApiTypeHandleKt.getMirror(type), specificRecord.asType()) && ctx.hasElement(specificRecord, property)) { return false // exclude } - if (ctx.isAssignable(ClassDefinitionHandleKt.getMirror(type), specificRecordBase.asType()) && ctx.hasElement(specificRecordBase, property)) { + if (ctx.isAssignable(OpenApiTypeHandleKt.getMirror(type), specificRecordBase.asType()) && ctx.hasElement(specificRecordBase, property)) { return false // exclude } diff --git a/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/OperationAnnotationsTest.kt b/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/OperationAnnotationsTest.kt new file mode 100644 index 00000000..ed789985 --- /dev/null +++ b/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/OperationAnnotationsTest.kt @@ -0,0 +1,98 @@ +@file:Suppress("unused") + +package io.javalin.openapi.processor + +import io.javalin.openapi.HttpMethod.POST +import io.javalin.openapi.OpenApi +import io.javalin.openapi.OpenApiContent +import io.javalin.openapi.OpenApiParam +import io.javalin.openapi.OpenApiRequestBody +import io.javalin.openapi.OpenApiResponse +import io.javalin.openapi.OpenApiSecurity +import io.javalin.openapi.processor.specification.OpenApiAnnotationProcessorSpecification +import net.javacrumbs.jsonunit.assertj.JsonAssertions.json +import net.javacrumbs.jsonunit.assertj.assertThatJson +import org.junit.jupiter.api.Test + +internal class OperationAnnotationsTest : OpenApiAnnotationProcessorSpecification() { + + @OpenApi( + path = "/parameters", + versions = ["should_describe_parameters"], + pathParams = [OpenApiParam(name = "id", type = Int::class, description = "Identifier", required = true)], + queryParams = [OpenApiParam(name = "page", type = Int::class)], + headers = [OpenApiParam(name = "X-Trace-Id")], + cookies = [OpenApiParam(name = "session")], + ) + @Test + fun should_describe_parameters() = withOpenApi("should_describe_parameters") { + assertThatJson(it) + .inPath("$.paths['/parameters'].get.parameters") + .isEqualTo(json(""" + [ + { "name": "session", "in": "cookie", "schema": { "type": "string" } }, + { "name": "X-Trace-Id", "in": "header", "schema": { "type": "string" } }, + { "name": "id", "in": "path", "description": "Identifier", "required": true, "schema": { "type": "integer", "format": "int32" } }, + { "name": "page", "in": "query", "schema": { "type": "integer", "format": "int32" } } + ] + """)) + } + + private class RequestDto(val field: String) + + @OpenApi( + path = "/request-body", + methods = [POST], + versions = ["should_describe_request_body"], + requestBody = OpenApiRequestBody(content = [OpenApiContent(from = RequestDto::class)], required = true, description = "Payload"), + ) + @Test + fun should_describe_request_body() = withOpenApi("should_describe_request_body") { + assertThatJson(it) + .inPath("$.paths['/request-body'].post.requestBody") + .isEqualTo(json(""" + { + "description": "Payload", + "content": { "application/json": { "schema": { "${'$'}ref": "#/components/schemas/RequestDto" } } }, + "required": true + } + """)) + + assertThatJson(it) + .inPath("$.components.schemas.RequestDto") + .isEqualTo(json("""{ "type": "object", "properties": { "field": { "type": "string" } }, "required": ["field"] }""")) + } + + @OpenApi( + path = "/secured", + versions = ["should_attach_security"], + security = [OpenApiSecurity(name = "BearerAuth", scopes = ["read", "write"])], + ) + @Test + fun should_attach_security() = withOpenApi("should_attach_security") { + assertThatJson(it) + .inPath("$.paths['/secured'].get.security") + .isEqualTo(json("""[ { "BearerAuth": ["read", "write"] } ]""")) + } + + @OpenApi( + path = "/responses", + versions = ["should_describe_multiple_responses"], + responses = [ + OpenApiResponse(status = "200", content = [OpenApiContent(from = String::class)]), + OpenApiResponse(status = "404", description = "Not found"), + ], + ) + @Test + fun should_describe_multiple_responses() = withOpenApi("should_describe_multiple_responses") { + assertThatJson(it) + .inPath("$.paths['/responses'].get.responses") + .isEqualTo(json(""" + { + "200": { "description": "OK", "content": { "text/plain": { "schema": { "type": "string" } } } }, + "404": { "description": "Not found" } + } + """)) + } + +} diff --git a/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/PropertySelectionTest.kt b/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/PropertySelectionTest.kt new file mode 100644 index 00000000..3f3b683a --- /dev/null +++ b/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/PropertySelectionTest.kt @@ -0,0 +1,56 @@ +@file:Suppress("unused") + +package io.javalin.openapi.processor + +import io.javalin.openapi.OpenApi +import io.javalin.openapi.OpenApiByFields +import io.javalin.openapi.OpenApiContent +import io.javalin.openapi.OpenApiIgnore +import io.javalin.openapi.OpenApiResponse +import io.javalin.openapi.processor.specification.OpenApiAnnotationProcessorSpecification +import net.javacrumbs.jsonunit.assertj.assertThatJson +import org.junit.jupiter.api.Test + +internal class PropertySelectionTest : OpenApiAnnotationProcessorSpecification() { + + private class IgnoreEntity( + val visible: String, + @get:OpenApiIgnore val hidden: String, + ) + + @OpenApi( + path = "/ignore", + versions = ["should_exclude_ignored_property"], + responses = [OpenApiResponse(status = "200", content = [OpenApiContent(from = IgnoreEntity::class)])] + ) + @Test + fun should_exclude_ignored_property() = withOpenApi("should_exclude_ignored_property") { + assertThatJson(it) + .inPath("$.components.schemas.IgnoreEntity.properties") + .isObject + .containsKey("visible") + .doesNotContainKey("hidden") + } + + // By default only getters are emitted; @OpenApiByFields additionally pulls in public fields (here the @JvmField one). + @OpenApiByFields + private class ByFieldsEntity { + @JvmField val fieldProperty: String = "" + val getterProperty: String = "" + } + + @OpenApi( + path = "/by-fields", + versions = ["should_include_public_fields_with_by_fields"], + responses = [OpenApiResponse(status = "200", content = [OpenApiContent(from = ByFieldsEntity::class)])] + ) + @Test + fun should_include_public_fields_with_by_fields() = withOpenApi("should_include_public_fields_with_by_fields") { + assertThatJson(it) + .inPath("$.components.schemas.ByFieldsEntity.properties") + .isObject + .containsKey("fieldProperty") + .containsKey("getterProperty") + } + +} diff --git a/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/ValidationAnnotationsTest.kt b/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/ValidationAnnotationsTest.kt new file mode 100644 index 00000000..073093a5 --- /dev/null +++ b/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/ValidationAnnotationsTest.kt @@ -0,0 +1,56 @@ +@file:Suppress("unused") + +package io.javalin.openapi.processor + +import io.javalin.openapi.OpenApi +import io.javalin.openapi.OpenApiArrayValidation +import io.javalin.openapi.OpenApiContent +import io.javalin.openapi.OpenApiNumberValidation +import io.javalin.openapi.OpenApiObjectValidation +import io.javalin.openapi.OpenApiResponse +import io.javalin.openapi.OpenApiStringValidation +import io.javalin.openapi.processor.specification.OpenApiAnnotationProcessorSpecification +import net.javacrumbs.jsonunit.assertj.JsonAssertions.json +import net.javacrumbs.jsonunit.assertj.assertThatJson +import org.junit.jupiter.api.Test + +internal class ValidationAnnotationsTest : OpenApiAnnotationProcessorSpecification() { + + private class ValidatedEntity( + @get:OpenApiNumberValidation(minimum = "1", maximum = "10", exclusiveMinimum = "0", exclusiveMaximum = "11", multipleOf = "2") + val score: Int, + @get:OpenApiStringValidation(minLength = "2", maxLength = "8", format = "email", pattern = "^[a-z]+$") + val name: String, + @get:OpenApiArrayValidation(minItems = "1", maxItems = "5", uniqueItems = true) + val tags: List, + @get:OpenApiObjectValidation(minProperties = "1", maxProperties = "3") + val meta: Map, + ) + + @OpenApi( + path = "/validations", + versions = ["should_emit_validation_keywords"], + responses = [OpenApiResponse(status = "200", content = [OpenApiContent(from = ValidatedEntity::class)])] + ) + @Test + fun should_emit_validation_keywords() = withOpenApi("should_emit_validation_keywords") { + val properties = "$.components.schemas.ValidatedEntity.properties" + + assertThatJson(it).inPath("$properties.score").isObject.isEqualTo(json(""" + { "type": "integer", "format": "int32", "minimum": 1, "maximum": 10, "exclusiveMinimum": 0, "exclusiveMaximum": 11, "multipleOf": 2 } + """)) + + assertThatJson(it).inPath("$properties.name").isObject.isEqualTo(json(""" + { "type": "string", "minLength": 2, "maxLength": 8, "format": "email", "pattern": "^[a-z]+$" } + """)) + + assertThatJson(it).inPath("$properties.tags").isObject.isEqualTo(json(""" + { "type": "array", "items": { "type": "string" }, "minItems": 1, "maxItems": 5, "uniqueItems": true } + """)) + + assertThatJson(it).inPath("$properties.meta").isObject.isEqualTo(json(""" + { "type": "object", "additionalProperties": { "type": "string" }, "minProperties": 1, "maxProperties": 3 } + """)) + } + +} diff --git a/openapi-dynamic/src/main/kotlin/io/javalin/openapi/dynamic/ReflectiveTypeIntrospector.kt b/openapi-dynamic/src/main/kotlin/io/javalin/openapi/dynamic/ReflectionSchemaContext.kt similarity index 51% rename from openapi-dynamic/src/main/kotlin/io/javalin/openapi/dynamic/ReflectiveTypeIntrospector.kt rename to openapi-dynamic/src/main/kotlin/io/javalin/openapi/dynamic/ReflectionSchemaContext.kt index 59fdb32f..38174937 100644 --- a/openapi-dynamic/src/main/kotlin/io/javalin/openapi/dynamic/ReflectiveTypeIntrospector.kt +++ b/openapi-dynamic/src/main/kotlin/io/javalin/openapi/dynamic/ReflectionSchemaContext.kt @@ -6,67 +6,76 @@ import io.javalin.introspection.InternalIntrospectionApi import io.javalin.introspection.PropertyView import io.javalin.introspection.runtime.ReflectionTypeIntrospector import io.javalin.openapi.OpenApiName -import io.javalin.openapi.experimental.ClassDefinition +import io.javalin.openapi.experimental.OpenApiType +import io.javalin.openapi.experimental.InternalOpenApiTypeApi import io.javalin.openapi.experimental.EmbeddedTypeProcessor import io.javalin.openapi.experimental.SchemaGenerationContext import io.javalin.openapi.experimental.SimpleType import io.javalin.openapi.experimental.StructureType -import io.javalin.openapi.experimental.defaults.ArrayEmbeddedTypeProcessor -import io.javalin.openapi.experimental.defaults.CompositionEmbeddedTypeProcessor -import io.javalin.openapi.experimental.defaults.DictionaryEmbeddedTypeProcessor +import io.javalin.openapi.experimental.defaults.createDefaultEmbeddedTypeProcessors import io.javalin.openapi.experimental.defaults.createDefaultSimpleTypeMappings +import io.javalin.openapi.experimental.processor.generators.ResultScheme import io.javalin.openapi.experimental.processor.generators.TypeSchemaGenerator import java.lang.reflect.Type import io.javalin.introspection.ClassDefinition as RawType -/** Runtime-reflection [SchemaGenerationContext]: drives the shared [TypeSchemaGenerator] over [ReflectionTypeIntrospector]. */ -class ReflectiveTypeIntrospector( +/** + * Runtime-reflection [SchemaGenerationContext] — the reflection counterpart of the annotation processor's context. + * Resolves [Type]s into the OpenAPI model and drives the shared [TypeSchemaGenerator]. Stateless beyond the generator's + * memo cache, so a fresh instance per document build keeps that cache document-scoped. + */ +class ReflectionSchemaContext( override val simpleTypeMappings: Map = createDefaultSimpleTypeMappings(), ) : SchemaGenerationContext { private val runtime = ReflectionTypeIntrospector() override val typeSchemaGenerator: TypeSchemaGenerator = TypeSchemaGenerator(this) - override val embeddedTypeProcessors: List = listOf( - CompositionEmbeddedTypeProcessor(), - ArrayEmbeddedTypeProcessor(), - DictionaryEmbeddedTypeProcessor() - ) + override val embeddedTypeProcessors: List = createDefaultEmbeddedTypeProcessors() - fun introspect(type: Type): ClassDefinition = - toClassDefinition(runtime.introspect(type)) + /** Resolve a reflection [type] into the OpenAPI [OpenApiType] model, applying `@OpenApiName`. */ + fun introspect(type: Type): OpenApiType = + toOpenApiType(runtime.introspect(type)) + + /** Full component schema for an already-resolved [type] — the body stored under `components/schemas`. */ + fun componentSchema(type: OpenApiType): ResultScheme = + typeSchemaGenerator.createTypeSchema(type) + + /** Inline schema (`$ref` or structure) for a raw reflection [type]. */ + fun inlineSchema(type: Type): ResultScheme = + typeSchemaGenerator.createEmbeddedTypeDescription(introspect(type)) - /** Map the policy-free [RawType] tree into the OpenAPI [ClassDefinition] model, applying `@OpenApiName`. */ @OptIn(InternalIntrospectionApi::class) - override fun toClassDefinition(raw: RawType): ClassDefinition { + override fun toOpenApiType(raw: RawType): OpenApiType { val erasure = raw.source as Class<*> val customName = erasure.getAnnotation(OpenApiName::class.java)?.value val packageName = erasure.`package`?.name?.takeIf { it.isNotEmpty() } - return ClassDefinition( + return OpenApiType( simpleName = customName ?: raw.simpleName, fullName = when { customName != null -> if (packageName == null) customName else "$packageName.$customName" else -> raw.fullName }, - generics = raw.generics.map { toClassDefinition(it) }, + generics = raw.generics.map { toOpenApiType(it) }, structureType = StructureType.valueOf(raw.structureType.name), handle = raw, ) } - override fun isEnum(type: ClassDefinition): Boolean = + override fun isEnum(type: OpenApiType): Boolean = type.raw.isEnum() - override fun annotationsOf(type: ClassDefinition): Annotations = + override fun annotationsOf(type: OpenApiType): Annotations = type.raw.getAnnotations() - override fun propertiesOf(type: ClassDefinition): List = + override fun propertiesOf(type: OpenApiType): List = type.raw.getProperties() - override fun enumConstantsOf(type: ClassDefinition): List = + override fun enumConstantsOf(type: OpenApiType): List = type.raw.getEnumConstants() ?: emptyList() } -private val ClassDefinition.raw: RawType +@OptIn(InternalOpenApiTypeApi::class) +private val OpenApiType.raw: RawType get() = handle as RawType diff --git a/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/fixtures/Cat.java b/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/fixtures/Cat.java new file mode 100644 index 00000000..661f7454 --- /dev/null +++ b/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/fixtures/Cat.java @@ -0,0 +1,7 @@ +package io.javalin.openapi.dynamic.fixtures; + +public class Cat { + public String getMeow() { + return ""; + } +} diff --git a/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/fixtures/Dog.java b/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/fixtures/Dog.java new file mode 100644 index 00000000..04a8cd68 --- /dev/null +++ b/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/fixtures/Dog.java @@ -0,0 +1,7 @@ +package io.javalin.openapi.dynamic.fixtures; + +public class Dog { + public String getBark() { + return ""; + } +} diff --git a/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/fixtures/Shape.java b/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/fixtures/Shape.java new file mode 100644 index 00000000..20dd846f --- /dev/null +++ b/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/fixtures/Shape.java @@ -0,0 +1,17 @@ +package io.javalin.openapi.dynamic.fixtures; + +import io.javalin.openapi.OneOf; + +public class Shape { + + @OneOf({ Dog.class, Cat.class }) + public Object getAnimal() { + return null; + } + + /** Composition with no explicit refs — reflection cannot discover subtypes, so this must NOT emit `oneOf: []`. */ + @OneOf({}) + public Object getEmpty() { + return null; + } +} diff --git a/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/fixtures/Validated.java b/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/fixtures/Validated.java new file mode 100644 index 00000000..f28f667c --- /dev/null +++ b/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/fixtures/Validated.java @@ -0,0 +1,17 @@ +package io.javalin.openapi.dynamic.fixtures; + +import io.javalin.openapi.OpenApiNumberValidation; +import io.javalin.openapi.OpenApiPropertyType; + +public class Validated { + + @OpenApiNumberValidation(minimum = "1", maximum = "10") + public int getScore() { + return 0; + } + + @OpenApiPropertyType(definedBy = String.class) + public int getRedirected() { + return 0; + } +} diff --git a/openapi-dynamic/src/test/kotlin/io/javalin/openapi/dynamic/DynamicCompositionTest.kt b/openapi-dynamic/src/test/kotlin/io/javalin/openapi/dynamic/DynamicCompositionTest.kt new file mode 100644 index 00000000..4d984e7e --- /dev/null +++ b/openapi-dynamic/src/test/kotlin/io/javalin/openapi/dynamic/DynamicCompositionTest.kt @@ -0,0 +1,42 @@ +package io.javalin.openapi.dynamic + +import com.fasterxml.jackson.databind.JsonNode +import io.javalin.openapi.dynamic.fixtures.Shape +import io.javalin.openapi.dynamic.fixtures.Validated +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +/** Covers the policy the reflection backend gained by routing through the shared generator (composition, validations, redirect). */ +class DynamicCompositionTest { + + private val schemaContext = ReflectionSchemaContext() + + private fun propertiesOf(type: Class<*>): JsonNode = + schemaContext.componentSchema(schemaContext.introspect(type)).json.path("properties") + + @Test + fun `emits oneOf with refs for composition annotations`() { + val refs = propertiesOf(Shape::class.java).path("animal").path("oneOf").map { it.path($$"$ref").asText() } + assertThat(refs).containsExactlyInAnyOrder( + "#/components/schemas/Dog", + "#/components/schemas/Cat", + ) + } + + @Test + fun `omits an empty composition instead of emitting an invalid oneOf`() { + assertThat(propertiesOf(Shape::class.java).path("empty").has("oneOf")).isFalse() + } + + @Test + fun `applies number validations`() { + val score = propertiesOf(Validated::class.java).path("score") + assertThat(score.path("minimum").asInt()).isEqualTo(1) + assertThat(score.path("maximum").asInt()).isEqualTo(10) + } + + @Test + fun `applies the OpenApiPropertyType redirect`() { + assertThat(propertiesOf(Validated::class.java).path("redirected").path("type").asText()).isEqualTo("string") + } +} diff --git a/openapi-dynamic/src/test/kotlin/io/javalin/openapi/dynamic/DynamicSchemaGeneratorTest.kt b/openapi-dynamic/src/test/kotlin/io/javalin/openapi/dynamic/DynamicSchemaGeneratorTest.kt index cc0cfbe9..111a7146 100644 --- a/openapi-dynamic/src/test/kotlin/io/javalin/openapi/dynamic/DynamicSchemaGeneratorTest.kt +++ b/openapi-dynamic/src/test/kotlin/io/javalin/openapi/dynamic/DynamicSchemaGeneratorTest.kt @@ -9,16 +9,13 @@ import org.junit.jupiter.api.Test class DynamicSchemaGeneratorTest { - private val introspector = ReflectiveTypeIntrospector() - private val generator = introspector.typeSchemaGenerator + private val schemaContext = ReflectionSchemaContext() private fun JsonNode.ref(): String = path($$"$ref").asText() private fun JsonNode.stringArray(): List = map { it.asText() } @Test fun `renders a full component graph through OpenApiSchemaBuilder via reflection`() { - val account = introspector.introspect(Account::class.java) - val builder = OpenApiSchemaBuilder().openApiVersion("3.1.0") builder.path("/account").operation("get") { responses { @@ -26,13 +23,13 @@ class DynamicSchemaGeneratorTest { description("OK") content { mediaType("application/json") { - schema(generator.createEmbeddedTypeDescription(account)) + schema(schemaContext.inlineSchema(Account::class.java)) } } } } } - builder.resolveComponentReferences { type -> generator.createTypeSchema(type) } + builder.resolveComponentReferences { type -> schemaContext.componentSchema(type) } val document = jsonMapper.readTree(builder.toJson()) val schemas = document.path("components").path("schemas") diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/EmbeddedTypeProcessor.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/EmbeddedTypeProcessor.kt index efe8c53a..b5480200 100644 --- a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/EmbeddedTypeProcessor.kt +++ b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/EmbeddedTypeProcessor.kt @@ -6,8 +6,8 @@ import io.javalin.openapi.experimental.processor.generators.PropertyComposition data class EmbeddedTypeProcessorContext( val parentContext: SchemaGenerationContext, val scheme: ObjectNode, - val references: MutableSet, - val type: ClassDefinition, + val references: MutableSet, + val type: OpenApiType, val inlineRefs: Boolean = false, val requiresNonNulls: Boolean = true, val composition: PropertyComposition? = null, diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/SchemaGenerationContext.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/SchemaGenerationContext.kt index 810e085f..056a7a79 100644 --- a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/SchemaGenerationContext.kt +++ b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/SchemaGenerationContext.kt @@ -17,20 +17,20 @@ interface SchemaGenerationContext { val simpleTypeMappings: Map val embeddedTypeProcessors: List - fun isEnum(type: ClassDefinition): Boolean + fun isEnum(type: OpenApiType): Boolean - fun annotationsOf(type: ClassDefinition): Annotations + fun annotationsOf(type: OpenApiType): Annotations - fun propertiesOf(type: ClassDefinition): List + fun propertiesOf(type: OpenApiType): List - fun enumConstantsOf(type: ClassDefinition): List + fun enumConstantsOf(type: OpenApiType): List - /** Lift a backend-neutral [RawType] into the OpenAPI [ClassDefinition] model (names + handle stay backend-specific). */ - fun toClassDefinition(raw: RawType): ClassDefinition + /** Lift a backend-neutral [RawType] into the OpenAPI [OpenApiType] model (names + handle stay backend-specific). */ + fun toOpenApiType(raw: RawType): OpenApiType /** Whether [property] of [type] should be emitted (platform-specific filtering, e.g. the AP `propertyInSchemeFilter`). */ - fun acceptsProperty(type: ClassDefinition, property: PropertyView): Boolean = true + fun acceptsProperty(type: OpenApiType, property: PropertyView): Boolean = true /** Discriminator subtypes discovered by the platform (AP round scan); empty when the backend cannot scan. */ - fun discriminatorSubtypes(type: ClassDefinition): List> = emptyList() + fun discriminatorSubtypes(type: OpenApiType): List> = emptyList() } diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/defaults/DefaultEmbeddedTypeProcessors.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/defaults/DefaultEmbeddedTypeProcessors.kt new file mode 100644 index 00000000..91e12feb --- /dev/null +++ b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/defaults/DefaultEmbeddedTypeProcessors.kt @@ -0,0 +1,10 @@ +package io.javalin.openapi.experimental.defaults + +import io.javalin.openapi.experimental.EmbeddedTypeProcessor + +/** Default [EmbeddedTypeProcessor] chain (composition → array → dictionary), shared by every backend. */ +fun createDefaultEmbeddedTypeProcessors(): MutableList = mutableListOf( + CompositionEmbeddedTypeProcessor(), + ArrayEmbeddedTypeProcessor(), + DictionaryEmbeddedTypeProcessor(), +) diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/CompositionGenerator.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/CompositionGenerator.kt index 88b29388..9e4925eb 100644 --- a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/CompositionGenerator.kt +++ b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/CompositionGenerator.kt @@ -11,7 +11,7 @@ import io.javalin.openapi.Composition.ANY_OF import io.javalin.openapi.Composition.ONE_OF import io.javalin.openapi.NULL_STRING import io.javalin.openapi.OneOf -import io.javalin.openapi.experimental.ClassDefinition +import io.javalin.openapi.experimental.OpenApiType import io.javalin.openapi.experimental.CustomProperty import io.javalin.openapi.experimental.SchemaGenerationContext import io.javalin.openapi.experimental.processor.shared.createJsonObjectOf @@ -27,7 +27,7 @@ fun findCompositionInElement(context: SchemaGenerationContext, annotations: Anno private fun compositionOf(context: SchemaGenerationContext, annotations: Annotations, annotationType: Class, composition: Composition): PropertyComposition? { val values = annotations.memberValues(annotationType) ?: return null - val references = annotations.resolveClasses(annotationType, "value").map { context.toClassDefinition(it) }.toSet() + val references = annotations.resolveClasses(annotationType, "value").map { context.toOpenApiType(it) }.toSet() val discriminator = (values["discriminator"] as? Map<*, *>)?.let { discriminatorInfo(context, it) } return PropertyComposition(composition, references, discriminator) } @@ -36,10 +36,10 @@ private fun discriminatorInfo(context: SchemaGenerationContext, discriminator: M val property = discriminator["property"] as Map<*, *> val mapping = (discriminator["mapping"] as? List<*>).orEmpty() .filterIsInstance>() - .map { (it["name"] as String) to context.toClassDefinition(it["value"] as RawType) } + .map { (it["name"] as String) to context.toOpenApiType(it["value"] as RawType) } return DiscriminatorInfo( propertyName = property["name"] as String, - propertyType = context.toClassDefinition(property["type"] as RawType), + propertyType = context.toOpenApiType(property["type"] as RawType), injectInMappings = property["injectInMappings"] as Boolean, mapping = mapping, ) @@ -47,9 +47,9 @@ private fun discriminatorInfo(context: SchemaGenerationContext, discriminator: M fun ObjectNode.createComposition( context: SchemaGenerationContext, - classDefinition: ClassDefinition, + classDefinition: OpenApiType, propertyComposition: PropertyComposition, - references: MutableSet, + references: MutableSet, inlineRefs: Boolean = false, requiresNonNulls: Boolean = true, ) { @@ -57,6 +57,10 @@ fun ObjectNode.createComposition( val refs = propertyComposition.references.ifEmpty { subtypes.map { it.second } } + // No explicit refs and no discoverable subtypes (e.g. reflection has no round scan): emitting an empty + // `oneOf: []` would be invalid OpenAPI, so skip the composition entirely. + if (refs.isEmpty()) return + when (inlineRefs) { true -> refs diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/PropertyComposition.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/PropertyComposition.kt index 526350a5..b83278fb 100644 --- a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/PropertyComposition.kt +++ b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/PropertyComposition.kt @@ -1,17 +1,17 @@ package io.javalin.openapi.experimental.processor.generators import io.javalin.openapi.Composition -import io.javalin.openapi.experimental.ClassDefinition +import io.javalin.openapi.experimental.OpenApiType data class PropertyComposition( val type: Composition, - val references: Set, + val references: Set, val discriminator: DiscriminatorInfo?, ) data class DiscriminatorInfo( val propertyName: String, - val propertyType: ClassDefinition, + val propertyType: OpenApiType, val injectInMappings: Boolean, - val mapping: List>, + val mapping: List>, ) diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/ResultScheme.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/ResultScheme.kt index b4a1c204..c390d587 100644 --- a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/ResultScheme.kt +++ b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/ResultScheme.kt @@ -3,13 +3,13 @@ package io.javalin.openapi.experimental.processor.generators import com.fasterxml.jackson.databind.JsonNode import com.fasterxml.jackson.databind.node.ObjectNode import io.javalin.openapi.OpenApiNamingStrategy -import io.javalin.openapi.experimental.ClassDefinition +import io.javalin.openapi.experimental.OpenApiType import io.javalin.openapi.experimental.processor.shared.createObjectNode import java.math.BigDecimal data class ResultScheme( val json: ObjectNode, - val references: Set + val references: Set ) { fun toJsonSchemaString(): String { val scheme = createObjectNode() @@ -21,7 +21,7 @@ data class ResultScheme( data class Property( val name: String, - val type: ClassDefinition, + val type: OpenApiType, val composition: PropertyComposition? = null, val required: Boolean = true, val nullable: Boolean = false, diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/TypeSchemaGenerator.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/TypeSchemaGenerator.kt index 5758363f..81404eee 100644 --- a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/TypeSchemaGenerator.kt +++ b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/TypeSchemaGenerator.kt @@ -17,20 +17,20 @@ class TypeSchemaGenerator(val context: SchemaGenerationContext) { private val processedProperties = mutableMapOf() fun createTypeSchema( - type: ClassDefinition, + type: OpenApiType, inlineRefs: Boolean = false, requireNonNullsByDefault: Boolean = true ): ResultScheme { val annotations = context.annotationsOf(type) val isEnum = context.isEnum(type) - val definedBy = annotations.resolveClass(OpenApiPropertyType::class.java, "definedBy")?.let { context.toClassDefinition(it) } + val definedBy = annotations.resolveClass(OpenApiPropertyType::class.java, "definedBy")?.let { context.toOpenApiType(it) } if (definedBy != null && !isEnum) { return createTypeSchema(definedBy, inlineRefs, requireNonNullsByDefault) } val schema = createObjectNode() - val references = mutableSetOf() + val references = mutableSetOf() val composition = findCompositionInElement(context, annotations) when { @@ -113,21 +113,21 @@ class TypeSchemaGenerator(val context: SchemaGenerationContext) { } fun createEmbeddedTypeDescription( - type: ClassDefinition, + type: OpenApiType, inlineRefs: Boolean = false, requiresNonNulls: Boolean = true, composition: PropertyComposition? = null, extra: Map = emptyMap(), nullable: Boolean = false, ): ResultScheme { - val definedBy = context.annotationsOf(type).resolveClass(OpenApiPropertyType::class.java, "definedBy")?.let { context.toClassDefinition(it) } + val definedBy = context.annotationsOf(type).resolveClass(OpenApiPropertyType::class.java, "definedBy")?.let { context.toOpenApiType(it) } if (definedBy != null && !context.isEnum(type)) { return createEmbeddedTypeDescription(definedBy, inlineRefs, requiresNonNulls, composition, extra, nullable) } val scheme = createObjectNode() - val references = mutableSetOf() + val references = mutableSetOf() val handledByCustomProcessor = context.embeddedTypeProcessors.firstOrNull { @@ -194,9 +194,9 @@ class TypeSchemaGenerator(val context: SchemaGenerationContext) { fun addType( scheme: ObjectNode, - type: ClassDefinition, + type: OpenApiType, inlineRefs: Boolean, - references: MutableSet, + references: MutableSet, requiresNonNulls: Boolean ) { when (val nonRefType = context.simpleTypeMappings[type.fullName]) { @@ -219,7 +219,7 @@ class TypeSchemaGenerator(val context: SchemaGenerationContext) { } -internal fun SchemaGenerationContext.findAllProperties(type: ClassDefinition, requireNonNulls: Boolean): Collection { +internal fun SchemaGenerationContext.findAllProperties(type: OpenApiType, requireNonNulls: Boolean): Collection { val annotations = annotationsOf(type) val byFields = annotations.memberValues(OpenApiByFields::class.java) val byFieldsOnly = byFields?.get("only") == true @@ -268,7 +268,7 @@ internal fun SchemaGenerationContext.findAllProperties(type: ClassDefinition, re properties.add( Property( name = finalName, - type = toClassDefinition(redirect ?: property.type), + type = toOpenApiType(redirect ?: property.type), composition = findCompositionInElement(this, property.annotations), required = required, nullable = isExplicitlyNullable, diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/schema/OpenApiSchemaBuilder.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/schema/OpenApiSchemaBuilder.kt index db8cc2d5..5c3fd091 100644 --- a/openapi-generator/src/main/kotlin/io/javalin/openapi/schema/OpenApiSchemaBuilder.kt +++ b/openapi-generator/src/main/kotlin/io/javalin/openapi/schema/OpenApiSchemaBuilder.kt @@ -14,7 +14,7 @@ import io.javalin.openapi.OpenApiServer import io.javalin.openapi.OpenID import io.javalin.openapi.Security import io.javalin.openapi.SecurityScheme -import io.javalin.openapi.experimental.ClassDefinition +import io.javalin.openapi.experimental.OpenApiType import io.javalin.openapi.experimental.processor.generators.ExampleGenerator import io.javalin.openapi.experimental.processor.generators.ResultScheme import io.javalin.openapi.experimental.processor.generators.toExampleProperty @@ -25,16 +25,16 @@ import java.util.TreeMap import java.util.function.Consumer fun interface ComponentSchemaResolver { - fun resolve(type: ClassDefinition): ResultScheme + fun resolve(type: OpenApiType): ResultScheme } class OpenApiSchemaBuilder { private val root = createObjectNode() private val paths = createObjectNode() private val componentSchemas = createObjectNode() - internal val componentReferences = mutableMapOf() + internal val componentReferences = mutableMapOf() - private val refCollector: (Set) -> Unit = { refs -> + private val refCollector: (Set) -> Unit = { refs -> componentReferences.putAll(refs.associateBy { it.fullName }) } @@ -126,7 +126,7 @@ class OpenApiSchemaBuilder { fun resolveComponentReferences(resolver: ComponentSchemaResolver) { val maxIterations = 1000 - val generatedComponents = TreeMap?> { a, b -> a.compareTo(b) } + val generatedComponents = TreeMap?> { a, b -> a.compareTo(b) } var iteration = 0 while (generatedComponents.size < componentReferences.size) { @@ -242,7 +242,7 @@ class SchemaBuilder { @OpenApiSchemaDsl class PathItemBuilder( private val pathItem: ObjectNode, - private val refCollector: (Set) -> Unit = {}, + private val refCollector: (Set) -> Unit = {}, ) { fun operation(method: String, configure: OperationBuilder.() -> Unit) { @@ -257,7 +257,7 @@ class PathItemBuilder( @OpenApiSchemaDsl class OperationBuilder( - private val refCollector: (Set) -> Unit = {}, + private val refCollector: (Set) -> Unit = {}, existing: ObjectNode? = null, ) { @@ -388,7 +388,7 @@ class OperationBuilder( @OpenApiSchemaDsl class ParametersBuilder( - private val refCollector: (Set) -> Unit = {}, + private val refCollector: (Set) -> Unit = {}, existing: ArrayNode? = null, ) { @@ -476,7 +476,7 @@ class ParametersBuilder( @OpenApiSchemaDsl class RequestBodyBuilder( - private val refCollector: (Set) -> Unit = {}, + private val refCollector: (Set) -> Unit = {}, existing: ObjectNode? = null, ) { @@ -532,7 +532,7 @@ class RequestBodyBuilder( @OpenApiSchemaDsl class ContentBuilder( - private val refCollector: (Set) -> Unit = {}, + private val refCollector: (Set) -> Unit = {}, existing: ObjectNode? = null, ) { @@ -563,7 +563,7 @@ interface ExampleHolder { @OpenApiSchemaDsl class MediaTypeBuilder( - private val refCollector: (Set) -> Unit = {}, + private val refCollector: (Set) -> Unit = {}, existing: ObjectNode? = null, ) : ExampleHolder { @@ -618,7 +618,7 @@ class MediaTypeBuilder( @OpenApiSchemaDsl class ObjectSchemaBuilder( - private val refCollector: (Set) -> Unit = {}, + private val refCollector: (Set) -> Unit = {}, ) : ExampleHolder { private val properties = createObjectNode() @@ -720,7 +720,7 @@ class ObjectSchemaBuilder( @OpenApiSchemaDsl class ResponsesBuilder( - private val refCollector: (Set) -> Unit = {}, + private val refCollector: (Set) -> Unit = {}, existing: ObjectNode? = null, ) { @@ -740,7 +740,7 @@ class ResponsesBuilder( @OpenApiSchemaDsl class ResponseBuilder( - private val refCollector: (Set) -> Unit = {}, + private val refCollector: (Set) -> Unit = {}, existing: ObjectNode? = null, ) { @@ -788,7 +788,7 @@ class ResponseBuilder( @OpenApiSchemaDsl class HeadersBuilder( - private val refCollector: (Set) -> Unit = {}, + private val refCollector: (Set) -> Unit = {}, existing: ObjectNode? = null, ) { @@ -858,7 +858,7 @@ class HeadersBuilder( @OpenApiSchemaDsl class CallbacksBuilder( - private val refCollector: (Set) -> Unit = {}, + private val refCollector: (Set) -> Unit = {}, existing: ObjectNode? = null, ) { @@ -890,7 +890,7 @@ class CallbacksBuilder( @OpenApiSchemaDsl class CallbackOperationBuilder( - private val refCollector: (Set) -> Unit = {}, + private val refCollector: (Set) -> Unit = {}, existing: ObjectNode? = null, ) { diff --git a/openapi-generator/src/test/kotlin/io/javalin/openapi/schema/OpenApiSchemaBuilderTest.kt b/openapi-generator/src/test/kotlin/io/javalin/openapi/schema/OpenApiSchemaBuilderTest.kt index c1dad709..9bc308ae 100644 --- a/openapi-generator/src/test/kotlin/io/javalin/openapi/schema/OpenApiSchemaBuilderTest.kt +++ b/openapi-generator/src/test/kotlin/io/javalin/openapi/schema/OpenApiSchemaBuilderTest.kt @@ -1,7 +1,7 @@ package io.javalin.openapi.schema import com.fasterxml.jackson.databind.JsonNode -import io.javalin.openapi.experimental.ClassDefinition +import io.javalin.openapi.experimental.OpenApiType import io.javalin.openapi.experimental.processor.generators.ResultScheme import io.javalin.openapi.experimental.processor.shared.createArrayNode import io.javalin.openapi.experimental.processor.shared.createObjectNode @@ -711,7 +711,7 @@ internal class OpenApiSchemaBuilderTest { fun `should resolve component references`() { val schema = builder() - val addressDef = ClassDefinition(simpleName = "Address", fullName = "com.example.Address") + val addressDef = OpenApiType(simpleName = "Address", fullName = "com.example.Address") val addressSchema = ResultScheme(createObjectNode().apply { put("type", "object") set("properties", createObjectNode().apply { @@ -748,8 +748,8 @@ internal class OpenApiSchemaBuilderTest { fun `should resolve transitive component references`() { val schema = builder() - val userDef = ClassDefinition(simpleName = "User", fullName = "com.example.User") - val addressDef = ClassDefinition(simpleName = "Address", fullName = "com.example.Address") + val userDef = OpenApiType(simpleName = "User", fullName = "com.example.User") + val addressDef = OpenApiType(simpleName = "Address", fullName = "com.example.Address") // User references Address val userSchema = ResultScheme(createObjectNode().apply { @@ -798,7 +798,7 @@ internal class OpenApiSchemaBuilderTest { fun `should skip java-lang-Object references`() { val schema = builder() - val objectDef = ClassDefinition(simpleName = "Object", fullName = "java.lang.Object") + val objectDef = OpenApiType(simpleName = "Object", fullName = "java.lang.Object") schema.path("/test").operation("get") { responses { @@ -826,8 +826,8 @@ internal class OpenApiSchemaBuilderTest { fun `should resolve circular references between types`() { val schema = builder() - val aDef = ClassDefinition(simpleName = "A", fullName = "com.example.A") - val bDef = ClassDefinition(simpleName = "B", fullName = "com.example.B") + val aDef = OpenApiType(simpleName = "A", fullName = "com.example.A") + val bDef = OpenApiType(simpleName = "B", fullName = "com.example.B") schema.path("/test").operation("get") { responses { @@ -861,7 +861,7 @@ internal class OpenApiSchemaBuilderTest { fun `should resolve deep transitive chain`() { val schema = builder() - val aDef = ClassDefinition(simpleName = "A", fullName = "com.example.A") + val aDef = OpenApiType(simpleName = "A", fullName = "com.example.A") schema.path("/test").operation("get") { responses { @@ -880,8 +880,8 @@ internal class OpenApiSchemaBuilderTest { } // A -> B -> C (chain of 3) - val bDef = ClassDefinition(simpleName = "B", fullName = "com.example.B") - val cDef = ClassDefinition(simpleName = "C", fullName = "com.example.C") + val bDef = OpenApiType(simpleName = "B", fullName = "com.example.B") + val cDef = OpenApiType(simpleName = "C", fullName = "com.example.C") schema.resolveComponentReferences { type -> when (type.fullName) { @@ -1168,7 +1168,7 @@ internal class OpenApiSchemaBuilderTest { @Test fun `should use content wrapper for parameter with complex type references`() { - val sorterDef = ClassDefinition(simpleName = "Sorter", fullName = "com.example.Sorter") + val sorterDef = OpenApiType(simpleName = "Sorter", fullName = "com.example.Sorter") val schema = builder() schema.path("/users").operation("get") { parameters { From c9ff59d462da4d27dcba44d12400aa9f59838a0d Mon Sep 17 00:00:00 2001 From: dzikoysk Date: Sun, 21 Jun 2026 02:12:28 +0200 Subject: [PATCH 13/43] feat: impl ksp backend Claude-Session: https://claude.ai/code/session_01NjkDTqWZkjLPL9Wn4AyFCf --- .../dynamic/ReflectionSchemaContext.kt | 77 ++----------------- ...Test.kt => ReflectionSchemaContextTest.kt} | 12 +-- .../experimental/IntrospectorSchemaContext.kt | 72 +++++++++++++++++ .../{ClassDefinitionApi.kt => OpenApiType.kt} | 23 ++++-- ...rocessorConfiguration.kt => SimpleType.kt} | 0 openapi-ksp/build.gradle.kts | 14 ++++ .../javalin/openapi/ksp/KspSchemaContext.kt | 18 +++++ .../openapi/ksp/KspSchemaContextTest.kt | 61 +++++++++++++++ .../io/javalin/openapi/ksp/fixtures/User.kt | 7 ++ settings.gradle.kts | 1 + 10 files changed, 203 insertions(+), 82 deletions(-) rename openapi-dynamic/src/test/kotlin/io/javalin/openapi/dynamic/{ReflectiveTypeIntrospectorTest.kt => ReflectionSchemaContextTest.kt} (88%) create mode 100644 openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/IntrospectorSchemaContext.kt rename openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/{ClassDefinitionApi.kt => OpenApiType.kt} (57%) rename openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/{OpenApiAnnotationProcessorConfiguration.kt => SimpleType.kt} (100%) create mode 100644 openapi-ksp/build.gradle.kts create mode 100644 openapi-ksp/src/main/kotlin/io/javalin/openapi/ksp/KspSchemaContext.kt create mode 100644 openapi-ksp/src/test/kotlin/io/javalin/openapi/ksp/KspSchemaContextTest.kt create mode 100644 openapi-ksp/src/test/kotlin/io/javalin/openapi/ksp/fixtures/User.kt diff --git a/openapi-dynamic/src/main/kotlin/io/javalin/openapi/dynamic/ReflectionSchemaContext.kt b/openapi-dynamic/src/main/kotlin/io/javalin/openapi/dynamic/ReflectionSchemaContext.kt index 38174937..ba679df8 100644 --- a/openapi-dynamic/src/main/kotlin/io/javalin/openapi/dynamic/ReflectionSchemaContext.kt +++ b/openapi-dynamic/src/main/kotlin/io/javalin/openapi/dynamic/ReflectionSchemaContext.kt @@ -1,81 +1,16 @@ package io.javalin.openapi.dynamic -import io.javalin.introspection.Annotations -import io.javalin.introspection.EnumConstantView -import io.javalin.introspection.InternalIntrospectionApi -import io.javalin.introspection.PropertyView +import io.javalin.introspection.TypeIntrospector import io.javalin.introspection.runtime.ReflectionTypeIntrospector -import io.javalin.openapi.OpenApiName -import io.javalin.openapi.experimental.OpenApiType -import io.javalin.openapi.experimental.InternalOpenApiTypeApi -import io.javalin.openapi.experimental.EmbeddedTypeProcessor -import io.javalin.openapi.experimental.SchemaGenerationContext +import io.javalin.openapi.experimental.IntrospectorSchemaContext import io.javalin.openapi.experimental.SimpleType -import io.javalin.openapi.experimental.StructureType -import io.javalin.openapi.experimental.defaults.createDefaultEmbeddedTypeProcessors import io.javalin.openapi.experimental.defaults.createDefaultSimpleTypeMappings -import io.javalin.openapi.experimental.processor.generators.ResultScheme -import io.javalin.openapi.experimental.processor.generators.TypeSchemaGenerator -import java.lang.reflect.Type -import io.javalin.introspection.ClassDefinition as RawType -/** - * Runtime-reflection [SchemaGenerationContext] — the reflection counterpart of the annotation processor's context. - * Resolves [Type]s into the OpenAPI model and drives the shared [TypeSchemaGenerator]. Stateless beyond the generator's - * memo cache, so a fresh instance per document build keeps that cache document-scoped. - */ +/** Runtime-reflection backend: drives the shared generator over [ReflectionTypeIntrospector]. */ class ReflectionSchemaContext( - override val simpleTypeMappings: Map = createDefaultSimpleTypeMappings(), -) : SchemaGenerationContext { + simpleTypeMappings: Map = createDefaultSimpleTypeMappings(), +) : IntrospectorSchemaContext(simpleTypeMappings) { - private val runtime = ReflectionTypeIntrospector() - - override val typeSchemaGenerator: TypeSchemaGenerator = TypeSchemaGenerator(this) - override val embeddedTypeProcessors: List = createDefaultEmbeddedTypeProcessors() - - /** Resolve a reflection [type] into the OpenAPI [OpenApiType] model, applying `@OpenApiName`. */ - fun introspect(type: Type): OpenApiType = - toOpenApiType(runtime.introspect(type)) - - /** Full component schema for an already-resolved [type] — the body stored under `components/schemas`. */ - fun componentSchema(type: OpenApiType): ResultScheme = - typeSchemaGenerator.createTypeSchema(type) - - /** Inline schema (`$ref` or structure) for a raw reflection [type]. */ - fun inlineSchema(type: Type): ResultScheme = - typeSchemaGenerator.createEmbeddedTypeDescription(introspect(type)) - - @OptIn(InternalIntrospectionApi::class) - override fun toOpenApiType(raw: RawType): OpenApiType { - val erasure = raw.source as Class<*> - val customName = erasure.getAnnotation(OpenApiName::class.java)?.value - val packageName = erasure.`package`?.name?.takeIf { it.isNotEmpty() } - return OpenApiType( - simpleName = customName ?: raw.simpleName, - fullName = when { - customName != null -> if (packageName == null) customName else "$packageName.$customName" - else -> raw.fullName - }, - generics = raw.generics.map { toOpenApiType(it) }, - structureType = StructureType.valueOf(raw.structureType.name), - handle = raw, - ) - } - - override fun isEnum(type: OpenApiType): Boolean = - type.raw.isEnum() - - override fun annotationsOf(type: OpenApiType): Annotations = - type.raw.getAnnotations() - - override fun propertiesOf(type: OpenApiType): List = - type.raw.getProperties() - - override fun enumConstantsOf(type: OpenApiType): List = - type.raw.getEnumConstants() ?: emptyList() + override val introspector: TypeIntrospector = ReflectionTypeIntrospector() } - -@OptIn(InternalOpenApiTypeApi::class) -private val OpenApiType.raw: RawType - get() = handle as RawType diff --git a/openapi-dynamic/src/test/kotlin/io/javalin/openapi/dynamic/ReflectiveTypeIntrospectorTest.kt b/openapi-dynamic/src/test/kotlin/io/javalin/openapi/dynamic/ReflectionSchemaContextTest.kt similarity index 88% rename from openapi-dynamic/src/test/kotlin/io/javalin/openapi/dynamic/ReflectiveTypeIntrospectorTest.kt rename to openapi-dynamic/src/test/kotlin/io/javalin/openapi/dynamic/ReflectionSchemaContextTest.kt index cc0bb3dc..14b70e72 100644 --- a/openapi-dynamic/src/test/kotlin/io/javalin/openapi/dynamic/ReflectiveTypeIntrospectorTest.kt +++ b/openapi-dynamic/src/test/kotlin/io/javalin/openapi/dynamic/ReflectionSchemaContextTest.kt @@ -11,19 +11,19 @@ import io.javalin.openapi.experimental.StructureType import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test -class ReflectiveTypeIntrospectorTest { +class ReflectionSchemaContextTest { - private val introspector = ReflectiveTypeIntrospector() + private val schemaContext = ReflectionSchemaContext() private fun schemaOf(type: Class<*>): JsonNode = - introspector.typeSchemaGenerator.createTypeSchema(introspector.introspect(type)).json + schemaContext.componentSchema(schemaContext.introspect(type)).json private fun propertyNames(type: Class<*>): List = schemaOf(type).path("properties").fieldNames().asSequence().toList() @Test - fun `resolves a class into the shared ClassDefinition model`() { - val account = introspector.introspect(Account::class.java) + fun `resolves a class into the shared OpenApiType model`() { + val account = schemaContext.introspect(Account::class.java) assertThat(account.simpleName).isEqualTo("Account") assertThat(account.fullName).isEqualTo("io.javalin.openapi.dynamic.fixtures.Account") @@ -59,7 +59,7 @@ class ReflectiveTypeIntrospectorTest { @Test fun `reads enum constants with renames`() { - assertThat(introspector.isEnum(introspector.introspect(Role::class.java))).isTrue() + assertThat(schemaContext.isEnum(schemaContext.introspect(Role::class.java))).isTrue() val role = schemaOf(Role::class.java) assertThat(role.path("type").asText()).isEqualTo("string") diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/IntrospectorSchemaContext.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/IntrospectorSchemaContext.kt new file mode 100644 index 00000000..bfc220c4 --- /dev/null +++ b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/IntrospectorSchemaContext.kt @@ -0,0 +1,72 @@ +package io.javalin.openapi.experimental + +import io.javalin.introspection.Annotations +import io.javalin.introspection.EnumConstantView +import io.javalin.introspection.PropertyView +import io.javalin.introspection.TypeIntrospector +import io.javalin.openapi.OpenApiName +import io.javalin.openapi.experimental.defaults.createDefaultEmbeddedTypeProcessors +import io.javalin.openapi.experimental.defaults.createDefaultSimpleTypeMappings +import io.javalin.openapi.experimental.processor.generators.ResultScheme +import io.javalin.openapi.experimental.processor.generators.TypeSchemaGenerator +import io.javalin.introspection.ClassDefinition as RawType + +/** + * [SchemaGenerationContext] driven entirely by a neutral [TypeIntrospector]: it carries the introspected [RawType] + * in [OpenApiType.handle] and delegates every navigation call to it. A concrete backend supplies only the + * [introspector] (runtime reflection, KSP, ...); `@OpenApiName` naming, the default processors, and schema generation + * are all shared. The annotation processor stays separate because it needs javac-native tokens (round scan, element + * filtering) that this neutral seam deliberately hides. + */ +abstract class IntrospectorSchemaContext( + override val simpleTypeMappings: Map = createDefaultSimpleTypeMappings(), +) : SchemaGenerationContext { + + protected abstract val introspector: TypeIntrospector + + override val typeSchemaGenerator: TypeSchemaGenerator = TypeSchemaGenerator(this) + override val embeddedTypeProcessors: List = createDefaultEmbeddedTypeProcessors() + + /** Resolve a backend-native type token (`Class`, `KSType`, ...) into the OpenAPI model. */ + fun introspect(nativeType: Any): OpenApiType = + toOpenApiType(introspector.introspect(nativeType)) + + /** Full component schema for an already-resolved [type] — the body stored under `components/schemas`. */ + fun componentSchema(type: OpenApiType): ResultScheme = + typeSchemaGenerator.createTypeSchema(type) + + /** Inline schema (`$ref` or structure) for a backend-native type token. */ + fun inlineSchema(nativeType: Any): ResultScheme = + typeSchemaGenerator.createEmbeddedTypeDescription(introspect(nativeType)) + + override fun toOpenApiType(raw: RawType): OpenApiType { + val customName = raw.getAnnotations().memberValues(OpenApiName::class.java)?.get("value") as? String + val packageName = raw.fullName.substringBeforeLast('.', "") + return OpenApiType( + simpleName = customName ?: raw.simpleName, + fullName = when { + customName == null -> raw.fullName + packageName.isEmpty() -> customName + else -> "$packageName.$customName" + }, + generics = raw.generics.map { toOpenApiType(it) }, + structureType = StructureType.valueOf(raw.structureType.name), + handle = raw, + ) + } + + override fun isEnum(type: OpenApiType): Boolean = + type.raw.isEnum() + + override fun annotationsOf(type: OpenApiType): Annotations = + type.raw.getAnnotations() + + override fun propertiesOf(type: OpenApiType): List = + type.raw.getProperties() + + override fun enumConstantsOf(type: OpenApiType): List = + type.raw.getEnumConstants() ?: emptyList() +} + +private val OpenApiType.raw: RawType + @OptIn(InternalOpenApiTypeApi::class) get() = handle as RawType diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/ClassDefinitionApi.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/OpenApiType.kt similarity index 57% rename from openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/ClassDefinitionApi.kt rename to openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/OpenApiType.kt index cef9429d..94ee6c74 100644 --- a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/ClassDefinitionApi.kt +++ b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/OpenApiType.kt @@ -2,19 +2,32 @@ package io.javalin.openapi.experimental import io.javalin.openapi.experimental.StructureType.DEFAULT -class ClassDefinition( +/** + * Opt-in marker for [OpenApiType.handle] — the backend-native token (`OpenApiTypeHandle`, a neutral + * `io.javalin.introspection.ClassDefinition`, ...). Only the backend that produced an [OpenApiType] may cast it; + * backend-agnostic generator code must navigate via [SchemaGenerationContext] instead. + */ +@RequiresOptIn( + level = RequiresOptIn.Level.ERROR, + message = "handle is the backend-native token behind OpenApiType; only the producing backend may cast it.", +) +@Retention(AnnotationRetention.BINARY) +@Target(AnnotationTarget.PROPERTY) +annotation class InternalOpenApiTypeApi + +class OpenApiType( val simpleName: String, val fullName: String, - val generics: List = emptyList(), + val generics: List = emptyList(), val structureType: StructureType = DEFAULT, val extra: MutableList = mutableListOf(), - @JvmField val handle: Any? = null + @property:InternalOpenApiTypeApi @JvmField val handle: Any? = null ) { override fun equals(other: Any?): Boolean = when { this === other -> true - other is ClassDefinition -> + other is OpenApiType -> this.fullName == other.fullName && this.generics == other.generics && this.structureType == other.structureType @@ -46,5 +59,5 @@ interface Extra class CustomProperty( val name: String, - val type: ClassDefinition + val type: OpenApiType ) : Extra diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/OpenApiAnnotationProcessorConfiguration.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/SimpleType.kt similarity index 100% rename from openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/OpenApiAnnotationProcessorConfiguration.kt rename to openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/SimpleType.kt diff --git a/openapi-ksp/build.gradle.kts b/openapi-ksp/build.gradle.kts new file mode 100644 index 00000000..4307ffb7 --- /dev/null +++ b/openapi-ksp/build.gradle.kts @@ -0,0 +1,14 @@ +description = "Javalin OpenAPI KSP | Kotlin Symbol Processing backend for OpenAPI schema generation (experimental)" + +dependencies { + api(project(":openapi-generator")) + api(project(":introspection:introspection-ksp")) + api(libs.ksp.symbol.processing.api) // KspSchemaContext's constructor exposes a KSP Resolver + + testImplementation(libs.kctfork.core) + testImplementation(libs.kctfork.ksp) + testImplementation(libs.junit.jupiter.api) + testImplementation(libs.junit.jupiter.engine) + testRuntimeOnly(libs.junit.platform.launcher) + testImplementation(libs.assertj.core) +} diff --git a/openapi-ksp/src/main/kotlin/io/javalin/openapi/ksp/KspSchemaContext.kt b/openapi-ksp/src/main/kotlin/io/javalin/openapi/ksp/KspSchemaContext.kt new file mode 100644 index 00000000..017cb2a2 --- /dev/null +++ b/openapi-ksp/src/main/kotlin/io/javalin/openapi/ksp/KspSchemaContext.kt @@ -0,0 +1,18 @@ +package io.javalin.openapi.ksp + +import com.google.devtools.ksp.processing.Resolver +import io.javalin.introspection.TypeIntrospector +import io.javalin.introspection.ksp.KspTypeIntrospector +import io.javalin.openapi.experimental.IntrospectorSchemaContext +import io.javalin.openapi.experimental.SimpleType +import io.javalin.openapi.experimental.defaults.createDefaultSimpleTypeMappings + +/** KSP backend: drives the shared generator over [KspTypeIntrospector] — the third backend on the same base. */ +class KspSchemaContext( + resolver: Resolver, + simpleTypeMappings: Map = createDefaultSimpleTypeMappings(), +) : IntrospectorSchemaContext(simpleTypeMappings) { + + override val introspector: TypeIntrospector = KspTypeIntrospector(resolver) + +} diff --git a/openapi-ksp/src/test/kotlin/io/javalin/openapi/ksp/KspSchemaContextTest.kt b/openapi-ksp/src/test/kotlin/io/javalin/openapi/ksp/KspSchemaContextTest.kt new file mode 100644 index 00000000..e1cf5c08 --- /dev/null +++ b/openapi-ksp/src/test/kotlin/io/javalin/openapi/ksp/KspSchemaContextTest.kt @@ -0,0 +1,61 @@ +package io.javalin.openapi.ksp + +import com.google.devtools.ksp.processing.Resolver +import com.google.devtools.ksp.processing.SymbolProcessor +import com.google.devtools.ksp.processing.SymbolProcessorEnvironment +import com.google.devtools.ksp.processing.SymbolProcessorProvider +import com.google.devtools.ksp.symbol.KSAnnotated +import com.tschuchort.compiletesting.KotlinCompilation +import com.tschuchort.compiletesting.SourceFile +import com.tschuchort.compiletesting.symbolProcessorProviders +import com.tschuchort.compiletesting.useKsp2 +import org.assertj.core.api.Assertions.assertThat +import org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi +import org.junit.jupiter.api.Test + +@OptIn(ExperimentalCompilerApi::class) +class KspSchemaContextTest { + + /** Runs KSP in-process over a trivial trigger; the block resolves the classpath fixtures via [Resolver]. */ + private fun withResolver(block: (Resolver) -> R): R { + var result: Result? = null + val provider = object : SymbolProcessorProvider { + override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor = + object : SymbolProcessor { + override fun process(resolver: Resolver): List { + if (result == null) result = runCatching { block(resolver) } + return emptyList() + } + } + } + val compilation = KotlinCompilation().apply { + useKsp2() + sources = listOf(SourceFile.kotlin("Trigger.kt", "package trigger\nclass Trigger")) + symbolProcessorProviders = mutableListOf(provider) + inheritClassPath = true + messageOutputStream = System.out + } + val compiled = compilation.compile() + check(compiled.exitCode == KotlinCompilation.ExitCode.OK) { "KSP compilation failed: ${compiled.messages}" } + return (result ?: error("KSP processor did not run")).getOrThrow() + } + + @Test + fun `generates a component schema end-to-end through the shared generator`() { + withResolver { resolver -> + val context = KspSchemaContext(resolver) + val user = resolver.getClassDeclarationByName(resolver.getKSNameFromString("io.javalin.openapi.ksp.fixtures.User"))!! + .asStarProjectedType() + + val schema = context.componentSchema(context.introspect(user)).json + val properties = schema.path("properties") + + assertThat(schema.path("type").asText()).isEqualTo("object") + assertThat(properties.path("id").path("type").asText()).isEqualTo("string") + assertThat(properties.path("age").path("type").asText()).isEqualTo("integer") + assertThat(properties.path("age").path("format").asText()).isEqualTo("int32") + assertThat(properties.path("tags").path("type").asText()).isEqualTo("array") + assertThat(properties.path("tags").path("items").path("type").asText()).isEqualTo("string") + } + } +} diff --git a/openapi-ksp/src/test/kotlin/io/javalin/openapi/ksp/fixtures/User.kt b/openapi-ksp/src/test/kotlin/io/javalin/openapi/ksp/fixtures/User.kt new file mode 100644 index 00000000..dde5230b --- /dev/null +++ b/openapi-ksp/src/test/kotlin/io/javalin/openapi/ksp/fixtures/User.kt @@ -0,0 +1,7 @@ +package io.javalin.openapi.ksp.fixtures + +class User( + val id: String, + val age: Int, + val tags: List, +) diff --git a/settings.gradle.kts b/settings.gradle.kts index bd23642f..abb9fbbf 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -5,6 +5,7 @@ include( "openapi-generator", "openapi-annotation-processor", "openapi-dynamic", + "openapi-ksp", "introspection", "introspection:introspection-api", "introspection:introspection-runtime", From 4411a498c5ab7eda3e636a34f4d3931789729652 Mon Sep 17 00:00:00 2001 From: dzikoysk Date: Sun, 21 Jun 2026 14:38:30 +0200 Subject: [PATCH 14/43] feat: ksp output Claude-Session: https://claude.ai/code/session_01NjkDTqWZkjLPL9Wn4AyFCf --- .../openapi/ksp/OpenApiSymbolProcessor.kt | 53 +++++++++++++++++++ ...ols.ksp.processing.SymbolProcessorProvider | 1 + .../openapi/ksp/OpenApiSymbolProcessorTest.kt | 48 +++++++++++++++++ 3 files changed, 102 insertions(+) create mode 100644 openapi-ksp/src/main/kotlin/io/javalin/openapi/ksp/OpenApiSymbolProcessor.kt create mode 100644 openapi-ksp/src/main/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider create mode 100644 openapi-ksp/src/test/kotlin/io/javalin/openapi/ksp/OpenApiSymbolProcessorTest.kt diff --git a/openapi-ksp/src/main/kotlin/io/javalin/openapi/ksp/OpenApiSymbolProcessor.kt b/openapi-ksp/src/main/kotlin/io/javalin/openapi/ksp/OpenApiSymbolProcessor.kt new file mode 100644 index 00000000..6e3df0a3 --- /dev/null +++ b/openapi-ksp/src/main/kotlin/io/javalin/openapi/ksp/OpenApiSymbolProcessor.kt @@ -0,0 +1,53 @@ +package io.javalin.openapi.ksp + +import com.google.devtools.ksp.processing.CodeGenerator +import com.google.devtools.ksp.processing.Dependencies +import com.google.devtools.ksp.processing.Resolver +import com.google.devtools.ksp.processing.SymbolProcessor +import com.google.devtools.ksp.processing.SymbolProcessorEnvironment +import com.google.devtools.ksp.processing.SymbolProcessorProvider +import com.google.devtools.ksp.symbol.KSAnnotated +import com.google.devtools.ksp.symbol.KSClassDeclaration +import io.javalin.openapi.JsonSchema + +/** + * KSP backend processor. It only does the backend-specific shell — *scan* annotated symbols via the [Resolver] and + * *write* resources via the [CodeGenerator]; the actual schema generation is the shared, backend-agnostic engine + * driven through [KspSchemaContext]. (Currently emits `@JsonSchema` json-schemes; `@OpenApi` documents follow once the + * operation generator is neutralized onto the shared seam.) + */ +class OpenApiSymbolProcessor(private val codeGenerator: CodeGenerator) : SymbolProcessor { + + override fun process(resolver: Resolver): List { + val context = KspSchemaContext(resolver) + + val generated = resolver.getSymbolsWithAnnotation(JsonSchema::class.qualifiedName!!) + .filterIsInstance() + .mapNotNull { declaration -> + val type = context.introspect(declaration.asStarProjectedType()) + if (context.annotationsOf(type).memberValues(JsonSchema::class.java)?.get("generateResource") == false) { + return@mapNotNull null + } + val json = context.typeSchemaGenerator.createTypeSchema(type, inlineRefs = true).toJsonSchemaString() + writeResource("json-schemes/${type.fullName}", json) + type.fullName + } + .toList() + + if (generated.isNotEmpty()) { + writeResource("json-schemes/index", generated.joinToString(separator = "\n")) + } + + return emptyList() + } + + private fun writeResource(path: String, content: String) = + codeGenerator.createNewFileByPath(Dependencies(aggregating = true), path, extensionName = "") + .use { it.write(content.toByteArray()) } + +} + +class OpenApiSymbolProcessorProvider : SymbolProcessorProvider { + override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor = + OpenApiSymbolProcessor(environment.codeGenerator) +} diff --git a/openapi-ksp/src/main/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider b/openapi-ksp/src/main/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider new file mode 100644 index 00000000..44b60885 --- /dev/null +++ b/openapi-ksp/src/main/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider @@ -0,0 +1 @@ +io.javalin.openapi.ksp.OpenApiSymbolProcessorProvider diff --git a/openapi-ksp/src/test/kotlin/io/javalin/openapi/ksp/OpenApiSymbolProcessorTest.kt b/openapi-ksp/src/test/kotlin/io/javalin/openapi/ksp/OpenApiSymbolProcessorTest.kt new file mode 100644 index 00000000..f2a303b6 --- /dev/null +++ b/openapi-ksp/src/test/kotlin/io/javalin/openapi/ksp/OpenApiSymbolProcessorTest.kt @@ -0,0 +1,48 @@ +package io.javalin.openapi.ksp + +import com.tschuchort.compiletesting.KotlinCompilation +import com.tschuchort.compiletesting.SourceFile +import com.tschuchort.compiletesting.kspSourcesDir +import com.tschuchort.compiletesting.symbolProcessorProviders +import com.tschuchort.compiletesting.useKsp2 +import io.javalin.openapi.experimental.processor.shared.jsonMapper +import org.assertj.core.api.Assertions.assertThat +import org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi +import org.junit.jupiter.api.Test + +@OptIn(ExperimentalCompilerApi::class) +class OpenApiSymbolProcessorTest { + + @Test + fun `writes a json-scheme resource for a JsonSchema type via the shared generator`() { + val compilation = KotlinCompilation().apply { + useKsp2() + sources = listOf( + SourceFile.kotlin( + "Widget.kt", + """ + package fixtures + import io.javalin.openapi.JsonSchema + @JsonSchema + class Widget(val name: String, val size: Int) + """.trimIndent() + ) + ) + symbolProcessorProviders = mutableListOf(OpenApiSymbolProcessorProvider()) + inheritClassPath = true + messageOutputStream = System.out + } + + val result = compilation.compile() + check(result.exitCode == KotlinCompilation.ExitCode.OK) { "KSP compilation failed: ${result.messages}" } + + val scheme = compilation.kspSourcesDir.walkTopDown().firstOrNull { it.name == "fixtures.Widget" } + ?: error("json-scheme not generated. Output tree:\n" + compilation.kspSourcesDir.walkTopDown().joinToString("\n")) + + val doc = jsonMapper.readTree(scheme.readText()) + assertThat(doc.path("type").asText()).isEqualTo("object") + assertThat(doc.path("properties").path("name").path("type").asText()).isEqualTo("string") + assertThat(doc.path("properties").path("size").path("type").asText()).isEqualTo("integer") + assertThat(doc.path("properties").path("size").path("format").asText()).isEqualTo("int32") + } +} From 325d20bc8ab5ec451ccbabbd11424b67cb146f70 Mon Sep 17 00:00:00 2001 From: dzikoysk Date: Sun, 21 Jun 2026 15:46:06 +0200 Subject: [PATCH 15/43] chore: cleanup docs --- .../io/javalin/introspection/Annotations.kt | 22 ++----------------- .../javalin/introspection/ClassDefinition.kt | 3 --- .../introspection/InternalIntrospectionApi.kt | 5 ----- .../io/javalin/introspection/PropertyView.kt | 3 --- .../javalin/introspection/TypeIntrospector.kt | 1 - .../introspection/jap/JapTypeIntrospector.kt | 1 - .../introspection/ksp/KspTypeIntrospector.kt | 11 ++-------- .../runtime/ReflectionTypeIntrospector.kt | 6 +---- .../openapi/dynamic/hook/OpenApiMetadata.kt | 3 --- .../dynamic/hook/RegisteredRoutesHook.kt | 1 - .../AnnotationProcessorContext.kt | 2 -- .../openapi/experimental/OpenApiTypeHandle.kt | 1 - .../processor/PropertySelectionTest.kt | 1 - .../dynamic/ReflectionSchemaContext.kt | 1 - .../openapi/dynamic/DynamicCompositionTest.kt | 1 - .../experimental/IntrospectorSchemaContext.kt | 10 --------- .../openapi/experimental/OpenApiType.kt | 5 ----- .../experimental/SchemaGenerationContext.kt | 8 ------- .../defaults/DefaultEmbeddedTypeProcessors.kt | 1 - .../generators/CompositionGenerator.kt | 3 +-- .../generators/TypeSchemaGenerator.kt | 1 - .../javalin/openapi/ksp/KspSchemaContext.kt | 1 - .../openapi/ksp/OpenApiSymbolProcessor.kt | 6 ----- .../openapi/ksp/KspSchemaContextTest.kt | 1 - 24 files changed, 6 insertions(+), 92 deletions(-) diff --git a/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/Annotations.kt b/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/Annotations.kt index 0642ca48..1069cc13 100644 --- a/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/Annotations.kt +++ b/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/Annotations.kt @@ -1,48 +1,30 @@ package io.javalin.introspection -/** - * Backend-agnostic, name/value-based access to annotations. Deliberately avoids handing back typed annotation - * instances or member lambdas — those can't be honored on KSP (no JVM instances). Class-valued members are read - * through [memberValues] (resolved to [ClassDefinition]), so [resolveClass]/[resolveClasses] are derived for free. - */ +// Name/value-based on purpose: typed annotation instances and member lambdas can't be honored on KSP. interface Annotations { - /** True if an annotation with this simple name is present (match e.g. `@NotNull` without depending on it). */ fun hasNamed(simpleName: String): Boolean - /** - * Members of [annotationType] (with defaults) as a name→value map, or null if absent. - * Class members → [ClassDefinition], enum members → constant name, nested annotations → name→value map, arrays → list. - */ + // Members (with defaults), or null if absent. Classes → ClassDefinition, enums → constant name, nested → map, arrays → list. fun memberValues(annotationType: Class): Map? - /** Each occurrence of a repeatable annotation, as a member value-map. */ fun memberValuesList(annotationType: Class): List> - /** Every annotation present, as neutral views — for scanning by meta-annotation without depending on the annotation types. */ fun all(): List - /** True if an annotation of [annotationType] is present. */ fun has(annotationType: Class): Boolean = memberValues(annotationType) != null - /** Resolve a class-valued member to a [ClassDefinition]. */ fun resolveClass(annotationType: Class, member: String): ClassDefinition? = memberValues(annotationType)?.get(member) as? ClassDefinition - /** Resolve a class-array-valued member to [ClassDefinition]s. */ fun resolveClasses(annotationType: Class, member: String): List = (memberValues(annotationType)?.get(member) as? List<*>)?.filterIsInstance() ?: emptyList() } interface AnnotationView { - val qualifiedName: String - val simpleName: String - - /** Annotations on this annotation's own type — e.g. to detect `@CustomAnnotation`. */ val meta: Annotations - fun values(): Map } diff --git a/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/ClassDefinition.kt b/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/ClassDefinition.kt index 8745acc4..f858ba9a 100644 --- a/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/ClassDefinition.kt +++ b/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/ClassDefinition.kt @@ -1,6 +1,5 @@ package io.javalin.introspection -/** A type resolved by a backend; navigation is implemented per backend. */ abstract class ClassDefinition( val simpleName: String, val fullName: String, @@ -8,7 +7,6 @@ abstract class ClassDefinition( val structureType: StructureType = StructureType.DEFAULT, ) { - /** Backend-native token this was resolved from (`Class`, `TypeMirror`, ...); only the producing backend may cast it. */ @InternalIntrospectionApi abstract val source: Any @@ -26,5 +24,4 @@ abstract class ClassDefinition( enum class StructureType { DEFAULT, ARRAY, DICTIONARY } -/** An enum constant paired with its declared annotations (e.g. for `@OpenApiName` overrides). */ class EnumConstantView(val name: String, val annotations: Annotations) diff --git a/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/InternalIntrospectionApi.kt b/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/InternalIntrospectionApi.kt index 7f0713d9..05269de3 100644 --- a/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/InternalIntrospectionApi.kt +++ b/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/InternalIntrospectionApi.kt @@ -1,10 +1,5 @@ package io.javalin.introspection -/** - * Opt-in marker for [ClassDefinition.source] / [PropertyView.source] — the backend-native token (`Class`, - * `TypeMirror`, `KSType`, ...). It is only meaningful to the backend that produced it; backend-agnostic code must - * never cast it. Opt in only inside a backend adapter that knows the concrete token type. - */ @RequiresOptIn( level = RequiresOptIn.Level.ERROR, message = "source is a backend-native token; only the producing backend may read/cast it. Agnostic code must not touch it.", diff --git a/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/PropertyView.kt b/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/PropertyView.kt index 09a0ac30..9b979541 100644 --- a/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/PropertyView.kt +++ b/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/PropertyView.kt @@ -1,6 +1,5 @@ package io.javalin.introspection -/** A raw member (getter, field, or record component) — no naming/ignore policy applied. */ class PropertyView( val name: String, val type: ClassDefinition, @@ -16,12 +15,10 @@ enum class Accessor { FIELD, GETTER, RECORD_COMPONENT } enum class Visibility { PUBLIC, PROTECTED, PACKAGE_PRIVATE, PRIVATE } -/** A getter name: `getX` / `isX` with an uppercase char right after the prefix (excludes e.g. `issue`, `getaway`). */ fun isGetterName(name: String): Boolean = (name.startsWith("get") && name.length > 3 && name[3].isUpperCase()) || (name.startsWith("is") && name.length > 2 && name[2].isUpperCase()) -/** Logical property name of a getter (`getId`→`id`, `isActive`→`active`); call only on names that pass [isGetterName]. */ fun propertyName(getterName: String): String = (if (getterName.startsWith("get")) getterName.removePrefix("get") else getterName.removePrefix("is")) .replaceFirstChar { it.lowercase() } diff --git a/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/TypeIntrospector.kt b/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/TypeIntrospector.kt index 128ac447..d501952d 100644 --- a/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/TypeIntrospector.kt +++ b/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/TypeIntrospector.kt @@ -1,6 +1,5 @@ package io.javalin.introspection -/** Resolves a backend-native type token (Class, TypeMirror, KSType) into a [ClassDefinition]. */ fun interface TypeIntrospector { fun introspect(source: Any): ClassDefinition } diff --git a/introspection/introspection-jap/src/main/kotlin/io/javalin/introspection/jap/JapTypeIntrospector.kt b/introspection/introspection-jap/src/main/kotlin/io/javalin/introspection/jap/JapTypeIntrospector.kt index 6b9c91af..f68ed9e6 100644 --- a/introspection/introspection-jap/src/main/kotlin/io/javalin/introspection/jap/JapTypeIntrospector.kt +++ b/introspection/introspection-jap/src/main/kotlin/io/javalin/introspection/jap/JapTypeIntrospector.kt @@ -34,7 +34,6 @@ import javax.lang.model.util.Elements import javax.lang.model.util.SimpleAnnotationValueVisitor8 import javax.lang.model.util.Types -/** [TypeIntrospector] backed by `javax.lang.model` (Java annotation processing). */ class JapTypeIntrospector(private val types: Types, private val elements: Elements) : TypeIntrospector { override fun introspect(source: Any): ClassDefinition { diff --git a/introspection/introspection-ksp/src/main/kotlin/io/javalin/introspection/ksp/KspTypeIntrospector.kt b/introspection/introspection-ksp/src/main/kotlin/io/javalin/introspection/ksp/KspTypeIntrospector.kt index c7ecb6ed..34229cca 100644 --- a/introspection/introspection-ksp/src/main/kotlin/io/javalin/introspection/ksp/KspTypeIntrospector.kt +++ b/introspection/introspection-ksp/src/main/kotlin/io/javalin/introspection/ksp/KspTypeIntrospector.kt @@ -26,13 +26,8 @@ import io.javalin.introspection.TypeIntrospector import io.javalin.introspection.Visibility import com.google.devtools.ksp.symbol.Visibility as KspVisibility -/** - * Prototype [TypeIntrospector] backed by Kotlin Symbol Processing (KSP). - * - * KSP works on the *source/symbol* model, so it surfaces a Kotlin `val x` as one logical property (not a private - * field + `getX()` like the compiled-bytecode backends), names builtins with Kotlin FQNs (`kotlin.Int`), and cannot - * materialize JVM annotation instances — so annotation access here is purely name/value based. - */ +// KSP can't materialize JVM annotation instances (annotation access is name/value only) and names builtins with +// Kotlin FQNs (`kotlin.Int`), which `canonicalName` normalizes to their JVM equivalents to match jap/reflection. class KspTypeIntrospector(private val resolver: Resolver) : TypeIntrospector { private val mapType = builtin("kotlin.collections.Map") @@ -43,7 +38,6 @@ class KspTypeIntrospector(private val resolver: Resolver) : TypeIntrospector { return resolve(source) } - /** Convenience for tests/harness: resolve a type by fully-qualified name. */ fun introspect(qualifiedName: String): ClassDefinition { val declaration = resolver.getClassDeclarationByName(resolver.getKSNameFromString(qualifiedName)) ?: error("KSP cannot resolve $qualifiedName") @@ -80,7 +74,6 @@ class KspTypeIntrospector(private val resolver: Resolver) : TypeIntrospector { ) } - /** Normalize Kotlin builtin FQNs (kotlin.Int, kotlin.String, kotlin.collections.Map, ...) to their JVM names, matching jap/reflection. */ @OptIn(KspExperimental::class) private fun canonicalName(declaration: KSDeclaration): String { val kotlinName = declaration.qualifiedName ?: return declaration.simpleName.asString() diff --git a/introspection/introspection-runtime/src/main/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospector.kt b/introspection/introspection-runtime/src/main/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospector.kt index 947aa3b2..611ebedc 100644 --- a/introspection/introspection-runtime/src/main/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospector.kt +++ b/introspection/introspection-runtime/src/main/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospector.kt @@ -25,7 +25,6 @@ import java.lang.reflect.ParameterizedType import java.lang.reflect.Type import java.lang.reflect.WildcardType -/** [TypeIntrospector] backed by reflection ([java.lang.reflect.Type]). */ class ReflectionTypeIntrospector : TypeIntrospector { override fun introspect(source: Any): ClassDefinition { @@ -134,7 +133,6 @@ private fun collectMembers(clazz: Class<*>): List { val members = mutableListOf() val getterNames = mutableSetOf() - // public getters (incl. inherited + interface defaults) for (method in clazz.methods) { if (Modifier.isStatic(method.modifiers) || method.isBridge || method.isSynthetic) continue if (method.parameterCount != 0 || method.declaringClass == Any::class.java) continue @@ -144,8 +142,7 @@ private fun collectMembers(clazz: Class<*>): List { } } - // non-public getters declared up the class hierarchy (clazz.methods only returns public ones), - // matching JAP's getAllMembers which also surfaces protected/package-private getters + // clazz.methods is public-only, so a second pass picks up inherited protected/package-private getters (jap parity) for (method in nonPublicGettersHierarchy(clazz)) { if (getterNames.add(method.name)) { members += Member(method.name, method.genericReturnType, Accessor.GETTER, visibilityOf(method.modifiers), false, method, listOf(method)) @@ -198,7 +195,6 @@ private fun declaredFieldsHierarchy(clazz: Class<*>): List { return fields } -/** Non-public getters declared up the class hierarchy (leaf-first), with the same inheritance rules as fields. */ private fun nonPublicGettersHierarchy(clazz: Class<*>): List { val getters = mutableListOf() var current: Class<*>? = clazz diff --git a/javalin-plugins/javalin-openapi-dynamic-hook/src/main/kotlin/io/javalin/openapi/dynamic/hook/OpenApiMetadata.kt b/javalin-plugins/javalin-openapi-dynamic-hook/src/main/kotlin/io/javalin/openapi/dynamic/hook/OpenApiMetadata.kt index b43d75c0..74d4a3bf 100644 --- a/javalin-plugins/javalin-openapi-dynamic-hook/src/main/kotlin/io/javalin/openapi/dynamic/hook/OpenApiMetadata.kt +++ b/javalin-plugins/javalin-openapi-dynamic-hook/src/main/kotlin/io/javalin/openapi/dynamic/hook/OpenApiMetadata.kt @@ -6,17 +6,14 @@ import io.javalin.openapi.schema.OperationBuilder import io.javalin.router.EndpointMetadata import java.util.function.Consumer -/** Endpoint metadata documenting a route through the OpenApi [OperationBuilder] DSL. */ class OpenApiMetadata(val configure: OperationBuilder.() -> Unit) : EndpointMetadata { companion object { - /** Java-friendly factory. */ @JvmStatic fun of(configure: Consumer): OpenApiMetadata = OpenApiMetadata { configure.accept(this) } } } -/** Resolve a DTO [type] to a schema inside the content DSL via the reflection engine. */ fun MediaTypeBuilder.schema(type: Class<*>): Unit = schema(ReflectionSchemaContext().inlineSchema(type)) diff --git a/javalin-plugins/javalin-openapi-dynamic-hook/src/main/kotlin/io/javalin/openapi/dynamic/hook/RegisteredRoutesHook.kt b/javalin-plugins/javalin-openapi-dynamic-hook/src/main/kotlin/io/javalin/openapi/dynamic/hook/RegisteredRoutesHook.kt index 13e45272..a7d19842 100644 --- a/javalin-plugins/javalin-openapi-dynamic-hook/src/main/kotlin/io/javalin/openapi/dynamic/hook/RegisteredRoutesHook.kt +++ b/javalin-plugins/javalin-openapi-dynamic-hook/src/main/kotlin/io/javalin/openapi/dynamic/hook/RegisteredRoutesHook.kt @@ -4,7 +4,6 @@ import io.javalin.openapi.dynamic.ReflectionSchemaContext import io.javalin.openapi.plugin.OpenApiHook import io.javalin.openapi.plugin.OpenApiHookContext -/** Autogenerates an OpenAPI operation for every registered Javalin route; [OpenApiMetadata] enriches a route when present. */ class RegisteredRoutesHook : OpenApiHook { override fun apply(context: OpenApiHookContext) { diff --git a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/AnnotationProcessorContext.kt b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/AnnotationProcessorContext.kt index f794bd56..3723ca65 100644 --- a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/AnnotationProcessorContext.kt +++ b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/AnnotationProcessorContext.kt @@ -51,7 +51,6 @@ class AnnotationProcessorContext( override fun enumConstantsOf(type: OpenApiType): List = japIntrospector.introspect(type.mirror).getEnumConstants() ?: emptyList() - /** Lift a backend-neutral [RawType] into the OpenAPI [OpenApiType] model; names + handle stay AP-specific. */ @OptIn(InternalIntrospectionApi::class) override fun toOpenApiType(raw: RawType): OpenApiType { val mirror = raw.source as TypeMirror @@ -67,7 +66,6 @@ class AnnotationProcessorContext( ) } - /** Resolve a [TypeMirror] into the OpenAPI model via the shared [JapTypeIntrospector]. */ fun TypeMirror.toOpenApiType(): OpenApiType = toOpenApiType(japIntrospector.introspect(this)) diff --git a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/OpenApiTypeHandle.kt b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/OpenApiTypeHandle.kt index d4d56167..e093a4b2 100644 --- a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/OpenApiTypeHandle.kt +++ b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/OpenApiTypeHandle.kt @@ -3,7 +3,6 @@ package io.javalin.openapi.experimental import javax.lang.model.element.Element import javax.lang.model.type.TypeMirror -/** The annotation-processing token carried in [OpenApiType.handle]. */ data class OpenApiTypeHandle( val mirror: TypeMirror, val source: Element diff --git a/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/PropertySelectionTest.kt b/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/PropertySelectionTest.kt index 3f3b683a..ec2b8fb9 100644 --- a/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/PropertySelectionTest.kt +++ b/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/PropertySelectionTest.kt @@ -32,7 +32,6 @@ internal class PropertySelectionTest : OpenApiAnnotationProcessorSpecification() .doesNotContainKey("hidden") } - // By default only getters are emitted; @OpenApiByFields additionally pulls in public fields (here the @JvmField one). @OpenApiByFields private class ByFieldsEntity { @JvmField val fieldProperty: String = "" diff --git a/openapi-dynamic/src/main/kotlin/io/javalin/openapi/dynamic/ReflectionSchemaContext.kt b/openapi-dynamic/src/main/kotlin/io/javalin/openapi/dynamic/ReflectionSchemaContext.kt index ba679df8..ee440e61 100644 --- a/openapi-dynamic/src/main/kotlin/io/javalin/openapi/dynamic/ReflectionSchemaContext.kt +++ b/openapi-dynamic/src/main/kotlin/io/javalin/openapi/dynamic/ReflectionSchemaContext.kt @@ -6,7 +6,6 @@ import io.javalin.openapi.experimental.IntrospectorSchemaContext import io.javalin.openapi.experimental.SimpleType import io.javalin.openapi.experimental.defaults.createDefaultSimpleTypeMappings -/** Runtime-reflection backend: drives the shared generator over [ReflectionTypeIntrospector]. */ class ReflectionSchemaContext( simpleTypeMappings: Map = createDefaultSimpleTypeMappings(), ) : IntrospectorSchemaContext(simpleTypeMappings) { diff --git a/openapi-dynamic/src/test/kotlin/io/javalin/openapi/dynamic/DynamicCompositionTest.kt b/openapi-dynamic/src/test/kotlin/io/javalin/openapi/dynamic/DynamicCompositionTest.kt index 4d984e7e..3f5f298f 100644 --- a/openapi-dynamic/src/test/kotlin/io/javalin/openapi/dynamic/DynamicCompositionTest.kt +++ b/openapi-dynamic/src/test/kotlin/io/javalin/openapi/dynamic/DynamicCompositionTest.kt @@ -6,7 +6,6 @@ import io.javalin.openapi.dynamic.fixtures.Validated import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test -/** Covers the policy the reflection backend gained by routing through the shared generator (composition, validations, redirect). */ class DynamicCompositionTest { private val schemaContext = ReflectionSchemaContext() diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/IntrospectorSchemaContext.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/IntrospectorSchemaContext.kt index bfc220c4..fbad0d05 100644 --- a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/IntrospectorSchemaContext.kt +++ b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/IntrospectorSchemaContext.kt @@ -11,13 +11,6 @@ import io.javalin.openapi.experimental.processor.generators.ResultScheme import io.javalin.openapi.experimental.processor.generators.TypeSchemaGenerator import io.javalin.introspection.ClassDefinition as RawType -/** - * [SchemaGenerationContext] driven entirely by a neutral [TypeIntrospector]: it carries the introspected [RawType] - * in [OpenApiType.handle] and delegates every navigation call to it. A concrete backend supplies only the - * [introspector] (runtime reflection, KSP, ...); `@OpenApiName` naming, the default processors, and schema generation - * are all shared. The annotation processor stays separate because it needs javac-native tokens (round scan, element - * filtering) that this neutral seam deliberately hides. - */ abstract class IntrospectorSchemaContext( override val simpleTypeMappings: Map = createDefaultSimpleTypeMappings(), ) : SchemaGenerationContext { @@ -27,15 +20,12 @@ abstract class IntrospectorSchemaContext( override val typeSchemaGenerator: TypeSchemaGenerator = TypeSchemaGenerator(this) override val embeddedTypeProcessors: List = createDefaultEmbeddedTypeProcessors() - /** Resolve a backend-native type token (`Class`, `KSType`, ...) into the OpenAPI model. */ fun introspect(nativeType: Any): OpenApiType = toOpenApiType(introspector.introspect(nativeType)) - /** Full component schema for an already-resolved [type] — the body stored under `components/schemas`. */ fun componentSchema(type: OpenApiType): ResultScheme = typeSchemaGenerator.createTypeSchema(type) - /** Inline schema (`$ref` or structure) for a backend-native type token. */ fun inlineSchema(nativeType: Any): ResultScheme = typeSchemaGenerator.createEmbeddedTypeDescription(introspect(nativeType)) diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/OpenApiType.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/OpenApiType.kt index 94ee6c74..97bdf834 100644 --- a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/OpenApiType.kt +++ b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/OpenApiType.kt @@ -2,11 +2,6 @@ package io.javalin.openapi.experimental import io.javalin.openapi.experimental.StructureType.DEFAULT -/** - * Opt-in marker for [OpenApiType.handle] — the backend-native token (`OpenApiTypeHandle`, a neutral - * `io.javalin.introspection.ClassDefinition`, ...). Only the backend that produced an [OpenApiType] may cast it; - * backend-agnostic generator code must navigate via [SchemaGenerationContext] instead. - */ @RequiresOptIn( level = RequiresOptIn.Level.ERROR, message = "handle is the backend-native token behind OpenApiType; only the producing backend may cast it.", diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/SchemaGenerationContext.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/SchemaGenerationContext.kt index 056a7a79..4f40c9e1 100644 --- a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/SchemaGenerationContext.kt +++ b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/SchemaGenerationContext.kt @@ -6,11 +6,6 @@ import io.javalin.introspection.PropertyView import io.javalin.openapi.experimental.processor.generators.TypeSchemaGenerator import io.javalin.introspection.ClassDefinition as RawType -/** - * Neutral platform seam the shared schema generator runs against. Each backend (annotation processing, runtime - * reflection, ...) supplies introspection, class conversion, property filtering, and discriminator subtypes; the - * generator itself stays free of any single type system. - */ interface SchemaGenerationContext { val typeSchemaGenerator: TypeSchemaGenerator @@ -25,12 +20,9 @@ interface SchemaGenerationContext { fun enumConstantsOf(type: OpenApiType): List - /** Lift a backend-neutral [RawType] into the OpenAPI [OpenApiType] model (names + handle stay backend-specific). */ fun toOpenApiType(raw: RawType): OpenApiType - /** Whether [property] of [type] should be emitted (platform-specific filtering, e.g. the AP `propertyInSchemeFilter`). */ fun acceptsProperty(type: OpenApiType, property: PropertyView): Boolean = true - /** Discriminator subtypes discovered by the platform (AP round scan); empty when the backend cannot scan. */ fun discriminatorSubtypes(type: OpenApiType): List> = emptyList() } diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/defaults/DefaultEmbeddedTypeProcessors.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/defaults/DefaultEmbeddedTypeProcessors.kt index 91e12feb..8f51eedd 100644 --- a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/defaults/DefaultEmbeddedTypeProcessors.kt +++ b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/defaults/DefaultEmbeddedTypeProcessors.kt @@ -2,7 +2,6 @@ package io.javalin.openapi.experimental.defaults import io.javalin.openapi.experimental.EmbeddedTypeProcessor -/** Default [EmbeddedTypeProcessor] chain (composition → array → dictionary), shared by every backend. */ fun createDefaultEmbeddedTypeProcessors(): MutableList = mutableListOf( CompositionEmbeddedTypeProcessor(), ArrayEmbeddedTypeProcessor(), diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/CompositionGenerator.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/CompositionGenerator.kt index 9e4925eb..e38eba69 100644 --- a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/CompositionGenerator.kt +++ b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/CompositionGenerator.kt @@ -57,8 +57,7 @@ fun ObjectNode.createComposition( val refs = propertyComposition.references.ifEmpty { subtypes.map { it.second } } - // No explicit refs and no discoverable subtypes (e.g. reflection has no round scan): emitting an empty - // `oneOf: []` would be invalid OpenAPI, so skip the composition entirely. + // an empty oneOf/anyOf/allOf is invalid OpenAPI, so skip when there are no refs/subtypes if (refs.isEmpty()) return when (inlineRefs) { diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/TypeSchemaGenerator.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/TypeSchemaGenerator.kt index 81404eee..3323a6ea 100644 --- a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/TypeSchemaGenerator.kt +++ b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/TypeSchemaGenerator.kt @@ -146,7 +146,6 @@ class TypeSchemaGenerator(val context: SchemaGenerationContext) { } if (handledByCustomProcessor == null) { - // Unwrap Optional as nullable T if (type.fullName == "java.util.Optional" && type.generics.size == 1) { return createEmbeddedTypeDescription(type.generics.first(), inlineRefs, requiresNonNulls, composition, extra, nullable = true) } diff --git a/openapi-ksp/src/main/kotlin/io/javalin/openapi/ksp/KspSchemaContext.kt b/openapi-ksp/src/main/kotlin/io/javalin/openapi/ksp/KspSchemaContext.kt index 017cb2a2..919c410e 100644 --- a/openapi-ksp/src/main/kotlin/io/javalin/openapi/ksp/KspSchemaContext.kt +++ b/openapi-ksp/src/main/kotlin/io/javalin/openapi/ksp/KspSchemaContext.kt @@ -7,7 +7,6 @@ import io.javalin.openapi.experimental.IntrospectorSchemaContext import io.javalin.openapi.experimental.SimpleType import io.javalin.openapi.experimental.defaults.createDefaultSimpleTypeMappings -/** KSP backend: drives the shared generator over [KspTypeIntrospector] — the third backend on the same base. */ class KspSchemaContext( resolver: Resolver, simpleTypeMappings: Map = createDefaultSimpleTypeMappings(), diff --git a/openapi-ksp/src/main/kotlin/io/javalin/openapi/ksp/OpenApiSymbolProcessor.kt b/openapi-ksp/src/main/kotlin/io/javalin/openapi/ksp/OpenApiSymbolProcessor.kt index 6e3df0a3..d149d0c4 100644 --- a/openapi-ksp/src/main/kotlin/io/javalin/openapi/ksp/OpenApiSymbolProcessor.kt +++ b/openapi-ksp/src/main/kotlin/io/javalin/openapi/ksp/OpenApiSymbolProcessor.kt @@ -10,12 +10,6 @@ import com.google.devtools.ksp.symbol.KSAnnotated import com.google.devtools.ksp.symbol.KSClassDeclaration import io.javalin.openapi.JsonSchema -/** - * KSP backend processor. It only does the backend-specific shell — *scan* annotated symbols via the [Resolver] and - * *write* resources via the [CodeGenerator]; the actual schema generation is the shared, backend-agnostic engine - * driven through [KspSchemaContext]. (Currently emits `@JsonSchema` json-schemes; `@OpenApi` documents follow once the - * operation generator is neutralized onto the shared seam.) - */ class OpenApiSymbolProcessor(private val codeGenerator: CodeGenerator) : SymbolProcessor { override fun process(resolver: Resolver): List { diff --git a/openapi-ksp/src/test/kotlin/io/javalin/openapi/ksp/KspSchemaContextTest.kt b/openapi-ksp/src/test/kotlin/io/javalin/openapi/ksp/KspSchemaContextTest.kt index e1cf5c08..4ae1dee3 100644 --- a/openapi-ksp/src/test/kotlin/io/javalin/openapi/ksp/KspSchemaContextTest.kt +++ b/openapi-ksp/src/test/kotlin/io/javalin/openapi/ksp/KspSchemaContextTest.kt @@ -16,7 +16,6 @@ import org.junit.jupiter.api.Test @OptIn(ExperimentalCompilerApi::class) class KspSchemaContextTest { - /** Runs KSP in-process over a trivial trigger; the block resolves the classpath fixtures via [Resolver]. */ private fun withResolver(block: (Resolver) -> R): R { var result: Result? = null val provider = object : SymbolProcessorProvider { From 46680919576a4cb4726e09c8d92851c0a4dc770f Mon Sep 17 00:00:00 2001 From: dzikoysk Date: Sun, 21 Jun 2026 16:59:56 +0200 Subject: [PATCH 16/43] chore: migrate ap specific openapi schema generator to new introspection api --- .../introspection/ksp/KspTypeIntrospector.kt | 3 + openapi-annotation-processor/build.gradle.kts | 4 - .../AnnotationProcessorContext.kt | 3 + .../processor/shared/ModelExtensions.kt | 18 - .../processor/generators/OpenApiGenerator.kt | 38 +- .../openapi/schema/OpenApiSchemaGenerator.kt | 369 ------------------ .../processor/generators/ExampleGenerator.kt | 9 - .../openapi/schema/OpenApiSchemaBuilder.kt | 3 +- .../openapi/schema/OpenApiSchemaGenerator.kt | 339 ++++++++++++++++ .../javalin/openapi/ksp/KspSchemaContext.kt | 8 +- .../openapi/ksp/OpenApiSymbolProcessor.kt | 56 ++- .../openapi/ksp/OpenApiSymbolProcessorTest.kt | 56 +++ .../io/javalin/openapi/OpenApiStatus.kt | 136 +++++++ 13 files changed, 613 insertions(+), 429 deletions(-) delete mode 100644 openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/schema/OpenApiSchemaGenerator.kt create mode 100644 openapi-generator/src/main/kotlin/io/javalin/openapi/schema/OpenApiSchemaGenerator.kt create mode 100644 openapi-specification/src/main/kotlin/io/javalin/openapi/OpenApiStatus.kt diff --git a/introspection/introspection-ksp/src/main/kotlin/io/javalin/introspection/ksp/KspTypeIntrospector.kt b/introspection/introspection-ksp/src/main/kotlin/io/javalin/introspection/ksp/KspTypeIntrospector.kt index 34229cca..ca46895d 100644 --- a/introspection/introspection-ksp/src/main/kotlin/io/javalin/introspection/ksp/KspTypeIntrospector.kt +++ b/introspection/introspection-ksp/src/main/kotlin/io/javalin/introspection/ksp/KspTypeIntrospector.kt @@ -44,6 +44,9 @@ class KspTypeIntrospector(private val resolver: Resolver) : TypeIntrospector { return resolve(declaration.asStarProjectedType()) } + fun annotationsOf(annotated: KSAnnotated): Annotations = + KspAnnotations(annotated) + private fun resolve(type: KSType, structureType: StructureType = DEFAULT): ClassDefinition { val declaration = type.declaration if (declaration is KSTypeParameter) { diff --git a/openapi-annotation-processor/build.gradle.kts b/openapi-annotation-processor/build.gradle.kts index 2f6f26c8..25b7d442 100644 --- a/openapi-annotation-processor/build.gradle.kts +++ b/openapi-annotation-processor/build.gradle.kts @@ -15,10 +15,6 @@ dependencies { implementation(kotlin("reflect")) implementation(libs.groovy) - implementation(libs.javalin) { - exclude(group = "org.slf4j") - } - implementation(libs.swagger.parser) { exclude(group = "com.fasterxml.jackson") exclude(group = "com.fasterxml.jackson.core") diff --git a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/AnnotationProcessorContext.kt b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/AnnotationProcessorContext.kt index 3723ca65..86e533a4 100644 --- a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/AnnotationProcessorContext.kt +++ b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/AnnotationProcessorContext.kt @@ -45,6 +45,9 @@ class AnnotationProcessorContext( override fun annotationsOf(type: OpenApiType): Annotations = japIntrospector.annotationsOf(type.source) + fun annotationsOf(element: Element): Annotations = + japIntrospector.annotationsOf(element) + override fun propertiesOf(type: OpenApiType): List = japIntrospector.introspect(type.mirror).getProperties() diff --git a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/processor/shared/ModelExtensions.kt b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/processor/shared/ModelExtensions.kt index ad77e756..67e69750 100644 --- a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/processor/shared/ModelExtensions.kt +++ b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/processor/shared/ModelExtensions.kt @@ -2,24 +2,6 @@ package io.javalin.openapi.experimental.processor.shared import io.javalin.openapi.experimental.AnnotationProcessorContext import javax.lang.model.element.TypeElement -import javax.lang.model.type.MirroredTypeException -import javax.lang.model.type.MirroredTypesException -import javax.lang.model.type.TypeMirror -import kotlin.reflect.KClass fun AnnotationProcessorContext.objectType(): TypeElement = forTypeElement(Object::class.java.name)!! fun AnnotationProcessorContext.mapType(): TypeElement = forTypeElement(Map::class.java.name)!! - -fun A.getTypeMirrors(supplier: A.() -> Array>): Set = - try { - throw Error(supplier().toString()) // always throws MirroredTypesException, because we cannot get Class instance from annotation at compile-time - } catch (mirroredTypeException: MirroredTypesException) { - mirroredTypeException.typeMirrors.toSet() - } - -fun > A.getTypeMirror(supplier: A.() -> K): TypeMirror = - try { - throw Error(supplier().toString()) // always throws MirroredTypeException, because we cannot get Class instance from annotation at compile-time - } catch (mirroredTypeException: MirroredTypeException) { - mirroredTypeException.typeMirror - } diff --git a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/processor/generators/OpenApiGenerator.kt b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/processor/generators/OpenApiGenerator.kt index 281f1a4c..ea67c094 100644 --- a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/processor/generators/OpenApiGenerator.kt +++ b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/processor/generators/OpenApiGenerator.kt @@ -1,6 +1,5 @@ package io.javalin.openapi.processor.generators -import io.javalin.http.HttpStatus import io.javalin.openapi.OpenApi import io.javalin.openapi.OpenApis import io.javalin.openapi.experimental.processor.shared.saveResource @@ -16,35 +15,25 @@ internal class OpenApiGenerator { private val schemaGenerator = OpenApiSchemaGenerator( context = context, - defaultStatusDescription = { status -> - status.toIntOrNull()?.let { HttpStatus.forStatus(it) }?.message - } + title = context.parameters.info.title, + version = context.parameters.info.version, ) fun generate(roundEnvironment: RoundEnvironment) { - val aggregatedOpenApiAnnotations = roundEnvironment.getElementsAnnotatedWith(OpenApis::class.java) - .flatMap { element -> - element - .getAnnotation(OpenApis::class.java)!! - .value - .asSequence() - .map { element to it } - } + val aggregatedRoutes = roundEnvironment.getElementsAnnotatedWith(OpenApis::class.java) + .flatMap { context.annotationsOf(it).memberValuesList(OpenApi::class.java) } - val standaloneOpenApiAnnotations = - roundEnvironment - .getElementsAnnotatedWith(OpenApi::class.java) - .map { it to it.getAnnotation(OpenApi::class.java)!! } + val standaloneRoutes = roundEnvironment.getElementsAnnotatedWith(OpenApi::class.java) + .flatMap { context.annotationsOf(it).memberValuesList(OpenApi::class.java) } - val openApiAnnotationsByVersion = (aggregatedOpenApiAnnotations + standaloneOpenApiAnnotations) - .flatMap { it.second.versions.map { version -> version to it } } + val routesByVersion = (aggregatedRoutes + standaloneRoutes) + .flatMap { route -> route.versions().map { version -> version to route } } .groupBy { (version, _) -> version } - .mapValues { (_, annotations) -> annotations.map { it.second } } + .mapValues { (_, routes) -> routes.map { it.second } } - openApiAnnotationsByVersion - .map { (version, openApiAnnotations) -> - val preparedOpenApiAnnotations = openApiAnnotations.toSet() - val generatedOpenApiSchema = schemaGenerator.generateSchema(preparedOpenApiAnnotations) + routesByVersion + .map { (version, routes) -> + val generatedOpenApiSchema = schemaGenerator.generateSchema(routes.toSet().toList()) val resourceName = "openapi-${version.replace(" ", "-")}.json" val resource = context.env.filer.saveResource(context, "openapi-plugin/$resourceName", generatedOpenApiSchema) @@ -70,4 +59,7 @@ internal class OpenApiGenerator { .let { context.env.filer.saveResource(context, "openapi-plugin/.index", it) } } + private fun Map.versions(): List = + (this["versions"] as? List<*>)?.filterIsInstance() ?: emptyList() + } diff --git a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/schema/OpenApiSchemaGenerator.kt b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/schema/OpenApiSchemaGenerator.kt deleted file mode 100644 index 9ad94e1b..00000000 --- a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/schema/OpenApiSchemaGenerator.kt +++ /dev/null @@ -1,369 +0,0 @@ -package io.javalin.openapi.schema - -import io.javalin.openapi.* -import io.javalin.openapi.OpenApiOperation.AUTO_GENERATE -import io.javalin.openapi.experimental.AnnotationProcessorContext -import io.javalin.openapi.experimental.StructureType.ARRAY -import io.javalin.openapi.experimental.mirror -import io.javalin.openapi.experimental.processor.generators.ResultScheme -import io.javalin.openapi.experimental.processor.shared.getTypeMirror -import java.util.Locale -import java.util.TreeMap -import javax.lang.model.element.Element -import javax.lang.model.type.TypeMirror -import javax.tools.Diagnostic.Kind.WARNING - -class OpenApiSchemaGenerator( - private val context: AnnotationProcessorContext, - private val defaultStatusDescription: (String) -> String? = { null }, -) { - - /** - * Based on https://swagger.io/specification/ - * - * @param openApiAnnotations annotation instances to map - * @return OpenApi JSON response - */ - fun generateSchema(openApiAnnotations: Collection>): String { - val schema = - OpenApiSchemaBuilder() - .openApiVersion("3.1.0") - .info { it.title(context.parameters.info.title).version(context.parameters.info.version) } - - for ((openApiElement, routeAnnotation) in openApiAnnotations.sortedBy { it.second.getFormattedPath() }) { - if (routeAnnotation.ignore) { - continue - } - - // https://swagger.io/specification/#paths-object - val pathBuilder = schema.path(routeAnnotation.getFormattedPath()) - - for (method in routeAnnotation.methods.sortedBy { it.name }) { - pathBuilder.operation(method.name.lowercase()) { - // General - tags(routeAnnotation.tags.toList()) - summary(routeAnnotation.summary.takeIf { it != NULL_STRING }) - description(routeAnnotation.description.takeIf { it != NULL_STRING }) - - // ExternalDocs - // ~ https://swagger.io/specification/#external-documentation-object - // UNSUPPORTED - - // OperationId - operationId(generateOperationId(method, routeAnnotation).takeIf { it != NULL_STRING }) - - // Parameters - // ~ https://swagger.io/specification/#parameter-object - buildParameters(routeAnnotation) - - // RequestBody - // ~ https://swagger.io/specification/#request-body-object - buildRequestBody(openApiElement, routeAnnotation.requestBody) - - // Responses - // ~ https://swagger.io/specification/#responses-object - buildResponses(openApiElement, routeAnnotation.responses) - - // Callbacks - // ~ https://swagger.io/specification/#callback-object - buildCallbacks(openApiElement, routeAnnotation.callbacks) - - // Deprecated - if (routeAnnotation.deprecated) { - deprecated(true) - } - - // Security - // ~ https://swagger.io/specification/#security-requirement-object - if (routeAnnotation.security.isNotEmpty()) { - security { - for (securityAnnotation in routeAnnotation.security.sortedBy { it.name }) { - securityRequirement(securityAnnotation.name, *securityAnnotation.scopes) - } - } - } - } - } - } - - schema.resolveComponentReferences { type -> context.typeSchemaGenerator.createTypeSchema(type, false) } - return schema.toJson() - } - - private fun OperationBuilder.buildParameters(routeAnnotation: OpenApi) { - parameters { - val parameterAnnotations = linkedMapOf( - In.COOKIE to routeAnnotation.cookies, - In.HEADER to routeAnnotation.headers, - In.PATH to routeAnnotation.pathParams, - In.QUERY to routeAnnotation.queryParams - ) - - parameterAnnotations.forEach { (parameterType, annotations) -> - annotations.forEach { parameterAnnotation -> - val paramSchema = createTypeDescriptionWithReferences(parameterAnnotation.getTypeMirror { type }) - parameter( - name = parameterAnnotation.name, - location = parameterType.identifier, - schema = paramSchema, - description = parameterAnnotation.description.takeIf { it != NULL_STRING }, - required = parameterAnnotation.required || parameterType == In.PATH, - deprecated = parameterAnnotation.deprecated, - allowEmptyValue = parameterAnnotation.allowEmptyValue, - example = parameterAnnotation.example.takeIf { it.isNotEmpty() }, - ) - } - } - } - } - - private fun OperationBuilder.buildRequestBody(element: Element, annotation: OpenApiRequestBody) { - requestBody { - description(annotation.description.takeIf { it != NULL_STRING }) - content { addResolvedContent(element, annotation.content) } - if (annotation.required) { required(true) } - } - } - - private fun OperationBuilder.buildResponses(element: Element, responseAnnotations: Array) { - responses { - for (responseAnnotation in responseAnnotations.sortedBy { it.status }) { - response(responseAnnotation.status) { - val description = responseAnnotation.description - .takeIf { it != NULL_STRING } - ?: defaultStatusDescription(responseAnnotation.status) - ?: "" - - description(description) - content { addResolvedContent(element, responseAnnotation.content) } - headers { - responseAnnotation.headers.forEach { headerParam -> - val headerSchema = createTypeDescriptionWithReferences(headerParam.getTypeMirror { type }) - header( - name = headerParam.name, - schema = headerSchema, - description = headerParam.description.takeIf { it != NULL_STRING }, - required = headerParam.required, - deprecated = headerParam.deprecated, - allowEmptyValue = headerParam.allowEmptyValue, - example = headerParam.example.takeIf { it.isNotEmpty() }, - ) - } - } - } - } - } - } - - private fun OperationBuilder.buildCallbacks(element: Element, callbackAnnotations: Array) { - if (callbackAnnotations.isEmpty()) { - return - } - - callbacks { - callbackAnnotations.forEach { callbackAnnotation -> - callback( - name = callbackAnnotation.name, - url = callbackAnnotation.url, - method = callbackAnnotation.method.name.lowercase() - ) { - summary(callbackAnnotation.summary.takeIf { it != NULL_STRING }) - description(callbackAnnotation.description.takeIf { it != NULL_STRING }) - requestBody { - description(callbackAnnotation.requestBody.description.takeIf { it != NULL_STRING }) - content { addResolvedContent(element, callbackAnnotation.requestBody.content) } - if (callbackAnnotation.requestBody.required) { required(true) } - } - responses { - for (responseAnnotation in callbackAnnotation.responses.sortedBy { it.status }) { - response(responseAnnotation.status) { - val description = responseAnnotation.description - .takeIf { it != NULL_STRING } - ?: defaultStatusDescription(responseAnnotation.status) - ?: "" - - description(description) - content { addResolvedContent(element, responseAnnotation.content) } - } - } - } - } - } - } - } - - private fun ContentBuilder.addResolvedContent(element: Element, contentAnnotations: Array) { - val resolvedEntries = TreeMap Unit>() - - for (contentAnnotation in contentAnnotations) { - val resolved = resolveMediaType(element, contentAnnotation) ?: continue - resolvedEntries[resolved.first] = resolved.second - } - - resolvedEntries.forEach { (mimeType, configure) -> mediaType(mimeType, configure) } - } - - enum class In(val identifier: String) { - QUERY("query"), - HEADER("header"), - PATH("path"), - COOKIE("cookie"), - } - - private fun generateOperationId( - httpMethod: HttpMethod, - openApi: OpenApi, - pathParamPrefix: String = "By" - ): String = - when (openApi.operationId) { - AUTO_GENERATE -> { - httpMethod.name.lowercase() + openApi.path.split('/') - .map { pathPart -> - if (pathPart.startsWith('{') || pathPart.startsWith('<')) { - val pathParam = pathPart - .drop(1) - .dropLast(1) - .split('-') - .joinToString(separator = "") { it.capitalise() } - pathParamPrefix + pathParam - } else { - pathPart.capitalise() - } - } - .toList() - .joinToString(separator = "") { - it.split('-').joinToString(separator = "") { it.capitalise() } - } - } - else -> openApi.operationId - } - - private fun String.capitalise(): String = this.replaceFirstChar { - it.titlecase(Locale.getDefault()) - } - - private fun resolveMediaType(element: Element, source: OpenApiContent): Pair Unit>? = - context.inContext { - var contentData = source.toData() - val from = source.getTypeMirror { contentData.from() } - - if (contentData.mimeType == null) { - contentData = - when (NULL_CLASS::class.qualifiedName) { - from.getFullName() -> contentData.copy(mimeType = contentData.type, type = null) - else -> contentData.copy(mimeType = detectContentType(from)) - } - } - - if (contentData.mimeType == null) { - val trees = context.trees - - if (trees != null) { - val compilationUnit = trees.getPath(element).compilationUnit - val tree = trees.getTree(element) - val startPosition = trees.sourcePositions.getStartPosition(compilationUnit, tree) - - context.env.messager.printMessage( - WARNING, - """ - OpenApi generator cannot find matching mime type defined. - Source: - Annotation in ${compilationUnit.lineMap.getLineNumber(startPosition)} at ${compilationUnit.sourceFile.name} line - Annotation: - $source - """.trimIndent() - ) - } - - return@inContext null - } - - val resolvedContentData = contentData - val fromMirror = source.getTypeMirror { resolvedContentData.from() } - - val configure: MediaTypeBuilder.() -> Unit = { - when (resolvedContentData.properties) { - null if resolvedContentData.additionalProperties == null && fromMirror.getFullName() != NULL_CLASS::class.java.name -> - schema(createTypeDescriptionWithReferences(fromMirror)) - - null if resolvedContentData.additionalProperties == null -> - schema { - resolvedContentData.type?.let { type(it) } - resolvedContentData.format?.let { format(it) } - } - - else -> objectSchema { - resolvedContentData.properties?.let { buildProperties(it) } - resolvedContentData.additionalProperties?.let { buildAdditionalProperties(it) } - } - } - - applyExample(resolvedContentData) - } - - return@inContext resolvedContentData.mimeType!! to configure - } - - private fun ExampleHolder.applyExample(contentData: OpenApiContentData) { - contentData.example?.let { example(it) } - contentData.exampleObjects?.let { applyExamples(it) } - } - - private fun ObjectSchemaBuilder.buildProperties(properties: List) { - context.inContext { - for (contentProperty in properties) { - val propertyFormat = contentProperty.format.takeIf { it != NULL_STRING } - val contentPropertyFrom = contentProperty.getTypeMirror { contentProperty.from } - val isResolved = contentPropertyFrom.getFullName() != NULL_CLASS::class.java.name - - if (contentProperty.isArray) { - if (isResolved) { - arrayProperty(contentProperty.name, createTypeDescriptionWithReferences(contentPropertyFrom)) - } else { - arrayProperty(contentProperty.name, contentProperty.type, propertyFormat) - } - } else { - if (isResolved) { - property(contentProperty.name, createTypeDescriptionWithReferences(contentPropertyFrom)) - } else { - property(contentProperty.name, contentProperty.type, propertyFormat) - } - } - } - } - } - - private fun ObjectSchemaBuilder.buildAdditionalProperties(annotation: OpenApiAdditionalContent) { - context.inContext { - val additionalData = annotation.toData() - val from = annotation.getTypeMirror { additionalData.from() } - - if (from.getFullName() != NULL_CLASS::class.java.name) { - additionalProperties(createTypeDescriptionWithReferences(from)) - } else { - additionalProperties(additionalData.type, additionalData.format) - } - - applyExample(additionalData) - } - } - - private fun detectContentType(typeMirror: TypeMirror): String = - context.inContext { - val model = typeMirror.toOpenApiType() - - when { - (model.structureType == ARRAY && model.simpleName == "Byte") || model.simpleName == "[B" || model.simpleName == "File" -> "application/octet-stream" - model.structureType == ARRAY -> "application/json" - model.simpleName == "String" -> "text/plain" - else -> "application/json" - } - } - - private fun createTypeDescriptionWithReferences(type: TypeMirror): ResultScheme = - context.inContext { - val model = type.toOpenApiType() - context.typeSchemaGenerator.createEmbeddedTypeDescription(model) - } - -} diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/ExampleGenerator.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/ExampleGenerator.kt index 3525c868..2b286483 100644 --- a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/ExampleGenerator.kt +++ b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/ExampleGenerator.kt @@ -3,7 +3,6 @@ package io.javalin.openapi.experimental.processor.generators import com.fasterxml.jackson.databind.JsonNode import com.fasterxml.jackson.databind.node.ObjectNode import io.javalin.openapi.NULL_STRING -import io.javalin.openapi.OpenApiExampleProperty import io.javalin.openapi.experimental.processor.shared.createArrayNode import io.javalin.openapi.experimental.processor.shared.createObjectNode import io.javalin.openapi.experimental.processor.shared.jsonMapper @@ -15,14 +14,6 @@ data class ExampleProperty( val objects: List? ) -fun OpenApiExampleProperty.toExampleProperty(): ExampleProperty = - ExampleProperty( - name = this.name.takeIf { it != NULL_STRING }, - value = this.value.takeIf { it != NULL_STRING }, - raw = this.raw.takeIf { it != NULL_STRING }, - objects = this.objects.map { it.toExampleProperty() }.takeIf { it.isNotEmpty() }, - ) - fun Map.toExampleProperty(): ExampleProperty = ExampleProperty( name = (this["name"] as? String)?.takeIf { it != NULL_STRING }, diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/schema/OpenApiSchemaBuilder.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/schema/OpenApiSchemaBuilder.kt index 5c3fd091..564a28b9 100644 --- a/openapi-generator/src/main/kotlin/io/javalin/openapi/schema/OpenApiSchemaBuilder.kt +++ b/openapi-generator/src/main/kotlin/io/javalin/openapi/schema/OpenApiSchemaBuilder.kt @@ -8,7 +8,6 @@ import io.javalin.openapi.BasicAuth import io.javalin.openapi.BearerAuth import io.javalin.openapi.CookieAuth import io.javalin.openapi.OAuth2 -import io.javalin.openapi.OpenApiExampleProperty import io.javalin.openapi.OpenApiInfo import io.javalin.openapi.OpenApiServer import io.javalin.openapi.OpenID @@ -554,7 +553,7 @@ interface ExampleHolder { fun example(value: String) fun exampleJson(value: JsonNode) - fun applyExamples(exampleObjects: List) { + fun applyExamples(exampleObjects: List>) { val generatorResult = ExampleGenerator.generateFromExamples(exampleObjects.map { it.toExampleProperty() }) generatorResult.simpleValue?.let { example(it) } ?: generatorResult.jsonElement?.let { exampleJson(it) } diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/schema/OpenApiSchemaGenerator.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/schema/OpenApiSchemaGenerator.kt new file mode 100644 index 00000000..ad009b96 --- /dev/null +++ b/openapi-generator/src/main/kotlin/io/javalin/openapi/schema/OpenApiSchemaGenerator.kt @@ -0,0 +1,339 @@ +package io.javalin.openapi.schema + +import io.javalin.introspection.ClassDefinition +import io.javalin.openapi.ContentType +import io.javalin.openapi.OpenApiStatus +import io.javalin.openapi.NULL_CLASS +import io.javalin.openapi.NULL_STRING +import io.javalin.openapi.OpenApiOperation.AUTO_GENERATE +import io.javalin.openapi.experimental.OpenApiType +import io.javalin.openapi.experimental.SchemaGenerationContext +import io.javalin.openapi.experimental.StructureType.ARRAY +import io.javalin.openapi.experimental.processor.generators.ResultScheme +import java.util.Locale +import java.util.TreeMap + +class OpenApiSchemaGenerator( + private val context: SchemaGenerationContext, + private val title: String, + private val version: String, + private val defaultStatusDescription: (String) -> String? = { OpenApiStatus.reasonPhrase(it) }, +) { + + private val nullClassName = NULL_CLASS::class.java.name + + fun generateSchema(routes: List>): String { + val schema = + OpenApiSchemaBuilder() + .openApiVersion("3.1.0") + .info { it.title(title).version(version) } + + for (route in routes.sortedBy { it.formattedPath() }) { + if (route["ignore"] as? Boolean == true) { + continue + } + + val pathBuilder = schema.path(route.formattedPath()) + + for (method in route.texts("methods").sorted()) { + pathBuilder.operation(method.lowercase()) { + tags(route.texts("tags")) + summary(route.text("summary")) + description(route.text("description")) + operationId(generateOperationId(method, route).takeIf { it != NULL_STRING }) + + buildParameters(route) + buildRequestBody(route.child("requestBody")) + buildResponses(route.maps("responses")) + buildCallbacks(route.maps("callbacks")) + + if (route["deprecated"] as? Boolean == true) { + deprecated(true) + } + + val securities = route.maps("security") + if (securities.isNotEmpty()) { + security { + for (security in securities.sortedBy { it["name"] as String }) { + securityRequirement(security["name"] as String, *security.texts("scopes").toTypedArray()) + } + } + } + } + } + } + + schema.resolveComponentReferences { type -> context.typeSchemaGenerator.createTypeSchema(type, false) } + return schema.toJson() + } + + private fun OperationBuilder.buildParameters(route: Map) { + parameters { + val parametersByLocation = linkedMapOf( + In.COOKIE to route.maps("cookies"), + In.HEADER to route.maps("headers"), + In.PATH to route.maps("pathParams"), + In.QUERY to route.maps("queryParams"), + ) + + parametersByLocation.forEach { (location, parameters) -> + parameters.forEach { parameter -> + parameter( + name = parameter["name"] as String, + location = location.identifier, + schema = createTypeDescriptionWithReferences(parameter["type"] as ClassDefinition), + description = parameter.text("description"), + required = (parameter["required"] as? Boolean ?: false) || location == In.PATH, + deprecated = parameter["deprecated"] as? Boolean ?: false, + allowEmptyValue = parameter["allowEmptyValue"] as? Boolean ?: false, + example = parameter.exampleText(), + ) + } + } + } + } + + private fun OperationBuilder.buildRequestBody(requestBody: Map?) { + if (requestBody == null) { + return + } + requestBody { + description(requestBody.text("description")) + content { addResolvedContent(requestBody.maps("content")) } + if (requestBody["required"] as? Boolean == true) { required(true) } + } + } + + private fun OperationBuilder.buildResponses(responses: List>) { + responses { + for (response in responses.sortedBy { it["status"] as String }) { + response(response["status"] as String) { + description(descriptionOf(response)) + content { addResolvedContent(response.maps("content")) } + headers { + response.maps("headers").forEach { header -> + header( + name = header["name"] as String, + schema = createTypeDescriptionWithReferences(header["type"] as ClassDefinition), + description = header.text("description"), + required = header["required"] as? Boolean ?: false, + deprecated = header["deprecated"] as? Boolean ?: false, + allowEmptyValue = header["allowEmptyValue"] as? Boolean ?: false, + example = header.exampleText(), + ) + } + } + } + } + } + } + + private fun OperationBuilder.buildCallbacks(callbacks: List>) { + if (callbacks.isEmpty()) { + return + } + + callbacks { + callbacks.forEach { callback -> + callback( + name = callback["name"] as String, + url = callback["url"] as String, + method = (callback["method"] as String).lowercase() + ) { + summary(callback.text("summary")) + description(callback.text("description")) + val callbackBody = callback.child("requestBody") + requestBody { + description(callbackBody?.text("description")) + content { addResolvedContent(callbackBody?.maps("content").orEmpty()) } + if (callbackBody?.get("required") as? Boolean == true) { required(true) } + } + responses { + for (response in callback.maps("responses").sortedBy { it["status"] as String }) { + response(response["status"] as String) { + description(descriptionOf(response)) + content { addResolvedContent(response.maps("content")) } + } + } + } + } + } + } + } + + private fun ContentBuilder.addResolvedContent(contents: List>) { + val resolvedEntries = TreeMap Unit>() + + for (content in contents) { + val resolved = resolveMediaType(content) ?: continue + resolvedEntries[resolved.first] = resolved.second + } + + resolvedEntries.forEach { (mimeType, configure) -> mediaType(mimeType, configure) } + } + + enum class In(val identifier: String) { + QUERY("query"), + HEADER("header"), + PATH("path"), + COOKIE("cookie"), + } + + private fun generateOperationId( + method: String, + route: Map, + pathParamPrefix: String = "By" + ): String = + when (val operationId = route["operationId"] as String) { + AUTO_GENERATE -> + method.lowercase() + (route["path"] as String).split('/') + .map { pathPart -> + if (pathPart.startsWith('{') || pathPart.startsWith('<')) { + val pathParam = pathPart + .drop(1) + .dropLast(1) + .split('-') + .joinToString(separator = "") { it.capitalise() } + pathParamPrefix + pathParam + } else { + pathPart.capitalise() + } + } + .joinToString(separator = "") { + it.split('-').joinToString(separator = "") { it.capitalise() } + } + else -> operationId + } + + private fun String.capitalise(): String = this.replaceFirstChar { + it.titlecase(Locale.getDefault()) + } + + private fun resolveMediaType(content: Map): Pair Unit>? { + val from = content["from"] as? ClassDefinition + val fromIsNull = from == null || from.fullName == nullClassName + var type = content.text("type") + var mimeType = (content["mimeType"] as? String)?.takeIf { it != ContentType.AUTODETECT } + + if (mimeType == null) { + if (fromIsNull) { + mimeType = type + type = null + } else { + mimeType = detectContentType(from!!) + } + } + + if (mimeType == null) { + return null + } + + val resolvedType = type + val format = content.text("format") + val properties = content.maps("properties").takeIf { it.isNotEmpty() } + val additionalProperties = content.child("additionalProperties")?.takeIf { it["_ignored"] != true } + + val configure: MediaTypeBuilder.() -> Unit = { + when { + properties == null && additionalProperties == null && !fromIsNull -> + schema(createTypeDescriptionWithReferences(from!!)) + + properties == null && additionalProperties == null -> + schema { + resolvedType?.let { type(it) } + format?.let { format(it) } + } + + else -> objectSchema { + properties?.let { buildProperties(it) } + additionalProperties?.let { buildAdditionalProperties(it) } + } + } + + applyExample(content) + } + + return mimeType to configure + } + + private fun ExampleHolder.applyExample(content: Map) { + content.text("example")?.let { example(it) } + content.maps("exampleObjects").takeIf { it.isNotEmpty() }?.let { applyExamples(it) } + } + + private fun ObjectSchemaBuilder.buildProperties(properties: List>) { + for (property in properties) { + val propertyFormat = property.text("format") + val from = property["from"] as? ClassDefinition + val isResolved = from != null && from.fullName != nullClassName + + if (property["isArray"] as? Boolean == true) { + if (isResolved) { + arrayProperty(property["name"] as String, createTypeDescriptionWithReferences(from!!)) + } else { + arrayProperty(property["name"] as String, property["type"] as String, propertyFormat) + } + } else { + if (isResolved) { + property(property["name"] as String, createTypeDescriptionWithReferences(from!!)) + } else { + property(property["name"] as String, property["type"] as String, propertyFormat) + } + } + } + } + + private fun ObjectSchemaBuilder.buildAdditionalProperties(additionalProperties: Map) { + val from = additionalProperties["from"] as? ClassDefinition + + if (from != null && from.fullName != nullClassName) { + additionalProperties(createTypeDescriptionWithReferences(from)) + } else { + additionalProperties(additionalProperties.text("type"), additionalProperties.text("format")) + } + + applyExample(additionalProperties) + } + + private fun detectContentType(from: ClassDefinition): String { + val model = context.toOpenApiType(from) + + return when { + (model.structureType == ARRAY && model.simpleName == "Byte") || model.simpleName == "[B" || model.simpleName == "File" -> "application/octet-stream" + model.structureType == ARRAY -> "application/json" + model.simpleName == "String" -> "text/plain" + else -> "application/json" + } + } + + private fun createTypeDescriptionWithReferences(from: ClassDefinition): ResultScheme { + val model: OpenApiType = context.toOpenApiType(from) + return context.typeSchemaGenerator.createEmbeddedTypeDescription(model) + } + + private fun descriptionOf(response: Map): String = + response.text("description") + ?: defaultStatusDescription(response["status"] as String) + ?: "" + +} + +private fun Map.formattedPath(): String = + (this["path"] as String).let { if (it.startsWith("/")) it else "/$it" } + +private fun Map.text(key: String): String? = + (this[key] as? String)?.takeIf { it != NULL_STRING } + +private fun Map.exampleText(): String? = + (this["example"] as? String)?.takeIf { it.isNotEmpty() } + +private fun Map.texts(key: String): List = + (this[key] as? List<*>)?.filterIsInstance() ?: emptyList() + +private fun Map.child(key: String): Map? { + @Suppress("UNCHECKED_CAST") + return this[key] as? Map +} + +private fun Map.maps(key: String): List> = + (this[key] as? List<*>)?.filterIsInstance>() ?: emptyList() diff --git a/openapi-ksp/src/main/kotlin/io/javalin/openapi/ksp/KspSchemaContext.kt b/openapi-ksp/src/main/kotlin/io/javalin/openapi/ksp/KspSchemaContext.kt index 919c410e..fff2c8d6 100644 --- a/openapi-ksp/src/main/kotlin/io/javalin/openapi/ksp/KspSchemaContext.kt +++ b/openapi-ksp/src/main/kotlin/io/javalin/openapi/ksp/KspSchemaContext.kt @@ -1,6 +1,8 @@ package io.javalin.openapi.ksp import com.google.devtools.ksp.processing.Resolver +import com.google.devtools.ksp.symbol.KSAnnotated +import io.javalin.introspection.Annotations import io.javalin.introspection.TypeIntrospector import io.javalin.introspection.ksp.KspTypeIntrospector import io.javalin.openapi.experimental.IntrospectorSchemaContext @@ -12,6 +14,10 @@ class KspSchemaContext( simpleTypeMappings: Map = createDefaultSimpleTypeMappings(), ) : IntrospectorSchemaContext(simpleTypeMappings) { - override val introspector: TypeIntrospector = KspTypeIntrospector(resolver) + private val kspTypeIntrospector = KspTypeIntrospector(resolver) + override val introspector: TypeIntrospector = kspTypeIntrospector + + fun annotationsOf(annotated: KSAnnotated): Annotations = + kspTypeIntrospector.annotationsOf(annotated) } diff --git a/openapi-ksp/src/main/kotlin/io/javalin/openapi/ksp/OpenApiSymbolProcessor.kt b/openapi-ksp/src/main/kotlin/io/javalin/openapi/ksp/OpenApiSymbolProcessor.kt index d149d0c4..b7f2e606 100644 --- a/openapi-ksp/src/main/kotlin/io/javalin/openapi/ksp/OpenApiSymbolProcessor.kt +++ b/openapi-ksp/src/main/kotlin/io/javalin/openapi/ksp/OpenApiSymbolProcessor.kt @@ -9,12 +9,25 @@ import com.google.devtools.ksp.processing.SymbolProcessorProvider import com.google.devtools.ksp.symbol.KSAnnotated import com.google.devtools.ksp.symbol.KSClassDeclaration import io.javalin.openapi.JsonSchema +import io.javalin.openapi.OpenApi +import io.javalin.openapi.OpenApis +import io.javalin.openapi.experimental.OPENAPI_INFO_TITLE +import io.javalin.openapi.experimental.OPENAPI_INFO_VERSION +import io.javalin.openapi.schema.OpenApiSchemaGenerator -class OpenApiSymbolProcessor(private val codeGenerator: CodeGenerator) : SymbolProcessor { +class OpenApiSymbolProcessor( + private val codeGenerator: CodeGenerator, + private val options: Map, +) : SymbolProcessor { override fun process(resolver: Resolver): List { val context = KspSchemaContext(resolver) + generateJsonSchemes(resolver, context) + generateOpenApiDocuments(resolver, context) + return emptyList() + } + private fun generateJsonSchemes(resolver: Resolver, context: KspSchemaContext) { val generated = resolver.getSymbolsWithAnnotation(JsonSchema::class.qualifiedName!!) .filterIsInstance() .mapNotNull { declaration -> @@ -31,10 +44,47 @@ class OpenApiSymbolProcessor(private val codeGenerator: CodeGenerator) : SymbolP if (generated.isNotEmpty()) { writeResource("json-schemes/index", generated.joinToString(separator = "\n")) } + } - return emptyList() + private fun generateOpenApiDocuments(resolver: Resolver, context: KspSchemaContext) { + val routes = (resolver.getSymbolsWithAnnotation(OpenApi::class.qualifiedName!!) + resolver.getSymbolsWithAnnotation(OpenApis::class.qualifiedName!!)) + .distinct() + .flatMap { annotated -> + val annotations = context.annotationsOf(annotated) + val direct = annotations.memberValuesList(OpenApi::class.java) + val viaContainer = (annotations.memberValues(OpenApis::class.java)?.get("value") as? List<*>) + ?.filterIsInstance>() + .orEmpty() + direct + viaContainer + } + .toList() + + if (routes.isEmpty()) { + return + } + + val generator = OpenApiSchemaGenerator( + context = context, + title = options[OPENAPI_INFO_TITLE] ?: "", + version = options[OPENAPI_INFO_VERSION] ?: "", + ) + + val generatedVersions = routes + .flatMap { route -> route.versions().map { version -> version to route } } + .groupBy { (version, _) -> version } + .map { (version, versionedRoutes) -> + val json = generator.generateSchema(versionedRoutes.map { it.second }.toSet().toList()) + val resourceName = "openapi-${version.replace(" ", "-")}.json" + writeResource("openapi-plugin/$resourceName", json) + resourceName + } + + writeResource("openapi-plugin/.index", generatedVersions.joinToString(separator = "\n")) } + private fun Map.versions(): List = + (this["versions"] as? List<*>)?.filterIsInstance() ?: emptyList() + private fun writeResource(path: String, content: String) = codeGenerator.createNewFileByPath(Dependencies(aggregating = true), path, extensionName = "") .use { it.write(content.toByteArray()) } @@ -43,5 +93,5 @@ class OpenApiSymbolProcessor(private val codeGenerator: CodeGenerator) : SymbolP class OpenApiSymbolProcessorProvider : SymbolProcessorProvider { override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor = - OpenApiSymbolProcessor(environment.codeGenerator) + OpenApiSymbolProcessor(environment.codeGenerator, environment.options) } diff --git a/openapi-ksp/src/test/kotlin/io/javalin/openapi/ksp/OpenApiSymbolProcessorTest.kt b/openapi-ksp/src/test/kotlin/io/javalin/openapi/ksp/OpenApiSymbolProcessorTest.kt index f2a303b6..5016dc0b 100644 --- a/openapi-ksp/src/test/kotlin/io/javalin/openapi/ksp/OpenApiSymbolProcessorTest.kt +++ b/openapi-ksp/src/test/kotlin/io/javalin/openapi/ksp/OpenApiSymbolProcessorTest.kt @@ -45,4 +45,60 @@ class OpenApiSymbolProcessorTest { assertThat(doc.path("properties").path("size").path("type").asText()).isEqualTo("integer") assertThat(doc.path("properties").path("size").path("format").asText()).isEqualTo("int32") } + + @Test + fun `writes an openapi document for an OpenApi route via the shared generator`() { + val compilation = KotlinCompilation().apply { + useKsp2() + sources = listOf( + SourceFile.kotlin( + "Routes.kt", + """ + package fixtures + import io.javalin.openapi.HttpMethod + import io.javalin.openapi.OpenApiStatus + import io.javalin.openapi.OpenApi + import io.javalin.openapi.OpenApiContent + import io.javalin.openapi.OpenApiResponse + + class Account(val id: String, val age: Int) + + class Routes { + @OpenApi( + path = "/account", + methods = [HttpMethod.GET], + responses = [OpenApiResponse(status = OpenApiStatus.OK, content = [OpenApiContent(from = Account::class)])] + ) + fun getAccount() {} + } + """.trimIndent() + ) + ) + symbolProcessorProviders = mutableListOf(OpenApiSymbolProcessorProvider()) + inheritClassPath = true + messageOutputStream = System.out + } + + val result = compilation.compile() + check(result.exitCode == KotlinCompilation.ExitCode.OK) { "KSP compilation failed: ${result.messages}" } + + val document = compilation.kspSourcesDir.walkTopDown().firstOrNull { it.name == "openapi-default.json" } + ?: error("openapi document not generated. Output tree:\n" + compilation.kspSourcesDir.walkTopDown().joinToString("\n")) + + val doc = jsonMapper.readTree(document.readText()) + assertThat(doc.path("openapi").asText()).isEqualTo("3.1.0") + + val operation = doc.path("paths").path("/account").path("get") + assertThat(operation.isMissingNode).isFalse() + + assertThat(operation.path("responses").path("200").path("description").asText()).isEqualTo("OK") + + val schemaRef = operation.path("responses").path("200").path("content").path("application/json").path("schema").path("\$ref").asText() + assertThat(schemaRef).isEqualTo("#/components/schemas/Account") + + val account = doc.path("components").path("schemas").path("Account") + assertThat(account.path("type").asText()).isEqualTo("object") + assertThat(account.path("properties").path("id").path("type").asText()).isEqualTo("string") + assertThat(account.path("properties").path("age").path("type").asText()).isEqualTo("integer") + } } diff --git a/openapi-specification/src/main/kotlin/io/javalin/openapi/OpenApiStatus.kt b/openapi-specification/src/main/kotlin/io/javalin/openapi/OpenApiStatus.kt new file mode 100644 index 00000000..cea939f3 --- /dev/null +++ b/openapi-specification/src/main/kotlin/io/javalin/openapi/OpenApiStatus.kt @@ -0,0 +1,136 @@ +package io.javalin.openapi + +object OpenApiStatus { + + const val CONTINUE = "100" + const val SWITCHING_PROTOCOLS = "101" + const val PROCESSING = "102" + const val EARLY_HINTS = "103" + const val OK = "200" + const val CREATED = "201" + const val ACCEPTED = "202" + const val NON_AUTHORITATIVE_INFORMATION = "203" + const val NO_CONTENT = "204" + const val RESET_CONTENT = "205" + const val PARTIAL_CONTENT = "206" + const val MULTI_STATUS = "207" + const val ALREADY_REPORTED = "208" + const val IM_USED = "226" + const val MULTIPLE_CHOICES = "300" + const val MOVED_PERMANENTLY = "301" + const val FOUND = "302" + const val SEE_OTHER = "303" + const val NOT_MODIFIED = "304" + const val USE_PROXY = "305" + const val TEMPORARY_REDIRECT = "307" + const val PERMANENT_REDIRECT = "308" + const val BAD_REQUEST = "400" + const val UNAUTHORIZED = "401" + const val PAYMENT_REQUIRED = "402" + const val FORBIDDEN = "403" + const val NOT_FOUND = "404" + const val METHOD_NOT_ALLOWED = "405" + const val NOT_ACCEPTABLE = "406" + const val PROXY_AUTHENTICATION_REQUIRED = "407" + const val REQUEST_TIMEOUT = "408" + const val CONFLICT = "409" + const val GONE = "410" + const val LENGTH_REQUIRED = "411" + const val PRECONDITION_FAILED = "412" + const val CONTENT_TOO_LARGE = "413" + const val URI_TOO_LONG = "414" + const val UNSUPPORTED_MEDIA_TYPE = "415" + const val RANGE_NOT_SATISFIABLE = "416" + const val EXPECTATION_FAILED = "417" + const val IM_A_TEAPOT = "418" + const val ENHANCE_YOUR_CALM = "420" + const val MISDIRECTED_REQUEST = "421" + const val UNPROCESSABLE_CONTENT = "422" + const val LOCKED = "423" + const val FAILED_DEPENDENCY = "424" + const val TOO_EARLY = "425" + const val UPGRADE_REQUIRED = "426" + const val PRECONDITION_REQUIRED = "428" + const val TOO_MANY_REQUESTS = "429" + const val REQUEST_HEADER_FIELDS_TOO_LARGE = "431" + const val UNAVAILABLE_FOR_LEGAL_REASONS = "451" + const val CLIENT_CLOSED_REQUEST = "499" + const val INTERNAL_SERVER_ERROR = "500" + const val NOT_IMPLEMENTED = "501" + const val BAD_GATEWAY = "502" + const val SERVICE_UNAVAILABLE = "503" + const val GATEWAY_TIMEOUT = "504" + const val HTTP_VERSION_NOT_SUPPORTED = "505" + const val INSUFFICIENT_STORAGE = "507" + const val LOOP_DETECTED = "508" + const val NETWORK_AUTHENTICATION_REQUIRED = "511" + + fun reasonPhrase(status: String): String? = + status.toIntOrNull()?.let { REASON_PHRASES[it] ?: "Unknown HTTP code" } + + private val REASON_PHRASES: Map = mapOf( + CONTINUE.toInt() to "Continue", + SWITCHING_PROTOCOLS.toInt() to "Switching Protocols", + PROCESSING.toInt() to "Processing", + EARLY_HINTS.toInt() to "Early Hints", + OK.toInt() to "OK", + CREATED.toInt() to "Created", + ACCEPTED.toInt() to "Accepted", + NON_AUTHORITATIVE_INFORMATION.toInt() to "Non Authoritative Information", + NO_CONTENT.toInt() to "No Content", + RESET_CONTENT.toInt() to "Reset Content", + PARTIAL_CONTENT.toInt() to "Partial Content", + MULTI_STATUS.toInt() to "Multi-Status", + ALREADY_REPORTED.toInt() to "Already Reported", + IM_USED.toInt() to "IM Used", + MULTIPLE_CHOICES.toInt() to "Multiple Choices", + MOVED_PERMANENTLY.toInt() to "Moved Permanently", + FOUND.toInt() to "Found", + SEE_OTHER.toInt() to "See Other", + NOT_MODIFIED.toInt() to "Not Modified", + USE_PROXY.toInt() to "Use Proxy", + TEMPORARY_REDIRECT.toInt() to "Temporary Redirect", + PERMANENT_REDIRECT.toInt() to "Permanent Redirect", + BAD_REQUEST.toInt() to "Bad Request", + UNAUTHORIZED.toInt() to "Unauthorized", + PAYMENT_REQUIRED.toInt() to "Payment Required", + FORBIDDEN.toInt() to "Forbidden", + NOT_FOUND.toInt() to "Not Found", + METHOD_NOT_ALLOWED.toInt() to "Method Not Allowed", + NOT_ACCEPTABLE.toInt() to "Not Acceptable", + PROXY_AUTHENTICATION_REQUIRED.toInt() to "Proxy Authentication Required", + REQUEST_TIMEOUT.toInt() to "Request Timeout", + CONFLICT.toInt() to "Conflict", + GONE.toInt() to "Gone", + LENGTH_REQUIRED.toInt() to "Length Required", + PRECONDITION_FAILED.toInt() to "Precondition Failed", + CONTENT_TOO_LARGE.toInt() to "Content Too Large", + URI_TOO_LONG.toInt() to "URI Too Long", + UNSUPPORTED_MEDIA_TYPE.toInt() to "Unsupported Media Type", + RANGE_NOT_SATISFIABLE.toInt() to "Range Not Satisfiable", + EXPECTATION_FAILED.toInt() to "Expectation Failed", + IM_A_TEAPOT.toInt() to "I'm a Teapot", + ENHANCE_YOUR_CALM.toInt() to "Enhance your Calm", + MISDIRECTED_REQUEST.toInt() to "Misdirected Request", + UNPROCESSABLE_CONTENT.toInt() to "Unprocessable Content", + LOCKED.toInt() to "Locked", + FAILED_DEPENDENCY.toInt() to "Failed Dependency", + TOO_EARLY.toInt() to "Too Early", + UPGRADE_REQUIRED.toInt() to "Upgrade Required", + PRECONDITION_REQUIRED.toInt() to "Precondition Required", + TOO_MANY_REQUESTS.toInt() to "Too Many Requests", + REQUEST_HEADER_FIELDS_TOO_LARGE.toInt() to "Request Header Fields Too Large", + UNAVAILABLE_FOR_LEGAL_REASONS.toInt() to "Unavailable for Legal Reason", + CLIENT_CLOSED_REQUEST.toInt() to "Client Closed Request", + INTERNAL_SERVER_ERROR.toInt() to "Server Error", + NOT_IMPLEMENTED.toInt() to "Not Implemented", + BAD_GATEWAY.toInt() to "Bad Gateway", + SERVICE_UNAVAILABLE.toInt() to "Service Unavailable", + GATEWAY_TIMEOUT.toInt() to "Gateway Timeout", + HTTP_VERSION_NOT_SUPPORTED.toInt() to "HTTP Version Not Supported", + INSUFFICIENT_STORAGE.toInt() to "Insufficient Storage", + LOOP_DETECTED.toInt() to "Loop Detected", + NETWORK_AUTHENTICATION_REQUIRED.toInt() to "Network Authentication Required", + ) + +} From 09d007bc1b13667b709bfb85d936b07903827f6a Mon Sep 17 00:00:00 2001 From: dzikoysk Date: Mon, 22 Jun 2026 23:41:17 +0200 Subject: [PATCH 17/43] feat: abstract common part of annotation processors --- .../introspection/CompileTimeIntrospector.kt | 10 +++ .../introspection/jap/JapTypeIntrospector.kt | 19 +++++- .../introspection/ksp/KspTypeIntrospector.kt | 15 ++++- .../AnnotationProcessorContext.kt | 13 ++-- .../experimental/IntrospectorSchemaContext.kt | 11 ++++ .../openapi/ksp/OpenApiSymbolProcessorTest.kt | 63 +++++++++++++++++++ 6 files changed, 120 insertions(+), 11 deletions(-) create mode 100644 introspection/introspection-api/src/main/kotlin/io/javalin/introspection/CompileTimeIntrospector.kt diff --git a/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/CompileTimeIntrospector.kt b/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/CompileTimeIntrospector.kt new file mode 100644 index 00000000..94bf4dc0 --- /dev/null +++ b/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/CompileTimeIntrospector.kt @@ -0,0 +1,10 @@ +package io.javalin.introspection + +interface CompileTimeIntrospector : TypeIntrospector { + + fun typesAnnotatedWith( + annotationType: Class, + assignableTo: ClassDefinition? = null, + ): List + +} diff --git a/introspection/introspection-jap/src/main/kotlin/io/javalin/introspection/jap/JapTypeIntrospector.kt b/introspection/introspection-jap/src/main/kotlin/io/javalin/introspection/jap/JapTypeIntrospector.kt index f68ed9e6..9f9e7f8b 100644 --- a/introspection/introspection-jap/src/main/kotlin/io/javalin/introspection/jap/JapTypeIntrospector.kt +++ b/introspection/introspection-jap/src/main/kotlin/io/javalin/introspection/jap/JapTypeIntrospector.kt @@ -4,6 +4,7 @@ import io.javalin.introspection.Accessor import io.javalin.introspection.AnnotationView import io.javalin.introspection.Annotations import io.javalin.introspection.ClassDefinition +import io.javalin.introspection.CompileTimeIntrospector import io.javalin.introspection.EnumConstantView import io.javalin.introspection.InternalIntrospectionApi import io.javalin.introspection.PropertyView @@ -11,10 +12,10 @@ import io.javalin.introspection.StructureType import io.javalin.introspection.StructureType.ARRAY import io.javalin.introspection.StructureType.DEFAULT import io.javalin.introspection.StructureType.DICTIONARY -import io.javalin.introspection.TypeIntrospector import io.javalin.introspection.Visibility import io.javalin.introspection.isGetterName import io.javalin.introspection.propertyName +import javax.annotation.processing.RoundEnvironment import javax.lang.model.element.AnnotationMirror import javax.lang.model.element.AnnotationValue import javax.lang.model.element.Element @@ -34,7 +35,11 @@ import javax.lang.model.util.Elements import javax.lang.model.util.SimpleAnnotationValueVisitor8 import javax.lang.model.util.Types -class JapTypeIntrospector(private val types: Types, private val elements: Elements) : TypeIntrospector { +class JapTypeIntrospector( + private val types: Types, + private val elements: Elements, + private val roundEnvProvider: () -> RoundEnvironment? = { null }, +) : CompileTimeIntrospector { override fun introspect(source: Any): ClassDefinition { require(source is TypeMirror) { "JapTypeIntrospector expects a javax.lang.model.type.TypeMirror, got ${source::class.java.name}" } @@ -44,6 +49,16 @@ class JapTypeIntrospector(private val types: Types, private val elements: Elemen fun annotationsOf(element: Element): Annotations = AnnotationsView(listOf(element)) + @OptIn(InternalIntrospectionApi::class) + override fun typesAnnotatedWith(annotationType: Class, assignableTo: ClassDefinition?): List { + val roundEnv = roundEnvProvider() ?: return emptyList() + val target = assignableTo?.source as? TypeMirror + return roundEnv.getElementsAnnotatedWith(annotationType) + .filterIsInstance() + .filter { target == null || types.isAssignable(it.asType(), target) } + .map { resolve(it.asType()) } + } + private fun resolve(mirror: TypeMirror, generics: List = emptyList(), structureType: StructureType = DEFAULT): ClassDefinition = when (mirror) { is TypeVariable -> (mirror.upperBound ?: mirror.lowerBound)?.let { resolve(it, generics, structureType) } ?: objectDefinition(structureType) diff --git a/introspection/introspection-ksp/src/main/kotlin/io/javalin/introspection/ksp/KspTypeIntrospector.kt b/introspection/introspection-ksp/src/main/kotlin/io/javalin/introspection/ksp/KspTypeIntrospector.kt index ca46895d..5c11b951 100644 --- a/introspection/introspection-ksp/src/main/kotlin/io/javalin/introspection/ksp/KspTypeIntrospector.kt +++ b/introspection/introspection-ksp/src/main/kotlin/io/javalin/introspection/ksp/KspTypeIntrospector.kt @@ -15,6 +15,7 @@ import io.javalin.introspection.Accessor import io.javalin.introspection.AnnotationView import io.javalin.introspection.Annotations import io.javalin.introspection.ClassDefinition +import io.javalin.introspection.CompileTimeIntrospector import io.javalin.introspection.EnumConstantView import io.javalin.introspection.InternalIntrospectionApi import io.javalin.introspection.PropertyView @@ -22,13 +23,12 @@ import io.javalin.introspection.StructureType import io.javalin.introspection.StructureType.ARRAY import io.javalin.introspection.StructureType.DEFAULT import io.javalin.introspection.StructureType.DICTIONARY -import io.javalin.introspection.TypeIntrospector import io.javalin.introspection.Visibility import com.google.devtools.ksp.symbol.Visibility as KspVisibility // KSP can't materialize JVM annotation instances (annotation access is name/value only) and names builtins with // Kotlin FQNs (`kotlin.Int`), which `canonicalName` normalizes to their JVM equivalents to match jap/reflection. -class KspTypeIntrospector(private val resolver: Resolver) : TypeIntrospector { +class KspTypeIntrospector(private val resolver: Resolver) : CompileTimeIntrospector { private val mapType = builtin("kotlin.collections.Map") private val collectionType = builtin("kotlin.collections.Collection") @@ -47,6 +47,17 @@ class KspTypeIntrospector(private val resolver: Resolver) : TypeIntrospector { fun annotationsOf(annotated: KSAnnotated): Annotations = KspAnnotations(annotated) + @OptIn(InternalIntrospectionApi::class) + override fun typesAnnotatedWith(annotationType: Class, assignableTo: ClassDefinition?): List { + val target = assignableTo?.source as? KSType + return resolver.getSymbolsWithAnnotation(annotationType.name) + .filterIsInstance() + .map { it.asStarProjectedType() } + .filter { target == null || target.isAssignableFrom(it) } + .map { resolve(it) } + .toList() + } + private fun resolve(type: KSType, structureType: StructureType = DEFAULT): ClassDefinition { val declaration = type.declaration if (declaration is KSTypeParameter) { diff --git a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/AnnotationProcessorContext.kt b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/AnnotationProcessorContext.kt index 86e533a4..9b7c82e4 100644 --- a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/AnnotationProcessorContext.kt +++ b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/AnnotationProcessorContext.kt @@ -37,7 +37,7 @@ class AnnotationProcessorContext( override val simpleTypeMappings: Map get() = configuration.simpleTypeMappings override val embeddedTypeProcessors: List get() = configuration.embeddedTypeProcessors - private val japIntrospector: JapTypeIntrospector by lazy { JapTypeIntrospector(types, env.elementUtils) } + private val japIntrospector: JapTypeIntrospector by lazy { JapTypeIntrospector(types, env.elementUtils) { roundEnv } } override fun isEnum(type: OpenApiType): Boolean = type.source.kind == ElementKind.ENUM @@ -77,12 +77,11 @@ class AnnotationProcessorContext( configuration.propertyInSchemeFilter?.filter(this, type, property.source as Element) != false override fun discriminatorSubtypes(type: OpenApiType): List> = - roundEnv!!.getElementsAnnotatedWith(DiscriminatorMappingName::class.java) - .asSequence() - .filterIsInstance() - .map { it.getAnnotation(DiscriminatorMappingName::class.java).value to it.asType().toOpenApiType() } - .filter { (_, subtype) -> isAssignable(subtype.mirror, type.mirror) } - .toList() + japIntrospector.typesAnnotatedWith(DiscriminatorMappingName::class.java, assignableTo = japIntrospector.introspect(type.mirror)) + .mapNotNull { subtype -> + (subtype.getAnnotations().memberValues(DiscriminatorMappingName::class.java)?.get("value") as? String) + ?.let { it to toOpenApiType(subtype) } + } fun inContext(body: AnnotationProcessorContext.() -> R): R = body() diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/IntrospectorSchemaContext.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/IntrospectorSchemaContext.kt index fbad0d05..3c67f71f 100644 --- a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/IntrospectorSchemaContext.kt +++ b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/IntrospectorSchemaContext.kt @@ -1,9 +1,11 @@ package io.javalin.openapi.experimental import io.javalin.introspection.Annotations +import io.javalin.introspection.CompileTimeIntrospector import io.javalin.introspection.EnumConstantView import io.javalin.introspection.PropertyView import io.javalin.introspection.TypeIntrospector +import io.javalin.openapi.DiscriminatorMappingName import io.javalin.openapi.OpenApiName import io.javalin.openapi.experimental.defaults.createDefaultEmbeddedTypeProcessors import io.javalin.openapi.experimental.defaults.createDefaultSimpleTypeMappings @@ -56,6 +58,15 @@ abstract class IntrospectorSchemaContext( override fun enumConstantsOf(type: OpenApiType): List = type.raw.getEnumConstants() ?: emptyList() + + override fun discriminatorSubtypes(type: OpenApiType): List> = + (introspector as? CompileTimeIntrospector) + ?.typesAnnotatedWith(DiscriminatorMappingName::class.java, assignableTo = type.raw) + ?.mapNotNull { subtype -> + (subtype.getAnnotations().memberValues(DiscriminatorMappingName::class.java)?.get("value") as? String) + ?.let { it to toOpenApiType(subtype) } + } + ?: emptyList() } private val OpenApiType.raw: RawType diff --git a/openapi-ksp/src/test/kotlin/io/javalin/openapi/ksp/OpenApiSymbolProcessorTest.kt b/openapi-ksp/src/test/kotlin/io/javalin/openapi/ksp/OpenApiSymbolProcessorTest.kt index 5016dc0b..da45a459 100644 --- a/openapi-ksp/src/test/kotlin/io/javalin/openapi/ksp/OpenApiSymbolProcessorTest.kt +++ b/openapi-ksp/src/test/kotlin/io/javalin/openapi/ksp/OpenApiSymbolProcessorTest.kt @@ -101,4 +101,67 @@ class OpenApiSymbolProcessorTest { assertThat(account.path("properties").path("id").path("type").asText()).isEqualTo("string") assertThat(account.path("properties").path("age").path("type").asText()).isEqualTo("integer") } + + @Test + fun `auto-discovers discriminator subtypes via DiscriminatorMappingName`() { + val compilation = KotlinCompilation().apply { + useKsp2() + sources = listOf( + SourceFile.kotlin( + "Shapes.kt", + """ + package fixtures + import io.javalin.openapi.Discriminator + import io.javalin.openapi.DiscriminatorMappingName + import io.javalin.openapi.DiscriminatorProperty + import io.javalin.openapi.HttpMethod + import io.javalin.openapi.OneOf + import io.javalin.openapi.OpenApi + import io.javalin.openapi.OpenApiContent + import io.javalin.openapi.OpenApiResponse + import io.javalin.openapi.OpenApiStatus + + @OneOf(discriminator = Discriminator(property = DiscriminatorProperty(name = "type"))) + sealed interface Shape + + @DiscriminatorMappingName("circle") + data class Circle(val radius: Int) : Shape + + @DiscriminatorMappingName("square") + data class Square(val side: Int) : Shape + + class Shapes { + @OpenApi( + path = "/shape", + methods = [HttpMethod.GET], + responses = [OpenApiResponse(status = OpenApiStatus.OK, content = [OpenApiContent(from = Shape::class)])] + ) + fun shape() {} + } + """.trimIndent() + ) + ) + symbolProcessorProviders = mutableListOf(OpenApiSymbolProcessorProvider()) + inheritClassPath = true + messageOutputStream = System.out + } + + val result = compilation.compile() + check(result.exitCode == KotlinCompilation.ExitCode.OK) { "KSP compilation failed: ${result.messages}" } + + val document = compilation.kspSourcesDir.walkTopDown().firstOrNull { it.name == "openapi-default.json" } + ?: error("openapi document not generated. Output tree:\n" + compilation.kspSourcesDir.walkTopDown().joinToString("\n")) + + val shape = jsonMapper.readTree(document.readText()).path("components").path("schemas").path("Shape") + + val refs = shape.path("oneOf").map { it.path("\$ref").asText() } + assertThat(refs).containsExactlyInAnyOrder( + "#/components/schemas/Circle", + "#/components/schemas/Square", + ) + + assertThat(shape.path("discriminator").path("propertyName").asText()).isEqualTo("type") + assertThat(shape.path("discriminator").path("mapping").path("circle").asText()).isEqualTo("#/components/schemas/Circle") + assertThat(shape.path("discriminator").path("mapping").path("square").asText()).isEqualTo("#/components/schemas/Square") + } } From 7b190fda4ffb31d0c1fc20b7c4fe8772bd0b954e Mon Sep 17 00:00:00 2001 From: dzikoysk Date: Tue, 23 Jun 2026 02:35:04 +0200 Subject: [PATCH 18/43] feat: ksp example project + docs --- docs/advanced/configuration.md | 11 ++++ docs/advanced/runtime-builder.md | 50 +++++++++++++++++++ docs/introduction/json-schema-setup.md | 18 +++++++ docs/introduction/setup.md | 45 +++++++++++++++++ examples/javalin-ksp-kotlin/build.gradle.kts | 27 ++++++++++ .../io/javalin/openapi/ksp/example/App.kt | 49 ++++++++++++++++++ gradle/libs.versions.toml | 1 + settings.gradle.kts | 3 +- 8 files changed, 203 insertions(+), 1 deletion(-) create mode 100644 examples/javalin-ksp-kotlin/build.gradle.kts create mode 100644 examples/javalin-ksp-kotlin/src/main/kotlin/io/javalin/openapi/ksp/example/App.kt diff --git a/docs/advanced/configuration.md b/docs/advanced/configuration.md index 4895cf52..53b7abe3 100644 --- a/docs/advanced/configuration.md +++ b/docs/advanced/configuration.md @@ -82,6 +82,13 @@ kapt { } ``` +```kotlin [Gradle (KSP)] +ksp { + arg("openapi.info.title", "My API") + arg("openapi.info.version", "1.0.0") +} +``` + ```xml [Maven] org.apache.maven.plugins @@ -97,6 +104,10 @@ kapt { ::: +::: tip +The KSP backend reads `openapi.info.title` and `openapi.info.version`. The `openapi.groovy.path` script and the custom mappings, filters, and processors below are APT/Kapt-only. +::: + ## Custom Type Mappings Map custom types to simple OpenAPI types: diff --git a/docs/advanced/runtime-builder.md b/docs/advanced/runtime-builder.md index 361ee006..75778b9f 100644 --- a/docs/advanced/runtime-builder.md +++ b/docs/advanced/runtime-builder.md @@ -219,6 +219,56 @@ val json = schema.toJson() Reopening an existing operation preserves all fields — only the fields you set are changed. This makes it safe to layer runtime additions on top of compile-time output. +## Auto-generating Docs for Registered Routes + +For a springdoc-style experience — documenting routes that are registered programmatically (and that the compile-time processor never sees) — use the **dynamic hook** module. It enumerates every route registered on Javalin at startup and contributes them to the served document. + +```kotlin [Gradle (Kotlin)] +dependencies { + val openapi = "7.2.2" + implementation("io.javalin.community.openapi:javalin-openapi-dynamic-hook:$openapi") +} +``` + +Register `RegisteredRoutesHook` on the `OpenApiPlugin`: + +```kotlin +Javalin.create { config -> + config.registerPlugin(OpenApiPlugin { it.withHook(RegisteredRoutesHook()) }) + + config.routes.get("/users") { /* ... */ } + config.routes.get("/users/{id}") { /* ... */ } +} +``` + +Every registered route is documented with its path, method, path parameters (typed as `string`), and a default `200` response. + +### Enriching a Route + +Javalin handlers are opaque lambdas, so without extra information the hook can only emit those stubs — it cannot infer request/response bodies. Attach an `OpenApiMetadata` to a route to describe it using the same operation DSL shown above; `schema(Class)` resolves the type through the reflection schema engine (no annotation processing required): + +```kotlin +config.routes.addEndpoint( + Endpoint.create(HandlerType.GET, "/users/{id}") + .addMetadata(OpenApiMetadata { + summary("Get a user") + responses { + response("200") { + description("The user") + content { mediaType("application/json") { schema(User::class.java) } } + } + } + }) + .handler { /* ... */ } +) +``` + +The path-parameter skeleton is still auto-added, then your metadata is overlaid on top, and referenced types (e.g. `User`) are emitted into `components/schemas`. + +::: warning +The hook currently documents the `OpenApiPlugin`'s own `/openapi` route (and any Swagger/ReDoc UI routes) alongside your endpoints. Runtime reflection is opt-in: it only happens when you add this module and register the hook, so the compile-time backends stay reflection-free. +::: + ## Merge Behavior The builder supports incremental construction. Reopening a path, operation, response, or media type merges with existing data: diff --git a/docs/introduction/json-schema-setup.md b/docs/introduction/json-schema-setup.md index a1bf71a5..945d3e5f 100644 --- a/docs/introduction/json-schema-setup.md +++ b/docs/introduction/json-schema-setup.md @@ -40,6 +40,24 @@ dependencies { } ``` +```kotlin [Gradle (Kotlin) with KSP] +plugins { + // Use a KSP version matching your Kotlin version (e.g. 2.3.9 for Kotlin 2.3.x) + id("com.google.devtools.ksp") version "2.3.9" +} + +dependencies { + val openapi = "7.2.2" + + ksp( + "io.javalin.community.openapi:openapi-ksp:$openapi" + ) + implementation( + "io.javalin.community.openapi:openapi-specification:$openapi" + ) +} +``` + ```xml [Maven] diff --git a/docs/introduction/setup.md b/docs/introduction/setup.md index f59841db..8967c683 100644 --- a/docs/introduction/setup.md +++ b/docs/introduction/setup.md @@ -61,6 +61,35 @@ dependencies { } ``` +```kotlin [Gradle (Kotlin) with KSP] +plugins { + // Use a KSP version matching your Kotlin version (e.g. 2.3.9 for Kotlin 2.3.x) + id("com.google.devtools.ksp") version "2.3.9" +} + +dependencies { + val openapi = "7.2.2" + + ksp( + "io.javalin.community.openapi:openapi-ksp:$openapi" + ) + implementation( + "io.javalin.community.openapi:javalin-openapi-plugin:$openapi" + ) + implementation( + "io.javalin.community.openapi:javalin-swagger-plugin:$openapi" + ) + implementation( + "io.javalin.community.openapi:javalin-redoc-plugin:$openapi" + ) +} + +ksp { + arg("openapi.info.title", "My API") + arg("openapi.info.version", "1.0.0") +} +``` + ```xml [Maven] @@ -103,6 +132,22 @@ dependencies { ::: +## Choosing a Processor + +The same generation engine runs on three backends — pick the one that matches your build: + +| Backend | Dependency | Sources | Best for | +|---------|------------|---------|----------| +| APT (`annotationProcessor`) | `openapi-annotation-processor` | Java | Java projects, or Kotlin via `kapt` | +| Kapt (`kapt`) | `openapi-annotation-processor` | Java + Kotlin | Mixed Java/Kotlin projects | +| KSP (`ksp`) | `openapi-ksp` *(experimental)* | Kotlin only | Kotlin-only projects wanting faster builds | + +All three emit identical `openapi-plugin/openapi-*.json` resources that the `OpenApiPlugin` serves — switching backends does not change the generated specification. + +::: warning KSP limitations +KSP processes **Kotlin sources only** — `@OpenApi`/`@JsonSchema` on Java types are not picked up (use APT/Kapt for those). The Groovy [compile-time configuration](../advanced/configuration) (custom type mappings, property filters, custom type processors) and parser validation are currently APT/Kapt-only. +::: + ## Register the Plugin ```kotlin diff --git a/examples/javalin-ksp-kotlin/build.gradle.kts b/examples/javalin-ksp-kotlin/build.gradle.kts new file mode 100644 index 00000000..dfff3311 --- /dev/null +++ b/examples/javalin-ksp-kotlin/build.gradle.kts @@ -0,0 +1,27 @@ +plugins { + alias(libs.plugins.ksp) +} + +dependencies { + ksp(project(":openapi-ksp")) + implementation(project(":javalin-plugins:javalin-openapi-plugin")) + implementation(project(":javalin-plugins:javalin-swagger-plugin")) + implementation(project(":javalin-plugins:javalin-redoc-plugin")) + + implementation(libs.javalin) + implementation(libs.jackson.databind) + implementation(libs.logback.classic) +} + +ksp { + arg("openapi.info.title", "Awesome KSP App") + arg("openapi.info.version", "1.0.0") +} + +application { + mainClass.set("io.javalin.openapi.ksp.example.AppKt") +} + +repositories { + mavenCentral() +} diff --git a/examples/javalin-ksp-kotlin/src/main/kotlin/io/javalin/openapi/ksp/example/App.kt b/examples/javalin-ksp-kotlin/src/main/kotlin/io/javalin/openapi/ksp/example/App.kt new file mode 100644 index 00000000..4ce78838 --- /dev/null +++ b/examples/javalin-ksp-kotlin/src/main/kotlin/io/javalin/openapi/ksp/example/App.kt @@ -0,0 +1,49 @@ +package io.javalin.openapi.ksp.example + +import io.javalin.Javalin +import io.javalin.http.Context +import io.javalin.http.Handler +import io.javalin.openapi.HttpMethod +import io.javalin.openapi.OpenApi +import io.javalin.openapi.OpenApiContent +import io.javalin.openapi.OpenApiResponse +import io.javalin.openapi.OpenApiStatus +import io.javalin.openapi.plugin.OpenApiPlugin +import io.javalin.openapi.plugin.redoc.ReDocPlugin +import io.javalin.openapi.plugin.swagger.SwaggerPlugin + +data class Account( + val id: String, + val age: Int, + val roles: List, +) + +class AccountHandler : Handler { + + @OpenApi( + path = "/account", + methods = [HttpMethod.GET], + summary = "Get the current account", + operationId = "getAccount", + responses = [OpenApiResponse(status = OpenApiStatus.OK, content = [OpenApiContent(from = Account::class)])] + ) + override fun handle(ctx: Context) { + ctx.json(Account(id = "u-1", age = 30, roles = listOf("admin"))) + } +} + +fun main() { + val app = Javalin.create { config -> + config.registerPlugin(OpenApiPlugin {}) + config.registerPlugin(SwaggerPlugin {}) + config.registerPlugin(ReDocPlugin {}) + + config.routes.get("/account", AccountHandler()) + } + + app.start(8080) + + println("OpenAPI document: http://localhost:8080/openapi") + println("Swagger UI: http://localhost:8080/swagger") + println("ReDoc: http://localhost:8080/redoc") +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8952d2a9..b487c2bd 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -67,4 +67,5 @@ unirest = { module = "com.konghq:unirest-java", version.ref = "unirest" } [plugins] kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } nexus-publish = { id = "io.github.gradle-nexus.publish-plugin", version.ref = "nexus-publish" } \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index abb9fbbf..226ccba7 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -18,5 +18,6 @@ include( "javalin-plugins:javalin-redoc-plugin", "javalin-plugins:javalin-openapi-dynamic-hook", "examples", - "examples:javalin-gradle-kotlin" + "examples:javalin-gradle-kotlin", + "examples:javalin-ksp-kotlin" ) \ No newline at end of file From 9f74c648071a27ea98492f9530da982509e9e678 Mon Sep 17 00:00:00 2001 From: dzikoysk Date: Wed, 24 Jun 2026 00:57:43 +0200 Subject: [PATCH 19/43] chore: update example --- .../main/kotlin/io/javalin/openapi/ksp/example/App.kt | 5 +---- .../javalin-ksp-kotlin/src/main/resources/logback.xml | 11 +++++++++++ 2 files changed, 12 insertions(+), 4 deletions(-) create mode 100644 examples/javalin-ksp-kotlin/src/main/resources/logback.xml diff --git a/examples/javalin-ksp-kotlin/src/main/kotlin/io/javalin/openapi/ksp/example/App.kt b/examples/javalin-ksp-kotlin/src/main/kotlin/io/javalin/openapi/ksp/example/App.kt index 4ce78838..e453b18e 100644 --- a/examples/javalin-ksp-kotlin/src/main/kotlin/io/javalin/openapi/ksp/example/App.kt +++ b/examples/javalin-ksp-kotlin/src/main/kotlin/io/javalin/openapi/ksp/example/App.kt @@ -19,7 +19,6 @@ data class Account( ) class AccountHandler : Handler { - @OpenApi( path = "/account", methods = [HttpMethod.GET], @@ -33,7 +32,7 @@ class AccountHandler : Handler { } fun main() { - val app = Javalin.create { config -> + Javalin.start { config -> config.registerPlugin(OpenApiPlugin {}) config.registerPlugin(SwaggerPlugin {}) config.registerPlugin(ReDocPlugin {}) @@ -41,8 +40,6 @@ fun main() { config.routes.get("/account", AccountHandler()) } - app.start(8080) - println("OpenAPI document: http://localhost:8080/openapi") println("Swagger UI: http://localhost:8080/swagger") println("ReDoc: http://localhost:8080/redoc") diff --git a/examples/javalin-ksp-kotlin/src/main/resources/logback.xml b/examples/javalin-ksp-kotlin/src/main/resources/logback.xml new file mode 100644 index 00000000..776c8b44 --- /dev/null +++ b/examples/javalin-ksp-kotlin/src/main/resources/logback.xml @@ -0,0 +1,11 @@ + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger - %msg%n + + + + + + + From a7e8c488dab4ddbd46e8394e7a7f7ff3747c237f Mon Sep 17 00:00:00 2001 From: dzikoysk Date: Sat, 27 Jun 2026 18:41:25 +0200 Subject: [PATCH 20/43] chore: adjust error messages --- .../src/main/kotlin/io/javalin/openapi/OpenApiStatus.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/openapi-specification/src/main/kotlin/io/javalin/openapi/OpenApiStatus.kt b/openapi-specification/src/main/kotlin/io/javalin/openapi/OpenApiStatus.kt index cea939f3..926aa768 100644 --- a/openapi-specification/src/main/kotlin/io/javalin/openapi/OpenApiStatus.kt +++ b/openapi-specification/src/main/kotlin/io/javalin/openapi/OpenApiStatus.kt @@ -76,7 +76,7 @@ object OpenApiStatus { OK.toInt() to "OK", CREATED.toInt() to "Created", ACCEPTED.toInt() to "Accepted", - NON_AUTHORITATIVE_INFORMATION.toInt() to "Non Authoritative Information", + NON_AUTHORITATIVE_INFORMATION.toInt() to "Non-Authoritative Information", NO_CONTENT.toInt() to "No Content", RESET_CONTENT.toInt() to "Reset Content", PARTIAL_CONTENT.toInt() to "Partial Content", @@ -109,7 +109,7 @@ object OpenApiStatus { UNSUPPORTED_MEDIA_TYPE.toInt() to "Unsupported Media Type", RANGE_NOT_SATISFIABLE.toInt() to "Range Not Satisfiable", EXPECTATION_FAILED.toInt() to "Expectation Failed", - IM_A_TEAPOT.toInt() to "I'm a Teapot", + IM_A_TEAPOT.toInt() to "I'm a teapot", ENHANCE_YOUR_CALM.toInt() to "Enhance your Calm", MISDIRECTED_REQUEST.toInt() to "Misdirected Request", UNPROCESSABLE_CONTENT.toInt() to "Unprocessable Content", @@ -120,9 +120,9 @@ object OpenApiStatus { PRECONDITION_REQUIRED.toInt() to "Precondition Required", TOO_MANY_REQUESTS.toInt() to "Too Many Requests", REQUEST_HEADER_FIELDS_TOO_LARGE.toInt() to "Request Header Fields Too Large", - UNAVAILABLE_FOR_LEGAL_REASONS.toInt() to "Unavailable for Legal Reason", + UNAVAILABLE_FOR_LEGAL_REASONS.toInt() to "Unavailable For Legal Reasons", CLIENT_CLOSED_REQUEST.toInt() to "Client Closed Request", - INTERNAL_SERVER_ERROR.toInt() to "Server Error", + INTERNAL_SERVER_ERROR.toInt() to "Internal Server Error", NOT_IMPLEMENTED.toInt() to "Not Implemented", BAD_GATEWAY.toInt() to "Bad Gateway", SERVICE_UNAVAILABLE.toInt() to "Service Unavailable", From 12f5e89e4f688c126b186510ad34723b2dd39d8b Mon Sep 17 00:00:00 2001 From: dzikoysk Date: Sun, 28 Jun 2026 13:26:06 +0200 Subject: [PATCH 21/43] chore: cleanup --- docs/index.md | 8 +-- docs/introduction/json-schema-setup.md | 14 ++--- docs/introduction/setup.md | 12 ++--- .../introspection/jap/JapTypeIntrospector.kt | 3 +- .../introspection/ksp/KspTypeIntrospector.kt | 19 +++++-- .../runtime/ReflectionTypeIntrospector.kt | 4 +- .../introspection-test/build.gradle.kts | 4 ++ .../test/IntrospectionParityTest.kt | 50 ++++++++++++++--- .../introspection/test/SymbolProcessing.kt | 46 ++++++++++++++++ .../introspection/test/fixtures/Fixtures.kt | 2 + .../AnnotationProcessorContext.kt | 9 +--- .../processor/shared/ModelExtensions.kt | 7 --- .../generators/JsonSchemaGenerator.kt | 4 +- .../processor/generators/OpenApiGenerator.kt | 14 +---- ...ExperimentalCompileOpenApiConfiguration.kt | 10 ++++ .../openapi/experimental/SimpleType.kt | 9 ---- .../CompositionEmbeddedTypeProcessor.kt | 2 +- .../generators/CompositionGenerator.kt | 4 +- .../generators/TypeSchemaGenerator.kt | 4 +- .../openapi/schema/OpenApiSchemaGenerator.kt | 6 +++ .../openapi/ksp/OpenApiSymbolProcessor.kt | 24 ++------- .../openapi/ksp/OpenApiSymbolProcessorTest.kt | 53 +++++++++++++++++++ 22 files changed, 218 insertions(+), 90 deletions(-) create mode 100644 introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/SymbolProcessing.kt delete mode 100644 openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/processor/shared/ModelExtensions.kt create mode 100644 openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/ExperimentalCompileOpenApiConfiguration.kt diff --git a/docs/index.md b/docs/index.md index 1f05d80a..cee8d403 100644 --- a/docs/index.md +++ b/docs/index.md @@ -4,7 +4,7 @@ layout: home hero: name: Javalin OpenAPI text: Compile-time API Documentation - tagline: Generate OpenAPI specifications and JSON Schemas at compile time with zero runtime reflection. Annotation-driven, type-safe, and ready for Swagger UI and ReDoc. + tagline: Generate OpenAPI specifications and JSON Schemas at compile time — via Java annotation processing, kapt, or KSP. Annotation-driven, type-safe, and ready for Swagger UI and ReDoc. actions: - theme: brand text: Get Started @@ -15,9 +15,11 @@ hero: features: - title: Compile-time Generation - details: Schemas are generated during compilation using annotation processing. No runtime reflection, no classpath scanning, no startup overhead. + details: Schemas are generated during compilation via Java annotation processing, kapt, or KSP. No runtime reflection, no classpath scanning, no startup overhead. - title: Two Modes - details: Generate OpenAPI 3.1.0 endpoint documentation with @OpenApi, or standalone JSON Schema 2020-12 files with @JsonSchema — using the same annotation processor. + details: Generate OpenAPI 3.1.0 endpoint documentation with @OpenApi, or standalone JSON Schema 2020-12 files with @JsonSchema — from the same engine. + - title: Optional Runtime Backend + details: An opt-in reflection backend documents routes registered programmatically at runtime — for the dynamic cases the compile-time processor can't see. - title: Swagger UI & ReDoc details: Built-in plugins serve Swagger UI and ReDoc out of the box. Register the plugin and your interactive API documentation is live. - title: Schema Customization diff --git a/docs/introduction/json-schema-setup.md b/docs/introduction/json-schema-setup.md index 945d3e5f..6cf2824f 100644 --- a/docs/introduction/json-schema-setup.md +++ b/docs/introduction/json-schema-setup.md @@ -42,7 +42,7 @@ dependencies { ```kotlin [Gradle (Kotlin) with KSP] plugins { - // Use a KSP version matching your Kotlin version (e.g. 2.3.9 for Kotlin 2.3.x) + // KSP is released against a specific Kotlin version - use the build that matches yours (github.com/google/ksp/releases) id("com.google.devtools.ksp") version "2.3.9" } @@ -124,12 +124,12 @@ At compile time, this generates a `/json-schemes/com.example.UserConfig` resourc ### Differences between the Two Modes -| Aspect | OpenAPI | JSON Schema | -|--------|---------|-------------| -| Output format | OpenAPI 3.1.0 | JSON Schema 2020-12 | -| Scope | Endpoint docs | Standalone type schemas | -| References | `$ref` | All types inlined | -| Trigger | `@OpenApi` | `@JsonSchema` | +| Aspect | OpenAPI | JSON Schema | +|---------------|---------------|-------------------------| +| Output format | OpenAPI 3.1.0 | JSON Schema 2020-12 | +| Scope | Endpoint docs | Standalone type schemas | +| References | `$ref` | All types inlined | +| Trigger | `@OpenApi` | `@JsonSchema` | Both modes are framework-agnostic at the annotation processing level — Javalin is only needed if you want to serve the specs via the Javalin plugins. diff --git a/docs/introduction/setup.md b/docs/introduction/setup.md index 8967c683..d7b20fcb 100644 --- a/docs/introduction/setup.md +++ b/docs/introduction/setup.md @@ -63,7 +63,7 @@ dependencies { ```kotlin [Gradle (Kotlin) with KSP] plugins { - // Use a KSP version matching your Kotlin version (e.g. 2.3.9 for Kotlin 2.3.x) + // KSP is released against a specific Kotlin version — use the build that matches yours (github.com/google/ksp/releases) id("com.google.devtools.ksp") version "2.3.9" } @@ -136,11 +136,11 @@ ksp { The same generation engine runs on three backends — pick the one that matches your build: -| Backend | Dependency | Sources | Best for | -|---------|------------|---------|----------| -| APT (`annotationProcessor`) | `openapi-annotation-processor` | Java | Java projects, or Kotlin via `kapt` | -| Kapt (`kapt`) | `openapi-annotation-processor` | Java + Kotlin | Mixed Java/Kotlin projects | -| KSP (`ksp`) | `openapi-ksp` *(experimental)* | Kotlin only | Kotlin-only projects wanting faster builds | +| Backend | Dependency | Sources | Best for | +|-----------------------------|--------------------------------|---------------|--------------------------------------------| +| APT (`annotationProcessor`) | `openapi-annotation-processor` | Java | Java projects, or Kotlin via `kapt` | +| Kapt (`kapt`) | `openapi-annotation-processor` | Java + Kotlin | Mixed Java/Kotlin projects | +| KSP (`ksp`) | `openapi-ksp` *(experimental)* | Kotlin only | Kotlin-only projects wanting faster builds | All three emit identical `openapi-plugin/openapi-*.json` resources that the `OpenApiPlugin` serves — switching backends does not change the generated specification. diff --git a/introspection/introspection-jap/src/main/kotlin/io/javalin/introspection/jap/JapTypeIntrospector.kt b/introspection/introspection-jap/src/main/kotlin/io/javalin/introspection/jap/JapTypeIntrospector.kt index 9f9e7f8b..df0a6460 100644 --- a/introspection/introspection-jap/src/main/kotlin/io/javalin/introspection/jap/JapTypeIntrospector.kt +++ b/introspection/introspection-jap/src/main/kotlin/io/javalin/introspection/jap/JapTypeIntrospector.kt @@ -15,6 +15,7 @@ import io.javalin.introspection.StructureType.DICTIONARY import io.javalin.introspection.Visibility import io.javalin.introspection.isGetterName import io.javalin.introspection.propertyName +import java.lang.annotation.Repeatable as JavaRepeatable import javax.annotation.processing.RoundEnvironment import javax.lang.model.element.AnnotationMirror import javax.lang.model.element.AnnotationValue @@ -234,7 +235,7 @@ class JapTypeIntrospector( val mirrors = sources.flatMap { it.annotationMirrors } val direct = mirrors.filter { it.named(annotationType.name) }.map { mirrorValues(it) } // javac wraps repeated annotations in their @Repeatable container; unwrap its `value` array - val containerName = annotationType.getAnnotation(java.lang.annotation.Repeatable::class.java)?.value?.java?.canonicalName + val containerName = annotationType.getAnnotation(JavaRepeatable::class.java)?.value?.java?.canonicalName val repeated = containerName ?.let { name -> mirrors.firstOrNull { it.named(name) } } ?.let { mirrorValues(it)["value"] as? List<*> } diff --git a/introspection/introspection-ksp/src/main/kotlin/io/javalin/introspection/ksp/KspTypeIntrospector.kt b/introspection/introspection-ksp/src/main/kotlin/io/javalin/introspection/ksp/KspTypeIntrospector.kt index 5c11b951..535971fa 100644 --- a/introspection/introspection-ksp/src/main/kotlin/io/javalin/introspection/ksp/KspTypeIntrospector.kt +++ b/introspection/introspection-ksp/src/main/kotlin/io/javalin/introspection/ksp/KspTypeIntrospector.kt @@ -24,6 +24,7 @@ import io.javalin.introspection.StructureType.ARRAY import io.javalin.introspection.StructureType.DEFAULT import io.javalin.introspection.StructureType.DICTIONARY import io.javalin.introspection.Visibility +import java.lang.annotation.Repeatable as JavaRepeatable import com.google.devtools.ksp.symbol.Visibility as KspVisibility // KSP can't materialize JVM annotation instances (annotation access is name/value only) and names builtins with @@ -161,11 +162,19 @@ class KspTypeIntrospector(private val resolver: Resolver) : CompileTimeIntrospec ?.firstOrNull { it.annotationType.resolve().declaration.qualifiedName?.asString() == annotationType.name } ?.let { argumentValues(it) } - override fun memberValuesList(annotationType: Class): List> = - element?.annotations - ?.filter { it.annotationType.resolve().declaration.qualifiedName?.asString() == annotationType.name } - ?.map { argumentValues(it) } - ?.toList() ?: emptyList() + override fun memberValuesList(annotationType: Class): List> { + val annotations = element?.annotations?.toList() ?: return emptyList() + val direct = annotations + .filter { it.annotationType.resolve().declaration.qualifiedName?.asString() == annotationType.name } + .map { argumentValues(it) } + val containerName = annotationType.getAnnotation(JavaRepeatable::class.java)?.value?.java?.canonicalName + val repeated = containerName + ?.let { name -> annotations.firstOrNull { it.annotationType.resolve().declaration.qualifiedName?.asString() == name } } + ?.let { argumentValues(it)["value"] as? List<*> } + ?.filterIsInstance>() + .orEmpty() + return direct + repeated + } override fun all(): List = element?.annotations?.map { KspAnnotationView(it) }?.toList() ?: emptyList() diff --git a/introspection/introspection-runtime/src/main/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospector.kt b/introspection/introspection-runtime/src/main/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospector.kt index 611ebedc..c33f1bc8 100644 --- a/introspection/introspection-runtime/src/main/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospector.kt +++ b/introspection/introspection-runtime/src/main/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospector.kt @@ -23,6 +23,7 @@ import java.lang.reflect.Method import java.lang.reflect.Modifier import java.lang.reflect.ParameterizedType import java.lang.reflect.Type +import java.lang.reflect.TypeVariable import java.lang.reflect.WildcardType class ReflectionTypeIntrospector : TypeIntrospector { @@ -37,6 +38,7 @@ private fun reflect(type: Type, generics: List = emptyList(), s when (type) { is GenericArrayType -> reflect(type.genericComponentType, generics, ARRAY) is WildcardType -> reflect(type.upperBounds.firstOrNull() ?: Any::class.java, generics, structureType) + is TypeVariable<*> -> reflect(type.bounds.firstOrNull() ?: Any::class.java, generics, structureType) is ParameterizedType -> parameterized(type, generics, structureType) is Class<*> -> raw(type, generics, structureType) else -> definition(Any::class.java, emptyList(), structureType) @@ -101,7 +103,7 @@ private class ReflectionClassDefinition( override fun getEnumConstants(): List? = erasure.takeIf { it.isEnum }?.enumConstants?.map { val name = (it as Enum<*>).name - EnumConstantView(name, ReflectionAnnotations(listOf(erasure.getField(name)))) + EnumConstantView(name, ReflectionAnnotations(listOfNotNull(runCatching { erasure.getDeclaredField(name) }.getOrNull()))) } override fun getProperties(): List = diff --git a/introspection/introspection-test/build.gradle.kts b/introspection/introspection-test/build.gradle.kts index 116685f3..18ed34a5 100644 --- a/introspection/introspection-test/build.gradle.kts +++ b/introspection/introspection-test/build.gradle.kts @@ -2,6 +2,10 @@ dependencies { testImplementation(project(":introspection:introspection-api")) testImplementation(project(":introspection:introspection-runtime")) testImplementation(project(":introspection:introspection-jap")) + testImplementation(project(":introspection:introspection-ksp")) + testImplementation(libs.ksp.symbol.processing.api) + testImplementation(libs.kctfork.core) + testImplementation(libs.kctfork.ksp) testImplementation(libs.junit.jupiter.api) testImplementation(libs.junit.jupiter.engine) diff --git a/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/IntrospectionParityTest.kt b/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/IntrospectionParityTest.kt index 002b0ab6..95b71e1d 100644 --- a/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/IntrospectionParityTest.kt +++ b/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/IntrospectionParityTest.kt @@ -9,6 +9,7 @@ import io.javalin.introspection.runtime.ReflectionTypeIntrospector import io.javalin.introspection.test.fixtures.Account import io.javalin.introspection.test.fixtures.Address import io.javalin.introspection.test.fixtures.Annotated +import io.javalin.introspection.test.fixtures.Bounded import io.javalin.introspection.test.fixtures.Color import io.javalin.introspection.test.fixtures.CrossPackageChild import io.javalin.introspection.test.fixtures.Derived @@ -37,17 +38,26 @@ class IntrospectionParityTest { assertThat(processedShape).isEqualTo(runtimeShape) } - @Test fun `scalar, collection, map and nested members match across backends`() = assertParity(Account::class) + @Test + fun `scalar, collection, map and nested members match across backends`() = assertParity(Account::class) - @Test fun `plain object members match across backends`() = assertParity(Address::class) + @Test + fun `plain object members match across backends`() = assertParity(Address::class) - @Test fun `enum constants match across backends`() = assertParity(Color::class) + @Test + fun `enum constants match across backends`() = assertParity(Color::class) + + @Test + fun `inherited getters and private superclass fields match across backends`() = assertParity(Derived::class) - @Test fun `inherited getters and private superclass fields match across backends`() = assertParity(Derived::class) + @Test + fun `record components match across backends`() = assertParity(Point::class) - @Test fun `record components match across backends`() = assertParity(Point::class) + @Test + fun `bounded type variables resolve to their bound across backends`() = assertParity(Bounded::class) - @Test fun `cross-package inherited fields match across backends`() = assertParity(CrossPackageChild::class) + @Test + fun `cross-package inherited fields match across backends`() = assertParity(CrossPackageChild::class) @Test fun `transient fields are flagged on both backends`() { @@ -137,6 +147,34 @@ class IntrospectionParityTest { val processedInts = AnnotationProcessing.introspect(Flagged::class) { it.getAnnotations().memberValues(Flags::class.java)!!.getValue("ints") } assertThat(processedInts).isEqualTo(runtimeInts).isEqualTo(listOf(1, 2, 3)) } + + @Test + fun `KSP reports the same property names and types as reflection`() { + fun namesAndTypes(definition: ClassDefinition) = + definition.getProperties().map { it.name to it.type.fullName }.toSet() + val runtimeProperties = namesAndTypes(runtime.introspect(Address::class.java)) + val kspProperties = SymbolProcessing.introspect(Address::class) { namesAndTypes(it) } + assertThat(kspProperties).isEqualTo(runtimeProperties) + .isEqualTo(setOf("city" to String::class.java.name, "zip" to String::class.java.name)) + } + + @Test + fun `KSP collapses each property to a single getter where the JVM backends split field and getter`() { + val kspAccessors = SymbolProcessing.introspect(Address::class) { definition -> + definition.getProperties().map { it.accessor } + } + assertThat(kspAccessors).containsExactly(Accessor.GETTER, Accessor.GETTER) + + val runtimeAccessors = runtime.introspect(Address::class.java).getProperties().map { it.accessor }.toSet() + assertThat(runtimeAccessors).containsExactlyInAnyOrder(Accessor.GETTER, Accessor.FIELD) + } + + @Test + fun `KSP enum constants match reflection`() { + val runtimeConstants = runtime.introspect(Color::class.java).getEnumConstants()?.map { it.name }?.sorted() + val kspConstants = SymbolProcessing.introspect(Color::class) { it.getEnumConstants()?.map { constant -> constant.name }?.sorted() } + assertThat(kspConstants).isEqualTo(runtimeConstants).isEqualTo(listOf("GREEN", "RED")) + } } private data class TypeShape( diff --git a/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/SymbolProcessing.kt b/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/SymbolProcessing.kt new file mode 100644 index 00000000..b826fa59 --- /dev/null +++ b/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/SymbolProcessing.kt @@ -0,0 +1,46 @@ +package io.javalin.introspection.test + +import com.google.devtools.ksp.processing.Resolver +import com.google.devtools.ksp.processing.SymbolProcessor +import com.google.devtools.ksp.processing.SymbolProcessorEnvironment +import com.google.devtools.ksp.processing.SymbolProcessorProvider +import com.google.devtools.ksp.symbol.KSAnnotated +import com.tschuchort.compiletesting.KotlinCompilation +import com.tschuchort.compiletesting.SourceFile +import com.tschuchort.compiletesting.symbolProcessorProviders +import com.tschuchort.compiletesting.useKsp2 +import io.javalin.introspection.ClassDefinition +import io.javalin.introspection.ksp.KspTypeIntrospector +import org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi +import kotlin.reflect.KClass + +@OptIn(ExperimentalCompilerApi::class) +object SymbolProcessing { + + fun introspect(type: KClass<*>, block: (ClassDefinition) -> R): R { + var result: Result? = null + val provider = object : SymbolProcessorProvider { + override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor = + object : SymbolProcessor { + override fun process(resolver: Resolver): List { + if (result == null) { + result = runCatching { block(KspTypeIntrospector(resolver).introspect(type.qualifiedName!!)) } + } + return emptyList() + } + } + } + + val compilation = KotlinCompilation().apply { + useKsp2() + sources = listOf(SourceFile.kotlin("Trigger.kt", "package trigger\nclass Trigger")) + symbolProcessorProviders = mutableListOf(provider) + inheritClassPath = true + messageOutputStream = System.out + } + + val compiled = compilation.compile() + check(compiled.exitCode == KotlinCompilation.ExitCode.OK) { "KSP compilation failed: ${compiled.messages}" } + return (result ?: error("KSP processor did not run")).getOrThrow() + } +} diff --git a/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/fixtures/Fixtures.kt b/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/fixtures/Fixtures.kt index 378261ff..25d8b59d 100644 --- a/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/fixtures/Fixtures.kt +++ b/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/fixtures/Fixtures.kt @@ -8,6 +8,8 @@ enum class Color { RED, GREEN } class Box(val value: T) +class Bounded(val value: T, val many: List) + @JvmRecord data class Point(val x: Int, val label: String, val tags: List) diff --git a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/AnnotationProcessorContext.kt b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/AnnotationProcessorContext.kt index 9b7c82e4..cd3621c2 100644 --- a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/AnnotationProcessorContext.kt +++ b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/AnnotationProcessorContext.kt @@ -9,8 +9,6 @@ import io.javalin.introspection.jap.JapTypeIntrospector import io.javalin.openapi.DiscriminatorMappingName import io.javalin.openapi.OpenApiName import io.javalin.openapi.experimental.processor.generators.TypeSchemaGenerator -import io.javalin.openapi.experimental.processor.shared.mapType -import io.javalin.openapi.experimental.processor.shared.objectType import javax.annotation.processing.Messager import javax.annotation.processing.ProcessingEnvironment import javax.annotation.processing.RoundEnvironment @@ -83,9 +81,6 @@ class AnnotationProcessorContext( ?.let { it to toOpenApiType(subtype) } } - fun inContext(body: AnnotationProcessorContext.() -> R): R = - body() - fun inDebug(body: (Messager) -> Unit) { if (configuration.debug) { body(env.messager) @@ -95,8 +90,8 @@ class AnnotationProcessorContext( fun forTypeElement(name: String): TypeElement? = env.elementUtils.getTypeElement(name) - fun forTypeElement(mirror: TypeMirror): TypeElement = - env.typeUtils.asElement(mirror) as TypeElement + private fun objectType(): TypeElement = forTypeElement(Object::class.java.name)!! + private fun mapType(): TypeElement = forTypeElement(Map::class.java.name)!! fun isAssignable(implementation: TypeMirror, superclass: TypeMirror): Boolean = env.typeUtils.isAssignable(implementation, superclass) diff --git a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/processor/shared/ModelExtensions.kt b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/processor/shared/ModelExtensions.kt deleted file mode 100644 index 67e69750..00000000 --- a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/processor/shared/ModelExtensions.kt +++ /dev/null @@ -1,7 +0,0 @@ -package io.javalin.openapi.experimental.processor.shared - -import io.javalin.openapi.experimental.AnnotationProcessorContext -import javax.lang.model.element.TypeElement - -fun AnnotationProcessorContext.objectType(): TypeElement = forTypeElement(Object::class.java.name)!! -fun AnnotationProcessorContext.mapType(): TypeElement = forTypeElement(Map::class.java.name)!! diff --git a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/processor/generators/JsonSchemaGenerator.kt b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/processor/generators/JsonSchemaGenerator.kt index 8c6c468e..72c25fbc 100644 --- a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/processor/generators/JsonSchemaGenerator.kt +++ b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/processor/generators/JsonSchemaGenerator.kt @@ -15,8 +15,8 @@ class JsonSchemaGenerator { .run { context.env.filer.saveResource(context, "json-schemes/index", joinToString(separator = "\n")) } private fun generate(element: Element): String = - context.inContext { - context.typeSchemaGenerator.createTypeSchema( + with(context) { + typeSchemaGenerator.createTypeSchema( type = element.asType().toOpenApiType(), inlineRefs = true ).toJsonSchemaString() diff --git a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/processor/generators/OpenApiGenerator.kt b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/processor/generators/OpenApiGenerator.kt index ea67c094..68bdc8cd 100644 --- a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/processor/generators/OpenApiGenerator.kt +++ b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/processor/generators/OpenApiGenerator.kt @@ -26,15 +26,8 @@ internal class OpenApiGenerator { val standaloneRoutes = roundEnvironment.getElementsAnnotatedWith(OpenApi::class.java) .flatMap { context.annotationsOf(it).memberValuesList(OpenApi::class.java) } - val routesByVersion = (aggregatedRoutes + standaloneRoutes) - .flatMap { route -> route.versions().map { version -> version to route } } - .groupBy { (version, _) -> version } - .mapValues { (_, routes) -> routes.map { it.second } } - - routesByVersion - .map { (version, routes) -> - val generatedOpenApiSchema = schemaGenerator.generateSchema(routes.toSet().toList()) - + schemaGenerator.generateVersionedSchemas(aggregatedRoutes + standaloneRoutes) + .map { (version, generatedOpenApiSchema) -> val resourceName = "openapi-${version.replace(" ", "-")}.json" val resource = context.env.filer.saveResource(context, "openapi-plugin/$resourceName", generatedOpenApiSchema) ?.toUri() @@ -59,7 +52,4 @@ internal class OpenApiGenerator { .let { context.env.filer.saveResource(context, "openapi-plugin/.index", it) } } - private fun Map.versions(): List = - (this["versions"] as? List<*>)?.filterIsInstance() ?: emptyList() - } diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/ExperimentalCompileOpenApiConfiguration.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/ExperimentalCompileOpenApiConfiguration.kt new file mode 100644 index 00000000..575ae684 --- /dev/null +++ b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/ExperimentalCompileOpenApiConfiguration.kt @@ -0,0 +1,10 @@ +package io.javalin.openapi.experimental + +import kotlin.annotation.AnnotationRetention.BINARY +import kotlin.annotation.AnnotationTarget.CLASS +import kotlin.annotation.AnnotationTarget.FUNCTION + +@RequiresOptIn +@Retention(BINARY) +@Target(CLASS, FUNCTION) +annotation class ExperimentalCompileOpenApiConfiguration diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/SimpleType.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/SimpleType.kt index 63b6c5be..d63961c9 100644 --- a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/SimpleType.kt +++ b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/SimpleType.kt @@ -1,14 +1,5 @@ package io.javalin.openapi.experimental -import kotlin.annotation.AnnotationRetention.BINARY -import kotlin.annotation.AnnotationTarget.CLASS -import kotlin.annotation.AnnotationTarget.FUNCTION - -@RequiresOptIn -@Retention(BINARY) -@Target(CLASS, FUNCTION) -annotation class ExperimentalCompileOpenApiConfiguration - data class SimpleType @JvmOverloads constructor( val type: String, val format: String? = null diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/defaults/CompositionEmbeddedTypeProcessor.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/defaults/CompositionEmbeddedTypeProcessor.kt index 2051bb77..22a23420 100644 --- a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/defaults/CompositionEmbeddedTypeProcessor.kt +++ b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/defaults/CompositionEmbeddedTypeProcessor.kt @@ -11,7 +11,7 @@ class CompositionEmbeddedTypeProcessor : EmbeddedTypeProcessor { ?.let { context.scheme.createComposition( context = context.parentContext, - classDefinition = context.type, + type = context.type, propertyComposition = it, references = context.references, inlineRefs = context.inlineRefs, diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/CompositionGenerator.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/CompositionGenerator.kt index e38eba69..0bf376be 100644 --- a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/CompositionGenerator.kt +++ b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/CompositionGenerator.kt @@ -47,13 +47,13 @@ private fun discriminatorInfo(context: SchemaGenerationContext, discriminator: M fun ObjectNode.createComposition( context: SchemaGenerationContext, - classDefinition: OpenApiType, + type: OpenApiType, propertyComposition: PropertyComposition, references: MutableSet, inlineRefs: Boolean = false, requiresNonNulls: Boolean = true, ) { - val subtypes by lazy { context.discriminatorSubtypes(classDefinition) } + val subtypes by lazy { context.discriminatorSubtypes(type) } val refs = propertyComposition.references.ifEmpty { subtypes.map { it.second } } diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/TypeSchemaGenerator.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/TypeSchemaGenerator.kt index 0d376250..05ef79f9 100644 --- a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/TypeSchemaGenerator.kt +++ b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/TypeSchemaGenerator.kt @@ -252,13 +252,13 @@ internal fun SchemaGenerationContext.findAllProperties(type: OpenApiType, requir val nullability = property.annotations.memberValues(OpenApiPropertyType::class.java)?.get("nullability") as? String val redirect = property.annotations.resolveClass(OpenApiPropertyType::class.java, "definedBy") - val isPrimitive = redirect == null && !property.nullable + val treatedAsNotNull = redirect == null && !property.nullable val isNotNull = when { nullability == Nullability.NOT_NULL.name -> true nullability == Nullability.NULLABLE.name -> false property.annotations.hasNamed("NotNull") -> true - isPrimitive -> true + treatedAsNotNull -> true property.annotations.hasNamed("Nullable") -> false else -> false } diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/schema/OpenApiSchemaGenerator.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/schema/OpenApiSchemaGenerator.kt index ad009b96..e088ef1c 100644 --- a/openapi-generator/src/main/kotlin/io/javalin/openapi/schema/OpenApiSchemaGenerator.kt +++ b/openapi-generator/src/main/kotlin/io/javalin/openapi/schema/OpenApiSchemaGenerator.kt @@ -67,6 +67,12 @@ class OpenApiSchemaGenerator( return schema.toJson() } + fun generateVersionedSchemas(routes: List>): Map = + routes + .flatMap { route -> route.texts("versions").map { version -> version to route } } + .groupBy({ it.first }, { it.second }) + .mapValues { (_, versionRoutes) -> generateSchema(versionRoutes.toSet().toList()) } + private fun OperationBuilder.buildParameters(route: Map) { parameters { val parametersByLocation = linkedMapOf( diff --git a/openapi-ksp/src/main/kotlin/io/javalin/openapi/ksp/OpenApiSymbolProcessor.kt b/openapi-ksp/src/main/kotlin/io/javalin/openapi/ksp/OpenApiSymbolProcessor.kt index b7f2e606..d01f892e 100644 --- a/openapi-ksp/src/main/kotlin/io/javalin/openapi/ksp/OpenApiSymbolProcessor.kt +++ b/openapi-ksp/src/main/kotlin/io/javalin/openapi/ksp/OpenApiSymbolProcessor.kt @@ -21,9 +21,8 @@ class OpenApiSymbolProcessor( ) : SymbolProcessor { override fun process(resolver: Resolver): List { - val context = KspSchemaContext(resolver) - generateJsonSchemes(resolver, context) - generateOpenApiDocuments(resolver, context) + generateJsonSchemes(resolver, KspSchemaContext(resolver)) + generateOpenApiDocuments(resolver, KspSchemaContext(resolver)) return emptyList() } @@ -49,14 +48,7 @@ class OpenApiSymbolProcessor( private fun generateOpenApiDocuments(resolver: Resolver, context: KspSchemaContext) { val routes = (resolver.getSymbolsWithAnnotation(OpenApi::class.qualifiedName!!) + resolver.getSymbolsWithAnnotation(OpenApis::class.qualifiedName!!)) .distinct() - .flatMap { annotated -> - val annotations = context.annotationsOf(annotated) - val direct = annotations.memberValuesList(OpenApi::class.java) - val viaContainer = (annotations.memberValues(OpenApis::class.java)?.get("value") as? List<*>) - ?.filterIsInstance>() - .orEmpty() - direct + viaContainer - } + .flatMap { annotated -> context.annotationsOf(annotated).memberValuesList(OpenApi::class.java) } .toList() if (routes.isEmpty()) { @@ -69,11 +61,8 @@ class OpenApiSymbolProcessor( version = options[OPENAPI_INFO_VERSION] ?: "", ) - val generatedVersions = routes - .flatMap { route -> route.versions().map { version -> version to route } } - .groupBy { (version, _) -> version } - .map { (version, versionedRoutes) -> - val json = generator.generateSchema(versionedRoutes.map { it.second }.toSet().toList()) + val generatedVersions = generator.generateVersionedSchemas(routes) + .map { (version, json) -> val resourceName = "openapi-${version.replace(" ", "-")}.json" writeResource("openapi-plugin/$resourceName", json) resourceName @@ -82,9 +71,6 @@ class OpenApiSymbolProcessor( writeResource("openapi-plugin/.index", generatedVersions.joinToString(separator = "\n")) } - private fun Map.versions(): List = - (this["versions"] as? List<*>)?.filterIsInstance() ?: emptyList() - private fun writeResource(path: String, content: String) = codeGenerator.createNewFileByPath(Dependencies(aggregating = true), path, extensionName = "") .use { it.write(content.toByteArray()) } diff --git a/openapi-ksp/src/test/kotlin/io/javalin/openapi/ksp/OpenApiSymbolProcessorTest.kt b/openapi-ksp/src/test/kotlin/io/javalin/openapi/ksp/OpenApiSymbolProcessorTest.kt index da45a459..ae62fcc1 100644 --- a/openapi-ksp/src/test/kotlin/io/javalin/openapi/ksp/OpenApiSymbolProcessorTest.kt +++ b/openapi-ksp/src/test/kotlin/io/javalin/openapi/ksp/OpenApiSymbolProcessorTest.kt @@ -164,4 +164,57 @@ class OpenApiSymbolProcessorTest { assertThat(shape.path("discriminator").path("mapping").path("circle").asText()).isEqualTo("#/components/schemas/Circle") assertThat(shape.path("discriminator").path("mapping").path("square").asText()).isEqualTo("#/components/schemas/Square") } + + @Test + fun `does not leak inlined sub-schemas from the JsonSchema pass into OpenApi refs`() { + val compilation = KotlinCompilation().apply { + useKsp2() + sources = listOf( + SourceFile.kotlin( + "Shared.kt", + """ + package fixtures + import io.javalin.openapi.HttpMethod + import io.javalin.openapi.JsonSchema + import io.javalin.openapi.OpenApi + import io.javalin.openapi.OpenApiContent + import io.javalin.openapi.OpenApiResponse + import io.javalin.openapi.OpenApiStatus + + data class Address(val city: String) + + @JsonSchema + class Profile(val address: Address) + + data class Account(val address: Address) + + class Routes { + @OpenApi( + path = "/account", + methods = [HttpMethod.GET], + responses = [OpenApiResponse(status = OpenApiStatus.OK, content = [OpenApiContent(from = Account::class)])] + ) + fun getAccount() {} + } + """.trimIndent() + ) + ) + symbolProcessorProviders = mutableListOf(OpenApiSymbolProcessorProvider()) + inheritClassPath = true + messageOutputStream = System.out + } + + val result = compilation.compile() + check(result.exitCode == KotlinCompilation.ExitCode.OK) { "KSP compilation failed: ${result.messages}" } + + val document = compilation.kspSourcesDir.walkTopDown().firstOrNull { it.name == "openapi-default.json" } + ?: error("openapi document not generated. Output tree:\n" + compilation.kspSourcesDir.walkTopDown().joinToString("\n")) + + val schemas = jsonMapper.readTree(document.readText()).path("components").path("schemas") + val addressProperty = schemas.path("Account").path("properties").path("address") + + assertThat(addressProperty.path("\$ref").asText()).isEqualTo("#/components/schemas/Address") + assertThat(addressProperty.has("properties")).isFalse() + assertThat(schemas.path("Address").path("properties").path("city").path("type").asText()).isEqualTo("string") + } } From 2f053182a3aa52e57b9fe42fd8c94f6eb2d56df7 Mon Sep 17 00:00:00 2001 From: dzikoysk Date: Sun, 28 Jun 2026 15:07:27 +0200 Subject: [PATCH 22/43] chore: docs --- docs/.vitepress/config.mjs | 3 +- docs/advanced/configuration.md | 187 ++--------------------- docs/advanced/runtime-builder.md | 12 +- docs/advanced/scripting.md | 203 +++++++++++++++++++++++++ docs/index.md | 8 +- docs/introduction/json-schema-setup.md | 26 ++-- docs/introduction/migration-from-6x.md | 12 +- docs/introduction/redoc.md | 2 +- docs/introduction/setup.md | 18 +-- docs/introduction/swagger.md | 2 +- docs/json-schema/custom-properties.md | 2 +- docs/json-schema/getting-started.md | 20 +-- docs/openapi/parameters.md | 4 +- docs/openapi/request-body.md | 10 +- docs/openapi/responses.md | 2 +- docs/openapi/schemas.md | 12 +- 16 files changed, 279 insertions(+), 244 deletions(-) create mode 100644 docs/advanced/scripting.md diff --git a/docs/.vitepress/config.mjs b/docs/.vitepress/config.mjs index 2ee07078..f1685e20 100644 --- a/docs/.vitepress/config.mjs +++ b/docs/.vitepress/config.mjs @@ -56,7 +56,8 @@ export default defineConfig({ text: 'Advanced', collapsed: false, items: [ - { text: 'Compile-time Configuration', link: '/advanced/configuration' }, + { text: 'Static Configuration', link: '/advanced/configuration' }, + { text: 'Scripting Configuration', link: '/advanced/scripting' }, { text: 'Runtime Builder DSL', link: '/advanced/runtime-builder' }, ], }, diff --git a/docs/advanced/configuration.md b/docs/advanced/configuration.md index 53b7abe3..eb11ba44 100644 --- a/docs/advanced/configuration.md +++ b/docs/advanced/configuration.md @@ -1,75 +1,14 @@ -# Compile-time Configuration +# Static Configuration -Configure the annotation processor using a Groovy script. This allows custom type mappings, property filters, and advanced type processors. +Pass key-value options to the backend at build time. The `info.*` options apply to both the annotation processor (APT/Kapt) and KSP; `openapi.groovy.path` is APT/Kapt-only. -## Setup +## Options -Create a Groovy configuration script (e.g. `openapi.groovy`) anywhere in your project -and point the annotation processor to it using the `openapi.groovy.path` option: - -```groovy -import io.javalin.openapi.experimental.* - -@ExperimentalCompileOpenApiConfiguration -class OpenApiConfiguration - implements OpenApiAnnotationProcessorConfigurer { - - @Override - void configure( - OpenApiAnnotationProcessorConfiguration configuration - ) { - // Configuration goes here - } -} -``` - -::: code-group - -```kotlin [Gradle (Kotlin)] -kapt { - arguments { - arg( - "openapi.groovy.path", - "$projectDir/src/main/compile/openapi.groovy" - ) - } -} -``` - -```groovy [Gradle (Groovy)] -kapt { - arguments { - arg( - 'openapi.groovy.path', - "$projectDir/src/main/compile/openapi.groovy" - ) - } -} -``` - -```xml [Maven] - - org.apache.maven.plugins - maven-compiler-plugin - - - -Aopenapi.groovy.path=${project.basedir}/src/main/compile/openapi.groovy - - - -``` - -::: - -## Annotation Processor Options - -The following options can be passed to the annotation processor: - -| Option | Description | -|-------------------------|----------------------------------------------------------| -| `openapi.info.title` | Set the `info.title` field in the generated specification | -| `openapi.info.version` | Set the `info.version` field in the generated specification | -| `openapi.groovy.path` | Path to the Groovy configuration script | +| Option | Description | +|------------------------|------------------------------------------------------------------------------------------------------| +| `openapi.info.title` | Set the `info.title` field in the generated specification | +| `openapi.info.version` | Set the `info.version` field in the generated specification | +| `openapi.groovy.path` | Path to a Groovy script for advanced configuration (APT/Kapt only, see [Scripting Configuration](./scripting)) | ::: code-group @@ -104,112 +43,4 @@ ksp { ::: -::: tip -The KSP backend reads `openapi.info.title` and `openapi.info.version`. The `openapi.groovy.path` script and the custom mappings, filters, and processors below are APT/Kapt-only. -::: - -## Custom Type Mappings - -Map custom types to simple OpenAPI types: - -```groovy -void configure( - OpenApiAnnotationProcessorConfiguration configuration -) { - configuration.simpleTypeMappings[ - 'org.bson.types.ObjectId' - ] = new SimpleType("string") - - configuration.simpleTypeMappings[ - 'com.example.CustomId' - ] = new SimpleType(/* type */ "integer", /* format */ "int64") -} -``` - -## Property Filters - -Control which properties are included in schemas: - -```groovy -configuration.propertyInSchemeFilter = { - ctx, type, property -> - !property.simpleName - .toString() - .startsWith("internal") -} -``` - -## Custom Type Processors - -Insert custom logic for handling specific types (e.g., unwrapping `AtomicReference`): - -```groovy -configuration.insertEmbeddedTypeProcessor({ - EmbeddedTypeProcessorContext context -> - if (context.type.simpleName == 'AtomicReference' - && context.type.generics.size() == 1) { - context.parentContext.typeSchemaGenerator.addType( - context.scheme, - context.type.generics[0], - context.inlineRefs, - context.references, - false - ) - return true // handled - } - - return false // use default processing -}) -``` - -Custom type processors run before all built-in type processing, so they can override the default behavior for any type. - -## Debug Mode - -Enable debug output during annotation processing: - -```groovy -configuration.debug = true -``` - -## Parser Validation - -Validate the generated specification with Swagger Parser: - -```groovy -configuration.validateWithParser = true // default -``` - -## Full Example - -```groovy -import io.javalin.openapi.experimental.* - -@ExperimentalCompileOpenApiConfiguration -class OpenApiConfiguration - implements OpenApiAnnotationProcessorConfigurer { - - @Override - void configure( - OpenApiAnnotationProcessorConfiguration configuration - ) { - configuration.simpleTypeMappings[ - 'org.bson.types.ObjectId' - ] = new SimpleType("string") - - configuration.simpleTypeMappings[ - 'com.example.Money' - ] = new SimpleType("string") - - configuration.propertyInSchemeFilter = { - ctx, type, property -> - !property.simpleName - .toString() - .startsWith("_") - } - - configuration.debug = false - configuration.validateWithParser = true - } -} -``` +For custom type mappings, property filters, and custom type processors, see [Scripting Configuration](./scripting). diff --git a/docs/advanced/runtime-builder.md b/docs/advanced/runtime-builder.md index 75778b9f..8d167ff8 100644 --- a/docs/advanced/runtime-builder.md +++ b/docs/advanced/runtime-builder.md @@ -4,9 +4,9 @@ The `OpenApiSchemaBuilder` provides a Kotlin DSL for building and modifying Open ## When to Use -- **Extend compile-time schemas** — add servers, security, or extra endpoints at startup -- **Dynamic endpoints** — describe routes registered at runtime that the annotation processor can't see -- **Testing** — build expected schemas in tests without JSON strings +- **Extend compile-time schemas** - add servers, security, or extra endpoints at startup +- **Dynamic endpoints** - describe routes registered at runtime that the annotation processor can't see +- **Testing** - build expected schemas in tests without JSON strings ## Basic Usage @@ -217,11 +217,11 @@ schema.path("/users").operation("get") { val json = schema.toJson() ``` -Reopening an existing operation preserves all fields — only the fields you set are changed. This makes it safe to layer runtime additions on top of compile-time output. +Reopening an existing operation preserves all fields - only the fields you set are changed. This makes it safe to layer runtime additions on top of compile-time output. ## Auto-generating Docs for Registered Routes -For a springdoc-style experience — documenting routes that are registered programmatically (and that the compile-time processor never sees) — use the **dynamic hook** module. It enumerates every route registered on Javalin at startup and contributes them to the served document. +For a springdoc-style experience - documenting routes that are registered programmatically (and that the compile-time processor never sees) - use the **dynamic hook** module. It enumerates every route registered on Javalin at startup and contributes them to the served document. ```kotlin [Gradle (Kotlin)] dependencies { @@ -245,7 +245,7 @@ Every registered route is documented with its path, method, path parameters (typ ### Enriching a Route -Javalin handlers are opaque lambdas, so without extra information the hook can only emit those stubs — it cannot infer request/response bodies. Attach an `OpenApiMetadata` to a route to describe it using the same operation DSL shown above; `schema(Class)` resolves the type through the reflection schema engine (no annotation processing required): +Javalin handlers are opaque lambdas, so without extra information the hook can only emit those stubs - it cannot infer request/response bodies. Attach an `OpenApiMetadata` to a route to describe it using the same operation DSL shown above; `schema(Class)` resolves the type through the reflection schema engine (no annotation processing required): ```kotlin config.routes.addEndpoint( diff --git a/docs/advanced/scripting.md b/docs/advanced/scripting.md new file mode 100644 index 00000000..bb09cd83 --- /dev/null +++ b/docs/advanced/scripting.md @@ -0,0 +1,203 @@ +# Scripting Configuration + +For advanced configuration - custom type mappings, property filters, and custom type processors - the annotation processor can run a Groovy script at compile time. + +::: warning Experimental, and intentionally temporary +Scripting (`@ExperimentalCompileOpenApiConfiguration`) is a stop-gap gateway that exists only because there are no built-in static options for these advanced cases yet. It is **experimental**: its API may change, and the goal is to fold its capabilities into proper [static configuration](./configuration) over time, so this scripting layer may shrink or fade away in future releases. Prefer static options where they exist; reach for scripting only when you must. + +It also applies to the **APT/Kapt backend only** - it is not loaded by the KSP backend. +::: + +## Setup + +Create a Groovy configuration script (e.g. `openapi.groovy`) anywhere in your project +and point the annotation processor at it with the `openapi.groovy.path` option: + +```groovy +import io.javalin.openapi.experimental.* + +@ExperimentalCompileOpenApiConfiguration +class OpenApiConfiguration + implements OpenApiAnnotationProcessorConfigurer { + + @Override + void configure( + OpenApiAnnotationProcessorConfiguration configuration + ) { + // Configuration goes here + } +} +``` + +::: code-group + +```kotlin [Gradle (Kotlin)] +kapt { + arguments { + arg( + "openapi.groovy.path", + "$projectDir/src/main/compile/openapi.groovy" + ) + } +} +``` + +```groovy [Gradle (Groovy)] +kapt { + arguments { + arg( + 'openapi.groovy.path', + "$projectDir/src/main/compile/openapi.groovy" + ) + } +} +``` + +```xml [Maven] + + org.apache.maven.plugins + maven-compiler-plugin + + + -Aopenapi.groovy.path=${project.basedir}/src/main/compile/openapi.groovy + + + +``` + +::: + +## Custom Type Mappings + +Map custom types to simple OpenAPI types: + +```groovy +void configure( + OpenApiAnnotationProcessorConfiguration configuration +) { + configuration.simpleTypeMappings[ + 'org.bson.types.ObjectId' + ] = new SimpleType("string") + + configuration.simpleTypeMappings[ + 'com.example.CustomId' + ] = new SimpleType(/* type */ "integer", /* format */ "int64") +} +``` + +## Property Filters + +Control which properties are included in schemas: + +```groovy +configuration.propertyInSchemeFilter = { + ctx, type, property -> + !property.simpleName + .toString() + .startsWith("internal") +} +``` + +## Custom Type Processors + +Insert custom logic for handling specific types (e.g., unwrapping `AtomicReference`): + +```groovy +configuration.insertEmbeddedTypeProcessor({ + EmbeddedTypeProcessorContext context -> + if (context.type.simpleName == 'AtomicReference' + && context.type.generics.size() == 1) { + context.parentContext.typeSchemaGenerator.addType( + context.scheme, + context.type.generics[0], + context.inlineRefs, + context.references, + false + ) + return true // handled + } + + return false // use default processing +}) +``` + +Custom type processors run before all built-in type processing, so they can override the default behavior for any type. + +## Debug Mode + +Enable debug output during annotation processing: + +```groovy +configuration.debug = true +``` + +## Parser Validation + +Validate the generated specification with Swagger Parser: + +```groovy +configuration.validateWithParser = true // default +``` + +## Full Example + +```groovy +import io.javalin.openapi.experimental.* + +@ExperimentalCompileOpenApiConfiguration +class OpenApiConfiguration + implements OpenApiAnnotationProcessorConfigurer { + + @Override + void configure( + OpenApiAnnotationProcessorConfiguration configuration + ) { + configuration.simpleTypeMappings[ + 'org.bson.types.ObjectId' + ] = new SimpleType("string") + + configuration.simpleTypeMappings[ + 'com.example.Money' + ] = new SimpleType("string") + + configuration.propertyInSchemeFilter = { + ctx, type, property -> + !property.simpleName + .toString() + .startsWith("_") + } + + configuration.debug = false + configuration.validateWithParser = true + } +} +``` + +## Migrating your `openapi.groovy` + +The scripting API renamed two types. If your script references the schema type or its mirror accessor - typically inside `propertyInSchemeFilter` - update these names: + +| Before | After | +|---------------------------|-----------------------| +| `ClassDefinition` | `OpenApiType` | +| `ClassDefinitionHandleKt` | `OpenApiTypeHandleKt` | + +```groovy +// before +import io.javalin.openapi.experimental.ClassDefinition +import io.javalin.openapi.experimental.ClassDefinitionHandleKt + +configuration.propertyInSchemeFilter = { ctx, ClassDefinition type, property -> + ctx.isAssignable(ClassDefinitionHandleKt.getMirror(type), ...) +} + +// after +import io.javalin.openapi.experimental.OpenApiType +import io.javalin.openapi.experimental.OpenApiTypeHandleKt + +configuration.propertyInSchemeFilter = { ctx, OpenApiType type, property -> + ctx.isAssignable(OpenApiTypeHandleKt.getMirror(type), ...) +} +``` + +Everything else is unchanged: `simpleTypeMappings`, `insertEmbeddedTypeProcessor`, `debug`, `validateWithParser`, and the `OpenApiAnnotationProcessorConfiguration`/`OpenApiAnnotationProcessorConfigurer`/`SimpleType`/`EmbeddedTypeProcessorContext` types all keep their names and members. Scripts that don't name `ClassDefinition`/`ClassDefinitionHandleKt` need no changes. diff --git a/docs/index.md b/docs/index.md index cee8d403..0a54d58e 100644 --- a/docs/index.md +++ b/docs/index.md @@ -4,7 +4,7 @@ layout: home hero: name: Javalin OpenAPI text: Compile-time API Documentation - tagline: Generate OpenAPI specifications and JSON Schemas at compile time — via Java annotation processing, kapt, or KSP. Annotation-driven, type-safe, and ready for Swagger UI and ReDoc. + tagline: Generate OpenAPI specifications and JSON Schemas at compile time - via Java annotation processing, kapt, or KSP. Annotation-driven, type-safe, and ready for Swagger UI and ReDoc. actions: - theme: brand text: Get Started @@ -17,9 +17,9 @@ features: - title: Compile-time Generation details: Schemas are generated during compilation via Java annotation processing, kapt, or KSP. No runtime reflection, no classpath scanning, no startup overhead. - title: Two Modes - details: Generate OpenAPI 3.1.0 endpoint documentation with @OpenApi, or standalone JSON Schema 2020-12 files with @JsonSchema — from the same engine. + details: Generate OpenAPI 3.1.0 endpoint documentation with @OpenApi, or standalone JSON Schema 2020-12 files with @JsonSchema, from the same engine. - title: Optional Runtime Backend - details: An opt-in reflection backend documents routes registered programmatically at runtime — for the dynamic cases the compile-time processor can't see. + details: An opt-in reflection backend documents routes registered programmatically at runtime, for the dynamic cases the compile-time processor can't see. - title: Swagger UI & ReDoc details: Built-in plugins serve Swagger UI and ReDoc out of the box. Register the plugin and your interactive API documentation is live. - title: Schema Customization @@ -27,5 +27,5 @@ features: - title: Enum Support details: Rename values with @OpenApiName, apply naming strategies with @OpenApiNaming, create integer enums with @OpenApiPropertyType, and add descriptions. - title: Compile-time Configuration - details: Fine-tune the annotation processor with openapi.groovy — custom type mappings, property filters, and embedded type processors. + details: Set API info through build options. For advanced cases like custom type mappings, an experimental Groovy hook is available while first-class options grow. --- diff --git a/docs/introduction/json-schema-setup.md b/docs/introduction/json-schema-setup.md index 6cf2824f..6390f313 100644 --- a/docs/introduction/json-schema-setup.md +++ b/docs/introduction/json-schema-setup.md @@ -10,7 +10,7 @@ You can serve them with any HTTP server, use them for validation, feed them to c ## Installation -You only need the annotation processor and the specification module — no Javalin plugins required: +You only need the annotation processor and the specification module - no Javalin plugins required: ::: code-group @@ -131,7 +131,7 @@ At compile time, this generates a `/json-schemes/com.example.UserConfig` resourc | References | `$ref` | All types inlined | | Trigger | `@OpenApi` | `@JsonSchema` | -Both modes are framework-agnostic at the annotation processing level — Javalin is only needed if you want to serve the specs via the Javalin plugins. +Both modes are framework-agnostic at the annotation processing level - Javalin is only needed if you want to serve the specs via the Javalin plugins. ## Loading OpenAPI Specs at Runtime @@ -189,17 +189,17 @@ class InternalEntity Most property-level annotations work in both OpenAPI and JSON Schema modes: -- `@OpenApiName` — override property names -- `@OpenApiDescription` — add descriptions -- `@OpenApiIgnore` — exclude properties -- `@OpenApiRequired` — force required -- `@OpenApiPropertyType` — override types -- `@OpenApiNullable` — mark nullable -- `@OpenApiNaming` — apply naming strategies -- `@OpenApiByFields` — resolve from fields instead of getters -- Validation annotations — `@OpenApiNumberValidation`, `@OpenApiStringValidation`, `@OpenApiArrayValidation`, `@OpenApiObjectValidation` +- `@OpenApiName` - override property names +- `@OpenApiDescription` - add descriptions +- `@OpenApiIgnore` - exclude properties +- `@OpenApiRequired` - force required +- `@OpenApiPropertyType` - override types +- `@OpenApiNullable` - mark nullable +- `@OpenApiNaming` - apply naming strategies +- `@OpenApiByFields` - resolve from fields instead of getters +- Validation annotations - `@OpenApiNumberValidation`, `@OpenApiStringValidation`, `@OpenApiArrayValidation`, `@OpenApiObjectValidation` ## Next Steps -- [Type Composition](../json-schema/getting-started) — `@OneOf`, `@AnyOf`, `@AllOf` with discriminators -- [Custom Properties](../json-schema/custom-properties) — add custom schema properties with `@Custom` +- [Type Composition](../json-schema/getting-started) - `@OneOf`, `@AnyOf`, `@AllOf` with discriminators +- [Custom Properties](../json-schema/custom-properties) - add custom schema properties with `@Custom` diff --git a/docs/introduction/migration-from-6x.md b/docs/introduction/migration-from-6x.md index 597f8aa7..022ca842 100644 --- a/docs/introduction/migration-from-6x.md +++ b/docs/introduction/migration-from-6x.md @@ -95,7 +95,7 @@ New built-in type mappings: ### Simplified Plugin API -Security methods are available directly on `OpenApiSchemaBuilder` — no more `withSecurity { ... }` wrapper: +Security methods are available directly on `OpenApiSchemaBuilder` - no more `withSecurity { ... }` wrapper: ```kotlin openApiConfig.withDefinitionConfiguration { version, builder -> @@ -232,7 +232,7 @@ Nullable `$ref` types are wrapped in `anyOf`: Nullable `oneOf`/`anyOf` types append a null entry: ```json -// 7.0 — oneOf with nullable +// 7.0 - oneOf with nullable { "oneOf": [ { "$ref": "#/components/schemas/Cat" }, @@ -242,7 +242,7 @@ Nullable `oneOf`/`anyOf` types append a null entry: } ``` -**No annotation changes needed** — this is handled automatically by the generator. If you have tooling that parses the generated spec (validators, code generators, etc.), it needs to understand 3.1.0 nullable semantics. +**No annotation changes needed** - this is handled automatically by the generator. If you have tooling that parses the generated spec (validators, code generators, etc.), it needs to understand 3.1.0 nullable semantics. #### `additionalProperties` default @@ -253,10 +253,10 @@ Nullable `oneOf`/`anyOf` types append a null entry: Changed from `Boolean` flags to numeric `String` values, matching JSON Schema 2020-12: ```kotlin -// 7.0 — numeric value (the exclusive bound itself) +// 7.0 - numeric value (the exclusive bound itself) @OpenApiNumberValidation(exclusiveMinimum = "0") -// 6.x — boolean flag (means: minimum is exclusive) +// 6.x - boolean flag (means: minimum is exclusive) @OpenApiNumberValidation(minimum = "0", exclusiveMinimum = true) ``` @@ -313,7 +313,7 @@ openApiConfig.withDefinitionConfiguration { version, definition -> Key differences: - `withInfo(...)` → `info(...)` - `withServer(...)` → `server(...)` -- Security methods (`withBearerAuth`, `withBasicAuth`, `withOAuth2`, etc.) moved from `SecurityComponentConfiguration` to `OpenApiSchemaBuilder` directly — no more `withSecurity(security -> ...)` wrapper +- Security methods (`withBearerAuth`, `withBasicAuth`, `withOAuth2`, etc.) moved from `SecurityComponentConfiguration` to `OpenApiSchemaBuilder` directly - no more `withSecurity(security -> ...)` wrapper - `withDefinitionProcessor` moved from `DefinitionConfiguration` to `OpenApiPluginConfiguration` #### Swagger & ReDoc configuration style diff --git a/docs/introduction/redoc.md b/docs/introduction/redoc.md index 7559abb5..315981bb 100644 --- a/docs/introduction/redoc.md +++ b/docs/introduction/redoc.md @@ -36,4 +36,4 @@ When running behind a reverse proxy that adds a base path, set `basePath` so the redoc.basePath = "/api" ``` -`ReDocPlugin` is repeatable — you can register multiple instances for different configurations. +`ReDocPlugin` is repeatable - you can register multiple instances for different configurations. diff --git a/docs/introduction/setup.md b/docs/introduction/setup.md index d7b20fcb..0f0951fa 100644 --- a/docs/introduction/setup.md +++ b/docs/introduction/setup.md @@ -63,7 +63,7 @@ dependencies { ```kotlin [Gradle (Kotlin) with KSP] plugins { - // KSP is released against a specific Kotlin version — use the build that matches yours (github.com/google/ksp/releases) + // KSP is released against a specific Kotlin version - use the build that matches yours (github.com/google/ksp/releases) id("com.google.devtools.ksp") version "2.3.9" } @@ -134,7 +134,7 @@ ksp { ## Choosing a Processor -The same generation engine runs on three backends — pick the one that matches your build: +The same generation engine runs on three backends - pick the one that matches your build: | Backend | Dependency | Sources | Best for | |-----------------------------|--------------------------------|---------------|--------------------------------------------| @@ -142,10 +142,10 @@ The same generation engine runs on three backends — pick the one that matches | Kapt (`kapt`) | `openapi-annotation-processor` | Java + Kotlin | Mixed Java/Kotlin projects | | KSP (`ksp`) | `openapi-ksp` *(experimental)* | Kotlin only | Kotlin-only projects wanting faster builds | -All three emit identical `openapi-plugin/openapi-*.json` resources that the `OpenApiPlugin` serves — switching backends does not change the generated specification. +All three emit identical `openapi-plugin/openapi-*.json` resources that the `OpenApiPlugin` serves - switching backends does not change the generated specification. ::: warning KSP limitations -KSP processes **Kotlin sources only** — `@OpenApi`/`@JsonSchema` on Java types are not picked up (use APT/Kapt for those). The Groovy [compile-time configuration](../advanced/configuration) (custom type mappings, property filters, custom type processors) and parser validation are currently APT/Kapt-only. +KSP processes **Kotlin sources only** - `@OpenApi`/`@JsonSchema` on Java types are not picked up (use APT/Kapt for those). The Groovy [scripting configuration](../advanced/scripting) (custom type mappings, property filters, custom type processors) and parser validation are currently APT/Kapt-only. ::: ## Register the Plugin @@ -251,11 +251,11 @@ openapi.withDefinitionConfiguration { version, builder -> } ``` -`OpenApiPlugin` is repeatable — you can register multiple instances for different configurations. +`OpenApiPlugin` is repeatable - you can register multiple instances for different configurations. ## Next Steps -- [Javalin Swagger UI](./swagger) — interactive API explorer -- [Javalin ReDoc](./redoc) — clean API reference -- [OpenAPI Getting Started](../openapi/getting-started) — annotate your first endpoint -- [Runtime Builder DSL](../advanced/runtime-builder) — build or extend specs programmatically at runtime +- [Javalin Swagger UI](./swagger) - interactive API explorer +- [Javalin ReDoc](./redoc) - clean API reference +- [OpenAPI Getting Started](../openapi/getting-started) - annotate your first endpoint +- [Runtime Builder DSL](../advanced/runtime-builder) - build or extend specs programmatically at runtime diff --git a/docs/introduction/swagger.md b/docs/introduction/swagger.md index 0b3911c3..d615d13c 100644 --- a/docs/introduction/swagger.md +++ b/docs/introduction/swagger.md @@ -57,4 +57,4 @@ When running behind a reverse proxy that adds a base path, set `basePath` so the swagger.basePath = "/api" ``` -`SwaggerPlugin` is repeatable — you can register multiple instances for different API versions or configurations. +`SwaggerPlugin` is repeatable - you can register multiple instances for different API versions or configurations. diff --git a/docs/json-schema/custom-properties.md b/docs/json-schema/custom-properties.md index 749172f4..939b864f 100644 --- a/docs/json-schema/custom-properties.md +++ b/docs/json-schema/custom-properties.md @@ -14,7 +14,7 @@ class InternalService { } ``` -`@Custom` is repeatable — you can add multiple custom properties to the same element. +`@Custom` is repeatable - you can add multiple custom properties to the same element. ## @CustomAnnotation diff --git a/docs/json-schema/getting-started.md b/docs/json-schema/getting-started.md index 9e9da6bb..2aa3128f 100644 --- a/docs/json-schema/getting-started.md +++ b/docs/json-schema/getting-started.md @@ -35,7 +35,7 @@ At compile time, this produces a `/json-schemes/com.example.ServerConfig` resour } ``` -All nested types are inlined directly — there are no `$ref` references. +All nested types are inlined directly - there are no `$ref` references. ## Loading at Runtime @@ -75,12 +75,12 @@ class InternalEntity All property-level `@OpenApi*` annotations also apply to JSON Schema output: -- `@OpenApiName` — override property names -- `@OpenApiDescription` — add descriptions -- `@OpenApiIgnore` — exclude properties -- `@OpenApiRequired` — force required -- `@OpenApiPropertyType` — override types -- `@OpenApiNullable` — mark nullable -- `@OpenApiNaming` — naming strategies (snake_case, kebab-case) -- `@OpenApiByFields` — resolve from fields instead of getters -- `@OpenApiNumberValidation`, `@OpenApiStringValidation`, `@OpenApiArrayValidation`, `@OpenApiObjectValidation` — validation constraints +- `@OpenApiName` - override property names +- `@OpenApiDescription` - add descriptions +- `@OpenApiIgnore` - exclude properties +- `@OpenApiRequired` - force required +- `@OpenApiPropertyType` - override types +- `@OpenApiNullable` - mark nullable +- `@OpenApiNaming` - naming strategies (snake_case, kebab-case) +- `@OpenApiByFields` - resolve from fields instead of getters +- `@OpenApiNumberValidation`, `@OpenApiStringValidation`, `@OpenApiArrayValidation`, `@OpenApiObjectValidation` - validation constraints diff --git a/docs/openapi/parameters.md b/docs/openapi/parameters.md index f3aa9813..100b4eec 100644 --- a/docs/openapi/parameters.md +++ b/docs/openapi/parameters.md @@ -83,9 +83,9 @@ Use `@OpenApiParam` to describe path, query, header, and cookie parameters. | Property | Type | Default | Description | |----------|------|---------|-------------| -| `name` | `String` | — | Parameter name (required) | +| `name` | `String` | - | Parameter name (required) | | `type` | `KClass<*>` | `String::class` | Parameter type | -| `description` | `String` | — | Description | +| `description` | `String` | - | Description | | `deprecated` | `Boolean` | `false` | Mark as deprecated | | `required` | `Boolean` | `false` | Mark as required | | `allowEmptyValue` | `Boolean` | `false` | Allow empty values | diff --git a/docs/openapi/request-body.md b/docs/openapi/request-body.md index 60c7f4be..cfb5f00d 100644 --- a/docs/openapi/request-body.md +++ b/docs/openapi/request-body.md @@ -109,11 +109,11 @@ Generates: | Property | Type | Default | Description | |----------|------|---------|-------------| -| `from` | `KClass<*>` | — | Schema type | +| `from` | `KClass<*>` | - | Schema type | | `mimeType` | `String` | Auto-detect | Content type | -| `type` | `String` | — | Override type | -| `format` | `String` | — | Override format | +| `type` | `String` | - | Override type | +| `format` | `String` | - | Override format | | `properties` | `OpenApiContentProperty[]` | `[]` | Inline properties | -| `additionalProperties` | `OpenApiAdditionalContent` | — | Map value type | -| `example` | `String` | — | Example value | +| `additionalProperties` | `OpenApiAdditionalContent` | - | Map value type | +| `example` | `String` | - | Example value | | `exampleObjects` | `OpenApiExampleProperty[]` | `[]` | Structured example | diff --git a/docs/openapi/responses.md b/docs/openapi/responses.md index e5cd56af..16738df6 100644 --- a/docs/openapi/responses.md +++ b/docs/openapi/responses.md @@ -74,7 +74,7 @@ OpenApiResponse( | Property | Type | Default | Description | |----------|------|---------|-------------| -| `status` | `String` | — | HTTP status code (required) | +| `status` | `String` | - | HTTP status code (required) | | `content` | `OpenApiContent[]` | `[]` | Response content | | `description` | `String` | `""` | Response description | | `headers` | `OpenApiParam[]` | `[]` | Response headers | diff --git a/docs/openapi/schemas.md b/docs/openapi/schemas.md index 1541084c..41afb9da 100644 --- a/docs/openapi/schemas.md +++ b/docs/openapi/schemas.md @@ -8,17 +8,17 @@ The annotation processor automatically generates OpenAPI component schemas from | Java Type | OpenAPI Type | Format | |-----------|-------------|--------| -| `boolean` / `Boolean` | `boolean` | — | +| `boolean` / `Boolean` | `boolean` | - | | `byte` / `Byte` | `integer` | `int32` | | `short` / `Short` | `integer` | `int32` | | `int` / `Integer` | `integer` | `int32` | | `long` / `Long` | `integer` | `int64` | | `float` / `Float` | `number` | `float` | | `double` / `Double` | `number` | `double` | -| `char` / `Character` | `string` | — | -| `String` | `string` | — | -| `BigDecimal` | `string` | — | -| `BigInteger` | `integer` | — | +| `char` / `Character` | `string` | - | +| `String` | `string` | - | +| `BigDecimal` | `string` | - | +| `BigInteger` | `integer` | - | | `UUID` | `string` | `uuid` | ### Date & Time Types @@ -175,7 +175,7 @@ Generic type parameters are resolved when used in concrete contexts. `Page ## Custom Type Mappings -Register custom type mappings in the [compile-time configuration](../advanced/configuration): +Register custom type mappings in the [scripting configuration](../advanced/scripting): ```groovy configuration.simpleTypeMappings['org.bson.types.ObjectId'] = new SimpleType("string") From 9821a3d973e435c998957621a910630db3d1633f Mon Sep 17 00:00:00 2001 From: dzikoysk Date: Sun, 28 Jun 2026 19:20:08 +0200 Subject: [PATCH 23/43] chore: cleanup test --- docs/advanced/runtime-builder.md | 6 +- docs/introduction/json-schema-setup.md | 2 +- docs/introduction/migration-from-6x.md | 10 +-- docs/openapi/enums.md | 6 +- docs/openapi/naming.md | 22 ++--- docs/openapi/request-body.md | 6 +- docs/openapi/schemas.md | 4 +- .../openapi/plugin/test/JavalinTest.java | 3 +- .../openapi/plugin/test/JavalinTest.java | 5 +- .../javalin/openapi/plugin/test/KotlinTest.kt | 2 +- .../ksp/KspTypeIntrospectorTest.kt | 33 +++++-- .../introspection/ksp/fixtures/Fixtures.kt | 25 ------ .../test/IntrospectionParityTest.kt | 90 +++++++++++++++---- .../introspection/test/fixtures/Fixtures.kt | 75 ---------------- .../dynamic/hook/{fixtures => }/User.java | 2 +- .../dynamic/hook/DynamicOpenApiHookTest.kt | 9 +- .../openapi/plugin/redoc/RedocPluginTest.kt | 5 +- .../AnnotationProcessorContext.kt | 20 +++-- .../dynamic/{fixtures => }/Account.java | 2 +- .../dynamic/{fixtures => }/Address.java | 2 +- .../openapi/dynamic/{fixtures => }/Cat.java | 2 +- .../openapi/dynamic/{fixtures => }/Dog.java | 2 +- .../dynamic/{fixtures => }/FieldsDto.java | 2 +- .../dynamic/{fixtures => }/NotNull.java | 2 +- .../openapi/dynamic/{fixtures => }/Role.java | 2 +- .../openapi/dynamic/{fixtures => }/Shape.java | 2 +- .../dynamic/{fixtures => }/SnakeCaseDto.java | 2 +- .../dynamic/{fixtures => }/TransientDto.java | 2 +- .../dynamic/{fixtures => }/Validated.java | 2 +- .../openapi/dynamic/DynamicCompositionTest.kt | 2 - .../dynamic/DynamicSchemaGeneratorTest.kt | 1 - .../dynamic/ReflectionSchemaContextTest.kt | 8 +- .../experimental/IntrospectorSchemaContext.kt | 16 ++-- .../openapi/ksp/KspSchemaContextTest.kt | 8 +- .../openapi/ksp/OpenApiSymbolProcessorTest.kt | 10 +-- .../io/javalin/openapi/ksp/fixtures/User.kt | 7 -- 36 files changed, 184 insertions(+), 215 deletions(-) delete mode 100644 introspection/introspection-ksp/src/test/kotlin/io/javalin/introspection/ksp/fixtures/Fixtures.kt delete mode 100644 introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/fixtures/Fixtures.kt rename javalin-plugins/javalin-openapi-dynamic-hook/src/test/java/io/javalin/openapi/dynamic/hook/{fixtures => }/User.java (72%) rename openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/{fixtures => }/Account.java (95%) rename openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/{fixtures => }/Address.java (87%) rename openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/{fixtures => }/Cat.java (63%) rename openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/{fixtures => }/Dog.java (63%) rename openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/{fixtures => }/FieldsDto.java (82%) rename openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/{fixtures => }/NotNull.java (88%) rename openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/{fixtures => }/Role.java (71%) rename openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/{fixtures => }/Shape.java (88%) rename openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/{fixtures => }/SnakeCaseDto.java (83%) rename openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/{fixtures => }/TransientDto.java (78%) rename openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/{fixtures => }/Validated.java (88%) delete mode 100644 openapi-ksp/src/test/kotlin/io/javalin/openapi/ksp/fixtures/User.kt diff --git a/docs/advanced/runtime-builder.md b/docs/advanced/runtime-builder.md index 8d167ff8..cec045ac 100644 --- a/docs/advanced/runtime-builder.md +++ b/docs/advanced/runtime-builder.md @@ -41,21 +41,21 @@ The `SchemaBuilder` DSL provides a clean way to define inline schemas without wo ```kotlin schema { type("string") } -// → { "type": "string" } +// -> { "type": "string" } ``` ### Type with Format ```kotlin schema { type("integer"); format("int32") } -// → { "type": "integer", "format": "int32" } +// -> { "type": "integer", "format": "int32" } ``` ### Reference ```kotlin schema { ref("#/components/schemas/User") } -// → { "$ref": "#/components/schemas/User" } +// -> { "$ref": "#/components/schemas/User" } ``` The schema DSL is available on media types, parameters, headers, and object schema properties. diff --git a/docs/introduction/json-schema-setup.md b/docs/introduction/json-schema-setup.md index 6390f313..f60bb6ee 100644 --- a/docs/introduction/json-schema-setup.md +++ b/docs/introduction/json-schema-setup.md @@ -140,7 +140,7 @@ Use `OpenApiLoader` to load the generated OpenAPI specifications from your class ```kotlin val loader = OpenApiLoader() -// version → JSON +// version -> JSON val schemes = loader.loadOpenApiSchemes() for ((version, json) in schemes) { diff --git a/docs/introduction/migration-from-6x.md b/docs/introduction/migration-from-6x.md index 022ca842..a069651d 100644 --- a/docs/introduction/migration-from-6x.md +++ b/docs/introduction/migration-from-6x.md @@ -48,8 +48,8 @@ New `@OpenApiNaming` annotation applies automatic name transformation to propert ```kotlin @OpenApiNaming(OpenApiNamingStrategy.SNAKE_CASE) class UserResponse( - val firstName: String, // → "first_name" - val lastName: String, // → "last_name" + val firstName: String, // -> "first_name" + val lastName: String, // -> "last_name" ) ``` @@ -198,7 +198,7 @@ kapt { If you don't use a Groovy configuration script, no changes are needed. -### OpenAPI 3.0.3 → 3.1.0 {#openapi-303-310} +### OpenAPI 3.0.3 -> 3.1.0 {#openapi-303-310} Generated specs are now **OpenAPI 3.1.0** (was 3.0.3), based on JSON Schema 2020-12. This changes the generated output in several ways. @@ -311,8 +311,8 @@ openApiConfig.withDefinitionConfiguration { version, definition -> ``` Key differences: -- `withInfo(...)` → `info(...)` -- `withServer(...)` → `server(...)` +- `withInfo(...)` -> `info(...)` +- `withServer(...)` -> `server(...)` - Security methods (`withBearerAuth`, `withBasicAuth`, `withOAuth2`, etc.) moved from `SecurityComponentConfiguration` to `OpenApiSchemaBuilder` directly - no more `withSecurity(security -> ...)` wrapper - `withDefinitionProcessor` moved from `DefinitionConfiguration` to `OpenApiPluginConfiguration` diff --git a/docs/openapi/enums.md b/docs/openapi/enums.md index 4f019dc6..843fe2d3 100644 --- a/docs/openapi/enums.md +++ b/docs/openapi/enums.md @@ -44,9 +44,9 @@ Apply `@OpenApiNaming` to transform all value names automatically: ```kotlin @OpenApiNaming(OpenApiNamingStrategy.KEBAB_CASE) enum class ErrorCode { - NOT_FOUND, // → "not-found" - BAD_REQUEST, // → "bad-request" - SERVER_ERROR // → "server-error" + NOT_FOUND, // -> "not-found" + BAD_REQUEST, // -> "bad-request" + SERVER_ERROR // -> "server-error" } ``` diff --git a/docs/openapi/naming.md b/docs/openapi/naming.md index eaefee7e..7de58216 100644 --- a/docs/openapi/naming.md +++ b/docs/openapi/naming.md @@ -9,9 +9,9 @@ Apply `@OpenApiNaming` on a class to transform all property names: ```kotlin @OpenApiNaming(OpenApiNamingStrategy.SNAKE_CASE) class UserProfile { - val firstName: String = "" // → "first_name" - val lastName: String = "" // → "last_name" - val emailAddress: String = "" // → "email_address" + val firstName: String = "" // -> "first_name" + val lastName: String = "" // -> "last_name" + val emailAddress: String = "" // -> "email_address" } ``` @@ -30,10 +30,10 @@ class UserProfile { ```kotlin @OpenApiNaming(OpenApiNamingStrategy.SNAKE_CASE) class UserProfile { - val firstName: String = "" // → "first_name" (from strategy) + val firstName: String = "" // -> "first_name" (from strategy) @OpenApiName("ID") - val id: String = "" // → "ID" (explicit override) + val id: String = "" // -> "ID" (explicit override) } ``` @@ -44,9 +44,9 @@ class UserProfile { ```kotlin @OpenApiNaming(OpenApiNamingStrategy.KEBAB_CASE) enum class StatusCode { - NOT_FOUND, // → "not-found" - BAD_REQUEST, // → "bad-request" - INTERNAL_ERROR // → "internal-error" + NOT_FOUND, // -> "not-found" + BAD_REQUEST, // -> "bad-request" + INTERNAL_ERROR // -> "internal-error" } ``` @@ -55,12 +55,12 @@ Use `@OpenApiName` on individual values to override specific entries: ```kotlin @OpenApiNaming(OpenApiNamingStrategy.KEBAB_CASE) enum class StatusCode { - NOT_FOUND, // → "not-found" + NOT_FOUND, // -> "not-found" @OpenApiName("custom-value") - BAD_REQUEST, // → "custom-value" + BAD_REQUEST, // -> "custom-value" - INTERNAL_ERROR // → "internal-error" + INTERNAL_ERROR // -> "internal-error" } ``` diff --git a/docs/openapi/request-body.md b/docs/openapi/request-body.md index cfb5f00d..1a88ab31 100644 --- a/docs/openapi/request-body.md +++ b/docs/openapi/request-body.md @@ -52,9 +52,9 @@ Use `@OpenApiRequestBody` and `@OpenApiContent` to describe request bodies. When `mimeType` is not specified, the content type is auto-detected from the `from` type: -- Object types → `application/json` -- `String` → `text/plain` -- `ByteArray`, `InputStream`, `File` → `application/octet-stream` +- Object types -> `application/json` +- `String` -> `text/plain` +- `ByteArray`, `InputStream`, `File` -> `application/octet-stream` ## Inline Properties diff --git a/docs/openapi/schemas.md b/docs/openapi/schemas.md index 41afb9da..bb8cb474 100644 --- a/docs/openapi/schemas.md +++ b/docs/openapi/schemas.md @@ -65,8 +65,8 @@ The annotation processor automatically generates OpenAPI component schemas from By default, properties are resolved from getter methods following JavaBean conventions. The `get` / `is` prefix is stripped: -- `getName()` → `name` -- `isActive()` → `active` +- `getName()` -> `name` +- `isActive()` -> `active` ### Java Records diff --git a/examples/javalin-gradle-kotlin/src/main/java/io/javalin/openapi/plugin/test/JavalinTest.java b/examples/javalin-gradle-kotlin/src/main/java/io/javalin/openapi/plugin/test/JavalinTest.java index 519d4a81..3d861a45 100644 --- a/examples/javalin-gradle-kotlin/src/main/java/io/javalin/openapi/plugin/test/JavalinTest.java +++ b/examples/javalin-gradle-kotlin/src/main/java/io/javalin/openapi/plugin/test/JavalinTest.java @@ -45,7 +45,7 @@ enum Rules implements RouteRole { * @param args args */ public static void main(String[] args) { - var app = Javalin.create(config -> { + Javalin.start(config -> { // config.routing.contextPath = "/custom"; String deprecatedDocsPath = "/api/openapi.json"; // by default it's /openapi @@ -110,7 +110,6 @@ public static void main(String[] args) { System.out.println(generatedJsonSchema.getContentAsString()); } }); - app.start(); } @OpenApi( diff --git a/examples/javalin-maven-java/src/main/java/io/javalin/openapi/plugin/test/JavalinTest.java b/examples/javalin-maven-java/src/main/java/io/javalin/openapi/plugin/test/JavalinTest.java index 1eb364f7..23f2a52c 100644 --- a/examples/javalin-maven-java/src/main/java/io/javalin/openapi/plugin/test/JavalinTest.java +++ b/examples/javalin-maven-java/src/main/java/io/javalin/openapi/plugin/test/JavalinTest.java @@ -41,7 +41,7 @@ public final class JavalinTest implements Handler { * @param args args */ public static void main(String[] args) { - Javalin.create(config -> { + Javalin.start(config -> { String deprecatedDocsPath = "/swagger-docs"; config.registerPlugin(new OpenApiPlugin(openApiConfig -> @@ -101,8 +101,7 @@ public static void main(String[] args) { config.registerPlugin(new ReDocPlugin(reDocConfiguration -> reDocConfiguration.documentationPath = deprecatedDocsPath )); - }) - .start(8080); + }); } private static final String ROUTE = "/main/{name}"; diff --git a/examples/javalin-maven-kotlin/src/main/kotlin/io/javalin/openapi/plugin/test/KotlinTest.kt b/examples/javalin-maven-kotlin/src/main/kotlin/io/javalin/openapi/plugin/test/KotlinTest.kt index 87244489..ac6be15c 100644 --- a/examples/javalin-maven-kotlin/src/main/kotlin/io/javalin/openapi/plugin/test/KotlinTest.kt +++ b/examples/javalin-maven-kotlin/src/main/kotlin/io/javalin/openapi/plugin/test/KotlinTest.kt @@ -14,7 +14,7 @@ import io.javalin.openapi.plugin.swagger.SwaggerPlugin path = "/" ) fun main() { - Javalin.createAndStart { config -> + Javalin.start { config -> config.registerPlugin( OpenApiPlugin { it.documentationPath = "/openapi" diff --git a/introspection/introspection-ksp/src/test/kotlin/io/javalin/introspection/ksp/KspTypeIntrospectorTest.kt b/introspection/introspection-ksp/src/test/kotlin/io/javalin/introspection/ksp/KspTypeIntrospectorTest.kt index 08242ee3..e415a269 100644 --- a/introspection/introspection-ksp/src/test/kotlin/io/javalin/introspection/ksp/KspTypeIntrospectorTest.kt +++ b/introspection/introspection-ksp/src/test/kotlin/io/javalin/introspection/ksp/KspTypeIntrospectorTest.kt @@ -10,15 +10,14 @@ import com.tschuchort.compiletesting.SourceFile import com.tschuchort.compiletesting.symbolProcessorProviders import com.tschuchort.compiletesting.useKsp2 import io.javalin.introspection.ClassDefinition -import io.javalin.introspection.ksp.fixtures.Ref import org.assertj.core.api.Assertions.assertThat import org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi import org.junit.jupiter.api.Test +import kotlin.reflect.KClass @OptIn(ExperimentalCompilerApi::class) class KspTypeIntrospectorTest { - /** Runs KSP in-process over a trivial trigger; the introspector resolves the classpath fixtures by name. */ private fun withKsp(block: (KspTypeIntrospector) -> R): R { var result: Result? = null val provider = object : SymbolProcessorProvider { @@ -54,9 +53,9 @@ class KspTypeIntrospectorTest { ) val snapshot = withKsp { ksp -> - val fixtures = "io.javalin.introspection.ksp.fixtures" - val account = ksp.introspect("$fixtures.Account") - val color = ksp.introspect("$fixtures.Color") + val pkg = "io.javalin.introspection.ksp" + val account = ksp.introspect("$pkg.Account") + val color = ksp.introspect("$pkg.Color") Snapshot( properties = account.getProperties().associate { it.name to "${it.type.fullName}:${it.type.structureType}" }, isEnum = color.isEnum(), @@ -92,6 +91,28 @@ class KspTypeIntrospectorTest { } private companion object { - const val REF_PKG = "io.javalin.introspection.ksp.fixtures" + const val REF_PKG = "io.javalin.introspection.ksp" } } + +enum class Color { RED, GREEN } + +annotation class Ref(val value: KClass<*>) + +class Address(val city: String, val zip: String) + +annotation class MetaMarker + +@MetaMarker +annotation class Tagged(val label: String) + +@Ref(Address::class) +@Tagged("x") +class Account( + val id: String, + val age: Int, + val color: Color, + val address: Address?, + val tags: List, + val meta: Map, +) diff --git a/introspection/introspection-ksp/src/test/kotlin/io/javalin/introspection/ksp/fixtures/Fixtures.kt b/introspection/introspection-ksp/src/test/kotlin/io/javalin/introspection/ksp/fixtures/Fixtures.kt deleted file mode 100644 index d0bfbe0b..00000000 --- a/introspection/introspection-ksp/src/test/kotlin/io/javalin/introspection/ksp/fixtures/Fixtures.kt +++ /dev/null @@ -1,25 +0,0 @@ -package io.javalin.introspection.ksp.fixtures - -import kotlin.reflect.KClass - -enum class Color { RED, GREEN } - -annotation class Ref(val value: KClass<*>) - -class Address(val city: String, val zip: String) - -annotation class MetaMarker - -@MetaMarker -annotation class Tagged(val label: String) - -@Ref(Address::class) -@Tagged("x") -class Account( - val id: String, - val age: Int, - val color: Color, - val address: Address?, - val tags: List, - val meta: Map, -) diff --git a/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/IntrospectionParityTest.kt b/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/IntrospectionParityTest.kt index 95b71e1d..f9a660a3 100644 --- a/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/IntrospectionParityTest.kt +++ b/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/IntrospectionParityTest.kt @@ -6,24 +6,7 @@ import io.javalin.introspection.ClassDefinition import io.javalin.introspection.StructureType import io.javalin.introspection.Visibility import io.javalin.introspection.runtime.ReflectionTypeIntrospector -import io.javalin.introspection.test.fixtures.Account -import io.javalin.introspection.test.fixtures.Address -import io.javalin.introspection.test.fixtures.Annotated -import io.javalin.introspection.test.fixtures.Bounded -import io.javalin.introspection.test.fixtures.Color -import io.javalin.introspection.test.fixtures.CrossPackageChild -import io.javalin.introspection.test.fixtures.Derived -import io.javalin.introspection.test.fixtures.Flagged -import io.javalin.introspection.test.fixtures.Flags -import io.javalin.introspection.test.fixtures.Holder -import io.javalin.introspection.test.fixtures.Outer -import io.javalin.introspection.test.fixtures.Point -import io.javalin.introspection.test.fixtures.Ref -import io.javalin.introspection.test.fixtures.Refs -import io.javalin.introspection.test.fixtures.Scanned -import io.javalin.introspection.test.fixtures.Tricky -import io.javalin.introspection.test.fixtures.WithTransient -import io.javalin.introspection.test.fixtures.Wrapped +import io.javalin.introspection.test.sub.PackagePrivateBase import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test import kotlin.reflect.KClass @@ -209,3 +192,74 @@ private fun ClassDefinition.toShape(): TypeShape = .map { PropertyShape(it.name, it.type.fullName, it.type.structureType, it.type.generics.map { g -> g.fullName }, it.accessor, it.nullable, it.visibility, it.transient) } .sortedBy { "${it.accessor}:${it.name}" }, ) + +class Address(val city: String, val zip: String) + +enum class Color { RED, GREEN } + +class Box(val value: T) + +class Bounded(val value: T, val many: List) + +@JvmRecord +data class Point(val x: Int, val label: String, val tags: List) + +class Account( + val id: String, + val age: Int, + val color: Color, + val address: Address?, + val tags: List, + val meta: Map, + val bounded: Box, +) + +open class Base(val baseField: String) { + val computed: String get() = "" + protected val secret: String get() = "" +} + +class Derived(val own: String) : Base("") + +class CrossPackageChild : PackagePrivateBase() + +class WithTransient(@Transient val skipped: String, val kept: String) + +class Tricky { + fun getName(): String = "" + fun issue(): String = "" + fun getaway(): String = "" + fun getResult() {} +} + +annotation class Marker + +class Annotated(@get:Marker val tagged: String, val plain: String) + +annotation class Meta(val note: String) + +annotation class Outer(val meta: Meta) + +@Outer(Meta("x")) +class Wrapped + +annotation class MetaMarker + +@MetaMarker +annotation class Tagged(val label: String) + +@Tagged("x") +class Scanned + +annotation class Flags(val ints: IntArray) + +@Flags(ints = [1, 2, 3]) +class Flagged + +annotation class Ref(val value: KClass<*>) + +annotation class Refs(vararg val value: KClass<*>) + +@Ref(Address::class) +@Refs(Address::class, Color::class) +class Holder diff --git a/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/fixtures/Fixtures.kt b/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/fixtures/Fixtures.kt deleted file mode 100644 index 25d8b59d..00000000 --- a/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/fixtures/Fixtures.kt +++ /dev/null @@ -1,75 +0,0 @@ -package io.javalin.introspection.test.fixtures - -import kotlin.reflect.KClass - -class Address(val city: String, val zip: String) - -enum class Color { RED, GREEN } - -class Box(val value: T) - -class Bounded(val value: T, val many: List) - -@JvmRecord -data class Point(val x: Int, val label: String, val tags: List) - -class Account( - val id: String, - val age: Int, - val color: Color, - val address: Address?, - val tags: List, - val meta: Map, - val bounded: Box, -) - -open class Base(val baseField: String) { - val computed: String get() = "" - protected val secret: String get() = "" -} - -class Derived(val own: String) : Base("") - -/** Extends a Java base in another package that has a package-private field, to exercise cross-package field inheritance. */ -class CrossPackageChild : io.javalin.introspection.test.sub.PackagePrivateBase() - -class WithTransient(@Transient val skipped: String, val kept: String) - -class Tricky { - fun getName(): String = "" - fun issue(): String = "" - fun getaway(): String = "" - fun getResult() {} -} - -annotation class Marker - -class Annotated(@get:Marker val tagged: String, val plain: String) - -annotation class Meta(val note: String) - -annotation class Outer(val meta: Meta) - -@Outer(Meta("x")) -class Wrapped - -annotation class MetaMarker - -@MetaMarker -annotation class Tagged(val label: String) - -@Tagged("x") -class Scanned - -annotation class Flags(val ints: IntArray) - -@Flags(ints = [1, 2, 3]) -class Flagged - -annotation class Ref(val value: KClass<*>) - -annotation class Refs(vararg val value: KClass<*>) - -@Ref(Address::class) -@Refs(Address::class, Color::class) -class Holder diff --git a/javalin-plugins/javalin-openapi-dynamic-hook/src/test/java/io/javalin/openapi/dynamic/hook/fixtures/User.java b/javalin-plugins/javalin-openapi-dynamic-hook/src/test/java/io/javalin/openapi/dynamic/hook/User.java similarity index 72% rename from javalin-plugins/javalin-openapi-dynamic-hook/src/test/java/io/javalin/openapi/dynamic/hook/fixtures/User.java rename to javalin-plugins/javalin-openapi-dynamic-hook/src/test/java/io/javalin/openapi/dynamic/hook/User.java index fbf22a38..a15869d2 100644 --- a/javalin-plugins/javalin-openapi-dynamic-hook/src/test/java/io/javalin/openapi/dynamic/hook/fixtures/User.java +++ b/javalin-plugins/javalin-openapi-dynamic-hook/src/test/java/io/javalin/openapi/dynamic/hook/User.java @@ -1,4 +1,4 @@ -package io.javalin.openapi.dynamic.hook.fixtures; +package io.javalin.openapi.dynamic.hook; public class User { diff --git a/javalin-plugins/javalin-openapi-dynamic-hook/src/test/kotlin/io/javalin/openapi/dynamic/hook/DynamicOpenApiHookTest.kt b/javalin-plugins/javalin-openapi-dynamic-hook/src/test/kotlin/io/javalin/openapi/dynamic/hook/DynamicOpenApiHookTest.kt index 99632775..f10720ad 100644 --- a/javalin-plugins/javalin-openapi-dynamic-hook/src/test/kotlin/io/javalin/openapi/dynamic/hook/DynamicOpenApiHookTest.kt +++ b/javalin-plugins/javalin-openapi-dynamic-hook/src/test/kotlin/io/javalin/openapi/dynamic/hook/DynamicOpenApiHookTest.kt @@ -2,7 +2,6 @@ package io.javalin.openapi.dynamic.hook import io.javalin.Javalin import io.javalin.http.HandlerType -import io.javalin.openapi.dynamic.hook.fixtures.User import io.javalin.openapi.experimental.processor.shared.jsonMapper import io.javalin.openapi.plugin.OpenApiPlugin import io.javalin.router.Endpoint @@ -14,13 +13,13 @@ class DynamicOpenApiHookTest { @Test fun `autogenerates docs for all registered Javalin routes`() { - val app = Javalin.create { config -> + val app = Javalin.start { config -> + config.jetty.port = 0 config.registerPlugin(OpenApiPlugin { it.withHook(RegisteredRoutesHook()) }) config.routes.get("/users") { } config.routes.post("/users") { } config.routes.get("/users/{id}") { } } - app.start(0) try { val body = Unirest.get("http://localhost:${app.port()}/openapi").asString().body @@ -44,7 +43,8 @@ class DynamicOpenApiHookTest { @Test fun `enriches a route from OpenApiMetadata, reusing the operation builder and schema engine`() { - val app = Javalin.create { config -> + val app = Javalin.start { config -> + config.jetty.port = 0 config.registerPlugin(OpenApiPlugin { it.withHook(RegisteredRoutesHook()) }) config.routes.addEndpoint( Endpoint.create(HandlerType.GET, "/users/{id}") @@ -60,7 +60,6 @@ class DynamicOpenApiHookTest { .handler { } ) } - app.start(0) try { val document = jsonMapper.readTree(Unirest.get("http://localhost:${app.port()}/openapi").asString().body) diff --git a/javalin-plugins/javalin-redoc-plugin/src/test/kotlin/io/javalin/openapi/plugin/redoc/RedocPluginTest.kt b/javalin-plugins/javalin-redoc-plugin/src/test/kotlin/io/javalin/openapi/plugin/redoc/RedocPluginTest.kt index 43f4c035..1c6ab9ef 100644 --- a/javalin-plugins/javalin-redoc-plugin/src/test/kotlin/io/javalin/openapi/plugin/redoc/RedocPluginTest.kt +++ b/javalin-plugins/javalin-redoc-plugin/src/test/kotlin/io/javalin/openapi/plugin/redoc/RedocPluginTest.kt @@ -10,7 +10,10 @@ internal class RedocPluginTest { @Test fun `should properly host redoc ui`() { - val app = Javalin.create { it.registerPlugin(ReDocPlugin()) }.start(0) + val app = Javalin.start { + it.jetty.port = 0 + it.registerPlugin(ReDocPlugin()) + } try { val response = Unirest.get("http://localhost:${app.port()}/redoc") diff --git a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/AnnotationProcessorContext.kt b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/AnnotationProcessorContext.kt index cd3621c2..cee18f7d 100644 --- a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/AnnotationProcessorContext.kt +++ b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/AnnotationProcessorContext.kt @@ -74,12 +74,13 @@ class AnnotationProcessorContext( override fun acceptsProperty(type: OpenApiType, property: PropertyView): Boolean = configuration.propertyInSchemeFilter?.filter(this, type, property.source as Element) != false - override fun discriminatorSubtypes(type: OpenApiType): List> = - japIntrospector.typesAnnotatedWith(DiscriminatorMappingName::class.java, assignableTo = japIntrospector.introspect(type.mirror)) - .mapNotNull { subtype -> - (subtype.getAnnotations().memberValues(DiscriminatorMappingName::class.java)?.get("value") as? String) - ?.let { it to toOpenApiType(subtype) } - } + override fun discriminatorSubtypes(type: OpenApiType): List> { + val subtypes = japIntrospector.typesAnnotatedWith(DiscriminatorMappingName::class.java, assignableTo = japIntrospector.introspect(type.mirror)) + return subtypes.mapNotNull { subtype -> + val name = subtype.getAnnotations().memberValues(DiscriminatorMappingName::class.java)?.get("value") as? String + if (name == null) null else name to toOpenApiType(subtype) + } + } fun inDebug(body: (Messager) -> Unit) { if (configuration.debug) { @@ -90,8 +91,11 @@ class AnnotationProcessorContext( fun forTypeElement(name: String): TypeElement? = env.elementUtils.getTypeElement(name) - private fun objectType(): TypeElement = forTypeElement(Object::class.java.name)!! - private fun mapType(): TypeElement = forTypeElement(Map::class.java.name)!! + private fun objectType(): TypeElement = + forTypeElement(Object::class.java.name)!! + + private fun mapType(): TypeElement = + forTypeElement(Map::class.java.name)!! fun isAssignable(implementation: TypeMirror, superclass: TypeMirror): Boolean = env.typeUtils.isAssignable(implementation, superclass) diff --git a/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/fixtures/Account.java b/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/Account.java similarity index 95% rename from openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/fixtures/Account.java rename to openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/Account.java index 575dcc61..21bc2ce7 100644 --- a/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/fixtures/Account.java +++ b/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/Account.java @@ -1,4 +1,4 @@ -package io.javalin.openapi.dynamic.fixtures; +package io.javalin.openapi.dynamic; import io.javalin.openapi.OpenApiDescription; import io.javalin.openapi.OpenApiIgnore; diff --git a/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/fixtures/Address.java b/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/Address.java similarity index 87% rename from openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/fixtures/Address.java rename to openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/Address.java index 3e53d2b7..4b6c0066 100644 --- a/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/fixtures/Address.java +++ b/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/Address.java @@ -1,4 +1,4 @@ -package io.javalin.openapi.dynamic.fixtures; +package io.javalin.openapi.dynamic; public class Address { diff --git a/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/fixtures/Cat.java b/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/Cat.java similarity index 63% rename from openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/fixtures/Cat.java rename to openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/Cat.java index 661f7454..88b1dfd0 100644 --- a/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/fixtures/Cat.java +++ b/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/Cat.java @@ -1,4 +1,4 @@ -package io.javalin.openapi.dynamic.fixtures; +package io.javalin.openapi.dynamic; public class Cat { public String getMeow() { diff --git a/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/fixtures/Dog.java b/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/Dog.java similarity index 63% rename from openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/fixtures/Dog.java rename to openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/Dog.java index 04a8cd68..06320fbe 100644 --- a/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/fixtures/Dog.java +++ b/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/Dog.java @@ -1,4 +1,4 @@ -package io.javalin.openapi.dynamic.fixtures; +package io.javalin.openapi.dynamic; public class Dog { public String getBark() { diff --git a/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/fixtures/FieldsDto.java b/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/FieldsDto.java similarity index 82% rename from openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/fixtures/FieldsDto.java rename to openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/FieldsDto.java index b711834f..9b54deb9 100644 --- a/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/fixtures/FieldsDto.java +++ b/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/FieldsDto.java @@ -1,4 +1,4 @@ -package io.javalin.openapi.dynamic.fixtures; +package io.javalin.openapi.dynamic; import io.javalin.openapi.OpenApiByFields; diff --git a/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/fixtures/NotNull.java b/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/NotNull.java similarity index 88% rename from openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/fixtures/NotNull.java rename to openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/NotNull.java index 13136136..24b281d5 100644 --- a/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/fixtures/NotNull.java +++ b/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/NotNull.java @@ -1,4 +1,4 @@ -package io.javalin.openapi.dynamic.fixtures; +package io.javalin.openapi.dynamic; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; diff --git a/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/fixtures/Role.java b/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/Role.java similarity index 71% rename from openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/fixtures/Role.java rename to openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/Role.java index b3fdb5be..09393bf9 100644 --- a/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/fixtures/Role.java +++ b/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/Role.java @@ -1,4 +1,4 @@ -package io.javalin.openapi.dynamic.fixtures; +package io.javalin.openapi.dynamic; import io.javalin.openapi.OpenApiName; diff --git a/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/fixtures/Shape.java b/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/Shape.java similarity index 88% rename from openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/fixtures/Shape.java rename to openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/Shape.java index 20dd846f..eec2d34c 100644 --- a/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/fixtures/Shape.java +++ b/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/Shape.java @@ -1,4 +1,4 @@ -package io.javalin.openapi.dynamic.fixtures; +package io.javalin.openapi.dynamic; import io.javalin.openapi.OneOf; diff --git a/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/fixtures/SnakeCaseDto.java b/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/SnakeCaseDto.java similarity index 83% rename from openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/fixtures/SnakeCaseDto.java rename to openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/SnakeCaseDto.java index f2a9534d..d0c59c93 100644 --- a/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/fixtures/SnakeCaseDto.java +++ b/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/SnakeCaseDto.java @@ -1,4 +1,4 @@ -package io.javalin.openapi.dynamic.fixtures; +package io.javalin.openapi.dynamic; import io.javalin.openapi.OpenApiNaming; import io.javalin.openapi.OpenApiNamingStrategy; diff --git a/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/fixtures/TransientDto.java b/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/TransientDto.java similarity index 78% rename from openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/fixtures/TransientDto.java rename to openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/TransientDto.java index 918798a9..fed3f817 100644 --- a/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/fixtures/TransientDto.java +++ b/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/TransientDto.java @@ -1,4 +1,4 @@ -package io.javalin.openapi.dynamic.fixtures; +package io.javalin.openapi.dynamic; import io.javalin.openapi.OpenApiByFields; diff --git a/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/fixtures/Validated.java b/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/Validated.java similarity index 88% rename from openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/fixtures/Validated.java rename to openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/Validated.java index f28f667c..5cb2052b 100644 --- a/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/fixtures/Validated.java +++ b/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/Validated.java @@ -1,4 +1,4 @@ -package io.javalin.openapi.dynamic.fixtures; +package io.javalin.openapi.dynamic; import io.javalin.openapi.OpenApiNumberValidation; import io.javalin.openapi.OpenApiPropertyType; diff --git a/openapi-dynamic/src/test/kotlin/io/javalin/openapi/dynamic/DynamicCompositionTest.kt b/openapi-dynamic/src/test/kotlin/io/javalin/openapi/dynamic/DynamicCompositionTest.kt index 3f5f298f..0a6ec42f 100644 --- a/openapi-dynamic/src/test/kotlin/io/javalin/openapi/dynamic/DynamicCompositionTest.kt +++ b/openapi-dynamic/src/test/kotlin/io/javalin/openapi/dynamic/DynamicCompositionTest.kt @@ -1,8 +1,6 @@ package io.javalin.openapi.dynamic import com.fasterxml.jackson.databind.JsonNode -import io.javalin.openapi.dynamic.fixtures.Shape -import io.javalin.openapi.dynamic.fixtures.Validated import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test diff --git a/openapi-dynamic/src/test/kotlin/io/javalin/openapi/dynamic/DynamicSchemaGeneratorTest.kt b/openapi-dynamic/src/test/kotlin/io/javalin/openapi/dynamic/DynamicSchemaGeneratorTest.kt index 111a7146..04ced115 100644 --- a/openapi-dynamic/src/test/kotlin/io/javalin/openapi/dynamic/DynamicSchemaGeneratorTest.kt +++ b/openapi-dynamic/src/test/kotlin/io/javalin/openapi/dynamic/DynamicSchemaGeneratorTest.kt @@ -1,7 +1,6 @@ package io.javalin.openapi.dynamic import com.fasterxml.jackson.databind.JsonNode -import io.javalin.openapi.dynamic.fixtures.Account import io.javalin.openapi.experimental.processor.shared.jsonMapper import io.javalin.openapi.schema.OpenApiSchemaBuilder import org.assertj.core.api.Assertions.assertThat diff --git a/openapi-dynamic/src/test/kotlin/io/javalin/openapi/dynamic/ReflectionSchemaContextTest.kt b/openapi-dynamic/src/test/kotlin/io/javalin/openapi/dynamic/ReflectionSchemaContextTest.kt index 14b70e72..eedbc129 100644 --- a/openapi-dynamic/src/test/kotlin/io/javalin/openapi/dynamic/ReflectionSchemaContextTest.kt +++ b/openapi-dynamic/src/test/kotlin/io/javalin/openapi/dynamic/ReflectionSchemaContextTest.kt @@ -1,12 +1,6 @@ package io.javalin.openapi.dynamic import com.fasterxml.jackson.databind.JsonNode -import io.javalin.openapi.dynamic.fixtures.Account -import io.javalin.openapi.dynamic.fixtures.Address -import io.javalin.openapi.dynamic.fixtures.FieldsDto -import io.javalin.openapi.dynamic.fixtures.Role -import io.javalin.openapi.dynamic.fixtures.SnakeCaseDto -import io.javalin.openapi.dynamic.fixtures.TransientDto import io.javalin.openapi.experimental.StructureType import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test @@ -26,7 +20,7 @@ class ReflectionSchemaContextTest { val account = schemaContext.introspect(Account::class.java) assertThat(account.simpleName).isEqualTo("Account") - assertThat(account.fullName).isEqualTo("io.javalin.openapi.dynamic.fixtures.Account") + assertThat(account.fullName).isEqualTo("io.javalin.openapi.dynamic.Account") assertThat(account.structureType).isEqualTo(StructureType.DEFAULT) } diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/IntrospectorSchemaContext.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/IntrospectorSchemaContext.kt index 3c67f71f..6296a132 100644 --- a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/IntrospectorSchemaContext.kt +++ b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/IntrospectorSchemaContext.kt @@ -59,14 +59,14 @@ abstract class IntrospectorSchemaContext( override fun enumConstantsOf(type: OpenApiType): List = type.raw.getEnumConstants() ?: emptyList() - override fun discriminatorSubtypes(type: OpenApiType): List> = - (introspector as? CompileTimeIntrospector) - ?.typesAnnotatedWith(DiscriminatorMappingName::class.java, assignableTo = type.raw) - ?.mapNotNull { subtype -> - (subtype.getAnnotations().memberValues(DiscriminatorMappingName::class.java)?.get("value") as? String) - ?.let { it to toOpenApiType(subtype) } - } - ?: emptyList() + override fun discriminatorSubtypes(type: OpenApiType): List> { + val scanner = introspector as? CompileTimeIntrospector ?: return emptyList() + val subtypes = scanner.typesAnnotatedWith(DiscriminatorMappingName::class.java, assignableTo = type.raw) + return subtypes.mapNotNull { subtype -> + val name = subtype.getAnnotations().memberValues(DiscriminatorMappingName::class.java)?.get("value") as? String + if (name == null) null else name to toOpenApiType(subtype) + } + } } private val OpenApiType.raw: RawType diff --git a/openapi-ksp/src/test/kotlin/io/javalin/openapi/ksp/KspSchemaContextTest.kt b/openapi-ksp/src/test/kotlin/io/javalin/openapi/ksp/KspSchemaContextTest.kt index 4ae1dee3..513f784b 100644 --- a/openapi-ksp/src/test/kotlin/io/javalin/openapi/ksp/KspSchemaContextTest.kt +++ b/openapi-ksp/src/test/kotlin/io/javalin/openapi/ksp/KspSchemaContextTest.kt @@ -43,7 +43,7 @@ class KspSchemaContextTest { fun `generates a component schema end-to-end through the shared generator`() { withResolver { resolver -> val context = KspSchemaContext(resolver) - val user = resolver.getClassDeclarationByName(resolver.getKSNameFromString("io.javalin.openapi.ksp.fixtures.User"))!! + val user = resolver.getClassDeclarationByName(resolver.getKSNameFromString("io.javalin.openapi.ksp.User"))!! .asStarProjectedType() val schema = context.componentSchema(context.introspect(user)).json @@ -58,3 +58,9 @@ class KspSchemaContextTest { } } } + +class User( + val id: String, + val age: Int, + val tags: List, +) diff --git a/openapi-ksp/src/test/kotlin/io/javalin/openapi/ksp/OpenApiSymbolProcessorTest.kt b/openapi-ksp/src/test/kotlin/io/javalin/openapi/ksp/OpenApiSymbolProcessorTest.kt index ae62fcc1..0175d622 100644 --- a/openapi-ksp/src/test/kotlin/io/javalin/openapi/ksp/OpenApiSymbolProcessorTest.kt +++ b/openapi-ksp/src/test/kotlin/io/javalin/openapi/ksp/OpenApiSymbolProcessorTest.kt @@ -21,7 +21,7 @@ class OpenApiSymbolProcessorTest { SourceFile.kotlin( "Widget.kt", """ - package fixtures + package app import io.javalin.openapi.JsonSchema @JsonSchema class Widget(val name: String, val size: Int) @@ -36,7 +36,7 @@ class OpenApiSymbolProcessorTest { val result = compilation.compile() check(result.exitCode == KotlinCompilation.ExitCode.OK) { "KSP compilation failed: ${result.messages}" } - val scheme = compilation.kspSourcesDir.walkTopDown().firstOrNull { it.name == "fixtures.Widget" } + val scheme = compilation.kspSourcesDir.walkTopDown().firstOrNull { it.name == "app.Widget" } ?: error("json-scheme not generated. Output tree:\n" + compilation.kspSourcesDir.walkTopDown().joinToString("\n")) val doc = jsonMapper.readTree(scheme.readText()) @@ -54,7 +54,7 @@ class OpenApiSymbolProcessorTest { SourceFile.kotlin( "Routes.kt", """ - package fixtures + package app import io.javalin.openapi.HttpMethod import io.javalin.openapi.OpenApiStatus import io.javalin.openapi.OpenApi @@ -110,7 +110,7 @@ class OpenApiSymbolProcessorTest { SourceFile.kotlin( "Shapes.kt", """ - package fixtures + package app import io.javalin.openapi.Discriminator import io.javalin.openapi.DiscriminatorMappingName import io.javalin.openapi.DiscriminatorProperty @@ -173,7 +173,7 @@ class OpenApiSymbolProcessorTest { SourceFile.kotlin( "Shared.kt", """ - package fixtures + package app import io.javalin.openapi.HttpMethod import io.javalin.openapi.JsonSchema import io.javalin.openapi.OpenApi diff --git a/openapi-ksp/src/test/kotlin/io/javalin/openapi/ksp/fixtures/User.kt b/openapi-ksp/src/test/kotlin/io/javalin/openapi/ksp/fixtures/User.kt deleted file mode 100644 index dde5230b..00000000 --- a/openapi-ksp/src/test/kotlin/io/javalin/openapi/ksp/fixtures/User.kt +++ /dev/null @@ -1,7 +0,0 @@ -package io.javalin.openapi.ksp.fixtures - -class User( - val id: String, - val age: Int, - val tags: List, -) From 2e3e72656e33265c583b7a17295b53db5085daa5 Mon Sep 17 00:00:00 2001 From: dzikoysk Date: Tue, 30 Jun 2026 01:20:40 +0200 Subject: [PATCH 24/43] chore: yet another cleanup --- docs/advanced/runtime-builder.md | 6 +- docs/introduction/migration-from-6x.md | 2 +- .../src/main/compile/openapi.groovy | 1 - .../io/javalin/introspection/AnnotationSet.kt | 62 ++++++++++ .../io/javalin/introspection/Annotations.kt | 30 ----- .../javalin/introspection/ClassDefinition.kt | 4 +- .../io/javalin/introspection/PropertyView.kt | 6 +- .../introspection/jap/JapTypeIntrospector.kt | 50 ++++---- .../introspection/ksp/KspTypeIntrospector.kt | 45 ++++--- .../ksp/KspTypeIntrospectorTest.kt | 6 +- .../runtime/ReflectionTypeIntrospector.kt | 42 ++++--- .../runtime/ReflectionTypeIntrospectorTest.kt | 17 +-- .../test/AnnotationProcessing.kt | 2 +- .../test/IntrospectionParityTest.kt | 36 +++--- .../dynamic/hook/RegisteredRoutesHook.kt | 2 +- .../AnnotationProcessorContext.kt | 23 ++-- .../processor/generators/OpenApiGenerator.kt | 4 +- .../src/test/compile/openapi.groovy | 1 - .../io/javalin/openapi/dynamic/Shape.java | 2 +- .../dynamic/DynamicSchemaGeneratorTest.kt | 8 +- .../experimental/IntrospectorSchemaContext.kt | 8 +- .../experimental/SchemaGenerationContext.kt | 4 +- .../generators/CompositionGenerator.kt | 12 +- .../generators/TypeSchemaGenerator.kt | 70 +++++------ .../openapi/schema/OpenApiSchemaBuilder.kt | 115 ++++++++++++------ .../openapi/schema/OpenApiSchemaGenerator.kt | 2 +- .../schema/OpenApiSchemaBuilderTest.kt | 14 +-- .../javalin/openapi/ksp/KspSchemaContext.kt | 4 +- .../openapi/ksp/OpenApiSymbolProcessor.kt | 4 +- 29 files changed, 325 insertions(+), 257 deletions(-) create mode 100644 introspection/introspection-api/src/main/kotlin/io/javalin/introspection/AnnotationSet.kt delete mode 100644 introspection/introspection-api/src/main/kotlin/io/javalin/introspection/Annotations.kt diff --git a/docs/advanced/runtime-builder.md b/docs/advanced/runtime-builder.md index cec045ac..32c17923 100644 --- a/docs/advanced/runtime-builder.md +++ b/docs/advanced/runtime-builder.md @@ -16,7 +16,7 @@ val spec = OpenApiSchemaBuilder() .info { it.title("My API").version("1.0") } spec.path("/users").operation("get") { - tags("users") + setTags("users") summary("List users") responses { response("200") { @@ -64,7 +64,7 @@ The schema DSL is available on media types, parameters, headers, and object sche ```kotlin spec.path("/users/{id}").operation("get") { - tags("users") + setTags("users") summary("Get user by ID") description("Returns a single user") operationId("getUserById") @@ -277,4 +277,4 @@ The builder supports incremental construction. Reopening a path, operation, resp - **Parameters**: same name + location replaces; different name or location appends - **Responses**: new status codes are added; same status code merges content and headers - **Content types**: new media types are added; same media type merges schema and example -- **Tags**: `tags(...)` replaces; `addTag(...)` / `addTags(...)` appends +- **Tags**: `setTags(...)` replaces; `addTag(...)` / `addTags(...)` appends diff --git a/docs/introduction/migration-from-6x.md b/docs/introduction/migration-from-6x.md index a069651d..96dd716a 100644 --- a/docs/introduction/migration-from-6x.md +++ b/docs/introduction/migration-from-6x.md @@ -20,7 +20,7 @@ The new `openapi-generator` module provides a Kotlin DSL for building or extendi ```kotlin spec.path("/users/{id}").operation("get") { - tags("users") + setTags("users") summary("Get user by ID") parameters { parameter("id", "path", required = true) { type("string") } diff --git a/examples/javalin-gradle-kotlin/src/main/compile/openapi.groovy b/examples/javalin-gradle-kotlin/src/main/compile/openapi.groovy index 548cfcae..9883c70a 100644 --- a/examples/javalin-gradle-kotlin/src/main/compile/openapi.groovy +++ b/examples/javalin-gradle-kotlin/src/main/compile/openapi.groovy @@ -7,7 +7,6 @@ class OpenApiConfiguration implements OpenApiAnnotationProcessorConfigurer { @Override void configure(OpenApiAnnotationProcessorConfiguration openApiAnnotationProcessorConfiguration) { - // openApiAnnotationProcessorConfiguration.debug = true } } \ No newline at end of file diff --git a/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/AnnotationSet.kt b/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/AnnotationSet.kt new file mode 100644 index 00000000..b40f6050 --- /dev/null +++ b/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/AnnotationSet.kt @@ -0,0 +1,62 @@ +package io.javalin.introspection + +/** The annotations on a single element. Name/value-based: KSP can't honor typed annotation instances. */ +interface AnnotationSet { + + fun all(): List + + fun find(type: Class): AnnotationView? + + fun findAll(type: Class): List + + fun contains(simpleName: String): Boolean + + fun contains(type: Class): Boolean = + find(type) != null + + companion object { + val EMPTY: AnnotationSet = object : AnnotationSet { + override fun all(): List = emptyList() + override fun find(type: Class): AnnotationView? = null + override fun findAll(type: Class): List = emptyList() + override fun contains(simpleName: String): Boolean = false + } + } +} + +interface AnnotationView { + + val simpleName: String + + val meta: AnnotationSet + + val values: Map + + fun value(member: String): Any? = + values[member] + + fun string(member: String): String? = + value(member) as? String + + fun boolean(member: String): Boolean? = + value(member) as? Boolean + + fun classValue(member: String): ClassDefinition? = + value(member) as? ClassDefinition + + fun classValues(member: String): List = + (value(member) as? List<*>)?.filterIsInstance().orEmpty() + + companion object { + fun of(simpleName: String, values: Map): AnnotationView = + ValuesAnnotationView(simpleName, values) + } +} + +private class ValuesAnnotationView( + override val simpleName: String, + override val values: Map, +) : AnnotationView { + override val meta: AnnotationSet + get() = AnnotationSet.EMPTY +} diff --git a/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/Annotations.kt b/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/Annotations.kt deleted file mode 100644 index 1069cc13..00000000 --- a/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/Annotations.kt +++ /dev/null @@ -1,30 +0,0 @@ -package io.javalin.introspection - -// Name/value-based on purpose: typed annotation instances and member lambdas can't be honored on KSP. -interface Annotations { - - fun hasNamed(simpleName: String): Boolean - - // Members (with defaults), or null if absent. Classes → ClassDefinition, enums → constant name, nested → map, arrays → list. - fun memberValues(annotationType: Class): Map? - - fun memberValuesList(annotationType: Class): List> - - fun all(): List - - fun has(annotationType: Class): Boolean = - memberValues(annotationType) != null - - fun resolveClass(annotationType: Class, member: String): ClassDefinition? = - memberValues(annotationType)?.get(member) as? ClassDefinition - - fun resolveClasses(annotationType: Class, member: String): List = - (memberValues(annotationType)?.get(member) as? List<*>)?.filterIsInstance() ?: emptyList() -} - -interface AnnotationView { - val qualifiedName: String - val simpleName: String - val meta: Annotations - fun values(): Map -} diff --git a/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/ClassDefinition.kt b/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/ClassDefinition.kt index f858ba9a..1c8969ad 100644 --- a/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/ClassDefinition.kt +++ b/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/ClassDefinition.kt @@ -16,7 +16,7 @@ abstract class ClassDefinition( abstract fun getProperties(): List - abstract fun getAnnotations(): Annotations + abstract fun getAnnotations(): AnnotationSet override fun toString(): String = if (generics.isEmpty()) fullName else "$fullName<${generics.joinToString(", ")}>" @@ -24,4 +24,4 @@ abstract class ClassDefinition( enum class StructureType { DEFAULT, ARRAY, DICTIONARY } -class EnumConstantView(val name: String, val annotations: Annotations) +class EnumConstantView(val name: String, val annotations: AnnotationSet) diff --git a/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/PropertyView.kt b/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/PropertyView.kt index 9b979541..5471aa36 100644 --- a/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/PropertyView.kt +++ b/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/PropertyView.kt @@ -5,15 +5,15 @@ class PropertyView( val type: ClassDefinition, val accessor: Accessor, val nullable: Boolean, - val visibility: Visibility, + val visibility: MemberVisibility, val transient: Boolean, @property:InternalIntrospectionApi val source: Any, - val annotations: Annotations, + val annotations: AnnotationSet, ) enum class Accessor { FIELD, GETTER, RECORD_COMPONENT } -enum class Visibility { PUBLIC, PROTECTED, PACKAGE_PRIVATE, PRIVATE } +enum class MemberVisibility { PUBLIC, PROTECTED, PACKAGE_PRIVATE, PRIVATE } fun isGetterName(name: String): Boolean = (name.startsWith("get") && name.length > 3 && name[3].isUpperCase()) || diff --git a/introspection/introspection-jap/src/main/kotlin/io/javalin/introspection/jap/JapTypeIntrospector.kt b/introspection/introspection-jap/src/main/kotlin/io/javalin/introspection/jap/JapTypeIntrospector.kt index df0a6460..adb241ee 100644 --- a/introspection/introspection-jap/src/main/kotlin/io/javalin/introspection/jap/JapTypeIntrospector.kt +++ b/introspection/introspection-jap/src/main/kotlin/io/javalin/introspection/jap/JapTypeIntrospector.kt @@ -2,7 +2,7 @@ package io.javalin.introspection.jap import io.javalin.introspection.Accessor import io.javalin.introspection.AnnotationView -import io.javalin.introspection.Annotations +import io.javalin.introspection.AnnotationSet import io.javalin.introspection.ClassDefinition import io.javalin.introspection.CompileTimeIntrospector import io.javalin.introspection.EnumConstantView @@ -12,7 +12,7 @@ import io.javalin.introspection.StructureType import io.javalin.introspection.StructureType.ARRAY import io.javalin.introspection.StructureType.DEFAULT import io.javalin.introspection.StructureType.DICTIONARY -import io.javalin.introspection.Visibility +import io.javalin.introspection.MemberVisibility import io.javalin.introspection.isGetterName import io.javalin.introspection.propertyName import java.lang.annotation.Repeatable as JavaRepeatable @@ -47,7 +47,7 @@ class JapTypeIntrospector( return resolve(source) } - fun annotationsOf(element: Element): Annotations = + fun annotationsOf(element: Element): AnnotationSet = AnnotationsView(listOf(element)) @OptIn(InternalIntrospectionApi::class) @@ -144,7 +144,7 @@ class JapTypeIntrospector( ?.filter { it.kind == ElementKind.ENUM_CONSTANT } ?.map { EnumConstantView(it.simpleName.toString(), AnnotationsView(listOf(it))) } - override fun getAnnotations(): Annotations = + override fun getAnnotations(): AnnotationSet = AnnotationsView(listOfNotNull(typeElement())) override fun getProperties(): List { @@ -157,7 +157,7 @@ class JapTypeIntrospector( type = resolve(component.asType()), accessor = Accessor.RECORD_COMPONENT, nullable = component.asType().nullable(), - visibility = Visibility.PUBLIC, + visibility = MemberVisibility.PUBLIC, transient = false, source = component, annotations = AnnotationsView(listOf(component)), @@ -213,33 +213,33 @@ class JapTypeIntrospector( private fun Element.isInstanceField(): Boolean = kind == ElementKind.FIELD && this is VariableElement && Modifier.STATIC !in modifiers - private fun Element.visibility(): Visibility = + private fun Element.visibility(): MemberVisibility = when { - Modifier.PUBLIC in modifiers -> Visibility.PUBLIC - Modifier.PROTECTED in modifiers -> Visibility.PROTECTED - Modifier.PRIVATE in modifiers -> Visibility.PRIVATE - else -> Visibility.PACKAGE_PRIVATE + Modifier.PUBLIC in modifiers -> MemberVisibility.PUBLIC + Modifier.PROTECTED in modifiers -> MemberVisibility.PROTECTED + Modifier.PRIVATE in modifiers -> MemberVisibility.PRIVATE + else -> MemberVisibility.PACKAGE_PRIVATE } } - private inner class AnnotationsView(private val sources: List) : Annotations { + private inner class AnnotationsView(private val sources: List) : AnnotationSet { - override fun hasNamed(simpleName: String): Boolean = + override fun contains(simpleName: String): Boolean = sources.any { source -> source.annotationMirrors.any { it.annotationType.asElement().simpleName.contentEquals(simpleName) } } - override fun memberValues(annotationType: Class): Map? = - sources.firstNotNullOfOrNull { source -> source.annotationMirrors.firstOrNull { it.named(annotationType.name) } } - ?.let { mirrorValues(it) } + override fun find(type: Class): AnnotationView? = + sources.firstNotNullOfOrNull { source -> source.annotationMirrors.firstOrNull { it.named(type.name) } } + ?.let { JapAnnotationView(it) } - override fun memberValuesList(annotationType: Class): List> { + override fun findAll(type: Class): List { val mirrors = sources.flatMap { it.annotationMirrors } - val direct = mirrors.filter { it.named(annotationType.name) }.map { mirrorValues(it) } + val direct = mirrors.filter { it.named(type.name) }.map { JapAnnotationView(it) } // javac wraps repeated annotations in their @Repeatable container; unwrap its `value` array - val containerName = annotationType.getAnnotation(JavaRepeatable::class.java)?.value?.java?.canonicalName - val repeated = containerName - ?.let { name -> mirrors.firstOrNull { it.named(name) } } - ?.let { mirrorValues(it)["value"] as? List<*> } + val containerName = type.getAnnotation(JavaRepeatable::class.java)?.value?.java?.canonicalName + val container = containerName?.let { name -> mirrors.firstOrNull { it.named(name) } } + val repeated = (container?.let { mirrorValues(it)["value"] } as? List<*>) ?.filterIsInstance>() + ?.map { AnnotationView.of(type.simpleName, it) } .orEmpty() return direct + repeated } @@ -252,13 +252,11 @@ class JapTypeIntrospector( } private inner class JapAnnotationView(private val mirror: AnnotationMirror) : AnnotationView { - override val qualifiedName: String - get() = (mirror.annotationType.asElement() as? TypeElement)?.qualifiedName?.toString() ?: simpleName override val simpleName: String get() = mirror.annotationType.asElement().simpleName.toString() - override val meta: Annotations + override val meta: AnnotationSet get() = AnnotationsView(listOf(mirror.annotationType.asElement())) - override fun values(): Map = - mirrorValues(mirror) + override val values: Map + get() = mirrorValues(mirror) } } diff --git a/introspection/introspection-ksp/src/main/kotlin/io/javalin/introspection/ksp/KspTypeIntrospector.kt b/introspection/introspection-ksp/src/main/kotlin/io/javalin/introspection/ksp/KspTypeIntrospector.kt index 535971fa..36df841c 100644 --- a/introspection/introspection-ksp/src/main/kotlin/io/javalin/introspection/ksp/KspTypeIntrospector.kt +++ b/introspection/introspection-ksp/src/main/kotlin/io/javalin/introspection/ksp/KspTypeIntrospector.kt @@ -13,7 +13,7 @@ import com.google.devtools.ksp.symbol.KSTypeParameter import com.google.devtools.ksp.symbol.Modifier import io.javalin.introspection.Accessor import io.javalin.introspection.AnnotationView -import io.javalin.introspection.Annotations +import io.javalin.introspection.AnnotationSet import io.javalin.introspection.ClassDefinition import io.javalin.introspection.CompileTimeIntrospector import io.javalin.introspection.EnumConstantView @@ -23,7 +23,7 @@ import io.javalin.introspection.StructureType import io.javalin.introspection.StructureType.ARRAY import io.javalin.introspection.StructureType.DEFAULT import io.javalin.introspection.StructureType.DICTIONARY -import io.javalin.introspection.Visibility +import io.javalin.introspection.MemberVisibility import java.lang.annotation.Repeatable as JavaRepeatable import com.google.devtools.ksp.symbol.Visibility as KspVisibility @@ -45,7 +45,7 @@ class KspTypeIntrospector(private val resolver: Resolver) : CompileTimeIntrospec return resolve(declaration.asStarProjectedType()) } - fun annotationsOf(annotated: KSAnnotated): Annotations = + fun annotationsOf(annotated: KSAnnotated): AnnotationSet = KspAnnotations(annotated) @OptIn(InternalIntrospectionApi::class) @@ -131,7 +131,7 @@ class KspTypeIntrospector(private val resolver: Resolver) : CompileTimeIntrospec ?.map { EnumConstantView(it.simpleName.asString(), KspAnnotations(it)) } ?.toList() - override fun getAnnotations(): Annotations = + override fun getAnnotations(): AnnotationSet = KspAnnotations(declaration) override fun getProperties(): List { @@ -152,26 +152,27 @@ class KspTypeIntrospector(private val resolver: Resolver) : CompileTimeIntrospec } } - private inner class KspAnnotations(private val element: KSAnnotated?) : Annotations { + private inner class KspAnnotations(private val element: KSAnnotated?) : AnnotationSet { - override fun hasNamed(simpleName: String): Boolean = + override fun contains(simpleName: String): Boolean = element?.annotations?.any { it.shortName.asString() == simpleName } == true - override fun memberValues(annotationType: Class): Map? = + override fun find(type: Class): AnnotationView? = element?.annotations - ?.firstOrNull { it.annotationType.resolve().declaration.qualifiedName?.asString() == annotationType.name } - ?.let { argumentValues(it) } + ?.firstOrNull { it.annotationType.resolve().declaration.qualifiedName?.asString() == type.name } + ?.let { KspAnnotationView(it) } - override fun memberValuesList(annotationType: Class): List> { + override fun findAll(type: Class): List { val annotations = element?.annotations?.toList() ?: return emptyList() val direct = annotations - .filter { it.annotationType.resolve().declaration.qualifiedName?.asString() == annotationType.name } - .map { argumentValues(it) } - val containerName = annotationType.getAnnotation(JavaRepeatable::class.java)?.value?.java?.canonicalName + .filter { it.annotationType.resolve().declaration.qualifiedName?.asString() == type.name } + .map { KspAnnotationView(it) } + val containerName = type.getAnnotation(JavaRepeatable::class.java)?.value?.java?.canonicalName val repeated = containerName ?.let { name -> annotations.firstOrNull { it.annotationType.resolve().declaration.qualifiedName?.asString() == name } } ?.let { argumentValues(it)["value"] as? List<*> } ?.filterIsInstance>() + ?.map { AnnotationView.of(type.simpleName, it) } .orEmpty() return direct + repeated } @@ -181,14 +182,12 @@ class KspTypeIntrospector(private val resolver: Resolver) : CompileTimeIntrospec } private inner class KspAnnotationView(private val annotation: KSAnnotation) : AnnotationView { - override val qualifiedName: String - get() = annotation.annotationType.resolve().declaration.qualifiedName?.asString() ?: simpleName override val simpleName: String get() = annotation.shortName.asString() - override val meta: Annotations + override val meta: AnnotationSet get() = KspAnnotations(annotation.annotationType.resolve().declaration) - override fun values(): Map = - argumentValues(annotation) + override val values: Map + get() = argumentValues(annotation) } private fun argumentValues(annotation: KSAnnotation): Map = @@ -203,11 +202,11 @@ class KspTypeIntrospector(private val resolver: Resolver) : CompileTimeIntrospec else -> value } - private fun visibilityOf(visibility: KspVisibility): Visibility = + private fun visibilityOf(visibility: KspVisibility): MemberVisibility = when (visibility) { - KspVisibility.PUBLIC, KspVisibility.INTERNAL -> Visibility.PUBLIC - KspVisibility.PROTECTED -> Visibility.PROTECTED - KspVisibility.PRIVATE, KspVisibility.LOCAL -> Visibility.PRIVATE - else -> Visibility.PACKAGE_PRIVATE + KspVisibility.PUBLIC, KspVisibility.INTERNAL -> MemberVisibility.PUBLIC + KspVisibility.PROTECTED -> MemberVisibility.PROTECTED + KspVisibility.PRIVATE, KspVisibility.LOCAL -> MemberVisibility.PRIVATE + else -> MemberVisibility.PACKAGE_PRIVATE } } diff --git a/introspection/introspection-ksp/src/test/kotlin/io/javalin/introspection/ksp/KspTypeIntrospectorTest.kt b/introspection/introspection-ksp/src/test/kotlin/io/javalin/introspection/ksp/KspTypeIntrospectorTest.kt index e415a269..6e4b25ad 100644 --- a/introspection/introspection-ksp/src/test/kotlin/io/javalin/introspection/ksp/KspTypeIntrospectorTest.kt +++ b/introspection/introspection-ksp/src/test/kotlin/io/javalin/introspection/ksp/KspTypeIntrospectorTest.kt @@ -60,9 +60,9 @@ class KspTypeIntrospectorTest { properties = account.getProperties().associate { it.name to "${it.type.fullName}:${it.type.structureType}" }, isEnum = color.isEnum(), enumConstants = color.getEnumConstants()?.map { it.name }, - hasRef = account.getAnnotations().hasNamed("Ref"), - refValue = (account.getAnnotations().memberValues(Ref::class.java)?.getValue("value") as? ClassDefinition)?.fullName, - scannedLabel = account.getAnnotations().all().first { it.meta.hasNamed("MetaMarker") }.values()["label"], + hasRef = account.getAnnotations().contains("Ref"), + refValue = (account.getAnnotations().find(Ref::class.java)?.classValue("value"))?.fullName, + scannedLabel = account.getAnnotations().all().first { it.meta.contains("MetaMarker") }.values["label"], ) } diff --git a/introspection/introspection-runtime/src/main/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospector.kt b/introspection/introspection-runtime/src/main/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospector.kt index c33f1bc8..935b7278 100644 --- a/introspection/introspection-runtime/src/main/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospector.kt +++ b/introspection/introspection-runtime/src/main/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospector.kt @@ -2,7 +2,7 @@ package io.javalin.introspection.runtime import io.javalin.introspection.Accessor import io.javalin.introspection.AnnotationView -import io.javalin.introspection.Annotations +import io.javalin.introspection.AnnotationSet import io.javalin.introspection.ClassDefinition import io.javalin.introspection.EnumConstantView import io.javalin.introspection.InternalIntrospectionApi @@ -12,7 +12,7 @@ import io.javalin.introspection.StructureType.ARRAY import io.javalin.introspection.StructureType.DEFAULT import io.javalin.introspection.StructureType.DICTIONARY import io.javalin.introspection.TypeIntrospector -import io.javalin.introspection.Visibility +import io.javalin.introspection.MemberVisibility import io.javalin.introspection.isGetterName import io.javalin.introspection.propertyName import java.lang.reflect.AnnotatedElement @@ -120,7 +120,7 @@ private class ReflectionClassDefinition( ) } - override fun getAnnotations(): Annotations = + override fun getAnnotations(): AnnotationSet = ReflectionAnnotations(listOf(erasure)) } @@ -128,7 +128,7 @@ private fun collectMembers(clazz: Class<*>): List { if (clazz.isRecord) { return clazz.recordComponents.map { component -> val backingField = runCatching { clazz.getDeclaredField(component.name) }.getOrNull() - Member(component.name, component.genericType, Accessor.RECORD_COMPONENT, Visibility.PUBLIC, false, component.accessor, listOfNotNull(component.accessor, backingField, component)) + Member(component.name, component.genericType, Accessor.RECORD_COMPONENT, MemberVisibility.PUBLIC, false, component.accessor, listOfNotNull(component.accessor, backingField, component)) } } @@ -163,18 +163,18 @@ private class Member( val name: String, val genericType: Type, val accessor: Accessor, - val visibility: Visibility, + val visibility: MemberVisibility, val transient: Boolean, val source: AnnotatedElement, val sources: List, ) -private fun visibilityOf(modifiers: Int): Visibility = +private fun visibilityOf(modifiers: Int): MemberVisibility = when { - Modifier.isPublic(modifiers) -> Visibility.PUBLIC - Modifier.isProtected(modifiers) -> Visibility.PROTECTED - Modifier.isPrivate(modifiers) -> Visibility.PRIVATE - else -> Visibility.PACKAGE_PRIVATE + Modifier.isPublic(modifiers) -> MemberVisibility.PUBLIC + Modifier.isProtected(modifiers) -> MemberVisibility.PROTECTED + Modifier.isPrivate(modifiers) -> MemberVisibility.PRIVATE + else -> MemberVisibility.PACKAGE_PRIVATE } private fun declaredFieldsHierarchy(clazz: Class<*>): List { @@ -218,26 +218,28 @@ private fun nonPublicGettersHierarchy(clazz: Class<*>): List { return getters } -private class ReflectionAnnotations(private val sources: List) : Annotations { +private class ReflectionAnnotations(private val sources: List) : AnnotationSet { - override fun hasNamed(simpleName: String): Boolean = + override fun contains(simpleName: String): Boolean = sources.any { source -> source.annotations.any { it.annotationClass.simpleName == simpleName } } - override fun memberValues(annotationType: Class): Map? = - sources.firstNotNullOfOrNull { it.getAnnotation(annotationType) }?.let { annotationToMap(it) } + override fun find(type: Class): AnnotationView? = + sources.firstNotNullOfOrNull { it.getAnnotation(type) }?.let { ReflectionAnnotationView(it) } - override fun memberValuesList(annotationType: Class): List> = - sources.flatMap { it.getAnnotationsByType(annotationType).toList() }.map { annotationToMap(it) } + override fun findAll(type: Class): List = + sources.flatMap { it.getAnnotationsByType(type).toList() }.map { ReflectionAnnotationView(it) } override fun all(): List = sources.flatMap { it.annotations.toList() }.distinctBy { it.annotationClass }.map { ReflectionAnnotationView(it) } } private class ReflectionAnnotationView(private val annotation: Annotation) : AnnotationView { - override val qualifiedName: String get() = annotation.annotationClass.java.name - override val simpleName: String get() = annotation.annotationClass.java.simpleName - override val meta: Annotations get() = ReflectionAnnotations(listOf(annotation.annotationClass.java)) - override fun values(): Map = annotationToMap(annotation) + override val simpleName: String + get() = annotation.annotationClass.java.simpleName + override val meta: AnnotationSet + get() = ReflectionAnnotations(listOf(annotation.annotationClass.java)) + override val values: Map + get() = annotationToMap(annotation) } private fun annotationToMap(annotation: Annotation): Map = diff --git a/introspection/introspection-runtime/src/test/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospectorTest.kt b/introspection/introspection-runtime/src/test/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospectorTest.kt index c177cf2f..55b1637e 100644 --- a/introspection/introspection-runtime/src/test/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospectorTest.kt +++ b/introspection/introspection-runtime/src/test/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospectorTest.kt @@ -51,9 +51,9 @@ class ReflectionTypeIntrospectorTest { @Test fun `exposes annotations without applying policy`() { val id = props(Account::class.java).getValue("id") - assertThat(id.annotations.has(Nn::class.java)).isTrue() - assertThat(id.annotations.hasNamed("Nn")).isTrue() - assertThat(props(Account::class.java).getValue("name").annotations.has(Nn::class.java)).isFalse() + assertThat(id.annotations.contains(Nn::class.java)).isTrue() + assertThat(id.annotations.contains("Nn")).isTrue() + assertThat(props(Account::class.java).getValue("name").annotations.contains(Nn::class.java)).isFalse() } @Test @@ -80,8 +80,8 @@ class ReflectionTypeIntrospectorTest { fun `resolves Class-valued annotation members into ClassDefinitions`() { val annotations = introspector.introspect(Holder::class.java).getAnnotations() - assertThat(annotations.resolveClass(Ref::class.java, "value")?.fullName).isEqualTo(Address::class.java.name) - assertThat(annotations.resolveClasses(Refs::class.java, "value").map { it.fullName }) + assertThat(annotations.find(Ref::class.java)?.classValue("value")?.fullName).isEqualTo(Address::class.java.name) + assertThat(annotations.find(Refs::class.java)?.classValues("value").orEmpty().map { it.fullName }) .containsExactly(Address::class.java.name, Color::class.java.name) } @@ -89,12 +89,12 @@ class ReflectionTypeIntrospectorTest { fun `reads all annotation members into a neutral value map`() { val annotations = introspector.introspect(Holder::class.java).getAnnotations() - val values = annotations.memberValues(Mixed::class.java)!! + val values = annotations.find(Mixed::class.java)?.values!! assertThat(values["name"]).isEqualTo("x") assertThat(values["count"]).isEqualTo(3) assertThat((values["type"] as io.javalin.introspection.ClassDefinition).fullName).isEqualTo(Address::class.java.name) assertThat(values["shade"]).isEqualTo("RED") - assertThat(annotations.memberValues(java.lang.Deprecated::class.java)).isNull() + assertThat(annotations.find(java.lang.Deprecated::class.java)?.values).isNull() } } @@ -105,7 +105,8 @@ private enum class Color { RED, GREEN } private annotation class Nn private class Account { - @Nn fun getId(): String = "" + @Nn + fun getId(): String = "" fun getAge(): Int = 0 fun getName(): String = "" fun getColor(): Color = Color.RED diff --git a/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/AnnotationProcessing.kt b/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/AnnotationProcessing.kt index 3e6a6969..dfdc00db 100644 --- a/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/AnnotationProcessing.kt +++ b/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/AnnotationProcessing.kt @@ -15,7 +15,7 @@ import kotlin.reflect.KClass /** * Runs the annotation-processing backend inside a real in-process `javac` run, so it can be tested like reflection. * - * The backend reads types via `javax.lang.model`, which the compiler only exposes to a running processor — there is + * The backend reads types via `javax.lang.model`, which the compiler only exposes to a running processor - there is * no such environment at plain runtime. So [introspect] compiles a throwaway source with a processor attached, * resolves [type] from inside it, and passes the result to [block]. The compiler discards its model once the run * ends, so [block] must finish all introspection (e.g. [ClassDefinition.getProperties]) before returning. diff --git a/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/IntrospectionParityTest.kt b/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/IntrospectionParityTest.kt index f9a660a3..71179f35 100644 --- a/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/IntrospectionParityTest.kt +++ b/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/IntrospectionParityTest.kt @@ -1,10 +1,10 @@ package io.javalin.introspection.test import io.javalin.introspection.Accessor -import io.javalin.introspection.Annotations +import io.javalin.introspection.AnnotationSet import io.javalin.introspection.ClassDefinition import io.javalin.introspection.StructureType -import io.javalin.introspection.Visibility +import io.javalin.introspection.MemberVisibility import io.javalin.introspection.runtime.ReflectionTypeIntrospector import io.javalin.introspection.test.sub.PackagePrivateBase import org.assertj.core.api.Assertions.assertThat @@ -63,10 +63,10 @@ class IntrospectionParityTest { fun `property-level annotations resolve identically across backends`() { val runtimeMarked = runtime.introspect(Annotated::class.java).getProperties() .filter { it.accessor == Accessor.GETTER } - .associate { it.name to it.annotations.hasNamed("Marker") } + .associate { it.name to it.annotations.contains("Marker") } val processedMarked = AnnotationProcessing.introspect(Annotated::class) { it.getProperties().filter { p -> p.accessor == Accessor.GETTER } - .associate { p -> p.name to p.annotations.hasNamed("Marker") } + .associate { p -> p.name to p.annotations.contains("Marker") } } assertThat(processedMarked).isEqualTo(runtimeMarked) assertThat(runtimeMarked.getValue("tagged")).isTrue() @@ -75,9 +75,9 @@ class IntrospectionParityTest { @Test fun `annotation enumeration with meta-annotations matches across backends`() { - fun scan(annotations: Annotations): Pair { - val tagged = annotations.all().first { it.meta.hasNamed("MetaMarker") } - return tagged.simpleName to tagged.values()["label"] + fun scan(annotations: AnnotationSet): Pair { + val tagged = annotations.all().first { it.meta.contains("MetaMarker") } + return tagged.simpleName to tagged.values["label"] } val runtimeScan = scan(runtime.introspect(Scanned::class.java).getAnnotations()) val processedScan = AnnotationProcessing.introspect(Scanned::class) { scan(it.getAnnotations()) } @@ -86,7 +86,7 @@ class IntrospectionParityTest { @Test fun `repeatable annotations enumerate identically across backends`() { - fun notes(annotations: Annotations) = annotations.memberValuesList(Note::class.java).map { it["value"] } + fun notes(annotations: AnnotationSet) = annotations.findAll(Note::class.java).map { it.value("value") } val runtimeNotes = notes(runtime.introspect(Noted::class.java).getAnnotations()) val processedNotes = AnnotationProcessing.introspect(Noted::class) { notes(it.getAnnotations()) } assertThat(processedNotes).isEqualTo(runtimeNotes) @@ -95,20 +95,20 @@ class IntrospectionParityTest { @Test fun `nested annotation members normalize to maps identically across backends`() { - val runtimeMeta = runtime.introspect(Wrapped::class.java).getAnnotations().memberValues(Outer::class.java)!!.getValue("meta") - val processedMeta = AnnotationProcessing.introspect(Wrapped::class) { it.getAnnotations().memberValues(Outer::class.java)!!.getValue("meta") } + val runtimeMeta = runtime.introspect(Wrapped::class.java).getAnnotations().find(Outer::class.java)!!.value("meta") + val processedMeta = AnnotationProcessing.introspect(Wrapped::class) { it.getAnnotations().find(Outer::class.java)!!.value("meta") } assertThat(processedMeta).isEqualTo(runtimeMeta).isEqualTo(mapOf("note" to "x")) } @Test fun `Class-valued annotation members resolve identically across backends`() { val runtimeAnnotations = runtime.introspect(Holder::class.java).getAnnotations() - val runtimeRef = runtimeAnnotations.resolveClass(Ref::class.java, "value")?.fullName - val runtimeRefs = runtimeAnnotations.resolveClasses(Refs::class.java, "value").map { it.fullName } + val runtimeRef = runtimeAnnotations.find(Ref::class.java)?.classValue("value")?.fullName + val runtimeRefs = runtimeAnnotations.find(Refs::class.java)?.classValues("value").orEmpty().map { it.fullName } val (processedRef, processedRefs) = AnnotationProcessing.introspect(Holder::class) { val annotations = it.getAnnotations() - annotations.resolveClass(Ref::class.java, "value")?.fullName to annotations.resolveClasses(Refs::class.java, "value").map { it.fullName } + annotations.find(Ref::class.java)?.classValue("value")?.fullName to annotations.find(Refs::class.java)?.classValues("value").orEmpty().map { it.fullName } } assertThat(processedRef).isEqualTo(runtimeRef).isEqualTo(Address::class.java.name) @@ -117,8 +117,8 @@ class IntrospectionParityTest { @Test fun `annotation value maps match across backends`() { - val runtimeValue = runtime.introspect(Holder::class.java).getAnnotations().memberValues(Ref::class.java)!!.getValue("value") - val processedValue = AnnotationProcessing.introspect(Holder::class) { it.getAnnotations().memberValues(Ref::class.java)!!.getValue("value") } + val runtimeValue = runtime.introspect(Holder::class.java).getAnnotations().find(Ref::class.java)!!.value("value") + val processedValue = AnnotationProcessing.introspect(Holder::class) { it.getAnnotations().find(Ref::class.java)!!.value("value") } assertThat((processedValue as ClassDefinition).fullName) .isEqualTo((runtimeValue as ClassDefinition).fullName) .isEqualTo(Address::class.java.name) @@ -126,8 +126,8 @@ class IntrospectionParityTest { @Test fun `primitive array annotation members normalize identically across backends`() { - val runtimeInts = runtime.introspect(Flagged::class.java).getAnnotations().memberValues(Flags::class.java)!!.getValue("ints") - val processedInts = AnnotationProcessing.introspect(Flagged::class) { it.getAnnotations().memberValues(Flags::class.java)!!.getValue("ints") } + val runtimeInts = runtime.introspect(Flagged::class.java).getAnnotations().find(Flags::class.java)!!.value("ints") + val processedInts = AnnotationProcessing.introspect(Flagged::class) { it.getAnnotations().find(Flags::class.java)!!.value("ints") } assertThat(processedInts).isEqualTo(runtimeInts).isEqualTo(listOf(1, 2, 3)) } @@ -176,7 +176,7 @@ private data class PropertyShape( val typeGenerics: List, val accessor: Accessor, val nullable: Boolean, - val visibility: Visibility, + val visibility: MemberVisibility, val transient: Boolean, ) diff --git a/javalin-plugins/javalin-openapi-dynamic-hook/src/main/kotlin/io/javalin/openapi/dynamic/hook/RegisteredRoutesHook.kt b/javalin-plugins/javalin-openapi-dynamic-hook/src/main/kotlin/io/javalin/openapi/dynamic/hook/RegisteredRoutesHook.kt index a7d19842..b239d0bf 100644 --- a/javalin-plugins/javalin-openapi-dynamic-hook/src/main/kotlin/io/javalin/openapi/dynamic/hook/RegisteredRoutesHook.kt +++ b/javalin-plugins/javalin-openapi-dynamic-hook/src/main/kotlin/io/javalin/openapi/dynamic/hook/RegisteredRoutesHook.kt @@ -36,7 +36,7 @@ class RegisteredRoutesHook : OpenApiHook { context.builder.resolveComponentReferences { type -> schemaContext.componentSchema(type) } } - /** Javalin `` → OpenAPI `{slashParam}`; `{param}` kept as-is. */ + /** Javalin `` -> OpenAPI `{slashParam}`; `{param}` kept as-is. */ private fun toOpenApiPath(path: String): String = path.replace(ANGLE_PARAM) { "{${it.groupValues[1]}}" } diff --git a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/AnnotationProcessorContext.kt b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/AnnotationProcessorContext.kt index cee18f7d..fed96ec2 100644 --- a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/AnnotationProcessorContext.kt +++ b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/AnnotationProcessorContext.kt @@ -1,7 +1,7 @@ package io.javalin.openapi.experimental import com.sun.source.util.Trees -import io.javalin.introspection.Annotations +import io.javalin.introspection.AnnotationSet import io.javalin.introspection.EnumConstantView import io.javalin.introspection.InternalIntrospectionApi import io.javalin.introspection.PropertyView @@ -40,10 +40,10 @@ class AnnotationProcessorContext( override fun isEnum(type: OpenApiType): Boolean = type.source.kind == ElementKind.ENUM - override fun annotationsOf(type: OpenApiType): Annotations = + override fun annotationsOf(type: OpenApiType): AnnotationSet = japIntrospector.annotationsOf(type.source) - fun annotationsOf(element: Element): Annotations = + fun annotationsOf(element: Element): AnnotationSet = japIntrospector.annotationsOf(element) override fun propertiesOf(type: OpenApiType): List = @@ -77,7 +77,7 @@ class AnnotationProcessorContext( override fun discriminatorSubtypes(type: OpenApiType): List> { val subtypes = japIntrospector.typesAnnotatedWith(DiscriminatorMappingName::class.java, assignableTo = japIntrospector.introspect(type.mirror)) return subtypes.mapNotNull { subtype -> - val name = subtype.getAnnotations().memberValues(DiscriminatorMappingName::class.java)?.get("value") as? String + val name = subtype.getAnnotations().find(DiscriminatorMappingName::class.java)?.string("value") if (name == null) null else name to toOpenApiType(subtype) } } @@ -108,13 +108,14 @@ class AnnotationProcessorContext( else -> false } - fun getFullName(mirror: TypeMirror): String = - env.typeUtils.asElement(mirror) - ?.getAnnotation(OpenApiName::class.java) - ?.value - ?.let { mirror.toString().substringBeforeLast(".") + "." + it } - ?: env.typeUtils.asElement(mirror)?.toString()?.substringBefore("<") - ?: mirror.toString().substringBefore("<") + fun getFullName(mirror: TypeMirror): String { + val element = env.typeUtils.asElement(mirror) + val customName = element?.getAnnotation(OpenApiName::class.java)?.value + if (customName != null) { + return mirror.toString().substringBeforeLast(".") + "." + customName + } + return element?.toString()?.substringBefore("<") ?: mirror.toString().substringBefore("<") + } /* Extension methods, should be replaced by context receivers in the future */ diff --git a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/processor/generators/OpenApiGenerator.kt b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/processor/generators/OpenApiGenerator.kt index 68bdc8cd..738a3805 100644 --- a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/processor/generators/OpenApiGenerator.kt +++ b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/processor/generators/OpenApiGenerator.kt @@ -21,10 +21,10 @@ internal class OpenApiGenerator { fun generate(roundEnvironment: RoundEnvironment) { val aggregatedRoutes = roundEnvironment.getElementsAnnotatedWith(OpenApis::class.java) - .flatMap { context.annotationsOf(it).memberValuesList(OpenApi::class.java) } + .flatMap { element -> context.annotationsOf(element).findAll(OpenApi::class.java).map { it.values } } val standaloneRoutes = roundEnvironment.getElementsAnnotatedWith(OpenApi::class.java) - .flatMap { context.annotationsOf(it).memberValuesList(OpenApi::class.java) } + .flatMap { element -> context.annotationsOf(element).findAll(OpenApi::class.java).map { it.values } } schemaGenerator.generateVersionedSchemas(aggregatedRoutes + standaloneRoutes) .map { (version, generatedOpenApiSchema) -> diff --git a/openapi-annotation-processor/src/test/compile/openapi.groovy b/openapi-annotation-processor/src/test/compile/openapi.groovy index f43e76a3..38470ae9 100644 --- a/openapi-annotation-processor/src/test/compile/openapi.groovy +++ b/openapi-annotation-processor/src/test/compile/openapi.groovy @@ -16,7 +16,6 @@ class OpenApiConfiguration implements OpenApiAnnotationProcessorConfigurer { @Override void configure(OpenApiAnnotationProcessorConfiguration configuration) { configuration.validateWithParser = false - // configuration.debug = false // Used by TypeMappersTest configuration.simpleTypeMappings['io.javalin.openapi.processor.TypeMappersTest.CustomType'] = new SimpleType("string") diff --git a/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/Shape.java b/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/Shape.java index eec2d34c..2f54f16f 100644 --- a/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/Shape.java +++ b/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/Shape.java @@ -9,7 +9,7 @@ public Object getAnimal() { return null; } - /** Composition with no explicit refs — reflection cannot discover subtypes, so this must NOT emit `oneOf: []`. */ + /** Composition with no explicit refs - reflection cannot discover subtypes, so this must NOT emit `oneOf: []`. */ @OneOf({}) public Object getEmpty() { return null; diff --git a/openapi-dynamic/src/test/kotlin/io/javalin/openapi/dynamic/DynamicSchemaGeneratorTest.kt b/openapi-dynamic/src/test/kotlin/io/javalin/openapi/dynamic/DynamicSchemaGeneratorTest.kt index 04ced115..d8500176 100644 --- a/openapi-dynamic/src/test/kotlin/io/javalin/openapi/dynamic/DynamicSchemaGeneratorTest.kt +++ b/openapi-dynamic/src/test/kotlin/io/javalin/openapi/dynamic/DynamicSchemaGeneratorTest.kt @@ -57,16 +57,16 @@ class DynamicSchemaGeneratorTest { assertThat(properties.has("secret")).isFalse() assertThat(properties.path("label").path("description").asText()).isEqualTo("Human readable label") - // Nested object → $ref + // Nested object -> $ref assertThat(properties.path("address").ref()).isEqualTo("#/components/schemas/Address") - // Enum → $ref (resolved to a string enum component) + // Enum -> $ref (resolved to a string enum component) assertThat(properties.path("role").ref()).isEqualTo("#/components/schemas/Role") - // Collection → array of items + // Collection -> array of items assertThat(properties.path("tags").path("type").asText()).isEqualTo("array") assertThat(properties.path("tags").path("items").path("type").asText()).isEqualTo("string") - // Map → object with additionalProperties + // Map -> object with additionalProperties assertThat(properties.path("meta").path("type").asText()).isEqualTo("object") assertThat(properties.path("meta").path("additionalProperties").path("type").asText()).isEqualTo("integer") diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/IntrospectorSchemaContext.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/IntrospectorSchemaContext.kt index 6296a132..3d02b4cc 100644 --- a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/IntrospectorSchemaContext.kt +++ b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/IntrospectorSchemaContext.kt @@ -1,6 +1,6 @@ package io.javalin.openapi.experimental -import io.javalin.introspection.Annotations +import io.javalin.introspection.AnnotationSet import io.javalin.introspection.CompileTimeIntrospector import io.javalin.introspection.EnumConstantView import io.javalin.introspection.PropertyView @@ -32,7 +32,7 @@ abstract class IntrospectorSchemaContext( typeSchemaGenerator.createEmbeddedTypeDescription(introspect(nativeType)) override fun toOpenApiType(raw: RawType): OpenApiType { - val customName = raw.getAnnotations().memberValues(OpenApiName::class.java)?.get("value") as? String + val customName = raw.getAnnotations().find(OpenApiName::class.java)?.string("value") val packageName = raw.fullName.substringBeforeLast('.', "") return OpenApiType( simpleName = customName ?: raw.simpleName, @@ -50,7 +50,7 @@ abstract class IntrospectorSchemaContext( override fun isEnum(type: OpenApiType): Boolean = type.raw.isEnum() - override fun annotationsOf(type: OpenApiType): Annotations = + override fun annotationsOf(type: OpenApiType): AnnotationSet = type.raw.getAnnotations() override fun propertiesOf(type: OpenApiType): List = @@ -63,7 +63,7 @@ abstract class IntrospectorSchemaContext( val scanner = introspector as? CompileTimeIntrospector ?: return emptyList() val subtypes = scanner.typesAnnotatedWith(DiscriminatorMappingName::class.java, assignableTo = type.raw) return subtypes.mapNotNull { subtype -> - val name = subtype.getAnnotations().memberValues(DiscriminatorMappingName::class.java)?.get("value") as? String + val name = subtype.getAnnotations().find(DiscriminatorMappingName::class.java)?.string("value") if (name == null) null else name to toOpenApiType(subtype) } } diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/SchemaGenerationContext.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/SchemaGenerationContext.kt index 4f40c9e1..febc1cae 100644 --- a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/SchemaGenerationContext.kt +++ b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/SchemaGenerationContext.kt @@ -1,6 +1,6 @@ package io.javalin.openapi.experimental -import io.javalin.introspection.Annotations +import io.javalin.introspection.AnnotationSet import io.javalin.introspection.EnumConstantView import io.javalin.introspection.PropertyView import io.javalin.openapi.experimental.processor.generators.TypeSchemaGenerator @@ -14,7 +14,7 @@ interface SchemaGenerationContext { fun isEnum(type: OpenApiType): Boolean - fun annotationsOf(type: OpenApiType): Annotations + fun annotationsOf(type: OpenApiType): AnnotationSet fun propertiesOf(type: OpenApiType): List diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/CompositionGenerator.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/CompositionGenerator.kt index 0bf376be..73c31b0f 100644 --- a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/CompositionGenerator.kt +++ b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/CompositionGenerator.kt @@ -2,7 +2,7 @@ package io.javalin.openapi.experimental.processor.generators import com.fasterxml.jackson.databind.JsonNode import com.fasterxml.jackson.databind.node.ObjectNode -import io.javalin.introspection.Annotations +import io.javalin.introspection.AnnotationSet import io.javalin.openapi.AllOf import io.javalin.openapi.AnyOf import io.javalin.openapi.Composition @@ -20,15 +20,15 @@ import io.javalin.openapi.experimental.processor.shared.toJsonArray import io.javalin.openapi.experimental.processor.shared.toJsonObject import io.javalin.introspection.ClassDefinition as RawType -fun findCompositionInElement(context: SchemaGenerationContext, annotations: Annotations): PropertyComposition? = +fun findCompositionInElement(context: SchemaGenerationContext, annotations: AnnotationSet): PropertyComposition? = compositionOf(context, annotations, OneOf::class.java, ONE_OF) ?: compositionOf(context, annotations, AnyOf::class.java, ANY_OF) ?: compositionOf(context, annotations, AllOf::class.java, ALL_OF) -private fun compositionOf(context: SchemaGenerationContext, annotations: Annotations, annotationType: Class, composition: Composition): PropertyComposition? { - val values = annotations.memberValues(annotationType) ?: return null - val references = annotations.resolveClasses(annotationType, "value").map { context.toOpenApiType(it) }.toSet() - val discriminator = (values["discriminator"] as? Map<*, *>)?.let { discriminatorInfo(context, it) } +private fun compositionOf(context: SchemaGenerationContext, annotations: AnnotationSet, annotationType: Class, composition: Composition): PropertyComposition? { + val annotation = annotations.find(annotationType) ?: return null + val references = annotation.classValues("value").map { context.toOpenApiType(it) }.toSet() + val discriminator = (annotation.value("discriminator") as? Map<*, *>)?.let { discriminatorInfo(context, it) } return PropertyComposition(composition, references, discriminator) } diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/TypeSchemaGenerator.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/TypeSchemaGenerator.kt index 05ef79f9..9db6ec76 100644 --- a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/TypeSchemaGenerator.kt +++ b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/TypeSchemaGenerator.kt @@ -4,8 +4,8 @@ import com.fasterxml.jackson.databind.JsonNode import com.fasterxml.jackson.databind.node.ArrayNode import com.fasterxml.jackson.databind.node.ObjectNode import io.javalin.introspection.Accessor -import io.javalin.introspection.Annotations -import io.javalin.introspection.Visibility as RawVisibility +import io.javalin.introspection.AnnotationSet +import io.javalin.introspection.MemberVisibility import io.javalin.openapi.* import io.javalin.openapi.experimental.* import io.javalin.openapi.experimental.processor.shared.* @@ -23,7 +23,7 @@ class TypeSchemaGenerator(val context: SchemaGenerationContext) { ): ResultScheme { val annotations = context.annotationsOf(type) val isEnum = context.isEnum(type) - val definedBy = annotations.resolveClass(OpenApiPropertyType::class.java, "definedBy")?.let { context.toOpenApiType(it) } + val definedBy = annotations.find(OpenApiPropertyType::class.java)?.classValue("definedBy")?.let { context.toOpenApiType(it) } if (definedBy != null && !isEnum) { return createTypeSchema(definedBy, inlineRefs, requireNonNullsByDefault) @@ -47,8 +47,8 @@ class TypeSchemaGenerator(val context: SchemaGenerationContext) { context.enumConstantsOf(type) .map { constant -> - val customName = constant.annotations.memberValues(OpenApiName::class.java)?.get("value") as? String - val description = constant.annotations.memberValues(OpenApiDescription::class.java)?.get("value") as? String + val customName = constant.annotations.find(OpenApiName::class.java)?.string("value") + val description = constant.annotations.find(OpenApiDescription::class.java)?.string("value") val name = when { customName != null -> customName namingStrategy != null -> translatePropertyName(namingStrategy, constant.name) @@ -83,7 +83,7 @@ class TypeSchemaGenerator(val context: SchemaGenerationContext) { val propertiesObject = createObjectNode() schema.set("properties", propertiesObject) - val requireNonNulls = (annotations.memberValues(JsonSchema::class.java)?.get("requireNonNulls") as? Boolean) + val requireNonNulls = (annotations.find(JsonSchema::class.java)?.boolean("requireNonNulls")) ?: requireNonNullsByDefault val properties = context.findAllProperties(type, requireNonNulls) @@ -128,7 +128,7 @@ class TypeSchemaGenerator(val context: SchemaGenerationContext) { extra: Map = emptyMap(), nullable: Boolean = false, ): ResultScheme { - val definedBy = context.annotationsOf(type).resolveClass(OpenApiPropertyType::class.java, "definedBy")?.let { context.toOpenApiType(it) } + val definedBy = context.annotationsOf(type).find(OpenApiPropertyType::class.java)?.classValue("definedBy")?.let { context.toOpenApiType(it) } if (definedBy != null && !context.isEnum(type)) { return createEmbeddedTypeDescription(definedBy, inlineRefs, requiresNonNulls, composition, extra, nullable) @@ -228,7 +228,7 @@ class TypeSchemaGenerator(val context: SchemaGenerationContext) { internal fun SchemaGenerationContext.findAllProperties(type: OpenApiType, requireNonNulls: Boolean): Collection { val annotations = annotationsOf(type) - val byFields = annotations.memberValues(OpenApiByFields::class.java) + val byFields = annotations.find(OpenApiByFields::class.java)?.values val byFieldsOnly = byFields?.get("only") == true val byFieldsVisibility = (byFields?.get("value") as? String)?.let { Visibility.valueOf(it) } val namingStrategy = annotations.namingStrategy() @@ -244,31 +244,31 @@ internal fun SchemaGenerationContext.findAllProperties(type: OpenApiType, requir Accessor.RECORD_COMPONENT -> {} } if (byFieldsVisibility != null && byFieldsVisibility.priority > property.visibility.toOpenApi().priority) continue - if (property.annotations.has(OpenApiIgnore::class.java) || property.transient) continue + if (property.annotations.contains(OpenApiIgnore::class.java) || property.transient) continue - val customName = property.annotations.memberValues(OpenApiName::class.java)?.get("value") as? String + val customName = property.annotations.find(OpenApiName::class.java)?.string("value") val name = customName ?: property.name val finalName = if (customName == null && namingStrategy != null) translatePropertyName(namingStrategy, name) else name - val nullability = property.annotations.memberValues(OpenApiPropertyType::class.java)?.get("nullability") as? String - val redirect = property.annotations.resolveClass(OpenApiPropertyType::class.java, "definedBy") + val nullability = property.annotations.find(OpenApiPropertyType::class.java)?.string("nullability") + val redirect = property.annotations.find(OpenApiPropertyType::class.java)?.classValue("definedBy") val treatedAsNotNull = redirect == null && !property.nullable val isNotNull = when { nullability == Nullability.NOT_NULL.name -> true nullability == Nullability.NULLABLE.name -> false - property.annotations.hasNamed("NotNull") -> true + property.annotations.contains("NotNull") -> true treatedAsNotNull -> true - property.annotations.hasNamed("Nullable") -> false + property.annotations.contains("Nullable") -> false else -> false } - val required = property.annotations.has(OpenApiRequired::class.java) || (requireNonNulls && isNotNull) + val required = property.annotations.contains(OpenApiRequired::class.java) || (requireNonNulls && isNotNull) - val explicitNullable = property.annotations.memberValues(OpenApiNullable::class.java)?.get("nullable") as? Boolean + val explicitNullable = property.annotations.find(OpenApiNullable::class.java)?.boolean("nullable") val isExplicitlyNullable = when { explicitNullable != null -> explicitNullable nullability == Nullability.NULLABLE.name -> true - property.annotations.hasNamed("Nullable") -> true + property.annotations.contains("Nullable") -> true else -> false } @@ -299,15 +299,15 @@ internal fun SchemaGenerationContext.findAllProperties(type: OpenApiType, requir return properties } -private fun Annotations.namingStrategy(): OpenApiNamingStrategy? = - (memberValues(OpenApiNaming::class.java)?.get("value") as? String)?.let { OpenApiNamingStrategy.valueOf(it) } +private fun AnnotationSet.namingStrategy(): OpenApiNamingStrategy? = + (find(OpenApiNaming::class.java)?.string("value"))?.let { OpenApiNamingStrategy.valueOf(it) } -private fun Annotations.findExtra(): Map { +private fun AnnotationSet.findExtra(): Map { val extra = mutableMapOf( - "description" to memberValues(OpenApiDescription::class.java)?.get("value") + "description" to find(OpenApiDescription::class.java)?.value("value") ) - memberValues(OpenApiExample::class.java)?.also { example -> + find(OpenApiExample::class.java)?.values?.also { example -> val value = example.notNull("value") val raw = example.notNull("raw") val objects = (example["objects"] as? List<*>)?.filterIsInstance>().orEmpty() @@ -321,7 +321,7 @@ private fun Annotations.findExtra(): Map { } } - memberValues(OpenApiNumberValidation::class.java)?.also { + find(OpenApiNumberValidation::class.java)?.values?.also { extra["minimum"] = it.notNull("minimum")?.toBigDecimal() extra["maximum"] = it.notNull("maximum")?.toBigDecimal() extra["exclusiveMinimum"] = it.notNull("exclusiveMinimum")?.toBigDecimal() @@ -329,42 +329,42 @@ private fun Annotations.findExtra(): Map { extra["multipleOf"] = it.notNull("multipleOf")?.toBigDecimal() } - memberValues(OpenApiStringValidation::class.java)?.also { + find(OpenApiStringValidation::class.java)?.values?.also { extra["minLength"] = it.notNull("minLength")?.toInt() extra["maxLength"] = it.notNull("maxLength")?.toInt() extra["format"] = it.notNull("format") extra["pattern"] = it.notNull("pattern") } - memberValues(OpenApiArrayValidation::class.java)?.also { + find(OpenApiArrayValidation::class.java)?.values?.also { extra["minItems"] = it.notNull("minItems")?.toInt() extra["maxItems"] = it.notNull("maxItems")?.toInt() extra["uniqueItems"] = (it["uniqueItems"] as? Boolean)?.takeIf { unique -> unique } } - memberValues(OpenApiObjectValidation::class.java)?.also { + find(OpenApiObjectValidation::class.java)?.values?.also { extra["minProperties"] = it.notNull("minProperties")?.toInt() extra["maxProperties"] = it.notNull("maxProperties")?.toInt() } - memberValuesList(Custom::class.java).forEach { custom -> - extra[custom["name"] as String] = custom["value"] + findAll(Custom::class.java).forEach { custom -> + extra[custom.value("name") as String] = custom.value("value") } all() - .filter { it.meta.has(CustomAnnotation::class.java) } - .flatMap { it.values().entries } + .filter { it.meta.contains(CustomAnnotation::class.java) } + .flatMap { it.values.entries } .forEach { (name, value) -> extra[name] = customAnnotationValue(value) } return extra } -private fun RawVisibility.toOpenApi(): Visibility = +private fun MemberVisibility.toOpenApi(): Visibility = when (this) { - RawVisibility.PUBLIC -> Visibility.PUBLIC - RawVisibility.PROTECTED -> Visibility.PROTECTED - RawVisibility.PRIVATE -> Visibility.PRIVATE - RawVisibility.PACKAGE_PRIVATE -> Visibility.DEFAULT + MemberVisibility.PUBLIC -> Visibility.PUBLIC + MemberVisibility.PROTECTED -> Visibility.PROTECTED + MemberVisibility.PRIVATE -> Visibility.PRIVATE + MemberVisibility.PACKAGE_PRIVATE -> Visibility.DEFAULT } private fun Map.notNull(key: String): String? = diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/schema/OpenApiSchemaBuilder.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/schema/OpenApiSchemaBuilder.kt index 564a28b9..725ef775 100644 --- a/openapi-generator/src/main/kotlin/io/javalin/openapi/schema/OpenApiSchemaBuilder.kt +++ b/openapi-generator/src/main/kotlin/io/javalin/openapi/schema/OpenApiSchemaBuilder.kt @@ -37,11 +37,13 @@ class OpenApiSchemaBuilder { componentReferences.putAll(refs.associateBy { it.fullName }) } - fun openApiVersion(version: String): OpenApiSchemaBuilder = apply { + fun openApiVersion(version: String): OpenApiSchemaBuilder = + apply { root.put("openapi", version) } - fun info(configure: Consumer): OpenApiSchemaBuilder = apply { + fun info(configure: Consumer): OpenApiSchemaBuilder = + apply { val infoJson = jsonMapper.convertValue(OpenApiInfo().also { configure.accept(it) }, JsonNode::class.java) val existingInfo = root.get("info") val updatedInfo: JsonNode = @@ -53,14 +55,16 @@ class OpenApiSchemaBuilder { root.set("info", updatedInfo) } - fun server(configure: Consumer): OpenApiSchemaBuilder = apply { + fun server(configure: Consumer): OpenApiSchemaBuilder = + apply { val serversArray = root.get("servers") as? ArrayNode ?: createArrayNode() serversArray.add(jsonMapper.convertValue(OpenApiServer().also { configure.accept(it) }, JsonNode::class.java)) root.set("servers", serversArray) } /** Add a named security scheme */ - fun withSecurityScheme(name: String, scheme: SecurityScheme): OpenApiSchemaBuilder = apply { + fun withSecurityScheme(name: String, scheme: SecurityScheme): OpenApiSchemaBuilder = + apply { val components = root.get("components") as? ObjectNode ?: createObjectNode().also { root.set("components", it) } val schemes = components.get("securitySchemes") as? ObjectNode ?: createObjectNode().also { components.set("securitySchemes", it) } schemes.set(name, jsonMapper.convertValue(scheme, JsonNode::class.java)) @@ -182,9 +186,11 @@ class OpenApiSchemaBuilder { return root } - fun toJson(): String = buildRoot().toPrettyString() + fun toJson(): String = + buildRoot().toPrettyString() - fun toCompactJson(): String = buildRoot().toString() + fun toCompactJson(): String = + buildRoot().toString() companion object { @JvmStatic @@ -231,11 +237,15 @@ annotation class OpenApiSchemaDsl class SchemaBuilder { private val schema = createObjectNode() - fun type(type: String): SchemaBuilder = apply { schema.put("type", type) } - fun format(format: String): SchemaBuilder = apply { schema.put("format", format) } - fun ref(ref: String): SchemaBuilder = apply { schema.put($$"$ref", ref) } + fun type(type: String): SchemaBuilder = + apply { schema.put("type", type) } + fun format(format: String): SchemaBuilder = + apply { schema.put("format", format) } + fun ref(ref: String): SchemaBuilder = + apply { schema.put($$"$ref", ref) } - internal fun build(): ObjectNode = schema + internal fun build(): ObjectNode = + schema } @OpenApiSchemaDsl @@ -251,7 +261,8 @@ class PathItemBuilder( pathItem.set(method, builder.build()) } - fun operation(method: String, configure: Consumer) = operation(method) { configure.accept(this) } + fun operation(method: String, configure: Consumer) = + operation(method) { configure.accept(this) } } @OpenApiSchemaDsl @@ -277,12 +288,12 @@ class OperationBuilder( private val MANAGED_FIELDS = setOf("tags", "parameters", "requestBody", "responses", "callbacks", "security", "deprecated") } - fun tags(vararg tags: String) { + fun setTags(vararg tags: String) { tagsArray = createArrayNode() tags.forEach { tagsArray.add(it) } } - fun tags(tags: Collection) { + fun setTags(tags: Collection) { tagsArray = createArrayNode() tags.forEach { tagsArray.add(it) } } @@ -347,11 +358,16 @@ class OperationBuilder( securityArray = builder.build() } - fun parameters(configure: Consumer) = parameters { configure.accept(this) } - fun requestBody(configure: Consumer) = requestBody { configure.accept(this) } - fun responses(configure: Consumer) = responses { configure.accept(this) } - fun callbacks(configure: Consumer) = callbacks { configure.accept(this) } - fun security(configure: Consumer) = security { configure.accept(this) } + fun parameters(configure: Consumer) = + parameters { configure.accept(this) } + fun requestBody(configure: Consumer) = + requestBody { configure.accept(this) } + fun responses(configure: Consumer) = + responses { configure.accept(this) } + fun callbacks(configure: Consumer) = + callbacks { configure.accept(this) } + fun security(configure: Consumer) = + security { configure.accept(this) } internal fun build(): ObjectNode { val result = createObjectNode() @@ -468,9 +484,11 @@ class ParametersBuilder( allowEmptyValue: Boolean, example: String?, schema: Consumer, - ) = parameter(name, location, description, required, deprecated, allowEmptyValue, example) { schema.accept(this) } + ) = + parameter(name, location, description, required, deprecated, allowEmptyValue, example) { schema.accept(this) } - internal fun build(): ArrayNode = parameters + internal fun build(): ArrayNode = + parameters } @OpenApiSchemaDsl @@ -502,7 +520,8 @@ class RequestBodyBuilder( } } - fun content(configure: Consumer) = content { configure.accept(this) } + fun content(configure: Consumer) = + content { configure.accept(this) } internal fun build(): ObjectNode { val result = createObjectNode() @@ -544,9 +563,11 @@ class ContentBuilder( content.set(mimeType, builder.build()) } - fun mediaType(mimeType: String, configure: Consumer) = mediaType(mimeType) { configure.accept(this) } + fun mediaType(mimeType: String, configure: Consumer) = + mediaType(mimeType) { configure.accept(this) } - internal fun build(): ObjectNode = content + internal fun build(): ObjectNode = + content } interface ExampleHolder { @@ -580,7 +601,8 @@ class MediaTypeBuilder( schemaObject = SchemaBuilder().apply(configure).build() } - fun schema(configure: Consumer) = schema { configure.accept(this) } + fun schema(configure: Consumer) = + schema { configure.accept(this) } fun objectSchema(configure: ObjectSchemaBuilder.() -> Unit) { val builder = ObjectSchemaBuilder(refCollector) @@ -588,7 +610,8 @@ class MediaTypeBuilder( schemaObject = builder.build() } - fun objectSchema(configure: Consumer) = objectSchema { configure.accept(this) } + fun objectSchema(configure: Consumer) = + objectSchema { configure.accept(this) } override fun example(value: String) { mediaType.put("example", value) @@ -634,7 +657,8 @@ class ObjectSchemaBuilder( properties.set(name, SchemaBuilder().apply(schema).build()) } - fun property(name: String, schema: Consumer) = property(name) { schema.accept(this) } + fun property(name: String, schema: Consumer) = + property(name) { schema.accept(this) } fun property(name: String, type: String, format: String?) { val schema = createObjectNode() @@ -658,7 +682,8 @@ class ObjectSchemaBuilder( properties.set(name, schema) } - fun arrayProperty(name: String, items: Consumer) = arrayProperty(name) { items.accept(this) } + fun arrayProperty(name: String, items: Consumer) = + arrayProperty(name) { items.accept(this) } fun arrayProperty(name: String, itemType: String, itemFormat: String?) { val itemSchema = createObjectNode() @@ -679,7 +704,8 @@ class ObjectSchemaBuilder( additionalPropertiesObject = SchemaBuilder().apply(schema).build() } - fun additionalProperties(schema: Consumer) = additionalProperties { schema.accept(this) } + fun additionalProperties(schema: Consumer) = + additionalProperties { schema.accept(this) } fun additionalProperties(type: String?, format: String?) { val schema = createObjectNode() @@ -732,9 +758,11 @@ class ResponsesBuilder( responses.set(status, builder.build()) } - fun response(status: String, configure: Consumer) = response(status) { configure.accept(this) } + fun response(status: String, configure: Consumer) = + response(status) { configure.accept(this) } - internal fun build(): ObjectNode = responses + internal fun build(): ObjectNode = + responses } @OpenApiSchemaDsl @@ -771,8 +799,10 @@ class ResponseBuilder( } } - fun content(configure: Consumer) = content { configure.accept(this) } - fun headers(configure: Consumer) = headers { configure.accept(this) } + fun content(configure: Consumer) = + content { configure.accept(this) } + fun headers(configure: Consumer) = + headers { configure.accept(this) } internal fun build(): ObjectNode { val result = createObjectNode() @@ -850,9 +880,11 @@ class HeadersBuilder( allowEmptyValue: Boolean, example: String?, schema: Consumer, - ) = header(name, description, required, deprecated, allowEmptyValue, example) { schema.accept(this) } + ) = + header(name, description, required, deprecated, allowEmptyValue, example) { schema.accept(this) } - internal fun build(): ObjectNode = headers + internal fun build(): ObjectNode = + headers } @OpenApiSchemaDsl @@ -882,9 +914,11 @@ class CallbacksBuilder( urlObject.set(method, builder.build()) } - fun callback(name: String, url: String, method: String, configure: Consumer) = callback(name, url, method) { configure.accept(this) } + fun callback(name: String, url: String, method: String, configure: Consumer) = + callback(name, url, method) { configure.accept(this) } - internal fun build(): ObjectNode = callbacks + internal fun build(): ObjectNode = + callbacks } @OpenApiSchemaDsl @@ -928,8 +962,10 @@ class CallbackOperationBuilder( responsesObject = builder.build() } - fun requestBody(configure: Consumer) = requestBody { configure.accept(this) } - fun responses(configure: Consumer) = responses { configure.accept(this) } + fun requestBody(configure: Consumer) = + requestBody { configure.accept(this) } + fun responses(configure: Consumer) = + responses { configure.accept(this) } internal fun build(): ObjectNode { val result = createObjectNode() @@ -958,5 +994,6 @@ class SecurityBuilder(existing: ArrayNode? = null) { security.add(entry) } - internal fun build(): ArrayNode = security + internal fun build(): ArrayNode = + security } diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/schema/OpenApiSchemaGenerator.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/schema/OpenApiSchemaGenerator.kt index e088ef1c..ed1938b3 100644 --- a/openapi-generator/src/main/kotlin/io/javalin/openapi/schema/OpenApiSchemaGenerator.kt +++ b/openapi-generator/src/main/kotlin/io/javalin/openapi/schema/OpenApiSchemaGenerator.kt @@ -37,7 +37,7 @@ class OpenApiSchemaGenerator( for (method in route.texts("methods").sorted()) { pathBuilder.operation(method.lowercase()) { - tags(route.texts("tags")) + setTags(route.texts("tags")) summary(route.text("summary")) description(route.text("description")) operationId(generateOperationId(method, route).takeIf { it != NULL_STRING }) diff --git a/openapi-generator/src/test/kotlin/io/javalin/openapi/schema/OpenApiSchemaBuilderTest.kt b/openapi-generator/src/test/kotlin/io/javalin/openapi/schema/OpenApiSchemaBuilderTest.kt index 9bc308ae..eaedfdc6 100644 --- a/openapi-generator/src/test/kotlin/io/javalin/openapi/schema/OpenApiSchemaBuilderTest.kt +++ b/openapi-generator/src/test/kotlin/io/javalin/openapi/schema/OpenApiSchemaBuilderTest.kt @@ -178,7 +178,7 @@ internal class OpenApiSchemaBuilderTest { fun `should build path with operation`() { val schema = builder() schema.path("/users").operation("get") { - tags("users", "admin") + setTags("users", "admin") summary("Get all users") description("Returns a list of users") operationId("getUsers") @@ -216,7 +216,7 @@ internal class OpenApiSchemaBuilderTest { fun `should preserve field ordering in operation`() { val schema = builder() schema.path("/test").operation("get") { - tags("test") + setTags("test") summary("Test") description("Desc") operationId("testOp") @@ -949,7 +949,7 @@ internal class OpenApiSchemaBuilderTest { fun `should preserve all fields when reopening operation with empty lambda`() { val schema = builder() schema.path("/users").operation("get") { - tags("users") + setTags("users") summary("List users") description("Returns all users") operationId("getUsers") @@ -1016,8 +1016,8 @@ internal class OpenApiSchemaBuilderTest { @Test fun `should replace tags when explicitly called`() { val schema = builder() - schema.path("/users").operation("get") { tags("users", "admin") } - schema.path("/users").operation("get") { tags("public") } + schema.path("/users").operation("get") { setTags("users", "admin") } + schema.path("/users").operation("get") { setTags("public") } assertThatJson(schema.toJson()).inPath("$.paths['/users'].get.tags").isArray.containsExactly("public") } @@ -1025,7 +1025,7 @@ internal class OpenApiSchemaBuilderTest { @Test fun `should append tags with addTag`() { val schema = builder() - schema.path("/users").operation("get") { tags("users") } + schema.path("/users").operation("get") { setTags("users") } schema.path("/users").operation("get") { addTag("admin") } assertThatJson(schema.toJson()).inPath("$.paths['/users'].get.tags").isArray @@ -1035,7 +1035,7 @@ internal class OpenApiSchemaBuilderTest { @Test fun `should append tags with addTags`() { val schema = builder() - schema.path("/users").operation("get") { tags("users") } + schema.path("/users").operation("get") { setTags("users") } schema.path("/users").operation("get") { addTags(listOf("admin", "public")) } assertThatJson(schema.toJson()).inPath("$.paths['/users'].get.tags").isArray diff --git a/openapi-ksp/src/main/kotlin/io/javalin/openapi/ksp/KspSchemaContext.kt b/openapi-ksp/src/main/kotlin/io/javalin/openapi/ksp/KspSchemaContext.kt index fff2c8d6..6abe4f11 100644 --- a/openapi-ksp/src/main/kotlin/io/javalin/openapi/ksp/KspSchemaContext.kt +++ b/openapi-ksp/src/main/kotlin/io/javalin/openapi/ksp/KspSchemaContext.kt @@ -2,7 +2,7 @@ package io.javalin.openapi.ksp import com.google.devtools.ksp.processing.Resolver import com.google.devtools.ksp.symbol.KSAnnotated -import io.javalin.introspection.Annotations +import io.javalin.introspection.AnnotationSet import io.javalin.introspection.TypeIntrospector import io.javalin.introspection.ksp.KspTypeIntrospector import io.javalin.openapi.experimental.IntrospectorSchemaContext @@ -17,7 +17,7 @@ class KspSchemaContext( private val kspTypeIntrospector = KspTypeIntrospector(resolver) override val introspector: TypeIntrospector = kspTypeIntrospector - fun annotationsOf(annotated: KSAnnotated): Annotations = + fun annotationsOf(annotated: KSAnnotated): AnnotationSet = kspTypeIntrospector.annotationsOf(annotated) } diff --git a/openapi-ksp/src/main/kotlin/io/javalin/openapi/ksp/OpenApiSymbolProcessor.kt b/openapi-ksp/src/main/kotlin/io/javalin/openapi/ksp/OpenApiSymbolProcessor.kt index d01f892e..d25b0a63 100644 --- a/openapi-ksp/src/main/kotlin/io/javalin/openapi/ksp/OpenApiSymbolProcessor.kt +++ b/openapi-ksp/src/main/kotlin/io/javalin/openapi/ksp/OpenApiSymbolProcessor.kt @@ -31,7 +31,7 @@ class OpenApiSymbolProcessor( .filterIsInstance() .mapNotNull { declaration -> val type = context.introspect(declaration.asStarProjectedType()) - if (context.annotationsOf(type).memberValues(JsonSchema::class.java)?.get("generateResource") == false) { + if (context.annotationsOf(type).find(JsonSchema::class.java)?.value("generateResource") == false) { return@mapNotNull null } val json = context.typeSchemaGenerator.createTypeSchema(type, inlineRefs = true).toJsonSchemaString() @@ -48,7 +48,7 @@ class OpenApiSymbolProcessor( private fun generateOpenApiDocuments(resolver: Resolver, context: KspSchemaContext) { val routes = (resolver.getSymbolsWithAnnotation(OpenApi::class.qualifiedName!!) + resolver.getSymbolsWithAnnotation(OpenApis::class.qualifiedName!!)) .distinct() - .flatMap { annotated -> context.annotationsOf(annotated).memberValuesList(OpenApi::class.java) } + .flatMap { annotated -> context.annotationsOf(annotated).findAll(OpenApi::class.java).map { it.values } } .toList() if (routes.isEmpty()) { From 1f958995323af3316f0fa164dbcfccda77614cd4 Mon Sep 17 00:00:00 2001 From: dzikoysk Date: Wed, 8 Jul 2026 20:10:33 +0200 Subject: [PATCH 25/43] chore: yet another cleanup --- build.gradle.kts | 2 +- docs/advanced/runtime-builder.md | 6 +++--- docs/introduction/migration-from-6x.md | 2 +- .../io/javalin/introspection/ClassDefinition.kt | 2 +- .../introspection/jap/JapTypeIntrospector.kt | 3 ++- .../introspection/ksp/KspTypeIntrospector.kt | 3 ++- .../introspection/ksp/KspTypeIntrospectorTest.kt | 2 +- .../runtime/ReflectionTypeIntrospector.kt | 4 ++-- .../runtime/ReflectionTypeIntrospectorTest.kt | 4 ++-- .../introspection/test/IntrospectionParityTest.kt | 8 ++++---- .../experimental/AnnotationProcessorContext.kt | 2 +- .../experimental/IntrospectorSchemaContext.kt | 2 +- .../javalin/openapi/schema/OpenApiSchemaBuilder.kt | 12 ++---------- .../openapi/schema/OpenApiSchemaGenerator.kt | 10 ++++++++-- .../openapi/schema/OpenApiSchemaBuilderTest.kt | 14 +++++++------- openapi-ksp/build.gradle.kts | 2 +- .../io/javalin/openapi/ksp/KspSchemaContext.kt | 2 +- 17 files changed, 40 insertions(+), 40 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 5b1181ad..bc1fb52c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -17,7 +17,7 @@ allprojects { apply(plugin = "maven-publish") group = "io.javalin.community.openapi" - version = "7.2.2" + version = "7.3.0-RC.1" repositories { mavenCentral() diff --git a/docs/advanced/runtime-builder.md b/docs/advanced/runtime-builder.md index 32c17923..cec045ac 100644 --- a/docs/advanced/runtime-builder.md +++ b/docs/advanced/runtime-builder.md @@ -16,7 +16,7 @@ val spec = OpenApiSchemaBuilder() .info { it.title("My API").version("1.0") } spec.path("/users").operation("get") { - setTags("users") + tags("users") summary("List users") responses { response("200") { @@ -64,7 +64,7 @@ The schema DSL is available on media types, parameters, headers, and object sche ```kotlin spec.path("/users/{id}").operation("get") { - setTags("users") + tags("users") summary("Get user by ID") description("Returns a single user") operationId("getUserById") @@ -277,4 +277,4 @@ The builder supports incremental construction. Reopening a path, operation, resp - **Parameters**: same name + location replaces; different name or location appends - **Responses**: new status codes are added; same status code merges content and headers - **Content types**: new media types are added; same media type merges schema and example -- **Tags**: `setTags(...)` replaces; `addTag(...)` / `addTags(...)` appends +- **Tags**: `tags(...)` replaces; `addTag(...)` / `addTags(...)` appends diff --git a/docs/introduction/migration-from-6x.md b/docs/introduction/migration-from-6x.md index 96dd716a..a069651d 100644 --- a/docs/introduction/migration-from-6x.md +++ b/docs/introduction/migration-from-6x.md @@ -20,7 +20,7 @@ The new `openapi-generator` module provides a Kotlin DSL for building or extendi ```kotlin spec.path("/users/{id}").operation("get") { - setTags("users") + tags("users") summary("Get user by ID") parameters { parameter("id", "path", required = true) { type("string") } diff --git a/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/ClassDefinition.kt b/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/ClassDefinition.kt index 1c8969ad..4494ab7f 100644 --- a/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/ClassDefinition.kt +++ b/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/ClassDefinition.kt @@ -12,7 +12,7 @@ abstract class ClassDefinition( abstract fun isEnum(): Boolean - abstract fun getEnumConstants(): List? + abstract fun getEnumConstants(): List abstract fun getProperties(): List diff --git a/introspection/introspection-jap/src/main/kotlin/io/javalin/introspection/jap/JapTypeIntrospector.kt b/introspection/introspection-jap/src/main/kotlin/io/javalin/introspection/jap/JapTypeIntrospector.kt index adb241ee..e54c1318 100644 --- a/introspection/introspection-jap/src/main/kotlin/io/javalin/introspection/jap/JapTypeIntrospector.kt +++ b/introspection/introspection-jap/src/main/kotlin/io/javalin/introspection/jap/JapTypeIntrospector.kt @@ -137,12 +137,13 @@ class JapTypeIntrospector( override fun isEnum(): Boolean = typeElement()?.kind == ElementKind.ENUM - override fun getEnumConstants(): List? = + override fun getEnumConstants(): List = typeElement() ?.takeIf { it.kind == ElementKind.ENUM } ?.enclosedElements ?.filter { it.kind == ElementKind.ENUM_CONSTANT } ?.map { EnumConstantView(it.simpleName.toString(), AnnotationsView(listOf(it))) } + .orEmpty() override fun getAnnotations(): AnnotationSet = AnnotationsView(listOfNotNull(typeElement())) diff --git a/introspection/introspection-ksp/src/main/kotlin/io/javalin/introspection/ksp/KspTypeIntrospector.kt b/introspection/introspection-ksp/src/main/kotlin/io/javalin/introspection/ksp/KspTypeIntrospector.kt index 36df841c..4fad5d6a 100644 --- a/introspection/introspection-ksp/src/main/kotlin/io/javalin/introspection/ksp/KspTypeIntrospector.kt +++ b/introspection/introspection-ksp/src/main/kotlin/io/javalin/introspection/ksp/KspTypeIntrospector.kt @@ -122,7 +122,7 @@ class KspTypeIntrospector(private val resolver: Resolver) : CompileTimeIntrospec override fun isEnum(): Boolean = declaration?.classKind == ClassKind.ENUM_CLASS - override fun getEnumConstants(): List? = + override fun getEnumConstants(): List = declaration ?.takeIf { it.classKind == ClassKind.ENUM_CLASS } ?.declarations @@ -130,6 +130,7 @@ class KspTypeIntrospector(private val resolver: Resolver) : CompileTimeIntrospec ?.filter { it.classKind == ClassKind.ENUM_ENTRY } ?.map { EnumConstantView(it.simpleName.asString(), KspAnnotations(it)) } ?.toList() + .orEmpty() override fun getAnnotations(): AnnotationSet = KspAnnotations(declaration) diff --git a/introspection/introspection-ksp/src/test/kotlin/io/javalin/introspection/ksp/KspTypeIntrospectorTest.kt b/introspection/introspection-ksp/src/test/kotlin/io/javalin/introspection/ksp/KspTypeIntrospectorTest.kt index 6e4b25ad..e79337ad 100644 --- a/introspection/introspection-ksp/src/test/kotlin/io/javalin/introspection/ksp/KspTypeIntrospectorTest.kt +++ b/introspection/introspection-ksp/src/test/kotlin/io/javalin/introspection/ksp/KspTypeIntrospectorTest.kt @@ -59,7 +59,7 @@ class KspTypeIntrospectorTest { Snapshot( properties = account.getProperties().associate { it.name to "${it.type.fullName}:${it.type.structureType}" }, isEnum = color.isEnum(), - enumConstants = color.getEnumConstants()?.map { it.name }, + enumConstants = color.getEnumConstants().map { it.name }, hasRef = account.getAnnotations().contains("Ref"), refValue = (account.getAnnotations().find(Ref::class.java)?.classValue("value"))?.fullName, scannedLabel = account.getAnnotations().all().first { it.meta.contains("MetaMarker") }.values["label"], diff --git a/introspection/introspection-runtime/src/main/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospector.kt b/introspection/introspection-runtime/src/main/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospector.kt index 935b7278..ba77f709 100644 --- a/introspection/introspection-runtime/src/main/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospector.kt +++ b/introspection/introspection-runtime/src/main/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospector.kt @@ -100,11 +100,11 @@ private class ReflectionClassDefinition( override fun isEnum(): Boolean = erasure.isEnum - override fun getEnumConstants(): List? = + override fun getEnumConstants(): List = erasure.takeIf { it.isEnum }?.enumConstants?.map { val name = (it as Enum<*>).name EnumConstantView(name, ReflectionAnnotations(listOfNotNull(runCatching { erasure.getDeclaredField(name) }.getOrNull()))) - } + }.orEmpty() override fun getProperties(): List = collectMembers(erasure).map { member -> diff --git a/introspection/introspection-runtime/src/test/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospectorTest.kt b/introspection/introspection-runtime/src/test/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospectorTest.kt index 55b1637e..6b6b7326 100644 --- a/introspection/introspection-runtime/src/test/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospectorTest.kt +++ b/introspection/introspection-runtime/src/test/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospectorTest.kt @@ -60,8 +60,8 @@ class ReflectionTypeIntrospectorTest { fun `reads enum constants raw`() { val color = introspector.introspect(Color::class.java) assertThat(color.isEnum()).isTrue() - assertThat(color.getEnumConstants()?.map { it.name }).containsExactly("RED", "GREEN") - assertThat(introspector.introspect(Account::class.java).getEnumConstants()).isNull() + assertThat(color.getEnumConstants().map { it.name }).containsExactly("RED", "GREEN") + assertThat(introspector.introspect(Account::class.java).getEnumConstants()).isEmpty() } @Test diff --git a/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/IntrospectionParityTest.kt b/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/IntrospectionParityTest.kt index 71179f35..58c83bed 100644 --- a/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/IntrospectionParityTest.kt +++ b/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/IntrospectionParityTest.kt @@ -154,8 +154,8 @@ class IntrospectionParityTest { @Test fun `KSP enum constants match reflection`() { - val runtimeConstants = runtime.introspect(Color::class.java).getEnumConstants()?.map { it.name }?.sorted() - val kspConstants = SymbolProcessing.introspect(Color::class) { it.getEnumConstants()?.map { constant -> constant.name }?.sorted() } + val runtimeConstants = runtime.introspect(Color::class.java).getEnumConstants().map { it.name }.sorted() + val kspConstants = SymbolProcessing.introspect(Color::class) { it.getEnumConstants().map { constant -> constant.name }.sorted() } assertThat(kspConstants).isEqualTo(runtimeConstants).isEqualTo(listOf("GREEN", "RED")) } } @@ -165,7 +165,7 @@ private data class TypeShape( val simpleName: String, val structure: StructureType, val isEnum: Boolean, - val enumConstants: List?, + val enumConstants: List, val properties: List, ) @@ -187,7 +187,7 @@ private fun ClassDefinition.toShape(): TypeShape = simpleName = simpleName, structure = structureType, isEnum = isEnum(), - enumConstants = getEnumConstants()?.map { it.name }?.sorted(), + enumConstants = getEnumConstants().map { it.name }.sorted(), properties = if (isEnum()) emptyList() else getProperties() .map { PropertyShape(it.name, it.type.fullName, it.type.structureType, it.type.generics.map { g -> g.fullName }, it.accessor, it.nullable, it.visibility, it.transient) } .sortedBy { "${it.accessor}:${it.name}" }, diff --git a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/AnnotationProcessorContext.kt b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/AnnotationProcessorContext.kt index fed96ec2..dad7bf61 100644 --- a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/AnnotationProcessorContext.kt +++ b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/AnnotationProcessorContext.kt @@ -50,7 +50,7 @@ class AnnotationProcessorContext( japIntrospector.introspect(type.mirror).getProperties() override fun enumConstantsOf(type: OpenApiType): List = - japIntrospector.introspect(type.mirror).getEnumConstants() ?: emptyList() + japIntrospector.introspect(type.mirror).getEnumConstants() @OptIn(InternalIntrospectionApi::class) override fun toOpenApiType(raw: RawType): OpenApiType { diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/IntrospectorSchemaContext.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/IntrospectorSchemaContext.kt index 3d02b4cc..01aa37bd 100644 --- a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/IntrospectorSchemaContext.kt +++ b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/IntrospectorSchemaContext.kt @@ -57,7 +57,7 @@ abstract class IntrospectorSchemaContext( type.raw.getProperties() override fun enumConstantsOf(type: OpenApiType): List = - type.raw.getEnumConstants() ?: emptyList() + type.raw.getEnumConstants() override fun discriminatorSubtypes(type: OpenApiType): List> { val scanner = introspector as? CompileTimeIntrospector ?: return emptyList() diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/schema/OpenApiSchemaBuilder.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/schema/OpenApiSchemaBuilder.kt index 725ef775..a49e5050 100644 --- a/openapi-generator/src/main/kotlin/io/javalin/openapi/schema/OpenApiSchemaBuilder.kt +++ b/openapi-generator/src/main/kotlin/io/javalin/openapi/schema/OpenApiSchemaBuilder.kt @@ -14,9 +14,7 @@ import io.javalin.openapi.OpenID import io.javalin.openapi.Security import io.javalin.openapi.SecurityScheme import io.javalin.openapi.experimental.OpenApiType -import io.javalin.openapi.experimental.processor.generators.ExampleGenerator import io.javalin.openapi.experimental.processor.generators.ResultScheme -import io.javalin.openapi.experimental.processor.generators.toExampleProperty import io.javalin.openapi.experimental.processor.shared.createArrayNode import io.javalin.openapi.experimental.processor.shared.createObjectNode import io.javalin.openapi.experimental.processor.shared.jsonMapper @@ -288,12 +286,12 @@ class OperationBuilder( private val MANAGED_FIELDS = setOf("tags", "parameters", "requestBody", "responses", "callbacks", "security", "deprecated") } - fun setTags(vararg tags: String) { + fun tags(vararg tags: String) { tagsArray = createArrayNode() tags.forEach { tagsArray.add(it) } } - fun setTags(tags: Collection) { + fun tags(tags: Collection) { tagsArray = createArrayNode() tags.forEach { tagsArray.add(it) } } @@ -573,12 +571,6 @@ class ContentBuilder( interface ExampleHolder { fun example(value: String) fun exampleJson(value: JsonNode) - - fun applyExamples(exampleObjects: List>) { - val generatorResult = ExampleGenerator.generateFromExamples(exampleObjects.map { it.toExampleProperty() }) - generatorResult.simpleValue?.let { example(it) } - ?: generatorResult.jsonElement?.let { exampleJson(it) } - } } @OpenApiSchemaDsl diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/schema/OpenApiSchemaGenerator.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/schema/OpenApiSchemaGenerator.kt index ed1938b3..f92b982b 100644 --- a/openapi-generator/src/main/kotlin/io/javalin/openapi/schema/OpenApiSchemaGenerator.kt +++ b/openapi-generator/src/main/kotlin/io/javalin/openapi/schema/OpenApiSchemaGenerator.kt @@ -9,7 +9,9 @@ import io.javalin.openapi.OpenApiOperation.AUTO_GENERATE import io.javalin.openapi.experimental.OpenApiType import io.javalin.openapi.experimental.SchemaGenerationContext import io.javalin.openapi.experimental.StructureType.ARRAY +import io.javalin.openapi.experimental.processor.generators.ExampleGenerator import io.javalin.openapi.experimental.processor.generators.ResultScheme +import io.javalin.openapi.experimental.processor.generators.toExampleProperty import java.util.Locale import java.util.TreeMap @@ -37,7 +39,7 @@ class OpenApiSchemaGenerator( for (method in route.texts("methods").sorted()) { pathBuilder.operation(method.lowercase()) { - setTags(route.texts("tags")) + tags(route.texts("tags")) summary(route.text("summary")) description(route.text("description")) operationId(generateOperationId(method, route).takeIf { it != NULL_STRING }) @@ -264,7 +266,11 @@ class OpenApiSchemaGenerator( private fun ExampleHolder.applyExample(content: Map) { content.text("example")?.let { example(it) } - content.maps("exampleObjects").takeIf { it.isNotEmpty() }?.let { applyExamples(it) } + content.maps("exampleObjects").takeIf { it.isNotEmpty() }?.let { exampleMaps -> + val result = ExampleGenerator.generateFromExamples(exampleMaps.map { it.toExampleProperty() }) + result.simpleValue?.let { example(it) } + result.jsonElement?.let { exampleJson(it) } + } } private fun ObjectSchemaBuilder.buildProperties(properties: List>) { diff --git a/openapi-generator/src/test/kotlin/io/javalin/openapi/schema/OpenApiSchemaBuilderTest.kt b/openapi-generator/src/test/kotlin/io/javalin/openapi/schema/OpenApiSchemaBuilderTest.kt index eaedfdc6..9bc308ae 100644 --- a/openapi-generator/src/test/kotlin/io/javalin/openapi/schema/OpenApiSchemaBuilderTest.kt +++ b/openapi-generator/src/test/kotlin/io/javalin/openapi/schema/OpenApiSchemaBuilderTest.kt @@ -178,7 +178,7 @@ internal class OpenApiSchemaBuilderTest { fun `should build path with operation`() { val schema = builder() schema.path("/users").operation("get") { - setTags("users", "admin") + tags("users", "admin") summary("Get all users") description("Returns a list of users") operationId("getUsers") @@ -216,7 +216,7 @@ internal class OpenApiSchemaBuilderTest { fun `should preserve field ordering in operation`() { val schema = builder() schema.path("/test").operation("get") { - setTags("test") + tags("test") summary("Test") description("Desc") operationId("testOp") @@ -949,7 +949,7 @@ internal class OpenApiSchemaBuilderTest { fun `should preserve all fields when reopening operation with empty lambda`() { val schema = builder() schema.path("/users").operation("get") { - setTags("users") + tags("users") summary("List users") description("Returns all users") operationId("getUsers") @@ -1016,8 +1016,8 @@ internal class OpenApiSchemaBuilderTest { @Test fun `should replace tags when explicitly called`() { val schema = builder() - schema.path("/users").operation("get") { setTags("users", "admin") } - schema.path("/users").operation("get") { setTags("public") } + schema.path("/users").operation("get") { tags("users", "admin") } + schema.path("/users").operation("get") { tags("public") } assertThatJson(schema.toJson()).inPath("$.paths['/users'].get.tags").isArray.containsExactly("public") } @@ -1025,7 +1025,7 @@ internal class OpenApiSchemaBuilderTest { @Test fun `should append tags with addTag`() { val schema = builder() - schema.path("/users").operation("get") { setTags("users") } + schema.path("/users").operation("get") { tags("users") } schema.path("/users").operation("get") { addTag("admin") } assertThatJson(schema.toJson()).inPath("$.paths['/users'].get.tags").isArray @@ -1035,7 +1035,7 @@ internal class OpenApiSchemaBuilderTest { @Test fun `should append tags with addTags`() { val schema = builder() - schema.path("/users").operation("get") { setTags("users") } + schema.path("/users").operation("get") { tags("users") } schema.path("/users").operation("get") { addTags(listOf("admin", "public")) } assertThatJson(schema.toJson()).inPath("$.paths['/users'].get.tags").isArray diff --git a/openapi-ksp/build.gradle.kts b/openapi-ksp/build.gradle.kts index 4307ffb7..3b3188f4 100644 --- a/openapi-ksp/build.gradle.kts +++ b/openapi-ksp/build.gradle.kts @@ -3,7 +3,7 @@ description = "Javalin OpenAPI KSP | Kotlin Symbol Processing backend for OpenAP dependencies { api(project(":openapi-generator")) api(project(":introspection:introspection-ksp")) - api(libs.ksp.symbol.processing.api) // KspSchemaContext's constructor exposes a KSP Resolver + api(libs.ksp.symbol.processing.api) // OpenApiSymbolProcessorProvider/Processor are public KSP SymbolProcessor types (ServiceLoader entry point) testImplementation(libs.kctfork.core) testImplementation(libs.kctfork.ksp) diff --git a/openapi-ksp/src/main/kotlin/io/javalin/openapi/ksp/KspSchemaContext.kt b/openapi-ksp/src/main/kotlin/io/javalin/openapi/ksp/KspSchemaContext.kt index 6abe4f11..a4d1d1cd 100644 --- a/openapi-ksp/src/main/kotlin/io/javalin/openapi/ksp/KspSchemaContext.kt +++ b/openapi-ksp/src/main/kotlin/io/javalin/openapi/ksp/KspSchemaContext.kt @@ -9,7 +9,7 @@ import io.javalin.openapi.experimental.IntrospectorSchemaContext import io.javalin.openapi.experimental.SimpleType import io.javalin.openapi.experimental.defaults.createDefaultSimpleTypeMappings -class KspSchemaContext( +internal class KspSchemaContext( resolver: Resolver, simpleTypeMappings: Map = createDefaultSimpleTypeMappings(), ) : IntrospectorSchemaContext(simpleTypeMappings) { From c14dd5532e79509e30e4c19fd112bc4328d221dc Mon Sep 17 00:00:00 2001 From: dzikoysk Date: Thu, 9 Jul 2026 02:42:14 +0200 Subject: [PATCH 26/43] chore: tests --- docs/advanced/runtime-builder.md | 2 +- docs/introduction/json-schema-setup.md | 10 +- docs/introduction/migration-from-6x.md | 6 +- docs/introduction/setup.md | 18 +- docs/openapi/schemas.md | 2 + examples/javalin-maven-java/pom.xml | 2 +- examples/javalin-maven-kotlin/pom.xml | 2 +- .../introspection/jap/JapTypeIntrospector.kt | 125 +++++++-- .../introspection/ksp/KspTypeIntrospector.kt | 71 ++++-- .../runtime/ReflectionTypeIntrospector.kt | 53 ++-- .../PackagePrivateAnnotationFixture.java | 13 + .../runtime/ReflectionTypeIntrospectorTest.kt | 23 ++ .../test/IntrospectionParityTest.kt | 38 +++ .../javalin-openapi-plugin/build.gradle.kts | 1 + .../src/test/kotlin/OpenApiPluginTest.kt | 62 +++++ .../AnnotationProcessorContext.kt | 9 +- .../openapi/processor/RegressionFixtures.java | 68 +++++ .../openapi/processor/RegressionNetTest.kt | 182 ++++++++++++++ .../experimental/SchemaGenerationContext.kt | 2 + .../generators/TypeSchemaGenerator.kt | 28 ++- .../openapi/schema/OpenApiSchemaGenerator.kt | 7 + .../javalin/openapi/ksp/KspSchemaContext.kt | 21 ++ .../openapi/ksp/OpenApiSymbolProcessor.kt | 17 +- .../openapi/ksp/KspSchemaContextTest.kt | 9 +- .../openapi/ksp/OpenApiSymbolProcessorTest.kt | 238 ++++++++++++++++++ 25 files changed, 924 insertions(+), 85 deletions(-) create mode 100644 introspection/introspection-runtime/src/test/java/io/javalin/introspection/runtime/PackagePrivateAnnotationFixture.java create mode 100644 openapi-annotation-processor/src/test/java/io/javalin/openapi/processor/RegressionFixtures.java create mode 100644 openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/RegressionNetTest.kt diff --git a/docs/advanced/runtime-builder.md b/docs/advanced/runtime-builder.md index cec045ac..3b1ef326 100644 --- a/docs/advanced/runtime-builder.md +++ b/docs/advanced/runtime-builder.md @@ -225,7 +225,7 @@ For a springdoc-style experience - documenting routes that are registered progra ```kotlin [Gradle (Kotlin)] dependencies { - val openapi = "7.2.2" + val openapi = "7.3.0-RC.1" implementation("io.javalin.community.openapi:javalin-openapi-dynamic-hook:$openapi") } ``` diff --git a/docs/introduction/json-schema-setup.md b/docs/introduction/json-schema-setup.md index f60bb6ee..67761fee 100644 --- a/docs/introduction/json-schema-setup.md +++ b/docs/introduction/json-schema-setup.md @@ -16,7 +16,7 @@ You only need the annotation processor and the specification module - no Javalin ```kotlin [Gradle (Kotlin)] dependencies { - val openapi = "7.2.2" + val openapi = "7.3.0-RC.1" annotationProcessor( "io.javalin.community.openapi:openapi-annotation-processor:$openapi" @@ -29,7 +29,7 @@ dependencies { ```kotlin [Gradle (Kotlin) with Kapt] dependencies { - val openapi = "7.2.2" + val openapi = "7.3.0-RC.1" kapt( "io.javalin.community.openapi:openapi-annotation-processor:$openapi" @@ -47,7 +47,7 @@ plugins { } dependencies { - val openapi = "7.2.2" + val openapi = "7.3.0-RC.1" ksp( "io.javalin.community.openapi:openapi-ksp:$openapi" @@ -63,7 +63,7 @@ dependencies { io.javalin.community.openapi openapi-specification - 7.2.2 + 7.3.0-RC.1 @@ -77,7 +77,7 @@ dependencies { io.javalin.community.openapi openapi-annotation-processor - 7.2.2 + 7.3.0-RC.1 diff --git a/docs/introduction/migration-from-6x.md b/docs/introduction/migration-from-6x.md index a069651d..9176df66 100644 --- a/docs/introduction/migration-from-6x.md +++ b/docs/introduction/migration-from-6x.md @@ -130,11 +130,11 @@ Update the version in your build file: ::: code-group ```kotlin [Gradle (Kotlin)] -val openapi = "7.2.2" // was 6.x +val openapi = "7.3.0-RC.1" // was 6.x ``` ```xml [Maven] -7.2.2 +7.3.0-RC.1 ``` ::: @@ -184,7 +184,7 @@ kapt { io.javalin.community.openapi openapi-annotation-processor - 7.2.2 + 7.3.0-RC.1 diff --git a/docs/introduction/setup.md b/docs/introduction/setup.md index 0f0951fa..0c1cc07f 100644 --- a/docs/introduction/setup.md +++ b/docs/introduction/setup.md @@ -19,7 +19,7 @@ repositories { } dependencies { - val openapi = "7.2.2" + val openapi = "7.3.0-RC.1" annotationProcessor( "io.javalin.community.openapi:openapi-annotation-processor:$openapi" @@ -44,7 +44,7 @@ plugins { } dependencies { - val openapi = "7.2.2" + val openapi = "7.3.0-RC.1" kapt( "io.javalin.community.openapi:openapi-annotation-processor:$openapi" @@ -68,7 +68,7 @@ plugins { } dependencies { - val openapi = "7.2.2" + val openapi = "7.3.0-RC.1" ksp( "io.javalin.community.openapi:openapi-ksp:$openapi" @@ -95,19 +95,19 @@ ksp { io.javalin.community.openapi javalin-openapi-plugin - 7.2.2 + 7.3.0-RC.1 io.javalin.community.openapi javalin-swagger-plugin - 7.2.2 + 7.3.0-RC.1 io.javalin.community.openapi javalin-redoc-plugin - 7.2.2 + 7.3.0-RC.1 @@ -121,7 +121,7 @@ ksp { io.javalin.community.openapi openapi-annotation-processor - 7.2.2 + 7.3.0-RC.1 @@ -142,10 +142,10 @@ The same generation engine runs on three backends - pick the one that matches yo | Kapt (`kapt`) | `openapi-annotation-processor` | Java + Kotlin | Mixed Java/Kotlin projects | | KSP (`ksp`) | `openapi-ksp` *(experimental)* | Kotlin only | Kotlin-only projects wanting faster builds | -All three emit identical `openapi-plugin/openapi-*.json` resources that the `OpenApiPlugin` serves - switching backends does not change the generated specification. +All three emit the same `openapi-plugin/openapi-*.json` resource format that the `OpenApiPlugin` serves. APT/Kapt remain the reference backends; KSP is experimental and has the limitations listed below. ::: warning KSP limitations -KSP processes **Kotlin sources only** - `@OpenApi`/`@JsonSchema` on Java types are not picked up (use APT/Kapt for those). The Groovy [scripting configuration](../advanced/scripting) (custom type mappings, property filters, custom type processors) and parser validation are currently APT/Kapt-only. +KSP processes **Kotlin sources only** - `@OpenApi`/`@JsonSchema` on Java types are not picked up (use APT/Kapt for those). The Groovy [scripting configuration](../advanced/scripting) (custom type mappings, property filters, custom type processors) and parser validation are currently APT/Kapt-only. `@OpenApiByFields(only = true)` is also APT/Kapt-only; the KSP backend fails the build with a clear error instead of generating an empty schema. ::: ## Register the Plugin diff --git a/docs/openapi/schemas.md b/docs/openapi/schemas.md index bb8cb474..0d94e6fd 100644 --- a/docs/openapi/schemas.md +++ b/docs/openapi/schemas.md @@ -86,6 +86,8 @@ class Config { The `value` parameter controls the minimum field visibility. Use `only = true` to ignore methods entirely. +`only = true` is supported by the APT/Kapt backend. KSP does not expose the Java-style field model needed for this mode yet, so the KSP backend fails the build with a clear error if it sees `@OpenApiByFields(only = true)`. + ## Property Annotations ### @OpenApiIgnore diff --git a/examples/javalin-maven-java/pom.xml b/examples/javalin-maven-java/pom.xml index 9d9cf41e..c2863a96 100644 --- a/examples/javalin-maven-java/pom.xml +++ b/examples/javalin-maven-java/pom.xml @@ -12,7 +12,7 @@ 11 11 7.2.2 - 7.2.2 + 7.3.0-RC.1 2.1.0 diff --git a/examples/javalin-maven-kotlin/pom.xml b/examples/javalin-maven-kotlin/pom.xml index 0ff2be0d..040cf91d 100644 --- a/examples/javalin-maven-kotlin/pom.xml +++ b/examples/javalin-maven-kotlin/pom.xml @@ -10,7 +10,7 @@ 7.2.2 - 7.2.2 + 7.3.0-RC.1 2.1.0 diff --git a/introspection/introspection-jap/src/main/kotlin/io/javalin/introspection/jap/JapTypeIntrospector.kt b/introspection/introspection-jap/src/main/kotlin/io/javalin/introspection/jap/JapTypeIntrospector.kt index e54c1318..88f4860d 100644 --- a/introspection/introspection-jap/src/main/kotlin/io/javalin/introspection/jap/JapTypeIntrospector.kt +++ b/introspection/introspection-jap/src/main/kotlin/io/javalin/introspection/jap/JapTypeIntrospector.kt @@ -15,6 +15,7 @@ import io.javalin.introspection.StructureType.DICTIONARY import io.javalin.introspection.MemberVisibility import io.javalin.introspection.isGetterName import io.javalin.introspection.propertyName +import java.lang.annotation.Inherited import java.lang.annotation.Repeatable as JavaRepeatable import javax.annotation.processing.RoundEnvironment import javax.lang.model.element.AnnotationMirror @@ -60,36 +61,58 @@ class JapTypeIntrospector( .map { resolve(it.asType()) } } - private fun resolve(mirror: TypeMirror, generics: List = emptyList(), structureType: StructureType = DEFAULT): ClassDefinition = + private fun resolve( + mirror: TypeMirror, + generics: List = emptyList(), + structureType: StructureType = DEFAULT, + visitingTypeVariables: Set = emptySet(), + ): ClassDefinition = when (mirror) { - is TypeVariable -> (mirror.upperBound ?: mirror.lowerBound)?.let { resolve(it, generics, structureType) } ?: objectDefinition(structureType) - is WildcardType -> resolve(mirror.extendsBound ?: objectMirror(), generics, structureType) - is ArrayType -> resolve(mirror.componentType, generics, ARRAY) + is TypeVariable -> { + val key = mirror.asElement()?.toString() ?: mirror.toString() + if (key in visitingTypeVariables) { + objectDefinition(structureType) + } else { + (mirror.upperBound ?: mirror.lowerBound)?.let { resolve(it, generics, structureType, visitingTypeVariables + key) } ?: objectDefinition(structureType) + } + } + is WildcardType -> resolve(mirror.extendsBound ?: objectMirror(), generics, structureType, visitingTypeVariables) + is ArrayType -> resolve(mirror.componentType, generics, ARRAY, visitingTypeVariables) is PrimitiveType -> definition(types.boxedClass(mirror).asType(), generics, structureType) - is DeclaredType -> declared(mirror, generics, structureType) - else -> types.asElement(mirror)?.asType()?.takeIf { it != mirror }?.let { resolve(it, generics, structureType) } ?: objectDefinition(structureType) + is DeclaredType -> declared(mirror, generics, structureType, visitingTypeVariables) + else -> types.asElement(mirror)?.asType()?.takeIf { it != mirror }?.let { resolve(it, generics, structureType, visitingTypeVariables) } ?: objectDefinition(structureType) } - private fun declared(mirror: DeclaredType, generics: List, structureType: StructureType): ClassDefinition { + private fun declared( + mirror: DeclaredType, + generics: List, + structureType: StructureType, + visitingTypeVariables: Set, + ): ClassDefinition { val erasure = types.erasure(mirror) return when { types.isAssignable(erasure, erasureOf("java.util.Map")) -> definition( mirror = mirror, generics = listOf( - resolve(mirror.typeArguments.getOrElse(0) { objectMirror() }), - resolve(mirror.typeArguments.getOrElse(1) { objectMirror() }), + resolve(mirror.typeArguments.getOrElse(0) { objectMirror() }, visitingTypeVariables = visitingTypeVariables), + resolve(mirror.typeArguments.getOrElse(1) { objectMirror() }, visitingTypeVariables = visitingTypeVariables), ), structureType = DICTIONARY, ) types.isAssignable(erasure, erasureOf("java.util.Collection")) -> - resolve(mirror.typeArguments.getOrElse(0) { objectMirror() }, generics, ARRAY) + resolve(mirror.typeArguments.getOrElse(0) { objectMirror() }, generics, ARRAY, visitingTypeVariables) else -> - definition(mirror, mirror.typeArguments.map { resolve(it) }, structureType) + definition(mirror, mirror.typeArguments.map { resolve(it, visitingTypeVariables = visitingTypeVariables) }, structureType) } } - private fun definition(mirror: TypeMirror, generics: List, structureType: StructureType): ClassDefinition { + private fun definition( + mirror: TypeMirror, + generics: List, + structureType: StructureType, + sourceMirror: TypeMirror = mirror, + ): ClassDefinition { val element = types.asElement(mirror) as? TypeElement val fullName = element?.qualifiedName?.toString() ?: mirror.toString().substringBefore("<") return Definition( @@ -98,9 +121,13 @@ class JapTypeIntrospector( generics = generics, structureType = structureType, mirror = mirror, + sourceMirror = sourceMirror, ) } + private fun primitiveDefinition(mirror: PrimitiveType): ClassDefinition = + definition(types.boxedClass(mirror).asType(), emptyList(), DEFAULT, sourceMirror = mirror) + private fun objectDefinition(structureType: StructureType = DEFAULT): ClassDefinition = definition(objectMirror(), emptyList(), structureType) @@ -113,7 +140,8 @@ class JapTypeIntrospector( private fun mirrorValues(mirror: AnnotationMirror): Map { val visitor = object : SimpleAnnotationValueVisitor8() { override fun defaultAction(value: Any?, p: Nothing?): Any? = value - override fun visitType(t: TypeMirror, p: Nothing?): Any = resolve(t) + override fun visitType(t: TypeMirror, p: Nothing?): Any = + if (t is PrimitiveType) primitiveDefinition(t) else resolve(t) override fun visitEnumConstant(constant: VariableElement, p: Nothing?): Any = constant.simpleName.toString() override fun visitArray(values: MutableList, p: Nothing?): Any = values.map { it.accept(this, null) } override fun visitAnnotation(nested: AnnotationMirror, p: Nothing?): Any = mirrorValues(nested) @@ -128,11 +156,12 @@ class JapTypeIntrospector( generics: List, structureType: StructureType, private val mirror: TypeMirror, + private val sourceMirror: TypeMirror = mirror, ) : ClassDefinition(simpleName, fullName, generics, structureType) { @InternalIntrospectionApi override val source: Any - get() = mirror + get() = sourceMirror override fun isEnum(): Boolean = typeElement()?.kind == ElementKind.ENUM @@ -146,13 +175,13 @@ class JapTypeIntrospector( .orEmpty() override fun getAnnotations(): AnnotationSet = - AnnotationsView(listOfNotNull(typeElement())) + AnnotationsView(listOfNotNull(typeElement()), includeInherited = true) override fun getProperties(): List { val element = typeElement() ?: return emptyList() if (element.kind == ElementKind.RECORD) { - return element.recordComponents.map { component -> + val recordProperties = element.recordComponents.map { component -> PropertyView( name = component.simpleName.toString(), type = resolve(component.asType()), @@ -164,6 +193,31 @@ class JapTypeIntrospector( annotations = AnnotationsView(listOf(component)), ) } + val recordPropertyNames = recordProperties.mapTo(mutableSetOf()) { it.name } + val extraGetters = elements.getAllMembers(element).mapNotNull { member -> + if (!member.isGetter()) { + return@mapNotNull null + } + + val getter = member as ExecutableElement + val name = propertyName(getter.simpleName.toString()) + if (name in recordPropertyNames) { + return@mapNotNull null + } + + PropertyView( + name = name, + type = resolve(getter.returnType), + accessor = Accessor.GETTER, + nullable = getter.returnType.nullable(), + visibility = getter.visibility(), + transient = false, + source = getter, + annotations = AnnotationsView(listOf(getter)), + ) + } + + return recordProperties + extraGetters } return elements.getAllMembers(element).mapNotNull { member -> @@ -208,7 +262,7 @@ class JapTypeIntrospector( if (Modifier.STATIC in modifiers) return false if (parameters.isNotEmpty() || enclosingElement?.toString() == "java.lang.Object") return false if (returnType.kind == TypeKind.VOID) return false - return isGetterName(simpleName.toString()) + return isGetterName(simpleName.toString()) || annotationMirrors.any { it.annotationType.asElement().simpleName.contentEquals("OpenApiName") } } private fun Element.isInstanceField(): Boolean = @@ -223,18 +277,40 @@ class JapTypeIntrospector( } } - private inner class AnnotationsView(private val sources: List) : AnnotationSet { + private inner class AnnotationsView( + private val sources: List, + private val includeInherited: Boolean = false, + ) : AnnotationSet { + + private fun mirrors(source: Element): List { + if (!includeInherited) { + return source.annotationMirrors + } + + val inherited = generateSequence((source as? TypeElement)?.superclass) { mirror -> + (types.asElement(mirror) as? TypeElement)?.superclass + } + .mapNotNull { types.asElement(it) as? TypeElement } + .takeWhile { it.qualifiedName.toString() != Object::class.java.name } + .flatMap { supertype -> + supertype.annotationMirrors + .filter { it.annotationType.asElement().getAnnotation(Inherited::class.java) != null } + } + .toList() + + return source.annotationMirrors + inherited + } override fun contains(simpleName: String): Boolean = - sources.any { source -> source.annotationMirrors.any { it.annotationType.asElement().simpleName.contentEquals(simpleName) } } + sources.any { source -> mirrors(source).any { it.annotationType.asElement().simpleName.contentEquals(simpleName) } } override fun find(type: Class): AnnotationView? = - sources.firstNotNullOfOrNull { source -> source.annotationMirrors.firstOrNull { it.named(type.name) } } + sources.firstNotNullOfOrNull { source -> mirrors(source).firstOrNull { it.named(type) } } ?.let { JapAnnotationView(it) } override fun findAll(type: Class): List { - val mirrors = sources.flatMap { it.annotationMirrors } - val direct = mirrors.filter { it.named(type.name) }.map { JapAnnotationView(it) } + val mirrors = sources.flatMap { mirrors(it) } + val direct = mirrors.filter { it.named(type) }.map { JapAnnotationView(it) } // javac wraps repeated annotations in their @Repeatable container; unwrap its `value` array val containerName = type.getAnnotation(JavaRepeatable::class.java)?.value?.java?.canonicalName val container = containerName?.let { name -> mirrors.firstOrNull { it.named(name) } } @@ -246,10 +322,13 @@ class JapTypeIntrospector( } override fun all(): List = - sources.flatMap { it.annotationMirrors }.distinctBy { it.annotationType.asElement() }.map { JapAnnotationView(it) } + sources.flatMap { mirrors(it) }.distinctBy { it.annotationType.asElement() }.map { JapAnnotationView(it) } private fun AnnotationMirror.named(qualifiedName: String): Boolean = (annotationType.asElement() as? TypeElement)?.qualifiedName?.contentEquals(qualifiedName) == true + + private fun AnnotationMirror.named(type: Class): Boolean = + named(type.name) || type.canonicalName?.let { named(it) } == true } private inner class JapAnnotationView(private val mirror: AnnotationMirror) : AnnotationView { diff --git a/introspection/introspection-ksp/src/main/kotlin/io/javalin/introspection/ksp/KspTypeIntrospector.kt b/introspection/introspection-ksp/src/main/kotlin/io/javalin/introspection/ksp/KspTypeIntrospector.kt index 4fad5d6a..46be53d7 100644 --- a/introspection/introspection-ksp/src/main/kotlin/io/javalin/introspection/ksp/KspTypeIntrospector.kt +++ b/introspection/introspection-ksp/src/main/kotlin/io/javalin/introspection/ksp/KspTypeIntrospector.kt @@ -33,6 +33,16 @@ class KspTypeIntrospector(private val resolver: Resolver) : CompileTimeIntrospec private val mapType = builtin("kotlin.collections.Map") private val collectionType = builtin("kotlin.collections.Collection") + private val primitiveArrayElementTypes = mapOf( + "kotlin.BooleanArray" to "kotlin.Boolean", + "kotlin.ByteArray" to "kotlin.Byte", + "kotlin.ShortArray" to "kotlin.Short", + "kotlin.IntArray" to "kotlin.Int", + "kotlin.LongArray" to "kotlin.Long", + "kotlin.FloatArray" to "kotlin.Float", + "kotlin.DoubleArray" to "kotlin.Double", + "kotlin.CharArray" to "kotlin.Char", + ) override fun introspect(source: Any): ClassDefinition { require(source is KSType) { "KspTypeIntrospector expects a com.google.devtools.ksp.symbol.KSType, got ${source::class.java.name}" } @@ -59,22 +69,32 @@ class KspTypeIntrospector(private val resolver: Resolver) : CompileTimeIntrospec .toList() } - private fun resolve(type: KSType, structureType: StructureType = DEFAULT): ClassDefinition { + private fun resolve( + type: KSType, + structureType: StructureType = DEFAULT, + visitingTypeParameters: Set = emptySet(), + ): ClassDefinition { val declaration = type.declaration if (declaration is KSTypeParameter) { + val key = declaration.qualifiedName?.asString() ?: declaration.simpleName.asString() + if (key in visitingTypeParameters) { + return objectDefinition(structureType) + } val bound = declaration.bounds.firstOrNull()?.resolve() - return if (bound != null) resolve(bound, structureType) else objectDefinition(structureType) + return if (bound != null) resolve(bound, structureType, visitingTypeParameters + key) else objectDefinition(structureType) } val qualifiedName = declaration.qualifiedName?.asString() ?: return objectDefinition(structureType) return when { mapType != null && mapType.isAssignableFrom(type.starProjection()) -> - definition(type, DICTIONARY, listOf(resolve(argument(type, 0)), resolve(argument(type, 1)))) + definition(type, DICTIONARY, listOf(resolve(argument(type, 0), visitingTypeParameters = visitingTypeParameters), resolve(argument(type, 1), visitingTypeParameters = visitingTypeParameters))) collectionType != null && collectionType.isAssignableFrom(type.starProjection()) -> - resolve(argument(type, 0), ARRAY) + resolve(argument(type, 0), ARRAY, visitingTypeParameters) qualifiedName == "kotlin.Array" -> - resolve(argument(type, 0), ARRAY) + resolve(argument(type, 0), ARRAY, visitingTypeParameters) + primitiveArrayElementTypes[qualifiedName] != null -> + resolve(builtin(primitiveArrayElementTypes.getValue(qualifiedName))!!, ARRAY, visitingTypeParameters) else -> - definition(type, structureType, type.arguments.mapNotNull { it.type?.resolve() }.map { resolve(it) }) + definition(type, structureType, type.arguments.mapNotNull { it.type?.resolve() }.map { resolve(it, visitingTypeParameters = visitingTypeParameters) }) } } @@ -137,7 +157,9 @@ class KspTypeIntrospector(private val resolver: Resolver) : CompileTimeIntrospec override fun getProperties(): List { val declaration = declaration ?: return emptyList() - return declaration.getAllProperties().map { property -> + return declaration.getAllProperties().filter { property -> + property.getVisibility() !in setOf(KspVisibility.PRIVATE, KspVisibility.LOCAL) + }.map { property -> val propertyType = property.type.resolve() PropertyView( name = property.simpleName.asString(), @@ -147,30 +169,35 @@ class KspTypeIntrospector(private val resolver: Resolver) : CompileTimeIntrospec visibility = visibilityOf(property.getVisibility()), transient = Modifier.JAVA_TRANSIENT in property.modifiers, source = property, - annotations = KspAnnotations(property), + annotations = KspAnnotations(listOfNotNull(property, property.getter)), ) }.toList() } } - private inner class KspAnnotations(private val element: KSAnnotated?) : AnnotationSet { + private inner class KspAnnotations(private val elements: List) : AnnotationSet { + + constructor(element: KSAnnotated?) : this(listOfNotNull(element)) + + private fun annotations(): List = + elements.flatMap { it.annotations.toList() } override fun contains(simpleName: String): Boolean = - element?.annotations?.any { it.shortName.asString() == simpleName } == true + annotations().any { it.shortName.asString() == simpleName } override fun find(type: Class): AnnotationView? = - element?.annotations - ?.firstOrNull { it.annotationType.resolve().declaration.qualifiedName?.asString() == type.name } + annotations() + .firstOrNull { it.named(type) } ?.let { KspAnnotationView(it) } override fun findAll(type: Class): List { - val annotations = element?.annotations?.toList() ?: return emptyList() + val annotations = annotations() val direct = annotations - .filter { it.annotationType.resolve().declaration.qualifiedName?.asString() == type.name } + .filter { it.named(type) } .map { KspAnnotationView(it) } val containerName = type.getAnnotation(JavaRepeatable::class.java)?.value?.java?.canonicalName val repeated = containerName - ?.let { name -> annotations.firstOrNull { it.annotationType.resolve().declaration.qualifiedName?.asString() == name } } + ?.let { name -> annotations.firstOrNull { it.qualifiedName() == name } } ?.let { argumentValues(it)["value"] as? List<*> } ?.filterIsInstance>() ?.map { AnnotationView.of(type.simpleName, it) } @@ -179,7 +206,15 @@ class KspTypeIntrospector(private val resolver: Resolver) : CompileTimeIntrospec } override fun all(): List = - element?.annotations?.map { KspAnnotationView(it) }?.toList() ?: emptyList() + annotations().distinctBy { it.annotationType.resolve().declaration }.map { KspAnnotationView(it) } + + private fun KSAnnotation.named(type: Class): Boolean { + val qualifiedName = qualifiedName() + return qualifiedName == type.name || qualifiedName == type.canonicalName + } + + private fun KSAnnotation.qualifiedName(): String? = + annotationType.resolve().declaration.qualifiedName?.asString() } private inner class KspAnnotationView(private val annotation: KSAnnotation) : AnnotationView { @@ -192,7 +227,9 @@ class KspTypeIntrospector(private val resolver: Resolver) : CompileTimeIntrospec } private fun argumentValues(annotation: KSAnnotation): Map = - annotation.arguments.associate { it.name!!.asString() to normalize(it.value) } + annotation.arguments.mapNotNull { argument -> + argument.name?.asString()?.let { it to normalize(argument.value) } + }.toMap() private fun normalize(value: Any?): Any? = when (value) { diff --git a/introspection/introspection-runtime/src/main/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospector.kt b/introspection/introspection-runtime/src/main/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospector.kt index ba77f709..1fbb9e90 100644 --- a/introspection/introspection-runtime/src/main/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospector.kt +++ b/introspection/introspection-runtime/src/main/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospector.kt @@ -34,26 +34,46 @@ class ReflectionTypeIntrospector : TypeIntrospector { } } -private fun reflect(type: Type, generics: List = emptyList(), structureType: StructureType = DEFAULT): ClassDefinition = +private fun reflect( + type: Type, + generics: List = emptyList(), + structureType: StructureType = DEFAULT, + visitingTypeVariables: Set> = emptySet(), +): ClassDefinition = when (type) { - is GenericArrayType -> reflect(type.genericComponentType, generics, ARRAY) - is WildcardType -> reflect(type.upperBounds.firstOrNull() ?: Any::class.java, generics, structureType) - is TypeVariable<*> -> reflect(type.bounds.firstOrNull() ?: Any::class.java, generics, structureType) - is ParameterizedType -> parameterized(type, generics, structureType) - is Class<*> -> raw(type, generics, structureType) + is GenericArrayType -> reflect(type.genericComponentType, generics, ARRAY, visitingTypeVariables) + is WildcardType -> reflect(type.upperBounds.firstOrNull() ?: Any::class.java, generics, structureType, visitingTypeVariables) + is TypeVariable<*> -> + if (type in visitingTypeVariables) { + objectDefinition(structureType) + } else { + reflect(type.bounds.firstOrNull() ?: Any::class.java, generics, structureType, visitingTypeVariables + type) + } + is ParameterizedType -> parameterized(type, generics, structureType, visitingTypeVariables) + is Class<*> -> raw(type, generics, structureType, visitingTypeVariables) else -> definition(Any::class.java, emptyList(), structureType) } -private fun raw(clazz: Class<*>, generics: List, structureType: StructureType): ClassDefinition = +private fun raw( + clazz: Class<*>, + generics: List, + structureType: StructureType, + visitingTypeVariables: Set>, +): ClassDefinition = when { - clazz.isArray -> reflect(clazz.componentType, generics, ARRAY) + clazz.isArray -> reflect(clazz.componentType, generics, ARRAY, visitingTypeVariables) clazz.isPrimitive -> definition(clazz.kotlin.javaObjectType, generics, structureType) Map::class.java.isAssignableFrom(clazz) -> definition(clazz, listOf(objectDefinition(), objectDefinition()), DICTIONARY) Collection::class.java.isAssignableFrom(clazz) -> objectDefinition(ARRAY) else -> definition(clazz, generics, structureType) } -private fun parameterized(type: ParameterizedType, generics: List, structureType: StructureType): ClassDefinition { +private fun parameterized( + type: ParameterizedType, + generics: List, + structureType: StructureType, + visitingTypeVariables: Set>, +): ClassDefinition { val erasure = type.rawType as Class<*> val arguments = type.actualTypeArguments return when { @@ -61,15 +81,15 @@ private fun parameterized(type: ParameterizedType, generics: List - reflect(arguments.getOrElse(0) { Any::class.java }, generics, ARRAY) + reflect(arguments.getOrElse(0) { Any::class.java }, generics, ARRAY, visitingTypeVariables) else -> - definition(erasure, arguments.map { reflect(it) }, structureType) + definition(erasure, arguments.map { reflect(it, visitingTypeVariables = visitingTypeVariables) }, structureType) } } @@ -152,7 +172,7 @@ private fun collectMembers(clazz: Class<*>): List { } for (field in declaredFieldsHierarchy(clazz)) { - if (Modifier.isStatic(field.modifiers)) continue + if (Modifier.isStatic(field.modifiers) || field.isSynthetic) continue members += Member(field.name, field.genericType, Accessor.FIELD, visibilityOf(field.modifiers), Modifier.isTransient(field.modifiers), field, listOf(field)) } @@ -243,7 +263,10 @@ private class ReflectionAnnotationView(private val annotation: Annotation) : Ann } private fun annotationToMap(annotation: Annotation): Map = - annotation.annotationClass.java.declaredMethods.associate { it.name to normalize(it.invoke(annotation)) } + annotation.annotationClass.java.declaredMethods.associate { + it.trySetAccessible() + it.name to normalize(it.invoke(annotation)) + } private fun normalize(value: Any?): Any? = when { diff --git a/introspection/introspection-runtime/src/test/java/io/javalin/introspection/runtime/PackagePrivateAnnotationFixture.java b/introspection/introspection-runtime/src/test/java/io/javalin/introspection/runtime/PackagePrivateAnnotationFixture.java new file mode 100644 index 00000000..90a253c9 --- /dev/null +++ b/introspection/introspection-runtime/src/test/java/io/javalin/introspection/runtime/PackagePrivateAnnotationFixture.java @@ -0,0 +1,13 @@ +package io.javalin.introspection.runtime; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +@Retention(RetentionPolicy.RUNTIME) +@interface PackagePrivateAnnotation { + String value(); +} + +@PackagePrivateAnnotation("package-private") +class PackagePrivateAnnotated { +} diff --git a/introspection/introspection-runtime/src/test/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospectorTest.kt b/introspection/introspection-runtime/src/test/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospectorTest.kt index 6b6b7326..e508f9e5 100644 --- a/introspection/introspection-runtime/src/test/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospectorTest.kt +++ b/introspection/introspection-runtime/src/test/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospectorTest.kt @@ -96,6 +96,23 @@ class ReflectionTypeIntrospectorTest { assertThat(values["shade"]).isEqualTo("RED") assertThat(annotations.find(java.lang.Deprecated::class.java)?.values).isNull() } + + @Test + fun `skips synthetic fields from inner classes`() { + val properties = introspector.introspect(RuntimeOuter.Inner::class.java).getProperties().map { it.name } + + assertThat(properties).contains("visible") + assertThat(properties).doesNotContain("this\$0") + } + + @Test + fun `reads values from package-private runtime annotations`() { + val annotation = introspector.introspect(PackagePrivateAnnotated::class.java) + .getAnnotations() + .find(PackagePrivateAnnotation::class.java) + + assertThat(annotation?.string("value")).isEqualTo("package-private") + } } private class Address @@ -123,3 +140,9 @@ private annotation class Mixed(val name: String, val count: Int, val type: KClas @Refs(Address::class, Color::class) @Mixed(name = "x", count = 3, type = Address::class, shade = Color.RED) private class Holder + +private class RuntimeOuter { + inner class Inner { + val visible: String = "" + } +} diff --git a/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/IntrospectionParityTest.kt b/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/IntrospectionParityTest.kt index 58c83bed..7f5c9abd 100644 --- a/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/IntrospectionParityTest.kt +++ b/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/IntrospectionParityTest.kt @@ -158,6 +158,35 @@ class IntrospectionParityTest { val kspConstants = SymbolProcessing.introspect(Color::class) { it.getEnumConstants().map { constant -> constant.name }.sorted() } assertThat(kspConstants).isEqualTo(runtimeConstants).isEqualTo(listOf("GREEN", "RED")) } + + @Test + fun `self-bounded type variables do not recurse forever across backends`() { + fun childType(definition: ClassDefinition): Pair> { + val child = definition.getProperties().first { it.name == "child" }.type + return child.fullName to child.generics.map { it.fullName } + } + + val runtimeChild = childType(runtime.introspect(SelfBounded::class.java)) + val processedChild = AnnotationProcessing.introspect(SelfBounded::class) { childType(it) } + val kspChild = SymbolProcessing.introspect(SelfBounded::class) { childType(it) } + + assertThat(processedChild).isEqualTo(runtimeChild) + assertThat(kspChild.first).isEqualTo(SelfBounded::class.java.name) + assertThat(kspChild.second).containsExactly(Any::class.java.name) + } + + @Test + fun `nested annotation classes can be found by class across backends`() { + fun value(definition: ClassDefinition): String? = + definition.getAnnotations().find(AnnotationContainer.Nested::class.java)?.string("value") + + val runtimeValue = value(runtime.introspect(NestedAnnotated::class.java)) + val processedValue = AnnotationProcessing.introspect(NestedAnnotated::class) { value(it) } + val kspValue = SymbolProcessing.introspect(NestedAnnotated::class) { value(it) } + + assertThat(processedValue).isEqualTo(runtimeValue).isEqualTo("nested") + assertThat(kspValue).isEqualTo(runtimeValue) + } } private data class TypeShape( @@ -263,3 +292,12 @@ annotation class Refs(vararg val value: KClass<*>) @Ref(Address::class) @Refs(Address::class, Color::class) class Holder + +class SelfBounded>(val child: T?) + +class AnnotationContainer { + annotation class Nested(val value: String) +} + +@AnnotationContainer.Nested("nested") +class NestedAnnotated diff --git a/javalin-plugins/javalin-openapi-plugin/build.gradle.kts b/javalin-plugins/javalin-openapi-plugin/build.gradle.kts index 049c70b4..4adfd9c1 100644 --- a/javalin-plugins/javalin-openapi-plugin/build.gradle.kts +++ b/javalin-plugins/javalin-openapi-plugin/build.gradle.kts @@ -10,6 +10,7 @@ dependencies { kaptTest(project(":openapi-annotation-processor")) + testImplementation(project(":openapi-dynamic")) testImplementation(libs.javalin) testImplementation(libs.junit.jupiter.params) testImplementation(libs.junit.jupiter.api) diff --git a/javalin-plugins/javalin-openapi-plugin/src/test/kotlin/OpenApiPluginTest.kt b/javalin-plugins/javalin-openapi-plugin/src/test/kotlin/OpenApiPluginTest.kt index 1eb549c4..da454dcf 100644 --- a/javalin-plugins/javalin-openapi-plugin/src/test/kotlin/OpenApiPluginTest.kt +++ b/javalin-plugins/javalin-openapi-plugin/src/test/kotlin/OpenApiPluginTest.kt @@ -1,5 +1,7 @@ import io.javalin.Javalin import io.javalin.openapi.OpenApi +import io.javalin.openapi.dynamic.ReflectionSchemaContext +import io.javalin.openapi.experimental.processor.shared.jsonMapper import io.javalin.openapi.plugin.OpenApiPlugin import kong.unirest.Unirest import org.assertj.core.api.Assertions.assertThat @@ -12,6 +14,8 @@ class OpenApiPluginTest { ) private object OpenApiTest + private data class DefinitionConfigurationUser(val id: String) + @Test fun `should support schema modifications in definition configuration`() { val app = @@ -65,4 +69,62 @@ class OpenApiPluginTest { } } + @Test + fun `should support explicit schema reference resolution in definition configuration`() { + val schemaContext = ReflectionSchemaContext() + + val app = Javalin.start { config -> + config.jetty.port = 0 + + config.registerPlugin( + OpenApiPlugin { + it.withDefinitionConfiguration { _, builder -> + builder.path("/runtime-user").operation("get") { + responses { + response("200") { + description("OK") + content { + mediaType("application/json") { + schema(schemaContext.inlineSchema(DefinitionConfigurationUser::class.java)) + } + } + } + } + } + builder.resolveComponentReferences { type -> schemaContext.componentSchema(type) } + } + } + ) + } + + try { + val response = Unirest.get("http://localhost:${app.port()}/openapi") + .asString() + .body + + val document = jsonMapper.readTree(response) + val schema = document + .path("paths") + .path("/runtime-user") + .path("get") + .path("responses") + .path("200") + .path("content") + .path("application/json") + .path("schema") + + assertThat(schema.path("\$ref").asText()).isEqualTo("#/components/schemas/DefinitionConfigurationUser") + assertThat(document.path("components").path("schemas").has("DefinitionConfigurationUser")).isTrue() + assertThat( + document.path("components") + .path("schemas") + .path("DefinitionConfigurationUser") + .path("properties") + .has("id") + ).isTrue() + } finally { + app.stop() + } + } + } diff --git a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/AnnotationProcessorContext.kt b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/AnnotationProcessorContext.kt index dad7bf61..286828b1 100644 --- a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/AnnotationProcessorContext.kt +++ b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/AnnotationProcessorContext.kt @@ -16,8 +16,10 @@ import javax.lang.model.element.Element import javax.lang.model.element.ElementKind import javax.lang.model.element.ExecutableElement import javax.lang.model.element.TypeElement +import javax.lang.model.type.PrimitiveType import javax.lang.model.type.TypeMirror import javax.lang.model.util.Types +import javax.tools.Diagnostic.Kind.WARNING import io.javalin.introspection.ClassDefinition as RawType import io.javalin.introspection.StructureType as RawStructureType @@ -54,7 +56,8 @@ class AnnotationProcessorContext( @OptIn(InternalIntrospectionApi::class) override fun toOpenApiType(raw: RawType): OpenApiType { - val mirror = raw.source as TypeMirror + val rawMirror = raw.source as TypeMirror + val mirror = if (rawMirror.kind.isPrimitive) types.boxedClass(rawMirror as PrimitiveType).asType() else rawMirror return OpenApiType( simpleName = mirror.getSimpleName(), fullName = mirror.getFullName(), @@ -67,6 +70,10 @@ class AnnotationProcessorContext( ) } + override fun reportWarning(message: String) { + env.messager.printMessage(WARNING, message) + } + fun TypeMirror.toOpenApiType(): OpenApiType = toOpenApiType(japIntrospector.introspect(this)) diff --git a/openapi-annotation-processor/src/test/java/io/javalin/openapi/processor/RegressionFixtures.java b/openapi-annotation-processor/src/test/java/io/javalin/openapi/processor/RegressionFixtures.java new file mode 100644 index 00000000..6b14756f --- /dev/null +++ b/openapi-annotation-processor/src/test/java/io/javalin/openapi/processor/RegressionFixtures.java @@ -0,0 +1,68 @@ +package io.javalin.openapi.processor; + +import io.javalin.openapi.CustomAnnotation; +import io.javalin.openapi.OpenApiName; +import io.javalin.openapi.OpenApiPropertyType; +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.time.Instant; + +class PrimitiveRedirectDto { + @OpenApiPropertyType(definedBy = long.class) + public Instant getCreatedAt() { + return Instant.EPOCH; + } +} + +class FluentOpenApiNameDto { + @OpenApiName("age") + public int age() { + return 1; + } +} + +@Inherited +@CustomAnnotation +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +@interface InheritedExtra { + String inherited(); +} + +@InheritedExtra(inherited = "yes") +class InheritedExtraBase { +} + +class InheritedExtraChild extends InheritedExtraBase { + public String getName() { + return ""; + } +} + +record RecordWithExtraGetter(String id) { + public String getDisplayName() { + return ""; + } +} + +@CustomAnnotation +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +@interface NestedExtra { + NestedValue nested(); +} + +@Retention(RetentionPolicy.RUNTIME) +@interface NestedValue { + String note(); +} + +@NestedExtra(nested = @NestedValue(note = "x")) +class NestedExtraDto { + public String getName() { + return ""; + } +} diff --git a/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/RegressionNetTest.kt b/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/RegressionNetTest.kt new file mode 100644 index 00000000..84b1024f --- /dev/null +++ b/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/RegressionNetTest.kt @@ -0,0 +1,182 @@ +@file:Suppress("unused") + +package io.javalin.openapi.processor + +import io.javalin.openapi.JsonSchema +import io.javalin.openapi.OpenApi +import io.javalin.openapi.OpenApiContent +import io.javalin.openapi.OpenApiResponse +import io.javalin.openapi.experimental.processor.shared.jsonMapper +import io.javalin.openapi.processor.specification.OpenApiAnnotationProcessorSpecification +import net.javacrumbs.jsonunit.assertj.JsonAssertions.json +import net.javacrumbs.jsonunit.assertj.assertThatJson +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import java.net.URI +import java.nio.file.Files +import javax.tools.Diagnostic +import javax.tools.DiagnosticCollector +import javax.tools.JavaFileObject +import javax.tools.SimpleJavaFileObject +import javax.tools.ToolProvider + +internal class RegressionNetTest : OpenApiAnnotationProcessorSpecification() { + + @OpenApi( + path = "/primitive-redirect", + versions = ["should_keep_primitive_redirect_required"], + responses = [OpenApiResponse(status = "200", content = [OpenApiContent(from = PrimitiveRedirectDto::class)])] + ) + @Test + fun should_keep_primitive_redirect_required() = withOpenApi("should_keep_primitive_redirect_required") { + assertThatJson(it) + .inPath("$.components.schemas.PrimitiveRedirectDto.properties.createdAt") + .isObject + .isEqualTo(json("""{ "type": "integer", "format": "int64" }""")) + + assertThatJson(it) + .inPath("$.components.schemas.PrimitiveRedirectDto.required") + .isEqualTo(json("""["createdAt"]""")) + } + + @OpenApi( + path = "/fluent-openapi-name", + versions = ["should_include_fluent_accessor_with_openapi_name"], + responses = [OpenApiResponse(status = "200", content = [OpenApiContent(from = FluentOpenApiNameDto::class)])] + ) + @Test + fun should_include_fluent_accessor_with_openapi_name() = withOpenApi("should_include_fluent_accessor_with_openapi_name") { + assertThatJson(it) + .inPath("$.components.schemas.FluentOpenApiNameDto.properties") + .isObject + .containsKey("age") + } + + @OpenApi( + path = "/inherited-custom-annotation", + versions = ["should_include_inherited_custom_annotation_extras"], + responses = [OpenApiResponse(status = "200", content = [OpenApiContent(from = InheritedExtraChild::class)])] + ) + @Test + fun should_include_inherited_custom_annotation_extras() = withOpenApi("should_include_inherited_custom_annotation_extras") { + assertThatJson(it) + .inPath("$.components.schemas.InheritedExtraChild") + .isObject + .containsEntry("inherited", "yes") + } + + @OpenApi( + path = "/record-extra-getter", + versions = ["should_include_record_components_and_extra_getters"], + responses = [OpenApiResponse(status = "200", content = [OpenApiContent(from = RecordWithExtraGetter::class)])] + ) + @Test + fun should_include_record_components_and_extra_getters() = withOpenApi("should_include_record_components_and_extra_getters") { + assertThatJson(it) + .inPath("$.components.schemas.RecordWithExtraGetter.properties") + .isObject + .containsKey("id") + .containsKey("displayName") + } + + @OpenApi( + path = "/nested-custom-annotation", + versions = ["should_emit_nested_custom_annotation_values_as_json"], + responses = [OpenApiResponse(status = "200", content = [OpenApiContent(from = NestedExtraDto::class)])] + ) + @Test + fun should_emit_nested_custom_annotation_values_as_json() = withOpenApi("should_emit_nested_custom_annotation_values_as_json") { + assertThatJson(it) + .inPath("$.components.schemas.NestedExtraDto.nested") + .isEqualTo(json("""{ "note": "x" }""")) + } + + @JsonSchema(requireNonNulls = false) + private class JsonSchemaWithoutRequired(val name: String, val age: Int) + + @Test + fun should_honor_json_schema_require_non_nulls_false() = withJsonScheme("JsonSchemaWithoutRequired") { + val document = jsonMapper.readTree(it) + + assertThat(document.path("\$schema").asText()).isEqualTo("https://json-schema.org/draft/2020-12/schema") + assertThat(document.has("required")).isFalse() + assertThat(document.path("properties").path("name").path("type").asText()).isEqualTo("string") + assertThat(document.path("properties").path("age").path("type").asText()).isEqualTo("integer") + } + + @JsonSchema(generateResource = false) + private class DisabledJsonSchema(val ignored: String) + + @Test + fun should_honor_json_schema_generate_resource_false() { + val generatedNames = io.javalin.openapi.JsonSchemaLoader().loadGeneratedSchemes().map { it.name } + + assertThat(generatedNames).noneMatch { it.contains("DisabledJsonSchema") } + } + + @JsonSchema + private class NestedJsonSchema(val child: NestedJsonSchemaChild) + + private class NestedJsonSchemaChild(val value: String) + + @Test + fun should_inline_nested_types_in_standalone_json_schema() = withJsonScheme("NestedJsonSchema") { + assertThatJson(it) + .inPath("$.properties.child") + .isObject + .isEqualTo(json(""" + { + "type": "object", + "properties": { + "value": { + "type": "string" + } + }, + "required": ["value"] + } + """)) + } + + @Test + fun should_warn_when_content_mime_type_cannot_be_resolved() { + val compiler = requireNotNull(ToolProvider.getSystemJavaCompiler()) { "A JDK is required (no system Java compiler)" } + val diagnostics = DiagnosticCollector() + val output = Files.createTempDirectory("openapi-diagnostics") + val source = object : SimpleJavaFileObject(URI.create("string:///app/UnresolvedContentRoute.java"), JavaFileObject.Kind.SOURCE) { + override fun getCharContent(ignoreEncodingErrors: Boolean): CharSequence = + """ + package app; + + import io.javalin.openapi.OpenApi; + import io.javalin.openapi.OpenApiContent; + import io.javalin.openapi.OpenApiResponse; + + class UnresolvedContentRoute { + @OpenApi( + path = "/unresolved", + responses = { + @OpenApiResponse(status = "200", content = { @OpenApiContent() }) + } + ) + public void route() { + } + } + """.trimIndent() + } + + val options = listOf( + "-classpath", System.getProperty("java.class.path"), + "-d", output.toString(), + "-s", output.resolve("generated").toString(), + ) + val task = compiler.getTask(null, null, diagnostics, options, null, listOf(source)) + task.setProcessors(listOf(OpenApiAnnotationProcessor())) + + assertThat(task.call()).isTrue() + assertThat(diagnostics.diagnostics) + .filteredOn { it.kind == Diagnostic.Kind.WARNING } + .anySatisfy { + assertThat(it.getMessage(null)).contains("OpenApi generator cannot find matching mime type defined") + } + } +} diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/SchemaGenerationContext.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/SchemaGenerationContext.kt index febc1cae..558bdb9a 100644 --- a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/SchemaGenerationContext.kt +++ b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/SchemaGenerationContext.kt @@ -25,4 +25,6 @@ interface SchemaGenerationContext { fun acceptsProperty(type: OpenApiType, property: PropertyView): Boolean = true fun discriminatorSubtypes(type: OpenApiType): List> = emptyList() + + fun reportWarning(message: String) {} } diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/TypeSchemaGenerator.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/TypeSchemaGenerator.kt index 9db6ec76..162716ce 100644 --- a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/TypeSchemaGenerator.kt +++ b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/TypeSchemaGenerator.kt @@ -5,6 +5,8 @@ import com.fasterxml.jackson.databind.node.ArrayNode import com.fasterxml.jackson.databind.node.ObjectNode import io.javalin.introspection.Accessor import io.javalin.introspection.AnnotationSet +import io.javalin.introspection.ClassDefinition as RawType +import io.javalin.introspection.InternalIntrospectionApi import io.javalin.introspection.MemberVisibility import io.javalin.openapi.* import io.javalin.openapi.experimental.* @@ -252,7 +254,7 @@ internal fun SchemaGenerationContext.findAllProperties(type: OpenApiType, requir val nullability = property.annotations.find(OpenApiPropertyType::class.java)?.string("nullability") val redirect = property.annotations.find(OpenApiPropertyType::class.java)?.classValue("definedBy") - val treatedAsNotNull = redirect == null && !property.nullable + val treatedAsNotNull = (redirect == null && !property.nullable) || redirect?.hasPrimitiveSource() == true val isNotNull = when { nullability == Nullability.NOT_NULL.name -> true @@ -370,10 +372,34 @@ private fun MemberVisibility.toOpenApi(): Visibility = private fun Map.notNull(key: String): String? = (this[key] as? String)?.takeIf { it != NULL_STRING } +private val primitiveSourceNames = setOf("boolean", "byte", "short", "int", "long", "float", "double", "char") + +@OptIn(InternalIntrospectionApi::class) +private fun RawType.hasPrimitiveSource(): Boolean = + source.toString() in primitiveSourceNames + private fun customAnnotationValue(value: Any?): Any? = when (value) { is String -> value.trimIndent() is io.javalin.introspection.ClassDefinition -> value.fullName + is Map<*, *> -> createObjectNode().also { node -> + value.forEach { (key, nestedValue) -> + val field = key as? String ?: return@forEach + when (val resolved = customAnnotationValue(nestedValue)) { + is Boolean -> node.put(field, resolved) + is Int -> node.put(field, resolved) + is Long -> node.put(field, resolved) + is Double -> node.put(field, resolved) + is Float -> node.put(field, resolved) + is Short -> node.put(field, resolved.toInt()) + is Byte -> node.put(field, resolved.toInt()) + is String -> node.put(field, resolved) + is JsonNode -> node.set(field, resolved) + null -> {} + else -> node.put(field, resolved.toString()) + } + } + } is List<*> -> createArrayNode().also { array -> value.forEach { when (val element = customAnnotationValue(it)) { diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/schema/OpenApiSchemaGenerator.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/schema/OpenApiSchemaGenerator.kt index f92b982b..902bddd4 100644 --- a/openapi-generator/src/main/kotlin/io/javalin/openapi/schema/OpenApiSchemaGenerator.kt +++ b/openapi-generator/src/main/kotlin/io/javalin/openapi/schema/OpenApiSchemaGenerator.kt @@ -233,6 +233,13 @@ class OpenApiSchemaGenerator( } if (mimeType == null) { + context.reportWarning( + """ + OpenApi generator cannot find matching mime type defined. + Content: + $content + """.trimIndent() + ) return null } diff --git a/openapi-ksp/src/main/kotlin/io/javalin/openapi/ksp/KspSchemaContext.kt b/openapi-ksp/src/main/kotlin/io/javalin/openapi/ksp/KspSchemaContext.kt index a4d1d1cd..89f35c25 100644 --- a/openapi-ksp/src/main/kotlin/io/javalin/openapi/ksp/KspSchemaContext.kt +++ b/openapi-ksp/src/main/kotlin/io/javalin/openapi/ksp/KspSchemaContext.kt @@ -1,16 +1,21 @@ package io.javalin.openapi.ksp import com.google.devtools.ksp.processing.Resolver +import com.google.devtools.ksp.processing.KSPLogger import com.google.devtools.ksp.symbol.KSAnnotated import io.javalin.introspection.AnnotationSet +import io.javalin.introspection.PropertyView import io.javalin.introspection.TypeIntrospector import io.javalin.introspection.ksp.KspTypeIntrospector +import io.javalin.openapi.OpenApiByFields import io.javalin.openapi.experimental.IntrospectorSchemaContext +import io.javalin.openapi.experimental.OpenApiType import io.javalin.openapi.experimental.SimpleType import io.javalin.openapi.experimental.defaults.createDefaultSimpleTypeMappings internal class KspSchemaContext( resolver: Resolver, + private val logger: KSPLogger, simpleTypeMappings: Map = createDefaultSimpleTypeMappings(), ) : IntrospectorSchemaContext(simpleTypeMappings) { @@ -20,4 +25,20 @@ internal class KspSchemaContext( fun annotationsOf(annotated: KSAnnotated): AnnotationSet = kspTypeIntrospector.annotationsOf(annotated) + override fun propertiesOf(type: OpenApiType): List { + if (annotationsOf(type).find(OpenApiByFields::class.java)?.boolean("only") == true) { + logger.error( + "KSP does not support @OpenApiByFields(only = true). " + + "Use APT/Kapt for field-only schema generation, or remove only = true." + ) + return emptyList() + } + + return super.propertiesOf(type) + } + + override fun reportWarning(message: String) { + logger.warn(message) + } + } diff --git a/openapi-ksp/src/main/kotlin/io/javalin/openapi/ksp/OpenApiSymbolProcessor.kt b/openapi-ksp/src/main/kotlin/io/javalin/openapi/ksp/OpenApiSymbolProcessor.kt index d25b0a63..bbfa9792 100644 --- a/openapi-ksp/src/main/kotlin/io/javalin/openapi/ksp/OpenApiSymbolProcessor.kt +++ b/openapi-ksp/src/main/kotlin/io/javalin/openapi/ksp/OpenApiSymbolProcessor.kt @@ -2,6 +2,7 @@ package io.javalin.openapi.ksp import com.google.devtools.ksp.processing.CodeGenerator import com.google.devtools.ksp.processing.Dependencies +import com.google.devtools.ksp.processing.KSPLogger import com.google.devtools.ksp.processing.Resolver import com.google.devtools.ksp.processing.SymbolProcessor import com.google.devtools.ksp.processing.SymbolProcessorEnvironment @@ -17,12 +18,15 @@ import io.javalin.openapi.schema.OpenApiSchemaGenerator class OpenApiSymbolProcessor( private val codeGenerator: CodeGenerator, + private val logger: KSPLogger, private val options: Map, ) : SymbolProcessor { + private val writtenResources = mutableSetOf() + override fun process(resolver: Resolver): List { - generateJsonSchemes(resolver, KspSchemaContext(resolver)) - generateOpenApiDocuments(resolver, KspSchemaContext(resolver)) + generateJsonSchemes(resolver, KspSchemaContext(resolver, logger)) + generateOpenApiDocuments(resolver, KspSchemaContext(resolver, logger)) return emptyList() } @@ -71,13 +75,18 @@ class OpenApiSymbolProcessor( writeResource("openapi-plugin/.index", generatedVersions.joinToString(separator = "\n")) } - private fun writeResource(path: String, content: String) = + private fun writeResource(path: String, content: String) { + if (!writtenResources.add(path)) { + return + } + codeGenerator.createNewFileByPath(Dependencies(aggregating = true), path, extensionName = "") .use { it.write(content.toByteArray()) } + } } class OpenApiSymbolProcessorProvider : SymbolProcessorProvider { override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor = - OpenApiSymbolProcessor(environment.codeGenerator, environment.options) + OpenApiSymbolProcessor(environment.codeGenerator, environment.logger, environment.options) } diff --git a/openapi-ksp/src/test/kotlin/io/javalin/openapi/ksp/KspSchemaContextTest.kt b/openapi-ksp/src/test/kotlin/io/javalin/openapi/ksp/KspSchemaContextTest.kt index 513f784b..e5ade95b 100644 --- a/openapi-ksp/src/test/kotlin/io/javalin/openapi/ksp/KspSchemaContextTest.kt +++ b/openapi-ksp/src/test/kotlin/io/javalin/openapi/ksp/KspSchemaContextTest.kt @@ -1,6 +1,7 @@ package io.javalin.openapi.ksp import com.google.devtools.ksp.processing.Resolver +import com.google.devtools.ksp.processing.KSPLogger import com.google.devtools.ksp.processing.SymbolProcessor import com.google.devtools.ksp.processing.SymbolProcessorEnvironment import com.google.devtools.ksp.processing.SymbolProcessorProvider @@ -16,13 +17,13 @@ import org.junit.jupiter.api.Test @OptIn(ExperimentalCompilerApi::class) class KspSchemaContextTest { - private fun withResolver(block: (Resolver) -> R): R { + private fun withResolver(block: (Resolver, KSPLogger) -> R): R { var result: Result? = null val provider = object : SymbolProcessorProvider { override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor = object : SymbolProcessor { override fun process(resolver: Resolver): List { - if (result == null) result = runCatching { block(resolver) } + if (result == null) result = runCatching { block(resolver, environment.logger) } return emptyList() } } @@ -41,8 +42,8 @@ class KspSchemaContextTest { @Test fun `generates a component schema end-to-end through the shared generator`() { - withResolver { resolver -> - val context = KspSchemaContext(resolver) + withResolver { resolver, logger -> + val context = KspSchemaContext(resolver, logger) val user = resolver.getClassDeclarationByName(resolver.getKSNameFromString("io.javalin.openapi.ksp.User"))!! .asStarProjectedType() diff --git a/openapi-ksp/src/test/kotlin/io/javalin/openapi/ksp/OpenApiSymbolProcessorTest.kt b/openapi-ksp/src/test/kotlin/io/javalin/openapi/ksp/OpenApiSymbolProcessorTest.kt index 0175d622..5b4df55f 100644 --- a/openapi-ksp/src/test/kotlin/io/javalin/openapi/ksp/OpenApiSymbolProcessorTest.kt +++ b/openapi-ksp/src/test/kotlin/io/javalin/openapi/ksp/OpenApiSymbolProcessorTest.kt @@ -1,5 +1,13 @@ package io.javalin.openapi.ksp +import com.google.devtools.ksp.processing.CodeGenerator +import com.google.devtools.ksp.processing.Dependencies +import com.google.devtools.ksp.processing.Resolver +import com.google.devtools.ksp.processing.SymbolProcessor +import com.google.devtools.ksp.processing.SymbolProcessorEnvironment +import com.google.devtools.ksp.processing.SymbolProcessorProvider +import com.google.devtools.ksp.symbol.KSAnnotated +import com.tschuchort.compiletesting.CompilationResult import com.tschuchort.compiletesting.KotlinCompilation import com.tschuchort.compiletesting.SourceFile import com.tschuchort.compiletesting.kspSourcesDir @@ -217,4 +225,234 @@ class OpenApiSymbolProcessorTest { assertThat(addressProperty.has("properties")).isFalse() assertThat(schemas.path("Address").path("properties").path("city").path("type").asText()).isEqualTo("string") } + + @Test + fun `fails loudly for OpenApiByFields only true`() { + val (_, result) = compileWithKsp( + SourceFile.kotlin( + "FieldsOnly.kt", + """ + package app + import io.javalin.openapi.JsonSchema + import io.javalin.openapi.OpenApiByFields + + @JsonSchema + @OpenApiByFields(only = true) + class FieldsOnly(val value: String) + """.trimIndent() + ) + ) + + assertThat(result.exitCode).isEqualTo(KotlinCompilation.ExitCode.COMPILATION_ERROR) + assertThat(result.messages).contains("KSP does not support @OpenApiByFields(only = true)") + } + + @Test + fun `honors getter-site OpenApiIgnore annotations`() { + val (compilation, result) = compileWithKsp( + SourceFile.kotlin( + "GetterIgnored.kt", + """ + package app + import io.javalin.openapi.JsonSchema + import io.javalin.openapi.OpenApiIgnore + + @JsonSchema + class GetterIgnored(@get:OpenApiIgnore val secret: String, val kept: String) + """.trimIndent() + ) + ) + check(result.exitCode == KotlinCompilation.ExitCode.OK) { "KSP compilation failed: ${result.messages}" } + + val properties = compilation.generatedJson("app.GetterIgnored").path("properties") + assertThat(properties.has("kept")).isTrue() + assertThat(properties.has("secret")).isFalse() + } + + @Test + fun `does not publish private Kotlin vals as schema properties`() { + val (compilation, result) = compileWithKsp( + SourceFile.kotlin( + "PrivateDto.kt", + """ + package app + import io.javalin.openapi.JsonSchema + + @JsonSchema + class PrivateDto(val visible: String, private val secret: String) + """.trimIndent() + ) + ) + check(result.exitCode == KotlinCompilation.ExitCode.OK) { "KSP compilation failed: ${result.messages}" } + + val properties = compilation.generatedJson("app.PrivateDto").path("properties") + assertThat(properties.has("visible")).isTrue() + assertThat(properties.has("secret")).isFalse() + } + + @Test + fun `maps ByteArray to binary string schema`() { + val (compilation, result) = compileWithKsp( + SourceFile.kotlin( + "BytesDto.kt", + """ + package app + import io.javalin.openapi.JsonSchema + + @JsonSchema + class BytesDto(val payload: ByteArray) + """.trimIndent() + ) + ) + check(result.exitCode == KotlinCompilation.ExitCode.OK) { "KSP compilation failed: ${result.messages}" } + + val payload = compilation.generatedJson("app.BytesDto").path("properties").path("payload") + assertThat(payload.path("type").asText()).isEqualTo("string") + assertThat(payload.path("format").asText()).isEqualTo("binary") + } + + @Test + fun `does not fail when another processor creates OpenApi routes in a later KSP round`() { + val (_, result) = compileWithKsp( + SourceFile.kotlin( + "InitialRoutes.kt", + """ + package app + import io.javalin.openapi.HttpMethod + import io.javalin.openapi.OpenApi + + class InitialRoutes { + @OpenApi(path = "/initial", methods = [HttpMethod.GET]) + fun initial() {} + } + """.trimIndent() + ), + providers = mutableListOf(OpenApiSymbolProcessorProvider(), LaterRoundRouteProcessorProvider()) + ) + + assertThat(result.exitCode).isEqualTo(KotlinCompilation.ExitCode.OK) + assertThat(result.messages).doesNotContain("FileAlreadyExistsException") + } + + @Test + fun `honors JsonSchema requireNonNulls false`() { + val (compilation, result) = compileWithKsp( + SourceFile.kotlin( + "OptionalSchema.kt", + """ + package app + import io.javalin.openapi.JsonSchema + + @JsonSchema(requireNonNulls = false) + class OptionalSchema(val name: String, val age: Int) + """.trimIndent() + ) + ) + check(result.exitCode == KotlinCompilation.ExitCode.OK) { "KSP compilation failed: ${result.messages}" } + + val document = compilation.generatedJson("app.OptionalSchema") + assertThat(document.path("\$schema").asText()).isEqualTo("https://json-schema.org/draft/2020-12/schema") + assertThat(document.has("required")).isFalse() + assertThat(document.path("properties").path("name").path("type").asText()).isEqualTo("string") + assertThat(document.path("properties").path("age").path("type").asText()).isEqualTo("integer") + } + + @Test + fun `honors JsonSchema generateResource false`() { + val (compilation, result) = compileWithKsp( + SourceFile.kotlin( + "DisabledSchema.kt", + """ + package app + import io.javalin.openapi.JsonSchema + + @JsonSchema(generateResource = false) + class DisabledSchema(val ignored: String) + """.trimIndent() + ) + ) + check(result.exitCode == KotlinCompilation.ExitCode.OK) { "KSP compilation failed: ${result.messages}" } + + val generatedNames = compilation.kspSourcesDir.walkTopDown().map { it.name }.toList() + assertThat(generatedNames).doesNotContain("app.DisabledSchema") + } + + @Test + fun `inlines nested types in standalone JsonSchema resources`() { + val (compilation, result) = compileWithKsp( + SourceFile.kotlin( + "NestedSchema.kt", + """ + package app + import io.javalin.openapi.JsonSchema + + class NestedChild(val value: String) + + @JsonSchema + class NestedSchema(val child: NestedChild) + """.trimIndent() + ) + ) + check(result.exitCode == KotlinCompilation.ExitCode.OK) { "KSP compilation failed: ${result.messages}" } + + val child = compilation.generatedJson("app.NestedSchema").path("properties").path("child") + assertThat(child.path("type").asText()).isEqualTo("object") + assertThat(child.path("properties").path("value").path("type").asText()).isEqualTo("string") + assertThat(child.path("required").map { it.asText() }).containsExactly("value") + } + + private fun compileWithKsp( + vararg sources: SourceFile, + providers: MutableList = mutableListOf(OpenApiSymbolProcessorProvider()), + ): Pair { + val compilation = KotlinCompilation().apply { + useKsp2() + this.sources = sources.toList() + symbolProcessorProviders = providers + inheritClassPath = true + messageOutputStream = System.out + } + + return compilation to compilation.compile() + } + + private fun KotlinCompilation.generatedJson(name: String) = + jsonMapper.readTree(generatedFile(name).readText()) + + private fun KotlinCompilation.generatedFile(name: String) = + kspSourcesDir.walkTopDown().firstOrNull { it.name == name } + ?: error("generated file $name not found. Output tree:\n" + kspSourcesDir.walkTopDown().joinToString("\n")) +} + +private class LaterRoundRouteProcessorProvider : SymbolProcessorProvider { + override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor = + LaterRoundRouteProcessor(environment.codeGenerator) +} + +private class LaterRoundRouteProcessor(private val codeGenerator: CodeGenerator) : SymbolProcessor { + private var generated = false + + override fun process(resolver: Resolver): List { + if (!generated) { + generated = true + codeGenerator.createNewFile(Dependencies(aggregating = true), "app", "GeneratedRoutes") + .use { + it.write( + """ + package app + + import io.javalin.openapi.HttpMethod + import io.javalin.openapi.OpenApi + + class GeneratedRoutes { + @OpenApi(path = "/generated", methods = [HttpMethod.GET]) + fun generated() {} + } + """.trimIndent().toByteArray() + ) + } + } + + return emptyList() + } } From db3bfb8b56d71c355cebdf2068d12e84ddf89494 Mon Sep 17 00:00:00 2001 From: dzikoysk Date: Thu, 9 Jul 2026 02:47:23 +0200 Subject: [PATCH 27/43] fix: tests --- .../io/javalin/introspection/jap/JapTypeIntrospector.kt | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/introspection/introspection-jap/src/main/kotlin/io/javalin/introspection/jap/JapTypeIntrospector.kt b/introspection/introspection-jap/src/main/kotlin/io/javalin/introspection/jap/JapTypeIntrospector.kt index 88f4860d..7732d849 100644 --- a/introspection/introspection-jap/src/main/kotlin/io/javalin/introspection/jap/JapTypeIntrospector.kt +++ b/introspection/introspection-jap/src/main/kotlin/io/javalin/introspection/jap/JapTypeIntrospector.kt @@ -49,7 +49,7 @@ class JapTypeIntrospector( } fun annotationsOf(element: Element): AnnotationSet = - AnnotationsView(listOf(element)) + AnnotationsView(listOf(element), includeInherited = element is TypeElement) @OptIn(InternalIntrospectionApi::class) override fun typesAnnotatedWith(annotationType: Class, assignableTo: ClassDefinition?): List { @@ -294,7 +294,7 @@ class JapTypeIntrospector( .takeWhile { it.qualifiedName.toString() != Object::class.java.name } .flatMap { supertype -> supertype.annotationMirrors - .filter { it.annotationType.asElement().getAnnotation(Inherited::class.java) != null } + .filter { it.annotationType.asElement().hasAnnotation(Inherited::class.java) } } .toList() @@ -329,6 +329,9 @@ class JapTypeIntrospector( private fun AnnotationMirror.named(type: Class): Boolean = named(type.name) || type.canonicalName?.let { named(it) } == true + + private fun Element.hasAnnotation(type: Class): Boolean = + annotationMirrors.any { it.named(type) } } private inner class JapAnnotationView(private val mirror: AnnotationMirror) : AnnotationView { From 2e6714955717f4c55d69cccf097eec1547a1cf4b Mon Sep 17 00:00:00 2001 From: dzikoysk Date: Mon, 13 Jul 2026 02:26:38 +0200 Subject: [PATCH 28/43] chore: cleanup --- docs/advanced/runtime-builder.md | 20 ++- .../build.gradle.kts | 2 + .../dynamic/hook/RegisteredRoutesHook.kt | 50 ++++++- .../dynamic/hook/DynamicOpenApiHookTest.kt | 116 +++++++++++++++- .../javalin/openapi/plugin/OpenApiHandler.kt | 5 +- .../openapi/plugin/redoc/ReDocHandler.kt | 5 +- .../plugin/redoc/ReDocWebJarHandler.kt | 5 +- .../openapi/plugin/swagger/SwaggerHandler.kt | 5 +- .../plugin/swagger/SwaggerWebJarHandler.kt | 5 +- .../AnnotationProcessorContext.kt | 5 + .../processor/generators/OpenApiGenerator.kt | 11 +- .../openapi/processor/RegressionNetTest.kt | 63 +++++++++ .../experimental/SchemaGenerationContext.kt | 2 + .../generators/TypeSchemaGenerator.kt | 6 +- .../openapi/schema/OpenApiSchemaBuilder.kt | 43 +++--- .../schema/OpenApiSchemaBuilderTest.kt | 11 ++ .../openapi/ksp/OpenApiSymbolProcessorTest.kt | 32 +++++ .../openapi/OpenApiPluginRouteHandler.kt | 3 + .../io/javalin/openapi/OpenApiStatus.kt | 128 +++++++++--------- .../io/javalin/openapi/OpenApiStatusTest.kt | 14 ++ 20 files changed, 421 insertions(+), 110 deletions(-) create mode 100644 openapi-specification/src/main/kotlin/io/javalin/openapi/OpenApiPluginRouteHandler.kt create mode 100644 openapi-specification/src/test/kotlin/io/javalin/openapi/OpenApiStatusTest.kt diff --git a/docs/advanced/runtime-builder.md b/docs/advanced/runtime-builder.md index 3b1ef326..8acfdeb6 100644 --- a/docs/advanced/runtime-builder.md +++ b/docs/advanced/runtime-builder.md @@ -221,7 +221,7 @@ Reopening an existing operation preserves all fields - only the fields you set a ## Auto-generating Docs for Registered Routes -For a springdoc-style experience - documenting routes that are registered programmatically (and that the compile-time processor never sees) - use the **dynamic hook** module. It enumerates every route registered on Javalin at startup and contributes them to the served document. +For a springdoc-style experience - documenting routes that are registered programmatically (and that the compile-time processor never sees) - use the **dynamic hook** module. It adds undocumented Javalin routes to the served document. ```kotlin [Gradle (Kotlin)] dependencies { @@ -233,7 +233,7 @@ dependencies { Register `RegisteredRoutesHook` on the `OpenApiPlugin`: ```kotlin -Javalin.create { config -> +Javalin.start { config -> config.registerPlugin(OpenApiPlugin { it.withHook(RegisteredRoutesHook()) }) config.routes.get("/users") { /* ... */ } @@ -241,7 +241,17 @@ Javalin.create { config -> } ``` -Every registered route is documented with its path, method, path parameters (typed as `string`), and a default `200` response. +Every newly discovered route is documented with its path, method, path parameters (typed as `string`), and a default `200` response. Existing operations from compile-time documentation are left unchanged unless the route carries runtime metadata. + +Routes registered by `OpenApiPlugin`, `SwaggerPlugin`, and `ReDocPlugin` are excluded by default, including custom UI and WebJar paths. Add prefix exclusions for your own non-API routes: + +```kotlin +RegisteredRoutesHook { routes -> + routes.withIgnoredPathPrefixes("/assets", "/internal") +} +``` + +Each prefix matches the path itself and its descendants, so `/assets` excludes `/assets/logo.svg` but not `/assets-admin`. A trailing `/*` is accepted as an equivalent spelling. To include the default excluded plugin routes, call `clearDefaultIgnoredRoutes()`. ### Enriching a Route @@ -263,10 +273,10 @@ config.routes.addEndpoint( ) ``` -The path-parameter skeleton is still auto-added, then your metadata is overlaid on top, and referenced types (e.g. `User`) are emitted into `components/schemas`. +For newly discovered operations, the path-parameter skeleton is auto-added before your metadata is applied. Existing operations retain their parameters, and referenced types (e.g. `User`) are emitted into `components/schemas`. ::: warning -The hook currently documents the `OpenApiPlugin`'s own `/openapi` route (and any Swagger/ReDoc UI routes) alongside your endpoints. Runtime reflection is opt-in: it only happens when you add this module and register the hook, so the compile-time backends stay reflection-free. +The document is built on its first request, so routes registered after that request are not included. Runtime reflection cannot see CLASS-retention annotations or automatically discover discriminator subtypes. Runtime reflection is opt-in: it only happens when you add this module and register the hook, so the compile-time backends stay reflection-free. ::: ## Merge Behavior diff --git a/javalin-plugins/javalin-openapi-dynamic-hook/build.gradle.kts b/javalin-plugins/javalin-openapi-dynamic-hook/build.gradle.kts index 4120eda6..1a54b670 100644 --- a/javalin-plugins/javalin-openapi-dynamic-hook/build.gradle.kts +++ b/javalin-plugins/javalin-openapi-dynamic-hook/build.gradle.kts @@ -5,6 +5,8 @@ dependencies { api(project(":javalin-plugins:javalin-openapi-plugin")) compileOnly(libs.javalin) + testImplementation(project(":javalin-plugins:javalin-redoc-plugin")) + testImplementation(project(":javalin-plugins:javalin-swagger-plugin")) testImplementation(libs.javalin) testImplementation(libs.junit.jupiter.api) testImplementation(libs.junit.jupiter.engine) diff --git a/javalin-plugins/javalin-openapi-dynamic-hook/src/main/kotlin/io/javalin/openapi/dynamic/hook/RegisteredRoutesHook.kt b/javalin-plugins/javalin-openapi-dynamic-hook/src/main/kotlin/io/javalin/openapi/dynamic/hook/RegisteredRoutesHook.kt index b239d0bf..6ed953c3 100644 --- a/javalin-plugins/javalin-openapi-dynamic-hook/src/main/kotlin/io/javalin/openapi/dynamic/hook/RegisteredRoutesHook.kt +++ b/javalin-plugins/javalin-openapi-dynamic-hook/src/main/kotlin/io/javalin/openapi/dynamic/hook/RegisteredRoutesHook.kt @@ -1,10 +1,46 @@ package io.javalin.openapi.dynamic.hook +import io.javalin.openapi.OpenApiPluginRouteHandler import io.javalin.openapi.dynamic.ReflectionSchemaContext import io.javalin.openapi.plugin.OpenApiHook import io.javalin.openapi.plugin.OpenApiHookContext +import io.javalin.router.Endpoint +import java.util.function.Consumer -class RegisteredRoutesHook : OpenApiHook { +class RegisteredRoutesHookConfiguration { + private var ignoreDefaultRoutes = true + private val ignoredPathPrefixes = linkedSetOf() + + fun clearDefaultIgnoredRoutes(): RegisteredRoutesHookConfiguration = apply { + ignoreDefaultRoutes = false + } + + fun withIgnoredPathPrefix(prefix: String): RegisteredRoutesHookConfiguration = + withIgnoredPathPrefixes(prefix) + + fun withIgnoredPathPrefixes(vararg prefixes: String): RegisteredRoutesHookConfiguration = apply { + prefixes.map(::normalizePathPrefix).forEach(ignoredPathPrefixes::add) + } + + internal fun ignores(endpoint: Endpoint): Boolean = + (ignoreDefaultRoutes && endpoint.handler is OpenApiPluginRouteHandler) || + ignoredPathPrefixes.any { endpoint.path.matchesPathPrefix(it) } + + private fun normalizePathPrefix(prefix: String): String { + val normalized = prefix.removeSuffix("/*").trimEnd('/').ifEmpty { "/" } + require(normalized.startsWith('/')) { "Ignored path prefixes must start with '/': $prefix" } + require('*' !in normalized) { "Ignored path prefixes only support a trailing /*: $prefix" } + return normalized + } + + private fun String.matchesPathPrefix(prefix: String): Boolean = + prefix == "/" || this == prefix || startsWith("$prefix/") +} + +class RegisteredRoutesHook @JvmOverloads constructor( + userConfig: Consumer = Consumer {}, +) : OpenApiHook { + private val config = RegisteredRoutesHookConfiguration().also(userConfig::accept) override fun apply(context: OpenApiHookContext) { val schemaContext = ReflectionSchemaContext() // document-scoped: owns the generator + its memo cache @@ -13,12 +49,20 @@ class RegisteredRoutesHook : OpenApiHook { context.state.internalRouter.allHttpHandlers() .map { it.endpoint } .filter { it.method.isHttpMethod } + .filterNot(config::ignores) .forEach { endpoint -> + val path = toOpenApiPath(endpoint.path) + val method = endpoint.method.name().lowercase() val pathParams = PATH_PARAM.findAll(endpoint.path).map { it.groupValues[1] }.toList() val metadata = endpoint.metadata(OpenApiMetadata::class.java) + val operationAlreadyDocumented = context.builder.hasOperation(path, method) + + if (metadata == null && operationAlreadyDocumented) { + return@forEach + } - context.builder.path(toOpenApiPath(endpoint.path)).operation(endpoint.method.name().lowercase()) { - if (pathParams.isNotEmpty()) { + context.builder.path(path).operation(method) { + if (!operationAlreadyDocumented && pathParams.isNotEmpty()) { parameters { pathParams.forEach { name -> parameter(name = name, location = "path", required = true) { type("string") } diff --git a/javalin-plugins/javalin-openapi-dynamic-hook/src/test/kotlin/io/javalin/openapi/dynamic/hook/DynamicOpenApiHookTest.kt b/javalin-plugins/javalin-openapi-dynamic-hook/src/test/kotlin/io/javalin/openapi/dynamic/hook/DynamicOpenApiHookTest.kt index f10720ad..851d230a 100644 --- a/javalin-plugins/javalin-openapi-dynamic-hook/src/test/kotlin/io/javalin/openapi/dynamic/hook/DynamicOpenApiHookTest.kt +++ b/javalin-plugins/javalin-openapi-dynamic-hook/src/test/kotlin/io/javalin/openapi/dynamic/hook/DynamicOpenApiHookTest.kt @@ -4,6 +4,8 @@ import io.javalin.Javalin import io.javalin.http.HandlerType import io.javalin.openapi.experimental.processor.shared.jsonMapper import io.javalin.openapi.plugin.OpenApiPlugin +import io.javalin.openapi.plugin.redoc.ReDocPlugin +import io.javalin.openapi.plugin.swagger.SwaggerPlugin import io.javalin.router.Endpoint import kong.unirest.Unirest import org.assertj.core.api.Assertions.assertThat @@ -12,7 +14,7 @@ import org.junit.jupiter.api.Test class DynamicOpenApiHookTest { @Test - fun `autogenerates docs for all registered Javalin routes`() { + fun `autogenerates docs for registered user routes`() { val app = Javalin.start { config -> config.jetty.port = 0 config.registerPlugin(OpenApiPlugin { it.withHook(RegisteredRoutesHook()) }) @@ -25,6 +27,7 @@ class DynamicOpenApiHookTest { val body = Unirest.get("http://localhost:${app.port()}/openapi").asString().body val paths = jsonMapper.readTree(body).path("paths") + assertThat(paths.has("/openapi")).isFalse() assertThat(paths.path("/users").has("get")).isTrue() assertThat(paths.path("/users").has("post")).isTrue() @@ -41,6 +44,81 @@ class DynamicOpenApiHookTest { } } + @Test + fun `ignores routes registered by OpenAPI plugins by default`() { + val app = Javalin.start { config -> + config.jetty.port = 0 + config.registerPlugin(OpenApiPlugin { it.withHook(RegisteredRoutesHook()) }) + config.registerPlugin(SwaggerPlugin { swagger -> + swagger + .withUiPath("/documentation/swagger") + .withWebJarPath("/documentation/assets/swagger") + }) + config.registerPlugin(ReDocPlugin { redoc -> + redoc + .withUiPath("/documentation/redoc") + .withWebJarPath("/documentation/assets/redoc") + }) + config.routes.get("/users") { } + } + + try { + val document = jsonMapper.readTree(Unirest.get("http://localhost:${app.port()}/openapi").asString().body) + val paths = document.path("paths").fieldNames().asSequence().toList() + + assertThat(paths).containsExactly("/users") + } finally { + app.stop() + } + } + + @Test + fun `includes default ignored routes when configured`() { + val app = Javalin.start { config -> + config.jetty.port = 0 + config.registerPlugin( + OpenApiPlugin { + it.withHook(RegisteredRoutesHook { routes -> routes.clearDefaultIgnoredRoutes() }) + } + ) + config.routes.get("/users") { } + } + + try { + val document = jsonMapper.readTree(Unirest.get("http://localhost:${app.port()}/openapi").asString().body) + val paths = document.path("paths") + + assertThat(paths.has("/openapi")).isTrue() + assertThat(paths.has("/users")).isTrue() + } finally { + app.stop() + } + } + + @Test + fun `ignores configured path prefixes without matching adjacent paths`() { + val app = Javalin.start { config -> + config.jetty.port = 0 + config.registerPlugin( + OpenApiPlugin { + it.withHook(RegisteredRoutesHook { routes -> routes.withIgnoredPathPrefix("/assets/*") }) + } + ) + config.routes.get("/assets/logo.svg") { } + config.routes.get("/assets-admin") { } + } + + try { + val document = jsonMapper.readTree(Unirest.get("http://localhost:${app.port()}/openapi").asString().body) + val paths = document.path("paths") + + assertThat(paths.has("/assets/logo.svg")).isFalse() + assertThat(paths.has("/assets-admin")).isTrue() + } finally { + app.stop() + } + } + @Test fun `enriches a route from OpenApiMetadata, reusing the operation builder and schema engine`() { val app = Javalin.start { config -> @@ -66,7 +144,6 @@ class DynamicOpenApiHookTest { val get = document.path("paths").path("/users/{id}").path("get") assertThat(get.path("summary").asText()).isEqualTo("Get a user") - // Path param skeleton is still auto-added, then the metadata is overlaid. assertThat(get.path("parameters")[0].path("name").asText()).isEqualTo("id") val schema = get.path("responses").path("200").path("content").path("application/json").path("schema") @@ -79,4 +156,39 @@ class DynamicOpenApiHookTest { app.stop() } } + + @Test + fun `leaves an existing operation untouched when no runtime metadata is present`() { + val app = Javalin.start { config -> + config.jetty.port = 0 + config.registerPlugin( + OpenApiPlugin { plugin -> + plugin.withHook { context -> + context.builder.path("/users/{id}").operation("get") { + summary("Find a user") + parameters { + parameter("id", "path", required = true) { type("integer") } + } + responses { + response("200") { description("A user") } + } + } + } + plugin.withHook(RegisteredRoutesHook()) + } + ) + config.routes.get("/users/{id}") { } + } + + try { + val document = jsonMapper.readTree(Unirest.get("http://localhost:${app.port()}/openapi").asString().body) + val operation = document.path("paths").path("/users/{id}").path("get") + + assertThat(operation.path("summary").asText()).isEqualTo("Find a user") + assertThat(operation.path("parameters")[0].path("schema").path("type").asText()).isEqualTo("integer") + assertThat(operation.path("responses").path("200").path("description").asText()).isEqualTo("A user") + } finally { + app.stop() + } + } } diff --git a/javalin-plugins/javalin-openapi-plugin/src/main/kotlin/io/javalin/openapi/plugin/OpenApiHandler.kt b/javalin-plugins/javalin-openapi-plugin/src/main/kotlin/io/javalin/openapi/plugin/OpenApiHandler.kt index 0aa7190c..1e0d9a0e 100644 --- a/javalin-plugins/javalin-openapi-plugin/src/main/kotlin/io/javalin/openapi/plugin/OpenApiHandler.kt +++ b/javalin-plugins/javalin-openapi-plugin/src/main/kotlin/io/javalin/openapi/plugin/OpenApiHandler.kt @@ -4,8 +4,9 @@ import io.javalin.http.ContentType import io.javalin.http.Context import io.javalin.http.Handler import io.javalin.http.Header +import io.javalin.openapi.OpenApiPluginRouteHandler -internal class OpenApiHandler(private val documentation: Lazy>) : Handler { +internal class OpenApiHandler(private val documentation: Lazy>) : Handler, OpenApiPluginRouteHandler { override fun handle(context: Context) { context @@ -15,4 +16,4 @@ internal class OpenApiHandler(private val documentation: Lazy>, private val customJavaScriptFiles: List> -) : Handler { +) : Handler, OpenApiPluginRouteHandler { private val swaggerUiHtml = createSwaggerUiHtml() @@ -134,4 +135,4 @@ class SwaggerHandler( return replace(multiplePathOperatorsRegex, "/") } -} \ No newline at end of file +} diff --git a/javalin-plugins/javalin-swagger-plugin/src/main/kotlin/io/javalin/openapi/plugin/swagger/SwaggerWebJarHandler.kt b/javalin-plugins/javalin-swagger-plugin/src/main/kotlin/io/javalin/openapi/plugin/swagger/SwaggerWebJarHandler.kt index 912c7f6a..0caf9c39 100644 --- a/javalin-plugins/javalin-swagger-plugin/src/main/kotlin/io/javalin/openapi/plugin/swagger/SwaggerWebJarHandler.kt +++ b/javalin-plugins/javalin-swagger-plugin/src/main/kotlin/io/javalin/openapi/plugin/swagger/SwaggerWebJarHandler.kt @@ -2,6 +2,7 @@ package io.javalin.openapi.plugin.swagger import io.javalin.http.Context import io.javalin.http.Handler +import io.javalin.openapi.OpenApiPluginRouteHandler import org.eclipse.jetty.http.HttpStatus import org.eclipse.jetty.http.MimeTypes import java.io.InputStream @@ -9,7 +10,7 @@ import java.io.InputStream internal class SwaggerWebJarHandler( private val swaggerWebJarPath: String, private val classLoader: ClassLoader = SwaggerWebJarHandler::class.java.classLoader, -) : Handler { +) : Handler, OpenApiPluginRouteHandler { override fun handle(context: Context) { val resourceRootPath = "META-INF/resources$swaggerWebJarPath" @@ -33,4 +34,4 @@ internal class SwaggerWebJarHandler( } } -} \ No newline at end of file +} diff --git a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/AnnotationProcessorContext.kt b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/AnnotationProcessorContext.kt index 286828b1..8b226d73 100644 --- a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/AnnotationProcessorContext.kt +++ b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/AnnotationProcessorContext.kt @@ -19,6 +19,7 @@ import javax.lang.model.element.TypeElement import javax.lang.model.type.PrimitiveType import javax.lang.model.type.TypeMirror import javax.lang.model.util.Types +import javax.tools.Diagnostic.Kind.NOTE import javax.tools.Diagnostic.Kind.WARNING import io.javalin.introspection.ClassDefinition as RawType import io.javalin.introspection.StructureType as RawStructureType @@ -74,6 +75,10 @@ class AnnotationProcessorContext( env.messager.printMessage(WARNING, message) } + override fun reportDebug(message: String) { + inDebug { it.printMessage(NOTE, message) } + } + fun TypeMirror.toOpenApiType(): OpenApiType = toOpenApiType(japIntrospector.introspect(this)) diff --git a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/processor/generators/OpenApiGenerator.kt b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/processor/generators/OpenApiGenerator.kt index 738a3805..31127df2 100644 --- a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/processor/generators/OpenApiGenerator.kt +++ b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/processor/generators/OpenApiGenerator.kt @@ -20,13 +20,14 @@ internal class OpenApiGenerator { ) fun generate(roundEnvironment: RoundEnvironment) { - val aggregatedRoutes = roundEnvironment.getElementsAnnotatedWith(OpenApis::class.java) + val routes = listOf( + roundEnvironment.getElementsAnnotatedWith(OpenApis::class.java), + roundEnvironment.getElementsAnnotatedWith(OpenApi::class.java), + ) + .flatten() .flatMap { element -> context.annotationsOf(element).findAll(OpenApi::class.java).map { it.values } } - val standaloneRoutes = roundEnvironment.getElementsAnnotatedWith(OpenApi::class.java) - .flatMap { element -> context.annotationsOf(element).findAll(OpenApi::class.java).map { it.values } } - - schemaGenerator.generateVersionedSchemas(aggregatedRoutes + standaloneRoutes) + schemaGenerator.generateVersionedSchemas(routes) .map { (version, generatedOpenApiSchema) -> val resourceName = "openapi-${version.replace(" ", "-")}.json" val resource = context.env.filer.saveResource(context, "openapi-plugin/$resourceName", generatedOpenApiSchema) diff --git a/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/RegressionNetTest.kt b/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/RegressionNetTest.kt index 84b1024f..8e75cdd4 100644 --- a/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/RegressionNetTest.kt +++ b/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/RegressionNetTest.kt @@ -14,6 +14,7 @@ import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test import java.net.URI import java.nio.file.Files +import javax.annotation.processing.ProcessingEnvironment import javax.tools.Diagnostic import javax.tools.DiagnosticCollector import javax.tools.JavaFileObject @@ -179,4 +180,66 @@ internal class RegressionNetTest : OpenApiAnnotationProcessorSpecification() { assertThat(it.getMessage(null)).contains("OpenApi generator cannot find matching mime type defined") } } + + @Test + fun should_emit_compact_debug_trace() { + val compiler = requireNotNull(ToolProvider.getSystemJavaCompiler()) { "A JDK is required (no system Java compiler)" } + val diagnostics = DiagnosticCollector() + val output = Files.createTempDirectory("openapi-debug") + val source = object : SimpleJavaFileObject(URI.create("string:///app/DebugRoute.java"), JavaFileObject.Kind.SOURCE) { + override fun getCharContent(ignoreEncodingErrors: Boolean): CharSequence = + """ + package app; + + import io.javalin.openapi.OpenApi; + import io.javalin.openapi.OpenApiContent; + import io.javalin.openapi.OpenApiResponse; + + class DebugRoute { + @OpenApi( + path = "/debug", + responses = { + @OpenApiResponse(status = "200", content = { @OpenApiContent(from = DebugDto.class) }) + } + ) + public void route() { + } + } + + class DebugDto { + public String getName() { + return ""; + } + } + """.trimIndent() + } + + val options = listOf( + "-classpath", System.getProperty("java.class.path"), + "-d", output.toString(), + "-s", output.resolve("generated").toString(), + ) + val task = compiler.getTask(null, null, diagnostics, options, null, listOf(source)) + task.setProcessors( + listOf( + object : OpenApiAnnotationProcessor() { + override fun init(processingEnv: ProcessingEnvironment) { + super.init(processingEnv) + context.configuration.debug = true + } + } + ) + ) + + assertThat(task.call()).isTrue() + val notes = diagnostics.diagnostics + .filter { it.kind == Diagnostic.Kind.NOTE } + .map { it.getMessage(null) } + assertThat(notes) + .contains( + "OpenApi | Debug mode enabled", + "OpenApi | Generating schema for app.DebugDto", + "OpenApi | Resolved 1 properties for app.DebugDto: name", + ) + } } diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/SchemaGenerationContext.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/SchemaGenerationContext.kt index 558bdb9a..f4acb981 100644 --- a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/SchemaGenerationContext.kt +++ b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/SchemaGenerationContext.kt @@ -27,4 +27,6 @@ interface SchemaGenerationContext { fun discriminatorSubtypes(type: OpenApiType): List> = emptyList() fun reportWarning(message: String) {} + + fun reportDebug(message: String) {} } diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/TypeSchemaGenerator.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/TypeSchemaGenerator.kt index 162716ce..918dd702 100644 --- a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/TypeSchemaGenerator.kt +++ b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/TypeSchemaGenerator.kt @@ -23,6 +23,8 @@ class TypeSchemaGenerator(val context: SchemaGenerationContext) { inlineRefs: Boolean = false, requireNonNullsByDefault: Boolean = true ): ResultScheme { + context.reportDebug("OpenApi | Generating schema for ${type.fullName}") + val annotations = context.annotationsOf(type) val isEnum = context.isEnum(type) val definedBy = annotations.find(OpenApiPropertyType::class.java)?.classValue("definedBy")?.let { context.toOpenApiType(it) } @@ -298,6 +300,8 @@ internal fun SchemaGenerationContext.findAllProperties(type: OpenApiType, requir ) } + reportDebug("OpenApi | Resolved ${properties.size} properties for ${type.fullName}: ${properties.joinToString { it.name }}") + return properties } @@ -381,7 +385,7 @@ private fun RawType.hasPrimitiveSource(): Boolean = private fun customAnnotationValue(value: Any?): Any? = when (value) { is String -> value.trimIndent() - is io.javalin.introspection.ClassDefinition -> value.fullName + is RawType -> value.fullName is Map<*, *> -> createObjectNode().also { node -> value.forEach { (key, nestedValue) -> val field = key as? String ?: return@forEach diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/schema/OpenApiSchemaBuilder.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/schema/OpenApiSchemaBuilder.kt index a49e5050..3feb0ce9 100644 --- a/openapi-generator/src/main/kotlin/io/javalin/openapi/schema/OpenApiSchemaBuilder.kt +++ b/openapi-generator/src/main/kotlin/io/javalin/openapi/schema/OpenApiSchemaBuilder.kt @@ -37,36 +37,36 @@ class OpenApiSchemaBuilder { fun openApiVersion(version: String): OpenApiSchemaBuilder = apply { - root.put("openapi", version) - } + root.put("openapi", version) + } fun info(configure: Consumer): OpenApiSchemaBuilder = apply { - val infoJson = jsonMapper.convertValue(OpenApiInfo().also { configure.accept(it) }, JsonNode::class.java) - val existingInfo = root.get("info") - val updatedInfo: JsonNode = - if (existingInfo != null) { - jsonMapper.readerForUpdating(existingInfo).readValue(infoJson) - } else { - infoJson - } - root.set("info", updatedInfo) - } + val infoJson = jsonMapper.convertValue(OpenApiInfo().also { configure.accept(it) }, JsonNode::class.java) + val existingInfo = root.get("info") + val updatedInfo: JsonNode = + if (existingInfo != null) { + jsonMapper.readerForUpdating(existingInfo).readValue(infoJson) + } else { + infoJson + } + root.set("info", updatedInfo) + } fun server(configure: Consumer): OpenApiSchemaBuilder = apply { - val serversArray = root.get("servers") as? ArrayNode ?: createArrayNode() - serversArray.add(jsonMapper.convertValue(OpenApiServer().also { configure.accept(it) }, JsonNode::class.java)) - root.set("servers", serversArray) - } + val serversArray = root.get("servers") as? ArrayNode ?: createArrayNode() + serversArray.add(jsonMapper.convertValue(OpenApiServer().also { configure.accept(it) }, JsonNode::class.java)) + root.set("servers", serversArray) + } /** Add a named security scheme */ fun withSecurityScheme(name: String, scheme: SecurityScheme): OpenApiSchemaBuilder = apply { - val components = root.get("components") as? ObjectNode ?: createObjectNode().also { root.set("components", it) } - val schemes = components.get("securitySchemes") as? ObjectNode ?: createObjectNode().also { components.set("securitySchemes", it) } - schemes.set(name, jsonMapper.convertValue(scheme, JsonNode::class.java)) - } + val components = root.get("components") as? ObjectNode ?: createObjectNode().also { root.set("components", it) } + val schemes = components.get("securitySchemes") as? ObjectNode ?: createObjectNode().also { components.set("securitySchemes", it) } + schemes.set(name, jsonMapper.convertValue(scheme, JsonNode::class.java)) + } /** Add HTTP Basic authentication scheme */ @JvmOverloads @@ -117,6 +117,9 @@ class OpenApiSchemaBuilder { return PathItemBuilder(paths.get(path) as ObjectNode, refCollector) } + fun hasOperation(path: String, method: String): Boolean = + (paths.get(path) as? ObjectNode)?.has(method) == true + fun addComponentSchema(name: String, schema: ResultScheme) { refCollector(schema.references) componentSchemas.set(name, schema.json) diff --git a/openapi-generator/src/test/kotlin/io/javalin/openapi/schema/OpenApiSchemaBuilderTest.kt b/openapi-generator/src/test/kotlin/io/javalin/openapi/schema/OpenApiSchemaBuilderTest.kt index 9bc308ae..015bc7cf 100644 --- a/openapi-generator/src/test/kotlin/io/javalin/openapi/schema/OpenApiSchemaBuilderTest.kt +++ b/openapi-generator/src/test/kotlin/io/javalin/openapi/schema/OpenApiSchemaBuilderTest.kt @@ -929,6 +929,17 @@ internal class OpenApiSchemaBuilderTest { assertThatJson(roundTripped).inPath("$.paths['/users'].get.summary").isEqualTo("List users") } + @Test + fun `should identify operations after fromJson`() { + val original = builder() + original.path("/users").operation("get") { } + + val schema = OpenApiSchemaBuilder.fromJson(original.toJson()) + + assert(schema.hasOperation("/users", "get")) + assert(!schema.hasOperation("/users", "post")) + } + @Test fun `should modify schema after fromJson`() { val original = """{"openapi":"3.1.0","info":{"title":"API","version":"1.0"},"paths":{},"components":{"schemas":{}}}""" diff --git a/openapi-ksp/src/test/kotlin/io/javalin/openapi/ksp/OpenApiSymbolProcessorTest.kt b/openapi-ksp/src/test/kotlin/io/javalin/openapi/ksp/OpenApiSymbolProcessorTest.kt index 5b4df55f..5272e9a2 100644 --- a/openapi-ksp/src/test/kotlin/io/javalin/openapi/ksp/OpenApiSymbolProcessorTest.kt +++ b/openapi-ksp/src/test/kotlin/io/javalin/openapi/ksp/OpenApiSymbolProcessorTest.kt @@ -311,6 +311,38 @@ class OpenApiSymbolProcessorTest { assertThat(payload.path("format").asText()).isEqualTo("binary") } + @Test + fun `deduplicates custom annotation extras across KSP property and getter sources`() { + val (compilation, result) = compileWithKsp( + SourceFile.kotlin( + "DuplicateCustomAnnotationDto.kt", + """ + package app + import io.javalin.openapi.CustomAnnotation + import io.javalin.openapi.JsonSchema + + @CustomAnnotation + @Target(AnnotationTarget.PROPERTY, AnnotationTarget.PROPERTY_GETTER) + annotation class KspExtra(val xDeduped: String) + + @JsonSchema + class DuplicateCustomAnnotationDto( + @KspExtra("property") + @get:KspExtra("getter") + val value: String + ) + """.trimIndent() + ) + ) + check(result.exitCode == KotlinCompilation.ExitCode.OK) { "KSP compilation failed: ${result.messages}" } + + val value = compilation.generatedJson("app.DuplicateCustomAnnotationDto") + .path("properties") + .path("value") + + assertThat(value.path("xDeduped").asText()).isEqualTo("property") + } + @Test fun `does not fail when another processor creates OpenApi routes in a later KSP round`() { val (_, result) = compileWithKsp( diff --git a/openapi-specification/src/main/kotlin/io/javalin/openapi/OpenApiPluginRouteHandler.kt b/openapi-specification/src/main/kotlin/io/javalin/openapi/OpenApiPluginRouteHandler.kt new file mode 100644 index 00000000..c2a0e9df --- /dev/null +++ b/openapi-specification/src/main/kotlin/io/javalin/openapi/OpenApiPluginRouteHandler.kt @@ -0,0 +1,3 @@ +package io.javalin.openapi + +interface OpenApiPluginRouteHandler diff --git a/openapi-specification/src/main/kotlin/io/javalin/openapi/OpenApiStatus.kt b/openapi-specification/src/main/kotlin/io/javalin/openapi/OpenApiStatus.kt index 926aa768..851e33a8 100644 --- a/openapi-specification/src/main/kotlin/io/javalin/openapi/OpenApiStatus.kt +++ b/openapi-specification/src/main/kotlin/io/javalin/openapi/OpenApiStatus.kt @@ -66,71 +66,71 @@ object OpenApiStatus { const val NETWORK_AUTHENTICATION_REQUIRED = "511" fun reasonPhrase(status: String): String? = - status.toIntOrNull()?.let { REASON_PHRASES[it] ?: "Unknown HTTP code" } + REASON_PHRASES[status] ?: status.toIntOrNull()?.let { "Unknown HTTP code" } - private val REASON_PHRASES: Map = mapOf( - CONTINUE.toInt() to "Continue", - SWITCHING_PROTOCOLS.toInt() to "Switching Protocols", - PROCESSING.toInt() to "Processing", - EARLY_HINTS.toInt() to "Early Hints", - OK.toInt() to "OK", - CREATED.toInt() to "Created", - ACCEPTED.toInt() to "Accepted", - NON_AUTHORITATIVE_INFORMATION.toInt() to "Non-Authoritative Information", - NO_CONTENT.toInt() to "No Content", - RESET_CONTENT.toInt() to "Reset Content", - PARTIAL_CONTENT.toInt() to "Partial Content", - MULTI_STATUS.toInt() to "Multi-Status", - ALREADY_REPORTED.toInt() to "Already Reported", - IM_USED.toInt() to "IM Used", - MULTIPLE_CHOICES.toInt() to "Multiple Choices", - MOVED_PERMANENTLY.toInt() to "Moved Permanently", - FOUND.toInt() to "Found", - SEE_OTHER.toInt() to "See Other", - NOT_MODIFIED.toInt() to "Not Modified", - USE_PROXY.toInt() to "Use Proxy", - TEMPORARY_REDIRECT.toInt() to "Temporary Redirect", - PERMANENT_REDIRECT.toInt() to "Permanent Redirect", - BAD_REQUEST.toInt() to "Bad Request", - UNAUTHORIZED.toInt() to "Unauthorized", - PAYMENT_REQUIRED.toInt() to "Payment Required", - FORBIDDEN.toInt() to "Forbidden", - NOT_FOUND.toInt() to "Not Found", - METHOD_NOT_ALLOWED.toInt() to "Method Not Allowed", - NOT_ACCEPTABLE.toInt() to "Not Acceptable", - PROXY_AUTHENTICATION_REQUIRED.toInt() to "Proxy Authentication Required", - REQUEST_TIMEOUT.toInt() to "Request Timeout", - CONFLICT.toInt() to "Conflict", - GONE.toInt() to "Gone", - LENGTH_REQUIRED.toInt() to "Length Required", - PRECONDITION_FAILED.toInt() to "Precondition Failed", - CONTENT_TOO_LARGE.toInt() to "Content Too Large", - URI_TOO_LONG.toInt() to "URI Too Long", - UNSUPPORTED_MEDIA_TYPE.toInt() to "Unsupported Media Type", - RANGE_NOT_SATISFIABLE.toInt() to "Range Not Satisfiable", - EXPECTATION_FAILED.toInt() to "Expectation Failed", - IM_A_TEAPOT.toInt() to "I'm a teapot", - ENHANCE_YOUR_CALM.toInt() to "Enhance your Calm", - MISDIRECTED_REQUEST.toInt() to "Misdirected Request", - UNPROCESSABLE_CONTENT.toInt() to "Unprocessable Content", - LOCKED.toInt() to "Locked", - FAILED_DEPENDENCY.toInt() to "Failed Dependency", - TOO_EARLY.toInt() to "Too Early", - UPGRADE_REQUIRED.toInt() to "Upgrade Required", - PRECONDITION_REQUIRED.toInt() to "Precondition Required", - TOO_MANY_REQUESTS.toInt() to "Too Many Requests", - REQUEST_HEADER_FIELDS_TOO_LARGE.toInt() to "Request Header Fields Too Large", - UNAVAILABLE_FOR_LEGAL_REASONS.toInt() to "Unavailable For Legal Reasons", - CLIENT_CLOSED_REQUEST.toInt() to "Client Closed Request", - INTERNAL_SERVER_ERROR.toInt() to "Internal Server Error", - NOT_IMPLEMENTED.toInt() to "Not Implemented", - BAD_GATEWAY.toInt() to "Bad Gateway", - SERVICE_UNAVAILABLE.toInt() to "Service Unavailable", - GATEWAY_TIMEOUT.toInt() to "Gateway Timeout", - HTTP_VERSION_NOT_SUPPORTED.toInt() to "HTTP Version Not Supported", - INSUFFICIENT_STORAGE.toInt() to "Insufficient Storage", - LOOP_DETECTED.toInt() to "Loop Detected", - NETWORK_AUTHENTICATION_REQUIRED.toInt() to "Network Authentication Required", + private val REASON_PHRASES: Map = mapOf( + CONTINUE to "Continue", + SWITCHING_PROTOCOLS to "Switching Protocols", + PROCESSING to "Processing", + EARLY_HINTS to "Early Hints", + OK to "OK", + CREATED to "Created", + ACCEPTED to "Accepted", + NON_AUTHORITATIVE_INFORMATION to "Non-Authoritative Information", + NO_CONTENT to "No Content", + RESET_CONTENT to "Reset Content", + PARTIAL_CONTENT to "Partial Content", + MULTI_STATUS to "Multi-Status", + ALREADY_REPORTED to "Already Reported", + IM_USED to "IM Used", + MULTIPLE_CHOICES to "Multiple Choices", + MOVED_PERMANENTLY to "Moved Permanently", + FOUND to "Found", + SEE_OTHER to "See Other", + NOT_MODIFIED to "Not Modified", + USE_PROXY to "Use Proxy", + TEMPORARY_REDIRECT to "Temporary Redirect", + PERMANENT_REDIRECT to "Permanent Redirect", + BAD_REQUEST to "Bad Request", + UNAUTHORIZED to "Unauthorized", + PAYMENT_REQUIRED to "Payment Required", + FORBIDDEN to "Forbidden", + NOT_FOUND to "Not Found", + METHOD_NOT_ALLOWED to "Method Not Allowed", + NOT_ACCEPTABLE to "Not Acceptable", + PROXY_AUTHENTICATION_REQUIRED to "Proxy Authentication Required", + REQUEST_TIMEOUT to "Request Timeout", + CONFLICT to "Conflict", + GONE to "Gone", + LENGTH_REQUIRED to "Length Required", + PRECONDITION_FAILED to "Precondition Failed", + CONTENT_TOO_LARGE to "Content Too Large", + URI_TOO_LONG to "URI Too Long", + UNSUPPORTED_MEDIA_TYPE to "Unsupported Media Type", + RANGE_NOT_SATISFIABLE to "Range Not Satisfiable", + EXPECTATION_FAILED to "Expectation Failed", + IM_A_TEAPOT to "I'm a teapot", + ENHANCE_YOUR_CALM to "Enhance your Calm", + MISDIRECTED_REQUEST to "Misdirected Request", + UNPROCESSABLE_CONTENT to "Unprocessable Content", + LOCKED to "Locked", + FAILED_DEPENDENCY to "Failed Dependency", + TOO_EARLY to "Too Early", + UPGRADE_REQUIRED to "Upgrade Required", + PRECONDITION_REQUIRED to "Precondition Required", + TOO_MANY_REQUESTS to "Too Many Requests", + REQUEST_HEADER_FIELDS_TOO_LARGE to "Request Header Fields Too Large", + UNAVAILABLE_FOR_LEGAL_REASONS to "Unavailable For Legal Reasons", + CLIENT_CLOSED_REQUEST to "Client Closed Request", + INTERNAL_SERVER_ERROR to "Internal Server Error", + NOT_IMPLEMENTED to "Not Implemented", + BAD_GATEWAY to "Bad Gateway", + SERVICE_UNAVAILABLE to "Service Unavailable", + GATEWAY_TIMEOUT to "Gateway Timeout", + HTTP_VERSION_NOT_SUPPORTED to "HTTP Version Not Supported", + INSUFFICIENT_STORAGE to "Insufficient Storage", + LOOP_DETECTED to "Loop Detected", + NETWORK_AUTHENTICATION_REQUIRED to "Network Authentication Required", ) } diff --git a/openapi-specification/src/test/kotlin/io/javalin/openapi/OpenApiStatusTest.kt b/openapi-specification/src/test/kotlin/io/javalin/openapi/OpenApiStatusTest.kt new file mode 100644 index 00000000..835774a1 --- /dev/null +++ b/openapi-specification/src/test/kotlin/io/javalin/openapi/OpenApiStatusTest.kt @@ -0,0 +1,14 @@ +package io.javalin.openapi + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +internal class OpenApiStatusTest { + + @Test + fun `returns known unknown and non-numeric status phrases`() { + assertThat(OpenApiStatus.reasonPhrase(OpenApiStatus.OK)).isEqualTo("OK") + assertThat(OpenApiStatus.reasonPhrase("599")).isEqualTo("Unknown HTTP code") + assertThat(OpenApiStatus.reasonPhrase("default")).isNull() + } +} From 66444cf3dc48c6bfa07feb29b984b22b4b02c71c Mon Sep 17 00:00:00 2001 From: dzikoysk Date: Sat, 18 Jul 2026 01:40:28 +0200 Subject: [PATCH 29/43] cr: address copilot comments --- .../main/kotlin/io/javalin/openapi/ksp/OpenApiSymbolProcessor.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/openapi-ksp/src/main/kotlin/io/javalin/openapi/ksp/OpenApiSymbolProcessor.kt b/openapi-ksp/src/main/kotlin/io/javalin/openapi/ksp/OpenApiSymbolProcessor.kt index bbfa9792..8857561f 100644 --- a/openapi-ksp/src/main/kotlin/io/javalin/openapi/ksp/OpenApiSymbolProcessor.kt +++ b/openapi-ksp/src/main/kotlin/io/javalin/openapi/ksp/OpenApiSymbolProcessor.kt @@ -25,6 +25,7 @@ class OpenApiSymbolProcessor( private val writtenResources = mutableSetOf() override fun process(resolver: Resolver): List { + // JsonSchema inlines references; its cache must not leak into OpenAPI component references. generateJsonSchemes(resolver, KspSchemaContext(resolver, logger)) generateOpenApiDocuments(resolver, KspSchemaContext(resolver, logger)) return emptyList() From 9cf4608f45d3a6613ca4db9701e43c377f330847 Mon Sep 17 00:00:00 2001 From: dzikoysk Date: Sun, 19 Jul 2026 15:23:42 +0200 Subject: [PATCH 30/43] chore: another round of cleanups --- .../io/javalin/introspection/AnnotationSet.kt | 69 +++--- .../javalin/introspection/ClassDefinition.kt | 15 +- .../introspection/InternalIntrospectionApi.kt | 2 +- ...{PropertyView.kt => PropertyProjection.kt} | 4 +- .../introspection/jap/JapTypeIntrospector.kt | 39 ++-- .../introspection/ksp/KspTypeIntrospector.kt | 33 +-- .../ksp/KspTypeIntrospectorTest.kt | 5 +- .../runtime/ReflectionTypeIntrospector.kt | 30 +-- .../runtime/ReflectionTypeIntrospectorTest.kt | 10 +- .../test/IntrospectionParityTest.kt | 28 +-- .../AnnotationProcessorContext.kt | 12 +- .../experimental/IntrospectorSchemaContext.kt | 12 +- .../experimental/SchemaGenerationContext.kt | 10 +- .../generators/CompositionGenerator.kt | 4 +- .../generators/TypeSchemaGenerator.kt | 39 ++-- .../openapi/schema/OpenApiRouteDefinition.kt | 220 ++++++++++++++++++ .../openapi/schema/OpenApiSchemaGenerator.kt | 218 ++++++++--------- .../javalin/openapi/ksp/KspSchemaContext.kt | 6 +- .../openapi/ksp/OpenApiSymbolProcessor.kt | 3 +- 19 files changed, 486 insertions(+), 273 deletions(-) rename introspection/introspection-api/src/main/kotlin/io/javalin/introspection/{PropertyView.kt => PropertyProjection.kt} (96%) create mode 100644 openapi-generator/src/main/kotlin/io/javalin/openapi/schema/OpenApiRouteDefinition.kt diff --git a/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/AnnotationSet.kt b/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/AnnotationSet.kt index b40f6050..f7c9481a 100644 --- a/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/AnnotationSet.kt +++ b/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/AnnotationSet.kt @@ -1,62 +1,63 @@ package io.javalin.introspection -/** The annotations on a single element. Name/value-based: KSP can't honor typed annotation instances. */ interface AnnotationSet { - fun all(): List + fun all(): List - fun find(type: Class): AnnotationView? + fun find(type: Class): AnnotationProjection? - fun findAll(type: Class): List + fun findAll(type: Class): List fun contains(simpleName: String): Boolean - fun contains(type: Class): Boolean = - find(type) != null + fun contains(type: Class): Boolean = find(type) != null - companion object { - val EMPTY: AnnotationSet = object : AnnotationSet { - override fun all(): List = emptyList() - override fun find(type: Class): AnnotationView? = null - override fun findAll(type: Class): List = emptyList() - override fun contains(simpleName: String): Boolean = false - } - } } -interface AnnotationView { +interface AnnotationProjection { val simpleName: String - val meta: AnnotationSet + val metadata: AnnotationSet val values: Map - fun value(member: String): Any? = - values[member] + operator fun get(member: String): AnnotationValue = AnnotationValue(values[member]) - fun string(member: String): String? = - value(member) as? String +} - fun boolean(member: String): Boolean? = - value(member) as? Boolean +data class AnnotationValue(private val value: Any?) { - fun classValue(member: String): ClassDefinition? = - value(member) as? ClassDefinition + fun raw(): Any? = value - fun classValues(member: String): List = - (value(member) as? List<*>)?.filterIsInstance().orEmpty() + fun asString(): String? = value as? String + + fun asBoolean(): Boolean? = value as? Boolean + + fun asClassDefinition(): ClassDefinition? = value as? ClassDefinition + + fun asClassDefinitions(): List = + asList().filterIsInstance() + + fun asList(): List<*> = value as? List<*> ?: emptyList() + + fun asMap(): Map<*, *>? = value as? Map<*, *> - companion object { - fun of(simpleName: String, values: Map): AnnotationView = - ValuesAnnotationView(simpleName, values) - } } -private class ValuesAnnotationView( +class RepeatableAnnotationProjection( override val simpleName: String, override val values: Map, -) : AnnotationView { - override val meta: AnnotationSet - get() = AnnotationSet.EMPTY +) : AnnotationProjection { + private companion object { + val EMPTY: AnnotationSet = object : AnnotationSet { + override fun all(): List = emptyList() + override fun find(type: Class): AnnotationProjection? = null + override fun findAll(type: Class): List = emptyList() + override fun contains(simpleName: String): Boolean = false + } + } + + override val metadata: AnnotationSet + get() = EMPTY } diff --git a/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/ClassDefinition.kt b/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/ClassDefinition.kt index 4494ab7f..fac96929 100644 --- a/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/ClassDefinition.kt +++ b/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/ClassDefinition.kt @@ -12,9 +12,9 @@ abstract class ClassDefinition( abstract fun isEnum(): Boolean - abstract fun getEnumConstants(): List + abstract fun getEnumConstants(): List - abstract fun getProperties(): List + abstract fun getProperties(): List abstract fun getAnnotations(): AnnotationSet @@ -22,6 +22,13 @@ abstract class ClassDefinition( if (generics.isEmpty()) fullName else "$fullName<${generics.joinToString(", ")}>" } -enum class StructureType { DEFAULT, ARRAY, DICTIONARY } +enum class StructureType { + DEFAULT, + ARRAY, + DICTIONARY, +} -class EnumConstantView(val name: String, val annotations: AnnotationSet) +data class EnumConstant( + val name: String, + val annotations: AnnotationSet, +) diff --git a/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/InternalIntrospectionApi.kt b/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/InternalIntrospectionApi.kt index 05269de3..10b442fb 100644 --- a/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/InternalIntrospectionApi.kt +++ b/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/InternalIntrospectionApi.kt @@ -2,7 +2,7 @@ package io.javalin.introspection @RequiresOptIn( level = RequiresOptIn.Level.ERROR, - message = "source is a backend-native token; only the producing backend may read/cast it. Agnostic code must not touch it.", + message = "Target agnostic code must not touch that part of the API.", ) @Retention(AnnotationRetention.BINARY) @Target(AnnotationTarget.PROPERTY) diff --git a/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/PropertyView.kt b/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/PropertyProjection.kt similarity index 96% rename from introspection/introspection-api/src/main/kotlin/io/javalin/introspection/PropertyView.kt rename to introspection/introspection-api/src/main/kotlin/io/javalin/introspection/PropertyProjection.kt index 5471aa36..043ddd56 100644 --- a/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/PropertyView.kt +++ b/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/PropertyProjection.kt @@ -1,14 +1,14 @@ package io.javalin.introspection -class PropertyView( +class PropertyProjection( val name: String, val type: ClassDefinition, val accessor: Accessor, val nullable: Boolean, val visibility: MemberVisibility, val transient: Boolean, - @property:InternalIntrospectionApi val source: Any, val annotations: AnnotationSet, + @property:InternalIntrospectionApi val source: Any, ) enum class Accessor { FIELD, GETTER, RECORD_COMPONENT } diff --git a/introspection/introspection-jap/src/main/kotlin/io/javalin/introspection/jap/JapTypeIntrospector.kt b/introspection/introspection-jap/src/main/kotlin/io/javalin/introspection/jap/JapTypeIntrospector.kt index 7732d849..e08c2370 100644 --- a/introspection/introspection-jap/src/main/kotlin/io/javalin/introspection/jap/JapTypeIntrospector.kt +++ b/introspection/introspection-jap/src/main/kotlin/io/javalin/introspection/jap/JapTypeIntrospector.kt @@ -1,13 +1,14 @@ package io.javalin.introspection.jap import io.javalin.introspection.Accessor -import io.javalin.introspection.AnnotationView +import io.javalin.introspection.AnnotationProjection import io.javalin.introspection.AnnotationSet import io.javalin.introspection.ClassDefinition import io.javalin.introspection.CompileTimeIntrospector -import io.javalin.introspection.EnumConstantView +import io.javalin.introspection.EnumConstant import io.javalin.introspection.InternalIntrospectionApi -import io.javalin.introspection.PropertyView +import io.javalin.introspection.PropertyProjection +import io.javalin.introspection.RepeatableAnnotationProjection import io.javalin.introspection.StructureType import io.javalin.introspection.StructureType.ARRAY import io.javalin.introspection.StructureType.DEFAULT @@ -166,23 +167,23 @@ class JapTypeIntrospector( override fun isEnum(): Boolean = typeElement()?.kind == ElementKind.ENUM - override fun getEnumConstants(): List = + override fun getEnumConstants(): List = typeElement() ?.takeIf { it.kind == ElementKind.ENUM } ?.enclosedElements ?.filter { it.kind == ElementKind.ENUM_CONSTANT } - ?.map { EnumConstantView(it.simpleName.toString(), AnnotationsView(listOf(it))) } + ?.map { EnumConstant(it.simpleName.toString(), AnnotationsView(listOf(it))) } .orEmpty() override fun getAnnotations(): AnnotationSet = AnnotationsView(listOfNotNull(typeElement()), includeInherited = true) - override fun getProperties(): List { + override fun getProperties(): List { val element = typeElement() ?: return emptyList() if (element.kind == ElementKind.RECORD) { val recordProperties = element.recordComponents.map { component -> - PropertyView( + PropertyProjection( name = component.simpleName.toString(), type = resolve(component.asType()), accessor = Accessor.RECORD_COMPONENT, @@ -205,7 +206,7 @@ class JapTypeIntrospector( return@mapNotNull null } - PropertyView( + PropertyProjection( name = name, type = resolve(getter.returnType), accessor = Accessor.GETTER, @@ -223,7 +224,7 @@ class JapTypeIntrospector( return elements.getAllMembers(element).mapNotNull { member -> when { member.isGetter() -> (member as ExecutableElement).let { getter -> - PropertyView( + PropertyProjection( name = propertyName(getter.simpleName.toString()), type = resolve(getter.returnType), accessor = Accessor.GETTER, @@ -235,7 +236,7 @@ class JapTypeIntrospector( ) } member.isInstanceField() -> (member as VariableElement).let { field -> - PropertyView( + PropertyProjection( name = field.simpleName.toString(), type = resolve(field.asType()), accessor = Accessor.FIELD, @@ -304,25 +305,25 @@ class JapTypeIntrospector( override fun contains(simpleName: String): Boolean = sources.any { source -> mirrors(source).any { it.annotationType.asElement().simpleName.contentEquals(simpleName) } } - override fun find(type: Class): AnnotationView? = + override fun find(type: Class): AnnotationProjection? = sources.firstNotNullOfOrNull { source -> mirrors(source).firstOrNull { it.named(type) } } - ?.let { JapAnnotationView(it) } + ?.let { JapAnnotationProjection(it) } - override fun findAll(type: Class): List { + override fun findAll(type: Class): List { val mirrors = sources.flatMap { mirrors(it) } - val direct = mirrors.filter { it.named(type) }.map { JapAnnotationView(it) } + val direct = mirrors.filter { it.named(type) }.map { JapAnnotationProjection(it) } // javac wraps repeated annotations in their @Repeatable container; unwrap its `value` array val containerName = type.getAnnotation(JavaRepeatable::class.java)?.value?.java?.canonicalName val container = containerName?.let { name -> mirrors.firstOrNull { it.named(name) } } val repeated = (container?.let { mirrorValues(it)["value"] } as? List<*>) ?.filterIsInstance>() - ?.map { AnnotationView.of(type.simpleName, it) } + ?.map { RepeatableAnnotationProjection(type.simpleName, it) } .orEmpty() return direct + repeated } - override fun all(): List = - sources.flatMap { mirrors(it) }.distinctBy { it.annotationType.asElement() }.map { JapAnnotationView(it) } + override fun all(): List = + sources.flatMap { mirrors(it) }.distinctBy { it.annotationType.asElement() }.map { JapAnnotationProjection(it) } private fun AnnotationMirror.named(qualifiedName: String): Boolean = (annotationType.asElement() as? TypeElement)?.qualifiedName?.contentEquals(qualifiedName) == true @@ -334,10 +335,10 @@ class JapTypeIntrospector( annotationMirrors.any { it.named(type) } } - private inner class JapAnnotationView(private val mirror: AnnotationMirror) : AnnotationView { + private inner class JapAnnotationProjection(private val mirror: AnnotationMirror) : AnnotationProjection { override val simpleName: String get() = mirror.annotationType.asElement().simpleName.toString() - override val meta: AnnotationSet + override val metadata: AnnotationSet get() = AnnotationsView(listOf(mirror.annotationType.asElement())) override val values: Map get() = mirrorValues(mirror) diff --git a/introspection/introspection-ksp/src/main/kotlin/io/javalin/introspection/ksp/KspTypeIntrospector.kt b/introspection/introspection-ksp/src/main/kotlin/io/javalin/introspection/ksp/KspTypeIntrospector.kt index 46be53d7..3a389732 100644 --- a/introspection/introspection-ksp/src/main/kotlin/io/javalin/introspection/ksp/KspTypeIntrospector.kt +++ b/introspection/introspection-ksp/src/main/kotlin/io/javalin/introspection/ksp/KspTypeIntrospector.kt @@ -12,13 +12,14 @@ import com.google.devtools.ksp.symbol.KSType import com.google.devtools.ksp.symbol.KSTypeParameter import com.google.devtools.ksp.symbol.Modifier import io.javalin.introspection.Accessor -import io.javalin.introspection.AnnotationView +import io.javalin.introspection.AnnotationProjection import io.javalin.introspection.AnnotationSet import io.javalin.introspection.ClassDefinition import io.javalin.introspection.CompileTimeIntrospector -import io.javalin.introspection.EnumConstantView +import io.javalin.introspection.EnumConstant import io.javalin.introspection.InternalIntrospectionApi -import io.javalin.introspection.PropertyView +import io.javalin.introspection.PropertyProjection +import io.javalin.introspection.RepeatableAnnotationProjection import io.javalin.introspection.StructureType import io.javalin.introspection.StructureType.ARRAY import io.javalin.introspection.StructureType.DEFAULT @@ -142,26 +143,26 @@ class KspTypeIntrospector(private val resolver: Resolver) : CompileTimeIntrospec override fun isEnum(): Boolean = declaration?.classKind == ClassKind.ENUM_CLASS - override fun getEnumConstants(): List = + override fun getEnumConstants(): List = declaration ?.takeIf { it.classKind == ClassKind.ENUM_CLASS } ?.declarations ?.filterIsInstance() ?.filter { it.classKind == ClassKind.ENUM_ENTRY } - ?.map { EnumConstantView(it.simpleName.asString(), KspAnnotations(it)) } + ?.map { EnumConstant(it.simpleName.asString(), KspAnnotations(it)) } ?.toList() .orEmpty() override fun getAnnotations(): AnnotationSet = KspAnnotations(declaration) - override fun getProperties(): List { + override fun getProperties(): List { val declaration = declaration ?: return emptyList() return declaration.getAllProperties().filter { property -> property.getVisibility() !in setOf(KspVisibility.PRIVATE, KspVisibility.LOCAL) }.map { property -> val propertyType = property.type.resolve() - PropertyView( + PropertyProjection( name = property.simpleName.asString(), type = resolve(propertyType), accessor = Accessor.GETTER, @@ -185,28 +186,28 @@ class KspTypeIntrospector(private val resolver: Resolver) : CompileTimeIntrospec override fun contains(simpleName: String): Boolean = annotations().any { it.shortName.asString() == simpleName } - override fun find(type: Class): AnnotationView? = + override fun find(type: Class): AnnotationProjection? = annotations() .firstOrNull { it.named(type) } - ?.let { KspAnnotationView(it) } + ?.let { KspAnnotationProjection(it) } - override fun findAll(type: Class): List { + override fun findAll(type: Class): List { val annotations = annotations() val direct = annotations .filter { it.named(type) } - .map { KspAnnotationView(it) } + .map { KspAnnotationProjection(it) } val containerName = type.getAnnotation(JavaRepeatable::class.java)?.value?.java?.canonicalName val repeated = containerName ?.let { name -> annotations.firstOrNull { it.qualifiedName() == name } } ?.let { argumentValues(it)["value"] as? List<*> } ?.filterIsInstance>() - ?.map { AnnotationView.of(type.simpleName, it) } + ?.map { RepeatableAnnotationProjection(type.simpleName, it) } .orEmpty() return direct + repeated } - override fun all(): List = - annotations().distinctBy { it.annotationType.resolve().declaration }.map { KspAnnotationView(it) } + override fun all(): List = + annotations().distinctBy { it.annotationType.resolve().declaration }.map { KspAnnotationProjection(it) } private fun KSAnnotation.named(type: Class): Boolean { val qualifiedName = qualifiedName() @@ -217,10 +218,10 @@ class KspTypeIntrospector(private val resolver: Resolver) : CompileTimeIntrospec annotationType.resolve().declaration.qualifiedName?.asString() } - private inner class KspAnnotationView(private val annotation: KSAnnotation) : AnnotationView { + private inner class KspAnnotationProjection(private val annotation: KSAnnotation) : AnnotationProjection { override val simpleName: String get() = annotation.shortName.asString() - override val meta: AnnotationSet + override val metadata: AnnotationSet get() = KspAnnotations(annotation.annotationType.resolve().declaration) override val values: Map get() = argumentValues(annotation) diff --git a/introspection/introspection-ksp/src/test/kotlin/io/javalin/introspection/ksp/KspTypeIntrospectorTest.kt b/introspection/introspection-ksp/src/test/kotlin/io/javalin/introspection/ksp/KspTypeIntrospectorTest.kt index e79337ad..75f13093 100644 --- a/introspection/introspection-ksp/src/test/kotlin/io/javalin/introspection/ksp/KspTypeIntrospectorTest.kt +++ b/introspection/introspection-ksp/src/test/kotlin/io/javalin/introspection/ksp/KspTypeIntrospectorTest.kt @@ -9,7 +9,6 @@ import com.tschuchort.compiletesting.KotlinCompilation import com.tschuchort.compiletesting.SourceFile import com.tschuchort.compiletesting.symbolProcessorProviders import com.tschuchort.compiletesting.useKsp2 -import io.javalin.introspection.ClassDefinition import org.assertj.core.api.Assertions.assertThat import org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi import org.junit.jupiter.api.Test @@ -61,8 +60,8 @@ class KspTypeIntrospectorTest { isEnum = color.isEnum(), enumConstants = color.getEnumConstants().map { it.name }, hasRef = account.getAnnotations().contains("Ref"), - refValue = (account.getAnnotations().find(Ref::class.java)?.classValue("value"))?.fullName, - scannedLabel = account.getAnnotations().all().first { it.meta.contains("MetaMarker") }.values["label"], + refValue = account.getAnnotations().find(Ref::class.java)?.get("value")?.asClassDefinition()?.fullName, + scannedLabel = account.getAnnotations().all().first { it.metadata.contains("MetaMarker") }.values["label"], ) } diff --git a/introspection/introspection-runtime/src/main/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospector.kt b/introspection/introspection-runtime/src/main/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospector.kt index 1fbb9e90..5d2331f8 100644 --- a/introspection/introspection-runtime/src/main/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospector.kt +++ b/introspection/introspection-runtime/src/main/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospector.kt @@ -1,12 +1,12 @@ package io.javalin.introspection.runtime import io.javalin.introspection.Accessor -import io.javalin.introspection.AnnotationView +import io.javalin.introspection.AnnotationProjection import io.javalin.introspection.AnnotationSet import io.javalin.introspection.ClassDefinition -import io.javalin.introspection.EnumConstantView +import io.javalin.introspection.EnumConstant import io.javalin.introspection.InternalIntrospectionApi -import io.javalin.introspection.PropertyView +import io.javalin.introspection.PropertyProjection import io.javalin.introspection.StructureType import io.javalin.introspection.StructureType.ARRAY import io.javalin.introspection.StructureType.DEFAULT @@ -120,15 +120,15 @@ private class ReflectionClassDefinition( override fun isEnum(): Boolean = erasure.isEnum - override fun getEnumConstants(): List = + override fun getEnumConstants(): List = erasure.takeIf { it.isEnum }?.enumConstants?.map { val name = (it as Enum<*>).name - EnumConstantView(name, ReflectionAnnotations(listOfNotNull(runCatching { erasure.getDeclaredField(name) }.getOrNull()))) + EnumConstant(name, ReflectionAnnotations(listOfNotNull(runCatching { erasure.getDeclaredField(name) }.getOrNull()))) }.orEmpty() - override fun getProperties(): List = + override fun getProperties(): List = collectMembers(erasure).map { member -> - PropertyView( + PropertyProjection( name = if (member.accessor == Accessor.GETTER) propertyName(member.name) else member.name, type = reflect(member.genericType), accessor = member.accessor, @@ -243,20 +243,20 @@ private class ReflectionAnnotations(private val sources: List) override fun contains(simpleName: String): Boolean = sources.any { source -> source.annotations.any { it.annotationClass.simpleName == simpleName } } - override fun find(type: Class): AnnotationView? = - sources.firstNotNullOfOrNull { it.getAnnotation(type) }?.let { ReflectionAnnotationView(it) } + override fun find(type: Class): AnnotationProjection? = + sources.firstNotNullOfOrNull { it.getAnnotation(type) }?.let { ReflectionAnnotationProjection(it) } - override fun findAll(type: Class): List = - sources.flatMap { it.getAnnotationsByType(type).toList() }.map { ReflectionAnnotationView(it) } + override fun findAll(type: Class): List = + sources.flatMap { it.getAnnotationsByType(type).toList() }.map { ReflectionAnnotationProjection(it) } - override fun all(): List = - sources.flatMap { it.annotations.toList() }.distinctBy { it.annotationClass }.map { ReflectionAnnotationView(it) } + override fun all(): List = + sources.flatMap { it.annotations.toList() }.distinctBy { it.annotationClass }.map { ReflectionAnnotationProjection(it) } } -private class ReflectionAnnotationView(private val annotation: Annotation) : AnnotationView { +private class ReflectionAnnotationProjection(private val annotation: Annotation) : AnnotationProjection { override val simpleName: String get() = annotation.annotationClass.java.simpleName - override val meta: AnnotationSet + override val metadata: AnnotationSet get() = ReflectionAnnotations(listOf(annotation.annotationClass.java)) override val values: Map get() = annotationToMap(annotation) diff --git a/introspection/introspection-runtime/src/test/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospectorTest.kt b/introspection/introspection-runtime/src/test/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospectorTest.kt index e508f9e5..39a34a3d 100644 --- a/introspection/introspection-runtime/src/test/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospectorTest.kt +++ b/introspection/introspection-runtime/src/test/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospectorTest.kt @@ -1,7 +1,7 @@ package io.javalin.introspection.runtime import io.javalin.introspection.Accessor -import io.javalin.introspection.PropertyView +import io.javalin.introspection.PropertyProjection import io.javalin.introspection.StructureType import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test @@ -11,7 +11,7 @@ class ReflectionTypeIntrospectorTest { private val introspector = ReflectionTypeIntrospector() - private fun props(type: Class<*>): Map = + private fun props(type: Class<*>): Map = introspector.introspect(type).getProperties().associateBy { it.name } @Test @@ -80,8 +80,8 @@ class ReflectionTypeIntrospectorTest { fun `resolves Class-valued annotation members into ClassDefinitions`() { val annotations = introspector.introspect(Holder::class.java).getAnnotations() - assertThat(annotations.find(Ref::class.java)?.classValue("value")?.fullName).isEqualTo(Address::class.java.name) - assertThat(annotations.find(Refs::class.java)?.classValues("value").orEmpty().map { it.fullName }) + assertThat(annotations.find(Ref::class.java)?.get("value")?.asClassDefinition()?.fullName).isEqualTo(Address::class.java.name) + assertThat(annotations.find(Refs::class.java)?.get("value")?.asClassDefinitions().orEmpty().map { it.fullName }) .containsExactly(Address::class.java.name, Color::class.java.name) } @@ -111,7 +111,7 @@ class ReflectionTypeIntrospectorTest { .getAnnotations() .find(PackagePrivateAnnotation::class.java) - assertThat(annotation?.string("value")).isEqualTo("package-private") + assertThat(annotation?.get("value")?.asString()).isEqualTo("package-private") } } diff --git a/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/IntrospectionParityTest.kt b/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/IntrospectionParityTest.kt index 7f5c9abd..1d3d3fce 100644 --- a/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/IntrospectionParityTest.kt +++ b/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/IntrospectionParityTest.kt @@ -76,7 +76,7 @@ class IntrospectionParityTest { @Test fun `annotation enumeration with meta-annotations matches across backends`() { fun scan(annotations: AnnotationSet): Pair { - val tagged = annotations.all().first { it.meta.contains("MetaMarker") } + val tagged = annotations.all().first { it.metadata.contains("MetaMarker") } return tagged.simpleName to tagged.values["label"] } val runtimeScan = scan(runtime.introspect(Scanned::class.java).getAnnotations()) @@ -86,7 +86,7 @@ class IntrospectionParityTest { @Test fun `repeatable annotations enumerate identically across backends`() { - fun notes(annotations: AnnotationSet) = annotations.findAll(Note::class.java).map { it.value("value") } + fun notes(annotations: AnnotationSet) = annotations.findAll(Note::class.java).map { it.get("value").asString() } val runtimeNotes = notes(runtime.introspect(Noted::class.java).getAnnotations()) val processedNotes = AnnotationProcessing.introspect(Noted::class) { notes(it.getAnnotations()) } assertThat(processedNotes).isEqualTo(runtimeNotes) @@ -95,20 +95,20 @@ class IntrospectionParityTest { @Test fun `nested annotation members normalize to maps identically across backends`() { - val runtimeMeta = runtime.introspect(Wrapped::class.java).getAnnotations().find(Outer::class.java)!!.value("meta") - val processedMeta = AnnotationProcessing.introspect(Wrapped::class) { it.getAnnotations().find(Outer::class.java)!!.value("meta") } + val runtimeMeta = runtime.introspect(Wrapped::class.java).getAnnotations().find(Outer::class.java)!!.get("meta").asMap() + val processedMeta = AnnotationProcessing.introspect(Wrapped::class) { it.getAnnotations().find(Outer::class.java)!!.get("meta").asMap() } assertThat(processedMeta).isEqualTo(runtimeMeta).isEqualTo(mapOf("note" to "x")) } @Test fun `Class-valued annotation members resolve identically across backends`() { val runtimeAnnotations = runtime.introspect(Holder::class.java).getAnnotations() - val runtimeRef = runtimeAnnotations.find(Ref::class.java)?.classValue("value")?.fullName - val runtimeRefs = runtimeAnnotations.find(Refs::class.java)?.classValues("value").orEmpty().map { it.fullName } + val runtimeRef = runtimeAnnotations.find(Ref::class.java)?.get("value")?.asClassDefinition()?.fullName + val runtimeRefs = runtimeAnnotations.find(Refs::class.java)?.get("value")?.asClassDefinitions().orEmpty().map { it.fullName } val (processedRef, processedRefs) = AnnotationProcessing.introspect(Holder::class) { val annotations = it.getAnnotations() - annotations.find(Ref::class.java)?.classValue("value")?.fullName to annotations.find(Refs::class.java)?.classValues("value").orEmpty().map { it.fullName } + annotations.find(Ref::class.java)?.get("value")?.asClassDefinition()?.fullName to annotations.find(Refs::class.java)?.get("value")?.asClassDefinitions().orEmpty().map { it.fullName } } assertThat(processedRef).isEqualTo(runtimeRef).isEqualTo(Address::class.java.name) @@ -117,17 +117,17 @@ class IntrospectionParityTest { @Test fun `annotation value maps match across backends`() { - val runtimeValue = runtime.introspect(Holder::class.java).getAnnotations().find(Ref::class.java)!!.value("value") - val processedValue = AnnotationProcessing.introspect(Holder::class) { it.getAnnotations().find(Ref::class.java)!!.value("value") } - assertThat((processedValue as ClassDefinition).fullName) - .isEqualTo((runtimeValue as ClassDefinition).fullName) + val runtimeValue = runtime.introspect(Holder::class.java).getAnnotations().find(Ref::class.java)!!.get("value").asClassDefinition() + val processedValue = AnnotationProcessing.introspect(Holder::class) { it.getAnnotations().find(Ref::class.java)!!.get("value").asClassDefinition() } + assertThat(processedValue?.fullName) + .isEqualTo(runtimeValue?.fullName) .isEqualTo(Address::class.java.name) } @Test fun `primitive array annotation members normalize identically across backends`() { - val runtimeInts = runtime.introspect(Flagged::class.java).getAnnotations().find(Flags::class.java)!!.value("ints") - val processedInts = AnnotationProcessing.introspect(Flagged::class) { it.getAnnotations().find(Flags::class.java)!!.value("ints") } + val runtimeInts = runtime.introspect(Flagged::class.java).getAnnotations().find(Flags::class.java)!!.get("ints").asList() + val processedInts = AnnotationProcessing.introspect(Flagged::class) { it.getAnnotations().find(Flags::class.java)!!.get("ints").asList() } assertThat(processedInts).isEqualTo(runtimeInts).isEqualTo(listOf(1, 2, 3)) } @@ -178,7 +178,7 @@ class IntrospectionParityTest { @Test fun `nested annotation classes can be found by class across backends`() { fun value(definition: ClassDefinition): String? = - definition.getAnnotations().find(AnnotationContainer.Nested::class.java)?.string("value") + definition.getAnnotations().find(AnnotationContainer.Nested::class.java)?.get("value")?.asString() val runtimeValue = value(runtime.introspect(NestedAnnotated::class.java)) val processedValue = AnnotationProcessing.introspect(NestedAnnotated::class) { value(it) } diff --git a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/AnnotationProcessorContext.kt b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/AnnotationProcessorContext.kt index 8b226d73..116b3022 100644 --- a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/AnnotationProcessorContext.kt +++ b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/AnnotationProcessorContext.kt @@ -2,9 +2,9 @@ package io.javalin.openapi.experimental import com.sun.source.util.Trees import io.javalin.introspection.AnnotationSet -import io.javalin.introspection.EnumConstantView +import io.javalin.introspection.EnumConstant import io.javalin.introspection.InternalIntrospectionApi -import io.javalin.introspection.PropertyView +import io.javalin.introspection.PropertyProjection import io.javalin.introspection.jap.JapTypeIntrospector import io.javalin.openapi.DiscriminatorMappingName import io.javalin.openapi.OpenApiName @@ -49,10 +49,10 @@ class AnnotationProcessorContext( fun annotationsOf(element: Element): AnnotationSet = japIntrospector.annotationsOf(element) - override fun propertiesOf(type: OpenApiType): List = + override fun propertiesOf(type: OpenApiType): List = japIntrospector.introspect(type.mirror).getProperties() - override fun enumConstantsOf(type: OpenApiType): List = + override fun enumConstantsOf(type: OpenApiType): List = japIntrospector.introspect(type.mirror).getEnumConstants() @OptIn(InternalIntrospectionApi::class) @@ -83,13 +83,13 @@ class AnnotationProcessorContext( toOpenApiType(japIntrospector.introspect(this)) @OptIn(InternalIntrospectionApi::class) - override fun acceptsProperty(type: OpenApiType, property: PropertyView): Boolean = + override fun acceptsProperty(type: OpenApiType, property: PropertyProjection): Boolean = configuration.propertyInSchemeFilter?.filter(this, type, property.source as Element) != false override fun discriminatorSubtypes(type: OpenApiType): List> { val subtypes = japIntrospector.typesAnnotatedWith(DiscriminatorMappingName::class.java, assignableTo = japIntrospector.introspect(type.mirror)) return subtypes.mapNotNull { subtype -> - val name = subtype.getAnnotations().find(DiscriminatorMappingName::class.java)?.string("value") + val name = subtype.getAnnotations().find(DiscriminatorMappingName::class.java)?.get("value")?.asString() if (name == null) null else name to toOpenApiType(subtype) } } diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/IntrospectorSchemaContext.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/IntrospectorSchemaContext.kt index 01aa37bd..4d8fc854 100644 --- a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/IntrospectorSchemaContext.kt +++ b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/IntrospectorSchemaContext.kt @@ -2,8 +2,8 @@ package io.javalin.openapi.experimental import io.javalin.introspection.AnnotationSet import io.javalin.introspection.CompileTimeIntrospector -import io.javalin.introspection.EnumConstantView -import io.javalin.introspection.PropertyView +import io.javalin.introspection.EnumConstant +import io.javalin.introspection.PropertyProjection import io.javalin.introspection.TypeIntrospector import io.javalin.openapi.DiscriminatorMappingName import io.javalin.openapi.OpenApiName @@ -32,7 +32,7 @@ abstract class IntrospectorSchemaContext( typeSchemaGenerator.createEmbeddedTypeDescription(introspect(nativeType)) override fun toOpenApiType(raw: RawType): OpenApiType { - val customName = raw.getAnnotations().find(OpenApiName::class.java)?.string("value") + val customName = raw.getAnnotations().find(OpenApiName::class.java)?.get("value")?.asString() val packageName = raw.fullName.substringBeforeLast('.', "") return OpenApiType( simpleName = customName ?: raw.simpleName, @@ -53,17 +53,17 @@ abstract class IntrospectorSchemaContext( override fun annotationsOf(type: OpenApiType): AnnotationSet = type.raw.getAnnotations() - override fun propertiesOf(type: OpenApiType): List = + override fun propertiesOf(type: OpenApiType): List = type.raw.getProperties() - override fun enumConstantsOf(type: OpenApiType): List = + override fun enumConstantsOf(type: OpenApiType): List = type.raw.getEnumConstants() override fun discriminatorSubtypes(type: OpenApiType): List> { val scanner = introspector as? CompileTimeIntrospector ?: return emptyList() val subtypes = scanner.typesAnnotatedWith(DiscriminatorMappingName::class.java, assignableTo = type.raw) return subtypes.mapNotNull { subtype -> - val name = subtype.getAnnotations().find(DiscriminatorMappingName::class.java)?.string("value") + val name = subtype.getAnnotations().find(DiscriminatorMappingName::class.java)?.get("value")?.asString() if (name == null) null else name to toOpenApiType(subtype) } } diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/SchemaGenerationContext.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/SchemaGenerationContext.kt index f4acb981..84ec2878 100644 --- a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/SchemaGenerationContext.kt +++ b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/SchemaGenerationContext.kt @@ -1,8 +1,8 @@ package io.javalin.openapi.experimental import io.javalin.introspection.AnnotationSet -import io.javalin.introspection.EnumConstantView -import io.javalin.introspection.PropertyView +import io.javalin.introspection.EnumConstant +import io.javalin.introspection.PropertyProjection import io.javalin.openapi.experimental.processor.generators.TypeSchemaGenerator import io.javalin.introspection.ClassDefinition as RawType @@ -16,13 +16,13 @@ interface SchemaGenerationContext { fun annotationsOf(type: OpenApiType): AnnotationSet - fun propertiesOf(type: OpenApiType): List + fun propertiesOf(type: OpenApiType): List - fun enumConstantsOf(type: OpenApiType): List + fun enumConstantsOf(type: OpenApiType): List fun toOpenApiType(raw: RawType): OpenApiType - fun acceptsProperty(type: OpenApiType, property: PropertyView): Boolean = true + fun acceptsProperty(type: OpenApiType, property: PropertyProjection): Boolean = true fun discriminatorSubtypes(type: OpenApiType): List> = emptyList() diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/CompositionGenerator.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/CompositionGenerator.kt index 73c31b0f..89a5139c 100644 --- a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/CompositionGenerator.kt +++ b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/CompositionGenerator.kt @@ -27,8 +27,8 @@ fun findCompositionInElement(context: SchemaGenerationContext, annotations: Anno private fun compositionOf(context: SchemaGenerationContext, annotations: AnnotationSet, annotationType: Class, composition: Composition): PropertyComposition? { val annotation = annotations.find(annotationType) ?: return null - val references = annotation.classValues("value").map { context.toOpenApiType(it) }.toSet() - val discriminator = (annotation.value("discriminator") as? Map<*, *>)?.let { discriminatorInfo(context, it) } + val references = annotation.get("value").asClassDefinitions().map { context.toOpenApiType(it) }.toSet() + val discriminator = annotation.get("discriminator").asMap()?.let { discriminatorInfo(context, it) } return PropertyComposition(composition, references, discriminator) } diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/TypeSchemaGenerator.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/TypeSchemaGenerator.kt index 918dd702..4a902329 100644 --- a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/TypeSchemaGenerator.kt +++ b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/TypeSchemaGenerator.kt @@ -18,6 +18,13 @@ class TypeSchemaGenerator(val context: SchemaGenerationContext) { // ~ https://github.com/javalin/javalin-openapi/issues/230 private val processedProperties = mutableMapOf() + private fun OpenApiType.definedBy(): OpenApiType? = + context.annotationsOf(this) + .find(OpenApiPropertyType::class.java) + ?.get("definedBy") + ?.asClassDefinition() + ?.let(context::toOpenApiType) + fun createTypeSchema( type: OpenApiType, inlineRefs: Boolean = false, @@ -27,7 +34,7 @@ class TypeSchemaGenerator(val context: SchemaGenerationContext) { val annotations = context.annotationsOf(type) val isEnum = context.isEnum(type) - val definedBy = annotations.find(OpenApiPropertyType::class.java)?.classValue("definedBy")?.let { context.toOpenApiType(it) } + val definedBy = type.definedBy() if (definedBy != null && !isEnum) { return createTypeSchema(definedBy, inlineRefs, requireNonNullsByDefault) @@ -51,8 +58,8 @@ class TypeSchemaGenerator(val context: SchemaGenerationContext) { context.enumConstantsOf(type) .map { constant -> - val customName = constant.annotations.find(OpenApiName::class.java)?.string("value") - val description = constant.annotations.find(OpenApiDescription::class.java)?.string("value") + val customName = constant.annotations.find(OpenApiName::class.java)?.get("value")?.asString() + val description = constant.annotations.find(OpenApiDescription::class.java)?.get("value")?.asString() val name = when { customName != null -> customName namingStrategy != null -> translatePropertyName(namingStrategy, constant.name) @@ -87,7 +94,7 @@ class TypeSchemaGenerator(val context: SchemaGenerationContext) { val propertiesObject = createObjectNode() schema.set("properties", propertiesObject) - val requireNonNulls = (annotations.find(JsonSchema::class.java)?.boolean("requireNonNulls")) + val requireNonNulls = (annotations.find(JsonSchema::class.java)?.get("requireNonNulls")?.asBoolean()) ?: requireNonNullsByDefault val properties = context.findAllProperties(type, requireNonNulls) @@ -132,7 +139,7 @@ class TypeSchemaGenerator(val context: SchemaGenerationContext) { extra: Map = emptyMap(), nullable: Boolean = false, ): ResultScheme { - val definedBy = context.annotationsOf(type).find(OpenApiPropertyType::class.java)?.classValue("definedBy")?.let { context.toOpenApiType(it) } + val definedBy = type.definedBy() if (definedBy != null && !context.isEnum(type)) { return createEmbeddedTypeDescription(definedBy, inlineRefs, requiresNonNulls, composition, extra, nullable) @@ -232,9 +239,9 @@ class TypeSchemaGenerator(val context: SchemaGenerationContext) { internal fun SchemaGenerationContext.findAllProperties(type: OpenApiType, requireNonNulls: Boolean): Collection { val annotations = annotationsOf(type) - val byFields = annotations.find(OpenApiByFields::class.java)?.values - val byFieldsOnly = byFields?.get("only") == true - val byFieldsVisibility = (byFields?.get("value") as? String)?.let { Visibility.valueOf(it) } + val byFields = annotations.find(OpenApiByFields::class.java) + val byFieldsOnly = byFields?.get("only")?.asBoolean() == true + val byFieldsVisibility = byFields?.get("value")?.asString()?.let { Visibility.valueOf(it) } val namingStrategy = annotations.namingStrategy() val properties = mutableListOf() @@ -250,12 +257,12 @@ internal fun SchemaGenerationContext.findAllProperties(type: OpenApiType, requir if (byFieldsVisibility != null && byFieldsVisibility.priority > property.visibility.toOpenApi().priority) continue if (property.annotations.contains(OpenApiIgnore::class.java) || property.transient) continue - val customName = property.annotations.find(OpenApiName::class.java)?.string("value") + val customName = property.annotations.find(OpenApiName::class.java)?.get("value")?.asString() val name = customName ?: property.name val finalName = if (customName == null && namingStrategy != null) translatePropertyName(namingStrategy, name) else name - val nullability = property.annotations.find(OpenApiPropertyType::class.java)?.string("nullability") - val redirect = property.annotations.find(OpenApiPropertyType::class.java)?.classValue("definedBy") + val nullability = property.annotations.find(OpenApiPropertyType::class.java)?.get("nullability")?.asString() + val redirect = property.annotations.find(OpenApiPropertyType::class.java)?.get("definedBy")?.asClassDefinition() val treatedAsNotNull = (redirect == null && !property.nullable) || redirect?.hasPrimitiveSource() == true val isNotNull = when { @@ -268,7 +275,7 @@ internal fun SchemaGenerationContext.findAllProperties(type: OpenApiType, requir } val required = property.annotations.contains(OpenApiRequired::class.java) || (requireNonNulls && isNotNull) - val explicitNullable = property.annotations.find(OpenApiNullable::class.java)?.boolean("nullable") + val explicitNullable = property.annotations.find(OpenApiNullable::class.java)?.get("nullable")?.asBoolean() val isExplicitlyNullable = when { explicitNullable != null -> explicitNullable nullability == Nullability.NULLABLE.name -> true @@ -306,11 +313,11 @@ internal fun SchemaGenerationContext.findAllProperties(type: OpenApiType, requir } private fun AnnotationSet.namingStrategy(): OpenApiNamingStrategy? = - (find(OpenApiNaming::class.java)?.string("value"))?.let { OpenApiNamingStrategy.valueOf(it) } + (find(OpenApiNaming::class.java)?.get("value")?.asString())?.let { OpenApiNamingStrategy.valueOf(it) } private fun AnnotationSet.findExtra(): Map { val extra = mutableMapOf( - "description" to find(OpenApiDescription::class.java)?.value("value") + "description" to find(OpenApiDescription::class.java)?.get("value")?.asString() ) find(OpenApiExample::class.java)?.values?.also { example -> @@ -354,11 +361,11 @@ private fun AnnotationSet.findExtra(): Map { } findAll(Custom::class.java).forEach { custom -> - extra[custom.value("name") as String] = custom.value("value") + extra[requireNotNull(custom.get("name").asString())] = custom.get("value").raw() } all() - .filter { it.meta.contains(CustomAnnotation::class.java) } + .filter { it.metadata.contains(CustomAnnotation::class.java) } .flatMap { it.values.entries } .forEach { (name, value) -> extra[name] = customAnnotationValue(value) } diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/schema/OpenApiRouteDefinition.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/schema/OpenApiRouteDefinition.kt new file mode 100644 index 00000000..615525a2 --- /dev/null +++ b/openapi-generator/src/main/kotlin/io/javalin/openapi/schema/OpenApiRouteDefinition.kt @@ -0,0 +1,220 @@ +package io.javalin.openapi.schema + +import io.javalin.introspection.ClassDefinition +import io.javalin.openapi.ContentType +import io.javalin.openapi.NULL_CLASS +import io.javalin.openapi.NULL_STRING +import io.javalin.openapi.experimental.processor.generators.ExampleProperty +import io.javalin.openapi.experimental.processor.generators.toExampleProperty + +internal data class OpenApiRouteDefinition( + val path: String, + val methods: List, + val versions: List, + val ignore: Boolean, + val summary: String?, + val description: String?, + val operationId: String, + val deprecated: Boolean, + val tags: List, + val cookies: List, + val headers: List, + val pathParameters: List, + val queryParameters: List, + val requestBody: OpenApiRequestBodyDefinition?, + val callbacks: List, + val responses: List, + val security: List, +) { + val formattedPath: String + get() = path.let { if (it.startsWith("/")) it else "/$it" } + + companion object { + fun from(values: Map): OpenApiRouteDefinition = + OpenApiRouteDefinition( + path = values.requiredString("path"), + methods = values.strings("methods"), + versions = values.strings("versions"), + ignore = values.boolean("ignore"), + summary = values.text("summary"), + description = values.text("description"), + operationId = values.requiredString("operationId"), + deprecated = values.boolean("deprecated"), + tags = values.strings("tags"), + cookies = values.maps("cookies").map(OpenApiParameterDefinition::from), + headers = values.maps("headers").map(OpenApiParameterDefinition::from), + pathParameters = values.maps("pathParams").map(OpenApiParameterDefinition::from), + queryParameters = values.maps("queryParams").map(OpenApiParameterDefinition::from), + requestBody = values.child("requestBody")?.let(OpenApiRequestBodyDefinition::from), + callbacks = values.maps("callbacks").map(OpenApiCallbackDefinition::from), + responses = values.maps("responses").map(OpenApiResponseDefinition::from), + security = values.maps("security").map(OpenApiSecurityDefinition::from), + ) + } +} + +internal data class OpenApiParameterDefinition( + val name: String, + val type: ClassDefinition, + val description: String?, + val required: Boolean, + val deprecated: Boolean, + val allowEmptyValue: Boolean, + val example: String?, +) { + companion object { + fun from(values: Map): OpenApiParameterDefinition = + OpenApiParameterDefinition( + name = values.requiredString("name"), + type = values.requiredClassDefinition("type"), + description = values.text("description"), + required = values.boolean("required"), + deprecated = values.boolean("deprecated"), + allowEmptyValue = values.boolean("allowEmptyValue"), + example = values.nonEmptyString("example"), + ) + } +} + +internal data class OpenApiRequestBodyDefinition( + val content: List, + val required: Boolean, + val description: String?, +) { + companion object { + fun from(values: Map): OpenApiRequestBodyDefinition = + OpenApiRequestBodyDefinition( + content = values.maps("content").map(OpenApiContentDefinition::from), + required = values.boolean("required"), + description = values.text("description"), + ) + } +} + +internal data class OpenApiResponseDefinition( + val status: String, + val content: List, + val description: String?, + val headers: List, +) { + companion object { + fun from(values: Map): OpenApiResponseDefinition = + OpenApiResponseDefinition( + status = values.requiredString("status"), + content = values.maps("content").map(OpenApiContentDefinition::from), + description = values.text("description"), + headers = values.maps("headers").map(OpenApiParameterDefinition::from), + ) + } +} + +internal data class OpenApiCallbackDefinition( + val name: String, + val url: String, + val method: String, + val summary: String?, + val description: String?, + val requestBody: OpenApiRequestBodyDefinition?, + val responses: List, +) { + companion object { + fun from(values: Map): OpenApiCallbackDefinition = + OpenApiCallbackDefinition( + name = values.requiredString("name"), + url = values.requiredString("url"), + method = values.requiredString("method"), + summary = values.text("summary"), + description = values.text("description"), + requestBody = values.child("requestBody")?.let(OpenApiRequestBodyDefinition::from), + responses = values.maps("responses").map(OpenApiResponseDefinition::from), + ) + } +} + +internal data class OpenApiSecurityDefinition( + val name: String, + val scopes: List, +) { + companion object { + fun from(values: Map): OpenApiSecurityDefinition = + OpenApiSecurityDefinition( + name = values.requiredString("name"), + scopes = values.strings("scopes"), + ) + } +} + +internal data class OpenApiContentDefinition( + val from: ClassDefinition?, + val mimeType: String?, + val type: String?, + val format: String?, + val properties: List, + val additionalProperties: OpenApiContentDefinition?, + val example: String?, + val exampleObjects: List, +) { + val hasResolvedSource: Boolean + get() = from != null && from.fullName != NULL_CLASS::class.java.name + + companion object { + fun from(values: Map): OpenApiContentDefinition = + OpenApiContentDefinition( + from = values.classDefinition("from"), + mimeType = values.string("mimeType")?.takeIf { it != ContentType.AUTODETECT }, + type = values.text("type"), + format = values.text("format"), + properties = values.maps("properties").map(OpenApiContentPropertyDefinition::from), + additionalProperties = values.child("additionalProperties") + ?.takeIf { !it.boolean("_ignored") } + ?.let(::from), + example = values.text("example"), + exampleObjects = values.maps("exampleObjects").map { it.toExampleProperty() }, + ) + } +} + +internal data class OpenApiContentPropertyDefinition( + val from: ClassDefinition?, + val name: String, + val isArray: Boolean, + val type: String, + val format: String?, +) { + val hasResolvedSource: Boolean + get() = from != null && from.fullName != NULL_CLASS::class.java.name + + companion object { + fun from(values: Map): OpenApiContentPropertyDefinition = + OpenApiContentPropertyDefinition( + from = values.classDefinition("from"), + name = values.requiredString("name"), + isArray = values.boolean("isArray"), + type = values.requiredString("type"), + format = values.text("format"), + ) + } +} + +private fun Map.requiredString(key: String): String = this[key] as String + +private fun Map.requiredClassDefinition(key: String): ClassDefinition = this[key] as ClassDefinition + +private fun Map.string(key: String): String? = this[key] as? String + +private fun Map.text(key: String): String? = string(key)?.takeIf { it != NULL_STRING } + +private fun Map.nonEmptyString(key: String): String? = string(key)?.takeIf { it.isNotEmpty() } + +private fun Map.boolean(key: String): Boolean = this[key] as? Boolean ?: false + +private fun Map.classDefinition(key: String): ClassDefinition? = this[key] as? ClassDefinition + +private fun Map.strings(key: String): List = + (this[key] as? List<*>)?.filterIsInstance().orEmpty() + +@Suppress("UNCHECKED_CAST") +private fun Map.child(key: String): Map? = this[key] as? Map + +private fun Map.maps(key: String): List> = + (this[key] as? List<*>)?.filterIsInstance>().orEmpty() diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/schema/OpenApiSchemaGenerator.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/schema/OpenApiSchemaGenerator.kt index 902bddd4..d1ddedb9 100644 --- a/openapi-generator/src/main/kotlin/io/javalin/openapi/schema/OpenApiSchemaGenerator.kt +++ b/openapi-generator/src/main/kotlin/io/javalin/openapi/schema/OpenApiSchemaGenerator.kt @@ -1,17 +1,14 @@ package io.javalin.openapi.schema import io.javalin.introspection.ClassDefinition -import io.javalin.openapi.ContentType -import io.javalin.openapi.OpenApiStatus -import io.javalin.openapi.NULL_CLASS import io.javalin.openapi.NULL_STRING +import io.javalin.openapi.OpenApiStatus import io.javalin.openapi.OpenApiOperation.AUTO_GENERATE import io.javalin.openapi.experimental.OpenApiType import io.javalin.openapi.experimental.SchemaGenerationContext import io.javalin.openapi.experimental.StructureType.ARRAY import io.javalin.openapi.experimental.processor.generators.ExampleGenerator import io.javalin.openapi.experimental.processor.generators.ResultScheme -import io.javalin.openapi.experimental.processor.generators.toExampleProperty import java.util.Locale import java.util.TreeMap @@ -22,42 +19,44 @@ class OpenApiSchemaGenerator( private val defaultStatusDescription: (String) -> String? = { OpenApiStatus.reasonPhrase(it) }, ) { - private val nullClassName = NULL_CLASS::class.java.name - fun generateSchema(routes: List>): String { + return generateRouteSchema(routes.map(OpenApiRouteDefinition::from)) + } + + private fun generateRouteSchema(routes: List): String { val schema = OpenApiSchemaBuilder() .openApiVersion("3.1.0") .info { it.title(title).version(version) } - for (route in routes.sortedBy { it.formattedPath() }) { - if (route["ignore"] as? Boolean == true) { + for (route in routes.sortedBy { it.formattedPath }) { + if (route.ignore) { continue } - val pathBuilder = schema.path(route.formattedPath()) + val pathBuilder = schema.path(route.formattedPath) - for (method in route.texts("methods").sorted()) { + for (method in route.methods.sorted()) { pathBuilder.operation(method.lowercase()) { - tags(route.texts("tags")) - summary(route.text("summary")) - description(route.text("description")) + tags(route.tags) + summary(route.summary) + description(route.description) operationId(generateOperationId(method, route).takeIf { it != NULL_STRING }) buildParameters(route) - buildRequestBody(route.child("requestBody")) - buildResponses(route.maps("responses")) - buildCallbacks(route.maps("callbacks")) + buildRequestBody(route.requestBody) + buildResponses(route.responses) + buildCallbacks(route.callbacks) - if (route["deprecated"] as? Boolean == true) { + if (route.deprecated) { deprecated(true) } - val securities = route.maps("security") + val securities = route.security if (securities.isNotEmpty()) { security { - for (security in securities.sortedBy { it["name"] as String }) { - securityRequirement(security["name"] as String, *security.texts("scopes").toTypedArray()) + for (security in securities.sortedBy { it.name }) { + securityRequirement(security.name, *security.scopes.toTypedArray()) } } } @@ -70,64 +69,67 @@ class OpenApiSchemaGenerator( } fun generateVersionedSchemas(routes: List>): Map = + generateVersionedRouteSchemas(routes.map(OpenApiRouteDefinition::from)) + + private fun generateVersionedRouteSchemas(routes: List): Map = routes - .flatMap { route -> route.texts("versions").map { version -> version to route } } + .flatMap { route -> route.versions.map { version -> version to route } } .groupBy({ it.first }, { it.second }) - .mapValues { (_, versionRoutes) -> generateSchema(versionRoutes.toSet().toList()) } + .mapValues { (_, versionRoutes) -> generateRouteSchema(versionRoutes.toSet().toList()) } - private fun OperationBuilder.buildParameters(route: Map) { + private fun OperationBuilder.buildParameters(route: OpenApiRouteDefinition) { parameters { val parametersByLocation = linkedMapOf( - In.COOKIE to route.maps("cookies"), - In.HEADER to route.maps("headers"), - In.PATH to route.maps("pathParams"), - In.QUERY to route.maps("queryParams"), + In.COOKIE to route.cookies, + In.HEADER to route.headers, + In.PATH to route.pathParameters, + In.QUERY to route.queryParameters, ) parametersByLocation.forEach { (location, parameters) -> parameters.forEach { parameter -> parameter( - name = parameter["name"] as String, + name = parameter.name, location = location.identifier, - schema = createTypeDescriptionWithReferences(parameter["type"] as ClassDefinition), - description = parameter.text("description"), - required = (parameter["required"] as? Boolean ?: false) || location == In.PATH, - deprecated = parameter["deprecated"] as? Boolean ?: false, - allowEmptyValue = parameter["allowEmptyValue"] as? Boolean ?: false, - example = parameter.exampleText(), + schema = createTypeDescriptionWithReferences(parameter.type), + description = parameter.description, + required = parameter.required || location == In.PATH, + deprecated = parameter.deprecated, + allowEmptyValue = parameter.allowEmptyValue, + example = parameter.example, ) } } } } - private fun OperationBuilder.buildRequestBody(requestBody: Map?) { + private fun OperationBuilder.buildRequestBody(requestBody: OpenApiRequestBodyDefinition?) { if (requestBody == null) { return } requestBody { - description(requestBody.text("description")) - content { addResolvedContent(requestBody.maps("content")) } - if (requestBody["required"] as? Boolean == true) { required(true) } + description(requestBody.description) + content { addResolvedContent(requestBody.content) } + if (requestBody.required) { required(true) } } } - private fun OperationBuilder.buildResponses(responses: List>) { + private fun OperationBuilder.buildResponses(responses: List) { responses { - for (response in responses.sortedBy { it["status"] as String }) { - response(response["status"] as String) { + for (response in responses.sortedBy { it.status }) { + response(response.status) { description(descriptionOf(response)) - content { addResolvedContent(response.maps("content")) } + content { addResolvedContent(response.content) } headers { - response.maps("headers").forEach { header -> + response.headers.forEach { header -> header( - name = header["name"] as String, - schema = createTypeDescriptionWithReferences(header["type"] as ClassDefinition), - description = header.text("description"), - required = header["required"] as? Boolean ?: false, - deprecated = header["deprecated"] as? Boolean ?: false, - allowEmptyValue = header["allowEmptyValue"] as? Boolean ?: false, - example = header.exampleText(), + name = header.name, + schema = createTypeDescriptionWithReferences(header.type), + description = header.description, + required = header.required, + deprecated = header.deprecated, + allowEmptyValue = header.allowEmptyValue, + example = header.example, ) } } @@ -136,7 +138,7 @@ class OpenApiSchemaGenerator( } } - private fun OperationBuilder.buildCallbacks(callbacks: List>) { + private fun OperationBuilder.buildCallbacks(callbacks: List) { if (callbacks.isEmpty()) { return } @@ -144,23 +146,23 @@ class OpenApiSchemaGenerator( callbacks { callbacks.forEach { callback -> callback( - name = callback["name"] as String, - url = callback["url"] as String, - method = (callback["method"] as String).lowercase() + name = callback.name, + url = callback.url, + method = callback.method.lowercase() ) { - summary(callback.text("summary")) - description(callback.text("description")) - val callbackBody = callback.child("requestBody") + summary(callback.summary) + description(callback.description) + val callbackBody = callback.requestBody requestBody { - description(callbackBody?.text("description")) - content { addResolvedContent(callbackBody?.maps("content").orEmpty()) } - if (callbackBody?.get("required") as? Boolean == true) { required(true) } + description(callbackBody?.description) + content { addResolvedContent(callbackBody?.content.orEmpty()) } + if (callbackBody?.required == true) { required(true) } } responses { - for (response in callback.maps("responses").sortedBy { it["status"] as String }) { - response(response["status"] as String) { + for (response in callback.responses.sortedBy { it.status }) { + response(response.status) { description(descriptionOf(response)) - content { addResolvedContent(response.maps("content")) } + content { addResolvedContent(response.content) } } } } @@ -169,7 +171,7 @@ class OpenApiSchemaGenerator( } } - private fun ContentBuilder.addResolvedContent(contents: List>) { + private fun ContentBuilder.addResolvedContent(contents: List) { val resolvedEntries = TreeMap Unit>() for (content in contents) { @@ -187,14 +189,10 @@ class OpenApiSchemaGenerator( COOKIE("cookie"), } - private fun generateOperationId( - method: String, - route: Map, - pathParamPrefix: String = "By" - ): String = - when (val operationId = route["operationId"] as String) { + private fun generateOperationId(method: String, route: OpenApiRouteDefinition, pathParamPrefix: String = "By"): String = + when (val operationId = route.operationId) { AUTO_GENERATE -> - method.lowercase() + (route["path"] as String).split('/') + method.lowercase() + route.path.split('/') .map { pathPart -> if (pathPart.startsWith('{') || pathPart.startsWith('<')) { val pathParam = pathPart @@ -217,11 +215,11 @@ class OpenApiSchemaGenerator( it.titlecase(Locale.getDefault()) } - private fun resolveMediaType(content: Map): Pair Unit>? { - val from = content["from"] as? ClassDefinition - val fromIsNull = from == null || from.fullName == nullClassName - var type = content.text("type") - var mimeType = (content["mimeType"] as? String)?.takeIf { it != ContentType.AUTODETECT } + private fun resolveMediaType(content: OpenApiContentDefinition): Pair Unit>? { + val from = content.from + val fromIsNull = !content.hasResolvedSource + var type = content.type + var mimeType = content.mimeType if (mimeType == null) { if (fromIsNull) { @@ -244,9 +242,9 @@ class OpenApiSchemaGenerator( } val resolvedType = type - val format = content.text("format") - val properties = content.maps("properties").takeIf { it.isNotEmpty() } - val additionalProperties = content.child("additionalProperties")?.takeIf { it["_ignored"] != true } + val format = content.format + val properties = content.properties.takeIf { it.isNotEmpty() } + val additionalProperties = content.additionalProperties val configure: MediaTypeBuilder.() -> Unit = { when { @@ -271,44 +269,42 @@ class OpenApiSchemaGenerator( return mimeType to configure } - private fun ExampleHolder.applyExample(content: Map) { - content.text("example")?.let { example(it) } - content.maps("exampleObjects").takeIf { it.isNotEmpty() }?.let { exampleMaps -> - val result = ExampleGenerator.generateFromExamples(exampleMaps.map { it.toExampleProperty() }) + private fun ExampleHolder.applyExample(content: OpenApiContentDefinition) { + content.example?.let { example(it) } + content.exampleObjects.takeIf { it.isNotEmpty() }?.let { examples -> + val result = ExampleGenerator.generateFromExamples(examples) result.simpleValue?.let { example(it) } result.jsonElement?.let { exampleJson(it) } } } - private fun ObjectSchemaBuilder.buildProperties(properties: List>) { + private fun ObjectSchemaBuilder.buildProperties(properties: List) { for (property in properties) { - val propertyFormat = property.text("format") - val from = property["from"] as? ClassDefinition - val isResolved = from != null && from.fullName != nullClassName + val from = property.from?.takeIf { property.hasResolvedSource } - if (property["isArray"] as? Boolean == true) { - if (isResolved) { - arrayProperty(property["name"] as String, createTypeDescriptionWithReferences(from!!)) + if (property.isArray) { + if (from != null) { + arrayProperty(property.name, createTypeDescriptionWithReferences(from)) } else { - arrayProperty(property["name"] as String, property["type"] as String, propertyFormat) + arrayProperty(property.name, property.type, property.format) } } else { - if (isResolved) { - property(property["name"] as String, createTypeDescriptionWithReferences(from!!)) + if (from != null) { + property(property.name, createTypeDescriptionWithReferences(from)) } else { - property(property["name"] as String, property["type"] as String, propertyFormat) + property(property.name, property.type, property.format) } } } } - private fun ObjectSchemaBuilder.buildAdditionalProperties(additionalProperties: Map) { - val from = additionalProperties["from"] as? ClassDefinition + private fun ObjectSchemaBuilder.buildAdditionalProperties(additionalProperties: OpenApiContentDefinition) { + val from = additionalProperties.from?.takeIf { additionalProperties.hasResolvedSource } - if (from != null && from.fullName != nullClassName) { + if (from != null) { additionalProperties(createTypeDescriptionWithReferences(from)) } else { - additionalProperties(additionalProperties.text("type"), additionalProperties.text("format")) + additionalProperties(additionalProperties.type, additionalProperties.format) } applyExample(additionalProperties) @@ -330,29 +326,9 @@ class OpenApiSchemaGenerator( return context.typeSchemaGenerator.createEmbeddedTypeDescription(model) } - private fun descriptionOf(response: Map): String = - response.text("description") - ?: defaultStatusDescription(response["status"] as String) + private fun descriptionOf(response: OpenApiResponseDefinition): String = + response.description + ?: defaultStatusDescription(response.status) ?: "" } - -private fun Map.formattedPath(): String = - (this["path"] as String).let { if (it.startsWith("/")) it else "/$it" } - -private fun Map.text(key: String): String? = - (this[key] as? String)?.takeIf { it != NULL_STRING } - -private fun Map.exampleText(): String? = - (this["example"] as? String)?.takeIf { it.isNotEmpty() } - -private fun Map.texts(key: String): List = - (this[key] as? List<*>)?.filterIsInstance() ?: emptyList() - -private fun Map.child(key: String): Map? { - @Suppress("UNCHECKED_CAST") - return this[key] as? Map -} - -private fun Map.maps(key: String): List> = - (this[key] as? List<*>)?.filterIsInstance>() ?: emptyList() diff --git a/openapi-ksp/src/main/kotlin/io/javalin/openapi/ksp/KspSchemaContext.kt b/openapi-ksp/src/main/kotlin/io/javalin/openapi/ksp/KspSchemaContext.kt index 89f35c25..d9a2d9ba 100644 --- a/openapi-ksp/src/main/kotlin/io/javalin/openapi/ksp/KspSchemaContext.kt +++ b/openapi-ksp/src/main/kotlin/io/javalin/openapi/ksp/KspSchemaContext.kt @@ -4,7 +4,7 @@ import com.google.devtools.ksp.processing.Resolver import com.google.devtools.ksp.processing.KSPLogger import com.google.devtools.ksp.symbol.KSAnnotated import io.javalin.introspection.AnnotationSet -import io.javalin.introspection.PropertyView +import io.javalin.introspection.PropertyProjection import io.javalin.introspection.TypeIntrospector import io.javalin.introspection.ksp.KspTypeIntrospector import io.javalin.openapi.OpenApiByFields @@ -25,8 +25,8 @@ internal class KspSchemaContext( fun annotationsOf(annotated: KSAnnotated): AnnotationSet = kspTypeIntrospector.annotationsOf(annotated) - override fun propertiesOf(type: OpenApiType): List { - if (annotationsOf(type).find(OpenApiByFields::class.java)?.boolean("only") == true) { + override fun propertiesOf(type: OpenApiType): List { + if (annotationsOf(type).find(OpenApiByFields::class.java)?.get("only")?.asBoolean() == true) { logger.error( "KSP does not support @OpenApiByFields(only = true). " + "Use APT/Kapt for field-only schema generation, or remove only = true." diff --git a/openapi-ksp/src/main/kotlin/io/javalin/openapi/ksp/OpenApiSymbolProcessor.kt b/openapi-ksp/src/main/kotlin/io/javalin/openapi/ksp/OpenApiSymbolProcessor.kt index 8857561f..77ad6f4a 100644 --- a/openapi-ksp/src/main/kotlin/io/javalin/openapi/ksp/OpenApiSymbolProcessor.kt +++ b/openapi-ksp/src/main/kotlin/io/javalin/openapi/ksp/OpenApiSymbolProcessor.kt @@ -36,7 +36,7 @@ class OpenApiSymbolProcessor( .filterIsInstance() .mapNotNull { declaration -> val type = context.introspect(declaration.asStarProjectedType()) - if (context.annotationsOf(type).find(JsonSchema::class.java)?.value("generateResource") == false) { + if (context.annotationsOf(type).find(JsonSchema::class.java)?.get("generateResource")?.asBoolean() == false) { return@mapNotNull null } val json = context.typeSchemaGenerator.createTypeSchema(type, inlineRefs = true).toJsonSchemaString() @@ -78,6 +78,7 @@ class OpenApiSymbolProcessor( private fun writeResource(path: String, content: String) { if (!writtenResources.add(path)) { + // KSP resources are immutable; routes another processor adds in later rounds are intentionally omitted. return } From 55f6e9c6ab5cd52da54cb43a966b291c9e3ce4b2 Mon Sep 17 00:00:00 2001 From: dzikoysk Date: Tue, 21 Jul 2026 02:27:21 +0200 Subject: [PATCH 31/43] chore: cleanup --- .../io/javalin/openapi/ksp/example/App.kt | 7 +- .../io/javalin/introspection/AnnotationSet.kt | 19 +- .../introspection/jap/JapTypeIntrospector.kt | 215 +++++++++++---- .../introspection/ksp/KspTypeIntrospector.kt | 107 ++++++-- .../ksp/KspTypeIntrospectorTest.kt | 4 +- .../runtime/ReflectionTypeIntrospector.kt | 159 ++++++++--- .../test/IntrospectionParityTest.kt | 19 +- .../openapi/dynamic/hook/OpenApiMetadata.kt | 2 +- .../dynamic/hook/RegisteredRoutesHook.kt | 53 ++-- .../openapi/plugin/OpenApiConfiguration.kt | 15 +- .../openapi/plugin/redoc/ReDocHandler.kt | 2 +- .../plugin/redoc/ReDocWebJarHandler.kt | 9 +- .../openapi/plugin/swagger/SwaggerHandler.kt | 4 +- .../AnnotationProcessorContext.kt | 35 ++- ...OpenApiAnnotationProcessorConfiguration.kt | 1 - .../openapi/experimental/OpenApiTypeHandle.kt | 2 +- .../shared/AnnotationProcessorExtensions.kt | 4 +- .../generators/JsonSchemaGenerator.kt | 15 +- .../processor/generators/OpenApiGenerator.kt | 44 ++-- ...xtures.java => RegressionTestSupport.java} | 0 .../dynamic/ReflectionSchemaContext.kt | 1 - .../openapi/dynamic/DynamicCompositionTest.kt | 5 +- .../defaults/ArrayEmbeddedTypeProcessor.kt | 33 ++- .../CompositionEmbeddedTypeProcessor.kt | 25 +- .../DictionaryEmbeddedTypeProcessor.kt | 49 ++-- .../generators/CompositionGenerator.kt | 96 ++++--- .../processor/generators/ExampleGenerator.kt | 12 +- .../processor/generators/ResultScheme.kt | 5 +- .../generators/TypeSchemaGenerator.kt | 194 ++++++++------ .../openapi/schema/OpenApiRouteDefinition.kt | 14 +- .../openapi/schema/OpenApiSchemaBuilder.kt | 156 +++++++---- .../openapi/schema/OpenApiSchemaGenerator.kt | 90 ++++--- .../schema/OpenApiRouteDefinitionTest.kt | 107 ++++++++ .../javalin/openapi/ksp/KspSchemaContext.kt | 2 +- .../openapi/ksp/OpenApiSymbolProcessor.kt | 64 +++-- .../openapi/ksp/KspSchemaContextTest.kt | 5 +- .../openapi/ksp/OpenApiSymbolProcessorTest.kt | 248 +++++++----------- 37 files changed, 1153 insertions(+), 669 deletions(-) rename openapi-annotation-processor/src/test/java/io/javalin/openapi/processor/{RegressionFixtures.java => RegressionTestSupport.java} (100%) create mode 100644 openapi-generator/src/test/kotlin/io/javalin/openapi/schema/OpenApiRouteDefinitionTest.kt diff --git a/examples/javalin-ksp-kotlin/src/main/kotlin/io/javalin/openapi/ksp/example/App.kt b/examples/javalin-ksp-kotlin/src/main/kotlin/io/javalin/openapi/ksp/example/App.kt index e453b18e..b941298f 100644 --- a/examples/javalin-ksp-kotlin/src/main/kotlin/io/javalin/openapi/ksp/example/App.kt +++ b/examples/javalin-ksp-kotlin/src/main/kotlin/io/javalin/openapi/ksp/example/App.kt @@ -24,7 +24,12 @@ class AccountHandler : Handler { methods = [HttpMethod.GET], summary = "Get the current account", operationId = "getAccount", - responses = [OpenApiResponse(status = OpenApiStatus.OK, content = [OpenApiContent(from = Account::class)])] + responses = [ + OpenApiResponse( + status = OpenApiStatus.OK, + content = [OpenApiContent(from = Account::class)], + ) + ], ) override fun handle(ctx: Context) { ctx.json(Account(id = "u-1", age = 30, roles = listOf("admin"))) diff --git a/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/AnnotationSet.kt b/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/AnnotationSet.kt index f7c9481a..33b66ec7 100644 --- a/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/AnnotationSet.kt +++ b/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/AnnotationSet.kt @@ -49,15 +49,12 @@ class RepeatableAnnotationProjection( override val simpleName: String, override val values: Map, ) : AnnotationProjection { - private companion object { - val EMPTY: AnnotationSet = object : AnnotationSet { - override fun all(): List = emptyList() - override fun find(type: Class): AnnotationProjection? = null - override fun findAll(type: Class): List = emptyList() - override fun contains(simpleName: String): Boolean = false - } - } - - override val metadata: AnnotationSet - get() = EMPTY + override val metadata: AnnotationSet = EmptyAnnotationSet +} + +private object EmptyAnnotationSet : AnnotationSet { + override fun all(): List = emptyList() + override fun find(type: Class): AnnotationProjection? = null + override fun findAll(type: Class): List = emptyList() + override fun contains(simpleName: String): Boolean = false } diff --git a/introspection/introspection-jap/src/main/kotlin/io/javalin/introspection/jap/JapTypeIntrospector.kt b/introspection/introspection-jap/src/main/kotlin/io/javalin/introspection/jap/JapTypeIntrospector.kt index e08c2370..a7903df4 100644 --- a/introspection/introspection-jap/src/main/kotlin/io/javalin/introspection/jap/JapTypeIntrospector.kt +++ b/introspection/introspection-jap/src/main/kotlin/io/javalin/introspection/jap/JapTypeIntrospector.kt @@ -7,13 +7,13 @@ import io.javalin.introspection.ClassDefinition import io.javalin.introspection.CompileTimeIntrospector import io.javalin.introspection.EnumConstant import io.javalin.introspection.InternalIntrospectionApi +import io.javalin.introspection.MemberVisibility import io.javalin.introspection.PropertyProjection import io.javalin.introspection.RepeatableAnnotationProjection import io.javalin.introspection.StructureType import io.javalin.introspection.StructureType.ARRAY import io.javalin.introspection.StructureType.DEFAULT import io.javalin.introspection.StructureType.DICTIONARY -import io.javalin.introspection.MemberVisibility import io.javalin.introspection.isGetterName import io.javalin.introspection.propertyName import java.lang.annotation.Inherited @@ -74,14 +74,57 @@ class JapTypeIntrospector( if (key in visitingTypeVariables) { objectDefinition(structureType) } else { - (mirror.upperBound ?: mirror.lowerBound)?.let { resolve(it, generics, structureType, visitingTypeVariables + key) } ?: objectDefinition(structureType) + val bound = mirror.upperBound ?: mirror.lowerBound + if (bound != null) { + resolve( + mirror = bound, + generics = generics, + structureType = structureType, + visitingTypeVariables = visitingTypeVariables + key, + ) + } else { + objectDefinition(structureType) + } } } - is WildcardType -> resolve(mirror.extendsBound ?: objectMirror(), generics, structureType, visitingTypeVariables) - is ArrayType -> resolve(mirror.componentType, generics, ARRAY, visitingTypeVariables) - is PrimitiveType -> definition(types.boxedClass(mirror).asType(), generics, structureType) - is DeclaredType -> declared(mirror, generics, structureType, visitingTypeVariables) - else -> types.asElement(mirror)?.asType()?.takeIf { it != mirror }?.let { resolve(it, generics, structureType, visitingTypeVariables) } ?: objectDefinition(structureType) + is WildcardType -> + resolve( + mirror = mirror.extendsBound ?: objectMirror(), + generics = generics, + structureType = structureType, + visitingTypeVariables = visitingTypeVariables, + ) + is ArrayType -> resolve( + mirror = mirror.componentType, + generics = generics, + structureType = ARRAY, + visitingTypeVariables = visitingTypeVariables, + ) + is PrimitiveType -> definition( + mirror = types.boxedClass(mirror).asType(), + generics = generics, + structureType = structureType, + ) + is DeclaredType -> declared( + mirror = mirror, + generics = generics, + structureType = structureType, + visitingTypeVariables = visitingTypeVariables, + ) + else -> + types + .asElement(mirror) + ?.asType() + ?.takeIf { it != mirror } + ?.let { + resolve( + mirror = it, + generics = generics, + structureType = structureType, + visitingTypeVariables = visitingTypeVariables + ) + } + ?: objectDefinition(structureType) } private fun declared( @@ -92,19 +135,34 @@ class JapTypeIntrospector( ): ClassDefinition { val erasure = types.erasure(mirror) return when { - types.isAssignable(erasure, erasureOf("java.util.Map")) -> + types.isAssignable(erasure, erasureOf("java.util.Map")) -> { + val keyType = resolve( + mirror.typeArguments.getOrElse(0) { objectMirror() }, + visitingTypeVariables = visitingTypeVariables, + ) + val valueType = resolve( + mirror.typeArguments.getOrElse(1) { objectMirror() }, + visitingTypeVariables = visitingTypeVariables, + ) definition( mirror = mirror, - generics = listOf( - resolve(mirror.typeArguments.getOrElse(0) { objectMirror() }, visitingTypeVariables = visitingTypeVariables), - resolve(mirror.typeArguments.getOrElse(1) { objectMirror() }, visitingTypeVariables = visitingTypeVariables), - ), + generics = listOf(keyType, valueType), structureType = DICTIONARY, ) + } types.isAssignable(erasure, erasureOf("java.util.Collection")) -> - resolve(mirror.typeArguments.getOrElse(0) { objectMirror() }, generics, ARRAY, visitingTypeVariables) + resolve( + mirror = mirror.typeArguments.getOrElse(0) { objectMirror() }, + generics = generics, + structureType = ARRAY, + visitingTypeVariables = visitingTypeVariables, + ) else -> - definition(mirror, mirror.typeArguments.map { resolve(it, visitingTypeVariables = visitingTypeVariables) }, structureType) + definition( + mirror = mirror, + generics = mirror.typeArguments.map { resolve(it, visitingTypeVariables = visitingTypeVariables) }, + structureType = structureType, + ) } } @@ -116,6 +174,7 @@ class JapTypeIntrospector( ): ClassDefinition { val element = types.asElement(mirror) as? TypeElement val fullName = element?.qualifiedName?.toString() ?: mirror.toString().substringBefore("<") + return Definition( simpleName = element?.simpleName?.toString() ?: fullName.substringAfterLast('.'), fullName = fullName, @@ -127,10 +186,19 @@ class JapTypeIntrospector( } private fun primitiveDefinition(mirror: PrimitiveType): ClassDefinition = - definition(types.boxedClass(mirror).asType(), emptyList(), DEFAULT, sourceMirror = mirror) + definition( + mirror = types.boxedClass(mirror).asType(), + generics = emptyList(), + structureType = DEFAULT, + sourceMirror = mirror, + ) private fun objectDefinition(structureType: StructureType = DEFAULT): ClassDefinition = - definition(objectMirror(), emptyList(), structureType) + definition( + mirror = objectMirror(), + generics = emptyList(), + structureType = structureType, + ) private fun objectMirror(): TypeMirror = elements.getTypeElement("java.lang.Object").asType() @@ -138,19 +206,28 @@ class JapTypeIntrospector( private fun erasureOf(name: String): TypeMirror = types.erasure(elements.getTypeElement(name).asType()) - private fun mirrorValues(mirror: AnnotationMirror): Map { - val visitor = object : SimpleAnnotationValueVisitor8() { - override fun defaultAction(value: Any?, p: Nothing?): Any? = value - override fun visitType(t: TypeMirror, p: Nothing?): Any = - if (t is PrimitiveType) primitiveDefinition(t) else resolve(t) - override fun visitEnumConstant(constant: VariableElement, p: Nothing?): Any = constant.simpleName.toString() - override fun visitArray(values: MutableList, p: Nothing?): Any = values.map { it.accept(this, null) } - override fun visitAnnotation(nested: AnnotationMirror, p: Nothing?): Any = mirrorValues(nested) - } - return elements.getElementValuesWithDefaults(mirror).entries - .associate { (member, value) -> member.simpleName.toString() to value.accept(visitor, null) } + private val annotationMirrorValueVisitor = object : SimpleAnnotationValueVisitor8() { + override fun defaultAction(value: Any?, p: Nothing?): Any? = value + + override fun visitType(type: TypeMirror, p: Nothing?): Any = + if (type is PrimitiveType) primitiveDefinition(type) else resolve(type) + + override fun visitEnumConstant(constant: VariableElement, p: Nothing?): Any = + constant.simpleName.toString() + + override fun visitArray(values: MutableList, p: Nothing?): Any = + values.map { it.accept(this, null) } + + override fun visitAnnotation(annotation: AnnotationMirror, p: Nothing?): Any = + mirrorValues(annotation) } + private fun mirrorValues(mirror: AnnotationMirror): Map = + elements + .getElementValuesWithDefaults(mirror) + .entries + .associate { (member, value) -> member.simpleName.toString() to value.accept(annotationMirrorValueVisitor, null) } + private inner class Definition( simpleName: String, fullName: String, @@ -158,7 +235,12 @@ class JapTypeIntrospector( structureType: StructureType, private val mirror: TypeMirror, private val sourceMirror: TypeMirror = mirror, - ) : ClassDefinition(simpleName, fullName, generics, structureType) { + ) : ClassDefinition( + simpleName = simpleName, + fullName = fullName, + generics = generics, + structureType = structureType, + ) { @InternalIntrospectionApi override val source: Any @@ -172,11 +254,19 @@ class JapTypeIntrospector( ?.takeIf { it.kind == ElementKind.ENUM } ?.enclosedElements ?.filter { it.kind == ElementKind.ENUM_CONSTANT } - ?.map { EnumConstant(it.simpleName.toString(), AnnotationsView(listOf(it))) } + ?.map { + EnumConstant( + name = it.simpleName.toString(), + annotations = AnnotationsView(sources = listOf(it)), + ) + } .orEmpty() override fun getAnnotations(): AnnotationSet = - AnnotationsView(listOfNotNull(typeElement()), includeInherited = true) + AnnotationsView( + sources = listOfNotNull(typeElement()), + includeInherited = true, + ) override fun getProperties(): List { val element = typeElement() ?: return emptyList() @@ -258,13 +348,14 @@ class JapTypeIntrospector( private fun TypeMirror.nullable(): Boolean = !kind.isPrimitive - private fun Element.isGetter(): Boolean { - if (kind != ElementKind.METHOD || this !is ExecutableElement) return false - if (Modifier.STATIC in modifiers) return false - if (parameters.isNotEmpty() || enclosingElement?.toString() == "java.lang.Object") return false - if (returnType.kind == TypeKind.VOID) return false - return isGetterName(simpleName.toString()) || annotationMirrors.any { it.annotationType.asElement().simpleName.contentEquals("OpenApiName") } - } + private fun Element.isGetter(): Boolean = + when { + kind != ElementKind.METHOD || this !is ExecutableElement -> false + Modifier.STATIC in modifiers -> false + parameters.isNotEmpty() || enclosingElement.toString() == "java.lang.Object" -> false + returnType.kind == TypeKind.VOID -> false + else -> isGetterName(simpleName.toString()) || annotationMirrors.any { it.annotationType.asElement().simpleName.contentEquals("OpenApiName") } + } private fun Element.isInstanceField(): Boolean = kind == ElementKind.FIELD && this is VariableElement && Modifier.STATIC !in modifiers @@ -288,33 +379,39 @@ class JapTypeIntrospector( return source.annotationMirrors } - val inherited = generateSequence((source as? TypeElement)?.superclass) { mirror -> - (types.asElement(mirror) as? TypeElement)?.superclass - } - .mapNotNull { types.asElement(it) as? TypeElement } - .takeWhile { it.qualifiedName.toString() != Object::class.java.name } - .flatMap { supertype -> - supertype.annotationMirrors - .filter { it.annotationType.asElement().hasAnnotation(Inherited::class.java) } - } - .toList() + val superclass = (source as? TypeElement)?.superclass + val inherited = + generateSequence(superclass) { mirror -> (types.asElement(mirror) as? TypeElement)?.superclass } + .mapNotNull { types.asElement(it) as? TypeElement } + .takeWhile { it.qualifiedName.toString() != Object::class.java.name } + .flatMap { supertype -> + supertype + .annotationMirrors + .filter { it.annotationType.asElement().hasAnnotation(Inherited::class.java) } + } + .toList() return source.annotationMirrors + inherited } override fun contains(simpleName: String): Boolean = - sources.any { source -> mirrors(source).any { it.annotationType.asElement().simpleName.contentEquals(simpleName) } } + sources.any { source -> + mirrors(source).any { + it.annotationType.asElement().simpleName.contentEquals(simpleName) + } + } override fun find(type: Class): AnnotationProjection? = - sources.firstNotNullOfOrNull { source -> mirrors(source).firstOrNull { it.named(type) } } + sources + .firstNotNullOfOrNull { source -> mirrors(source).firstOrNull { it.named(type) } } ?.let { JapAnnotationProjection(it) } override fun findAll(type: Class): List { - val mirrors = sources.flatMap { mirrors(it) } - val direct = mirrors.filter { it.named(type) }.map { JapAnnotationProjection(it) } + val annotationMirrors = sources.flatMap { mirrors(it) } + val direct = annotationMirrors.filter { it.named(type) }.map { JapAnnotationProjection(it) } // javac wraps repeated annotations in their @Repeatable container; unwrap its `value` array val containerName = type.getAnnotation(JavaRepeatable::class.java)?.value?.java?.canonicalName - val container = containerName?.let { name -> mirrors.firstOrNull { it.named(name) } } + val container = containerName?.let { name -> annotationMirrors.firstOrNull { it.named(name) } } val repeated = (container?.let { mirrorValues(it)["value"] } as? List<*>) ?.filterIsInstance>() ?.map { RepeatableAnnotationProjection(type.simpleName, it) } @@ -323,24 +420,34 @@ class JapTypeIntrospector( } override fun all(): List = - sources.flatMap { mirrors(it) }.distinctBy { it.annotationType.asElement() }.map { JapAnnotationProjection(it) } + sources + .flatMap { mirrors(it) } + .distinctBy { it.annotationType.asElement() } + .map { JapAnnotationProjection(it) } private fun AnnotationMirror.named(qualifiedName: String): Boolean = - (annotationType.asElement() as? TypeElement)?.qualifiedName?.contentEquals(qualifiedName) == true + (annotationType.asElement() as? TypeElement) + ?.qualifiedName + ?.contentEquals(qualifiedName) == true private fun AnnotationMirror.named(type: Class): Boolean = named(type.name) || type.canonicalName?.let { named(it) } == true private fun Element.hasAnnotation(type: Class): Boolean = annotationMirrors.any { it.named(type) } + } private inner class JapAnnotationProjection(private val mirror: AnnotationMirror) : AnnotationProjection { + override val simpleName: String get() = mirror.annotationType.asElement().simpleName.toString() + override val metadata: AnnotationSet get() = AnnotationsView(listOf(mirror.annotationType.asElement())) + override val values: Map get() = mirrorValues(mirror) + } } diff --git a/introspection/introspection-ksp/src/main/kotlin/io/javalin/introspection/ksp/KspTypeIntrospector.kt b/introspection/introspection-ksp/src/main/kotlin/io/javalin/introspection/ksp/KspTypeIntrospector.kt index 3a389732..6aa524da 100644 --- a/introspection/introspection-ksp/src/main/kotlin/io/javalin/introspection/ksp/KspTypeIntrospector.kt +++ b/introspection/introspection-ksp/src/main/kotlin/io/javalin/introspection/ksp/KspTypeIntrospector.kt @@ -82,20 +82,51 @@ class KspTypeIntrospector(private val resolver: Resolver) : CompileTimeIntrospec return objectDefinition(structureType) } val bound = declaration.bounds.firstOrNull()?.resolve() - return if (bound != null) resolve(bound, structureType, visitingTypeParameters + key) else objectDefinition(structureType) + return if (bound != null) { + resolve( + type = bound, + structureType = structureType, + visitingTypeParameters = visitingTypeParameters + key, + ) + } else { + objectDefinition(structureType) + } } val qualifiedName = declaration.qualifiedName?.asString() ?: return objectDefinition(structureType) return when { - mapType != null && mapType.isAssignableFrom(type.starProjection()) -> - definition(type, DICTIONARY, listOf(resolve(argument(type, 0), visitingTypeParameters = visitingTypeParameters), resolve(argument(type, 1), visitingTypeParameters = visitingTypeParameters))) + mapType != null && mapType.isAssignableFrom(type.starProjection()) -> { + val keyType = resolve(argument(type, 0), visitingTypeParameters = visitingTypeParameters) + val valueType = resolve(argument(type, 1), visitingTypeParameters = visitingTypeParameters) + definition( + type = type, + structureType = DICTIONARY, + generics = listOf(keyType, valueType), + ) + } collectionType != null && collectionType.isAssignableFrom(type.starProjection()) -> - resolve(argument(type, 0), ARRAY, visitingTypeParameters) + resolve( + type = argument(type, 0), + structureType = ARRAY, + visitingTypeParameters = visitingTypeParameters, + ) qualifiedName == "kotlin.Array" -> - resolve(argument(type, 0), ARRAY, visitingTypeParameters) + resolve( + type = argument(type, 0), + structureType = ARRAY, + visitingTypeParameters = visitingTypeParameters, + ) primitiveArrayElementTypes[qualifiedName] != null -> - resolve(builtin(primitiveArrayElementTypes.getValue(qualifiedName))!!, ARRAY, visitingTypeParameters) - else -> - definition(type, structureType, type.arguments.mapNotNull { it.type?.resolve() }.map { resolve(it, visitingTypeParameters = visitingTypeParameters) }) + resolve( + type = builtin(primitiveArrayElementTypes.getValue(qualifiedName))!!, + structureType = ARRAY, + visitingTypeParameters = visitingTypeParameters, + ) + else -> { + val generics = type.arguments + .mapNotNull { it.type?.resolve() } + .map { resolve(it, visitingTypeParameters = visitingTypeParameters) } + definition(type = type, structureType = structureType, generics = generics) + } } } @@ -117,7 +148,11 @@ class KspTypeIntrospector(private val resolver: Resolver) : CompileTimeIntrospec } private fun objectDefinition(structureType: StructureType = DEFAULT): ClassDefinition = - definition(builtin("kotlin.Any")!!, structureType, emptyList()) + definition( + type = builtin("kotlin.Any")!!, + structureType = structureType, + generics = emptyList(), + ) private fun argument(type: KSType, index: Int): KSType = type.arguments.getOrNull(index)?.type?.resolve() ?: builtin("kotlin.Any")!! @@ -131,7 +166,12 @@ class KspTypeIntrospector(private val resolver: Resolver) : CompileTimeIntrospec generics: List, structureType: StructureType, private val type: KSType, - ) : ClassDefinition(simpleName, fullName, generics, structureType) { + ) : ClassDefinition( + simpleName = simpleName, + fullName = fullName, + generics = generics, + structureType = structureType, + ) { private val declaration: KSClassDeclaration? get() = type.declaration as? KSClassDeclaration @@ -149,30 +189,36 @@ class KspTypeIntrospector(private val resolver: Resolver) : CompileTimeIntrospec ?.declarations ?.filterIsInstance() ?.filter { it.classKind == ClassKind.ENUM_ENTRY } - ?.map { EnumConstant(it.simpleName.asString(), KspAnnotations(it)) } + ?.map { + EnumConstant( + name = it.simpleName.asString(), + annotations = KspAnnotations(elements = listOf(it)), + ) + } ?.toList() .orEmpty() - override fun getAnnotations(): AnnotationSet = - KspAnnotations(declaration) + override fun getAnnotations(): AnnotationSet = KspAnnotations(declaration) override fun getProperties(): List { val declaration = declaration ?: return emptyList() - return declaration.getAllProperties().filter { property -> - property.getVisibility() !in setOf(KspVisibility.PRIVATE, KspVisibility.LOCAL) - }.map { property -> - val propertyType = property.type.resolve() - PropertyProjection( - name = property.simpleName.asString(), - type = resolve(propertyType), - accessor = Accessor.GETTER, - nullable = propertyType.isMarkedNullable, - visibility = visibilityOf(property.getVisibility()), - transient = Modifier.JAVA_TRANSIENT in property.modifiers, - source = property, - annotations = KspAnnotations(listOfNotNull(property, property.getter)), - ) - }.toList() + return declaration + .getAllProperties() + .filter { property -> property.getVisibility() !in setOf(KspVisibility.PRIVATE, KspVisibility.LOCAL) } + .map { property -> + val propertyType = property.type.resolve() + PropertyProjection( + name = property.simpleName.asString(), + type = resolve(propertyType), + accessor = Accessor.GETTER, + nullable = propertyType.isMarkedNullable, + visibility = visibilityOf(property.getVisibility()), + transient = Modifier.JAVA_TRANSIENT in property.modifiers, + source = property, + annotations = KspAnnotations(elements = listOfNotNull(property, property.getter)), + ) + } + .toList() } } @@ -196,6 +242,7 @@ class KspTypeIntrospector(private val resolver: Resolver) : CompileTimeIntrospec val direct = annotations .filter { it.named(type) } .map { KspAnnotationProjection(it) } + val containerName = type.getAnnotation(JavaRepeatable::class.java)?.value?.java?.canonicalName val repeated = containerName ?.let { name -> annotations.firstOrNull { it.qualifiedName() == name } } @@ -219,12 +266,16 @@ class KspTypeIntrospector(private val resolver: Resolver) : CompileTimeIntrospec } private inner class KspAnnotationProjection(private val annotation: KSAnnotation) : AnnotationProjection { + override val simpleName: String get() = annotation.shortName.asString() + override val metadata: AnnotationSet get() = KspAnnotations(annotation.annotationType.resolve().declaration) + override val values: Map get() = argumentValues(annotation) + } private fun argumentValues(annotation: KSAnnotation): Map = diff --git a/introspection/introspection-ksp/src/test/kotlin/io/javalin/introspection/ksp/KspTypeIntrospectorTest.kt b/introspection/introspection-ksp/src/test/kotlin/io/javalin/introspection/ksp/KspTypeIntrospectorTest.kt index 75f13093..293070b5 100644 --- a/introspection/introspection-ksp/src/test/kotlin/io/javalin/introspection/ksp/KspTypeIntrospectorTest.kt +++ b/introspection/introspection-ksp/src/test/kotlin/io/javalin/introspection/ksp/KspTypeIntrospectorTest.kt @@ -48,7 +48,7 @@ class KspTypeIntrospectorTest { val enumConstants: List?, val hasRef: Boolean, val refValue: String?, - val scannedLabel: Any?, + val scannedLabel: String?, ) val snapshot = withKsp { ksp -> @@ -61,7 +61,7 @@ class KspTypeIntrospectorTest { enumConstants = color.getEnumConstants().map { it.name }, hasRef = account.getAnnotations().contains("Ref"), refValue = account.getAnnotations().find(Ref::class.java)?.get("value")?.asClassDefinition()?.fullName, - scannedLabel = account.getAnnotations().all().first { it.metadata.contains("MetaMarker") }.values["label"], + scannedLabel = account.getAnnotations().all().first { it.metadata.contains("MetaMarker") }["label"].asString(), ) } diff --git a/introspection/introspection-runtime/src/main/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospector.kt b/introspection/introspection-runtime/src/main/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospector.kt index 5d2331f8..3d64ba16 100644 --- a/introspection/introspection-runtime/src/main/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospector.kt +++ b/introspection/introspection-runtime/src/main/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospector.kt @@ -41,17 +41,44 @@ private fun reflect( visitingTypeVariables: Set> = emptySet(), ): ClassDefinition = when (type) { - is GenericArrayType -> reflect(type.genericComponentType, generics, ARRAY, visitingTypeVariables) - is WildcardType -> reflect(type.upperBounds.firstOrNull() ?: Any::class.java, generics, structureType, visitingTypeVariables) + is GenericArrayType -> + reflect( + type = type.genericComponentType, + generics = generics, + structureType = ARRAY, + visitingTypeVariables = visitingTypeVariables, + ) + is WildcardType -> + reflect( + type = type.upperBounds.firstOrNull() ?: Any::class.java, + generics = generics, + structureType = structureType, + visitingTypeVariables = visitingTypeVariables, + ) is TypeVariable<*> -> if (type in visitingTypeVariables) { objectDefinition(structureType) } else { - reflect(type.bounds.firstOrNull() ?: Any::class.java, generics, structureType, visitingTypeVariables + type) + reflect( + type = type.bounds.firstOrNull() ?: Any::class.java, + generics = generics, + structureType = structureType, + visitingTypeVariables = visitingTypeVariables + type, + ) } - is ParameterizedType -> parameterized(type, generics, structureType, visitingTypeVariables) - is Class<*> -> raw(type, generics, structureType, visitingTypeVariables) - else -> definition(Any::class.java, emptyList(), structureType) + is ParameterizedType -> parameterized( + type = type, + generics = generics, + structureType = structureType, + visitingTypeVariables = visitingTypeVariables, + ) + is Class<*> -> raw( + clazz = type, + generics = generics, + structureType = structureType, + visitingTypeVariables = visitingTypeVariables, + ) + else -> definition(erasure = Any::class.java, generics = emptyList(), structureType = structureType) } private fun raw( @@ -61,11 +88,21 @@ private fun raw( visitingTypeVariables: Set>, ): ClassDefinition = when { - clazz.isArray -> reflect(clazz.componentType, generics, ARRAY, visitingTypeVariables) - clazz.isPrimitive -> definition(clazz.kotlin.javaObjectType, generics, structureType) - Map::class.java.isAssignableFrom(clazz) -> definition(clazz, listOf(objectDefinition(), objectDefinition()), DICTIONARY) + clazz.isArray -> reflect( + type = clazz.componentType, + generics = generics, + structureType = ARRAY, + visitingTypeVariables = visitingTypeVariables, + ) + clazz.isPrimitive -> definition(erasure = clazz.kotlin.javaObjectType, generics = generics, structureType = structureType) + Map::class.java.isAssignableFrom(clazz) -> + definition( + erasure = clazz, + generics = listOf(objectDefinition(), objectDefinition()), + structureType = DICTIONARY, + ) Collection::class.java.isAssignableFrom(clazz) -> objectDefinition(ARRAY) - else -> definition(clazz, generics, structureType) + else -> definition(erasure = clazz, generics = generics, structureType = structureType) } private fun parameterized( @@ -77,19 +114,28 @@ private fun parameterized( val erasure = type.rawType as Class<*> val arguments = type.actualTypeArguments return when { - Map::class.java.isAssignableFrom(erasure) -> + Map::class.java.isAssignableFrom(erasure) -> { + val keyType = reflect(arguments.getOrElse(0) { Any::class.java }, visitingTypeVariables = visitingTypeVariables) + val valueType = reflect(arguments.getOrElse(1) { Any::class.java }, visitingTypeVariables = visitingTypeVariables) definition( erasure = erasure, - generics = listOf( - reflect(arguments.getOrElse(0) { Any::class.java }, visitingTypeVariables = visitingTypeVariables), - reflect(arguments.getOrElse(1) { Any::class.java }, visitingTypeVariables = visitingTypeVariables), - ), + generics = listOf(keyType, valueType), structureType = DICTIONARY, ) + } Collection::class.java.isAssignableFrom(erasure) -> - reflect(arguments.getOrElse(0) { Any::class.java }, generics, ARRAY, visitingTypeVariables) - else -> - definition(erasure, arguments.map { reflect(it, visitingTypeVariables = visitingTypeVariables) }, structureType) + reflect( + type = arguments.getOrElse(0) { Any::class.java }, + generics = generics, + structureType = ARRAY, + visitingTypeVariables = visitingTypeVariables, + ) + else -> { + val resolvedGenerics = arguments.map { + reflect(it, visitingTypeVariables = visitingTypeVariables) + } + definition(erasure = erasure, generics = resolvedGenerics, structureType = structureType) + } } } @@ -103,7 +149,7 @@ private fun definition(erasure: Class<*>, generics: List, struc ) private fun objectDefinition(structureType: StructureType = DEFAULT): ClassDefinition = - definition(Any::class.java, emptyList(), structureType) + definition(erasure = Any::class.java, generics = emptyList(), structureType = structureType) private class ReflectionClassDefinition( simpleName: String, @@ -111,20 +157,33 @@ private class ReflectionClassDefinition( generics: List, structureType: StructureType, private val erasure: Class<*>, -) : ClassDefinition(simpleName, fullName, generics, structureType) { +) : ClassDefinition( + simpleName = simpleName, + fullName = fullName, + generics = generics, + structureType = structureType, +) { @InternalIntrospectionApi - override val source: Any - get() = erasure + override val source: Any = erasure override fun isEnum(): Boolean = erasure.isEnum - override fun getEnumConstants(): List = - erasure.takeIf { it.isEnum }?.enumConstants?.map { - val name = (it as Enum<*>).name - EnumConstant(name, ReflectionAnnotations(listOfNotNull(runCatching { erasure.getDeclaredField(name) }.getOrNull()))) - }.orEmpty() + override fun getEnumConstants(): List { + if (!erasure.isEnum) { + return emptyList() + } + + return erasure.enumConstants.map { constant -> + val name = (constant as Enum<*>).name + val field = runCatching { erasure.getDeclaredField(name) }.getOrNull() + EnumConstant( + name = name, + annotations = ReflectionAnnotations(listOfNotNull(field)), + ) + } + } override fun getProperties(): List = collectMembers(erasure).map { member -> @@ -140,15 +199,22 @@ private class ReflectionClassDefinition( ) } - override fun getAnnotations(): AnnotationSet = - ReflectionAnnotations(listOf(erasure)) + override fun getAnnotations(): AnnotationSet = ReflectionAnnotations(listOf(erasure)) } private fun collectMembers(clazz: Class<*>): List { if (clazz.isRecord) { return clazz.recordComponents.map { component -> val backingField = runCatching { clazz.getDeclaredField(component.name) }.getOrNull() - Member(component.name, component.genericType, Accessor.RECORD_COMPONENT, MemberVisibility.PUBLIC, false, component.accessor, listOfNotNull(component.accessor, backingField, component)) + Member( + name = component.name, + genericType = component.genericType, + accessor = Accessor.RECORD_COMPONENT, + visibility = MemberVisibility.PUBLIC, + transient = false, + source = component.accessor, + sources = listOfNotNull(component.accessor, backingField, component), + ) } } @@ -160,20 +226,44 @@ private fun collectMembers(clazz: Class<*>): List { if (method.parameterCount != 0 || method.declaringClass == Any::class.java) continue if (method.returnType == Void.TYPE || !isGetterName(method.name)) continue if (getterNames.add(method.name)) { - members += Member(method.name, method.genericReturnType, Accessor.GETTER, visibilityOf(method.modifiers), false, method, listOf(method)) + members += Member( + name = method.name, + genericType = method.genericReturnType, + accessor = Accessor.GETTER, + visibility = visibilityOf(method.modifiers), + transient = false, + source = method, + sources = listOf(method), + ) } } // clazz.methods is public-only, so a second pass picks up inherited protected/package-private getters (jap parity) for (method in nonPublicGettersHierarchy(clazz)) { if (getterNames.add(method.name)) { - members += Member(method.name, method.genericReturnType, Accessor.GETTER, visibilityOf(method.modifiers), false, method, listOf(method)) + members += Member( + name = method.name, + genericType = method.genericReturnType, + accessor = Accessor.GETTER, + visibility = visibilityOf(method.modifiers), + transient = false, + source = method, + sources = listOf(method), + ) } } for (field in declaredFieldsHierarchy(clazz)) { if (Modifier.isStatic(field.modifiers) || field.isSynthetic) continue - members += Member(field.name, field.genericType, Accessor.FIELD, visibilityOf(field.modifiers), Modifier.isTransient(field.modifiers), field, listOf(field)) + members += Member( + name = field.name, + genericType = field.genericType, + accessor = Accessor.FIELD, + visibility = visibilityOf(field.modifiers), + transient = Modifier.isTransient(field.modifiers), + source = field, + sources = listOf(field), + ) } return members @@ -254,10 +344,13 @@ private class ReflectionAnnotations(private val sources: List) } private class ReflectionAnnotationProjection(private val annotation: Annotation) : AnnotationProjection { + override val simpleName: String get() = annotation.annotationClass.java.simpleName + override val metadata: AnnotationSet get() = ReflectionAnnotations(listOf(annotation.annotationClass.java)) + override val values: Map get() = annotationToMap(annotation) } diff --git a/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/IntrospectionParityTest.kt b/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/IntrospectionParityTest.kt index 1d3d3fce..a6c5e037 100644 --- a/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/IntrospectionParityTest.kt +++ b/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/IntrospectionParityTest.kt @@ -3,8 +3,8 @@ package io.javalin.introspection.test import io.javalin.introspection.Accessor import io.javalin.introspection.AnnotationSet import io.javalin.introspection.ClassDefinition -import io.javalin.introspection.StructureType import io.javalin.introspection.MemberVisibility +import io.javalin.introspection.StructureType import io.javalin.introspection.runtime.ReflectionTypeIntrospector import io.javalin.introspection.test.sub.PackagePrivateBase import org.assertj.core.api.Assertions.assertThat @@ -75,9 +75,9 @@ class IntrospectionParityTest { @Test fun `annotation enumeration with meta-annotations matches across backends`() { - fun scan(annotations: AnnotationSet): Pair { + fun scan(annotations: AnnotationSet): Pair { val tagged = annotations.all().first { it.metadata.contains("MetaMarker") } - return tagged.simpleName to tagged.values["label"] + return tagged.simpleName to tagged["label"].asString() } val runtimeScan = scan(runtime.introspect(Scanned::class.java).getAnnotations()) val processedScan = AnnotationProcessing.introspect(Scanned::class) { scan(it.getAnnotations()) } @@ -218,7 +218,18 @@ private fun ClassDefinition.toShape(): TypeShape = isEnum = isEnum(), enumConstants = getEnumConstants().map { it.name }.sorted(), properties = if (isEnum()) emptyList() else getProperties() - .map { PropertyShape(it.name, it.type.fullName, it.type.structureType, it.type.generics.map { g -> g.fullName }, it.accessor, it.nullable, it.visibility, it.transient) } + .map { property -> + PropertyShape( + name = property.name, + typeFullName = property.type.fullName, + typeStructure = property.type.structureType, + typeGenerics = property.type.generics.map { it.fullName }, + accessor = property.accessor, + nullable = property.nullable, + visibility = property.visibility, + transient = property.transient, + ) + } .sortedBy { "${it.accessor}:${it.name}" }, ) diff --git a/javalin-plugins/javalin-openapi-dynamic-hook/src/main/kotlin/io/javalin/openapi/dynamic/hook/OpenApiMetadata.kt b/javalin-plugins/javalin-openapi-dynamic-hook/src/main/kotlin/io/javalin/openapi/dynamic/hook/OpenApiMetadata.kt index 74d4a3bf..56ed3408 100644 --- a/javalin-plugins/javalin-openapi-dynamic-hook/src/main/kotlin/io/javalin/openapi/dynamic/hook/OpenApiMetadata.kt +++ b/javalin-plugins/javalin-openapi-dynamic-hook/src/main/kotlin/io/javalin/openapi/dynamic/hook/OpenApiMetadata.kt @@ -15,5 +15,5 @@ class OpenApiMetadata(val configure: OperationBuilder.() -> Unit) : EndpointMeta } } -fun MediaTypeBuilder.schema(type: Class<*>): Unit = +fun MediaTypeBuilder.schema(type: Class<*>) = schema(ReflectionSchemaContext().inlineSchema(type)) diff --git a/javalin-plugins/javalin-openapi-dynamic-hook/src/main/kotlin/io/javalin/openapi/dynamic/hook/RegisteredRoutesHook.kt b/javalin-plugins/javalin-openapi-dynamic-hook/src/main/kotlin/io/javalin/openapi/dynamic/hook/RegisteredRoutesHook.kt index 6ed953c3..27ac7e9c 100644 --- a/javalin-plugins/javalin-openapi-dynamic-hook/src/main/kotlin/io/javalin/openapi/dynamic/hook/RegisteredRoutesHook.kt +++ b/javalin-plugins/javalin-openapi-dynamic-hook/src/main/kotlin/io/javalin/openapi/dynamic/hook/RegisteredRoutesHook.kt @@ -19,7 +19,9 @@ class RegisteredRoutesHookConfiguration { withIgnoredPathPrefixes(prefix) fun withIgnoredPathPrefixes(vararg prefixes: String): RegisteredRoutesHookConfiguration = apply { - prefixes.map(::normalizePathPrefix).forEach(ignoredPathPrefixes::add) + prefixes.forEach { prefix -> + ignoredPathPrefixes.add(normalizePathPrefix(prefix)) + } } internal fun ignores(endpoint: Endpoint): Boolean = @@ -46,36 +48,37 @@ class RegisteredRoutesHook @JvmOverloads constructor( val schemaContext = ReflectionSchemaContext() // document-scoped: owns the generator + its memo cache context.builder.openApiVersion("3.1.0") - context.state.internalRouter.allHttpHandlers() - .map { it.endpoint } - .filter { it.method.isHttpMethod } - .filterNot(config::ignores) - .forEach { endpoint -> - val path = toOpenApiPath(endpoint.path) - val method = endpoint.method.name().lowercase() - val pathParams = PATH_PARAM.findAll(endpoint.path).map { it.groupValues[1] }.toList() - val metadata = endpoint.metadata(OpenApiMetadata::class.java) - val operationAlreadyDocumented = context.builder.hasOperation(path, method) - - if (metadata == null && operationAlreadyDocumented) { - return@forEach - } + for (handler in context.state.internalRouter.allHttpHandlers()) { + val endpoint = handler.endpoint + if (!endpoint.method.isHttpMethod || config.ignores(endpoint)) { + continue + } - context.builder.path(path).operation(method) { - if (!operationAlreadyDocumented && pathParams.isNotEmpty()) { - parameters { - pathParams.forEach { name -> - parameter(name = name, location = "path", required = true) { type("string") } - } + val path = toOpenApiPath(endpoint.path) + val method = endpoint.method.name().lowercase() + val pathParams = PATH_PARAM.findAll(endpoint.path).map { it.groupValues[1] }.toList() + val metadata = endpoint.metadata(OpenApiMetadata::class.java) + val operationAlreadyDocumented = context.builder.hasOperation(path, method) + + if (metadata == null && operationAlreadyDocumented) { + continue + } + + context.builder.path(path).operation(method) { + if (!operationAlreadyDocumented && pathParams.isNotEmpty()) { + parameters { + pathParams.forEach { name -> + parameter(name = name, location = "path", required = true) { type("string") } } } + } - when (metadata) { - null -> responses { response("200") { description("OK") } } - else -> metadata.configure(this) - } + when (metadata) { + null -> responses { response("200") { description("OK") } } + else -> metadata.configure(this) } } + } context.builder.resolveComponentReferences { type -> schemaContext.componentSchema(type) } } diff --git a/javalin-plugins/javalin-openapi-plugin/src/main/kotlin/io/javalin/openapi/plugin/OpenApiConfiguration.kt b/javalin-plugins/javalin-openapi-plugin/src/main/kotlin/io/javalin/openapi/plugin/OpenApiConfiguration.kt index f3cf0bad..52617a2f 100644 --- a/javalin-plugins/javalin-openapi-plugin/src/main/kotlin/io/javalin/openapi/plugin/OpenApiConfiguration.kt +++ b/javalin-plugins/javalin-openapi-plugin/src/main/kotlin/io/javalin/openapi/plugin/OpenApiConfiguration.kt @@ -37,39 +37,38 @@ class OpenApiPluginConfiguration @JvmOverloads constructor( ) { /** Register a hook that extends the document at runtime (e.g. dynamically generated routes) */ - fun withHook(hook: OpenApiHook): OpenApiPluginConfiguration = also { + fun withHook(hook: OpenApiHook): OpenApiPluginConfiguration = apply { this.hooks.add(hook) } /** Path to host documentation as JSON */ - fun withDocumentationPath(path: String): OpenApiPluginConfiguration = also { + fun withDocumentationPath(path: String): OpenApiPluginConfiguration = apply { this.documentationPath = path } /** List of roles eligible to access OpenApi routes */ - fun withRoles(vararg roles: RouteRole): OpenApiPluginConfiguration = also { + fun withRoles(vararg roles: RouteRole): OpenApiPluginConfiguration = apply { this.roles = arrayOf(*roles) } /** Pretty print JSON output */ @JvmOverloads - fun withPrettyOutput(enabled: Boolean = true): OpenApiPluginConfiguration = also { + fun withPrettyOutput(enabled: Boolean = true): OpenApiPluginConfiguration = apply { this.prettyOutputEnabled = enabled } /** Dynamically apply custom changes to generated OpenApi specifications */ - fun withDefinitionConfiguration(definitionConfigurationConfigurer: BiConsumer): OpenApiPluginConfiguration = also { + fun withDefinitionConfiguration(definitionConfigurationConfigurer: BiConsumer): OpenApiPluginConfiguration = apply { this.definitionConfiguration = definitionConfigurationConfigurer } /** Global definition processor applied to all versions without their own processor */ - fun withDefinitionProcessor(definitionProcessor: DefinitionProcessor): OpenApiPluginConfiguration = also { + fun withDefinitionProcessor(definitionProcessor: DefinitionProcessor): OpenApiPluginConfiguration = apply { this.definitionProcessor = definitionProcessor } /** Set custom class loader for loading generated OpenAPI resources from classpath */ - fun withResourceClassLoader(classLoader: ClassLoader): OpenApiPluginConfiguration = also { + fun withResourceClassLoader(classLoader: ClassLoader): OpenApiPluginConfiguration = apply { this.resourceClassLoader = classLoader } - } diff --git a/javalin-plugins/javalin-redoc-plugin/src/main/kotlin/io/javalin/openapi/plugin/redoc/ReDocHandler.kt b/javalin-plugins/javalin-redoc-plugin/src/main/kotlin/io/javalin/openapi/plugin/redoc/ReDocHandler.kt index f6cff730..6906a9cd 100644 --- a/javalin-plugins/javalin-redoc-plugin/src/main/kotlin/io/javalin/openapi/plugin/redoc/ReDocHandler.kt +++ b/javalin-plugins/javalin-redoc-plugin/src/main/kotlin/io/javalin/openapi/plugin/redoc/ReDocHandler.kt @@ -12,7 +12,7 @@ class ReDocHandler( private val documentationPath: String, private val version: String, private val routingPath: String, - private val basePath: String? + private val basePath: String?, ) : Handler, OpenApiPluginRouteHandler { override fun handle(context: Context) { diff --git a/javalin-plugins/javalin-redoc-plugin/src/main/kotlin/io/javalin/openapi/plugin/redoc/ReDocWebJarHandler.kt b/javalin-plugins/javalin-redoc-plugin/src/main/kotlin/io/javalin/openapi/plugin/redoc/ReDocWebJarHandler.kt index f89a2570..318c5024 100644 --- a/javalin-plugins/javalin-redoc-plugin/src/main/kotlin/io/javalin/openapi/plugin/redoc/ReDocWebJarHandler.kt +++ b/javalin-plugins/javalin-redoc-plugin/src/main/kotlin/io/javalin/openapi/plugin/redoc/ReDocWebJarHandler.kt @@ -9,11 +9,14 @@ import org.eclipse.jetty.http.MimeTypes internal class ReDocWebJarHandler( private val redocWebJarPath: String, private val classLoader: ClassLoader = ReDocWebJarHandler::class.java.classLoader, -) : Handler, OpenApiPluginRouteHandler { + ) : Handler, OpenApiPluginRouteHandler { override fun handle(context: Context) { - val resourcePath = "META-INF/resources" + redocWebJarPath + context.path().replaceFirst(context.contextPath(), "").replaceFirst(redocWebJarPath, "") - val resource = classLoader.getResourceAsStream(resourcePath) + val resourceRootPath = "META-INF/resources$redocWebJarPath" + val requestedResource = context.path() + .replaceFirst(context.contextPath(), "") + .replaceFirst(redocWebJarPath, "") + val resource = classLoader.getResourceAsStream(resourceRootPath + requestedResource) if (resource == null) { context.status(HttpStatus.NOT_FOUND_404) diff --git a/javalin-plugins/javalin-swagger-plugin/src/main/kotlin/io/javalin/openapi/plugin/swagger/SwaggerHandler.kt b/javalin-plugins/javalin-swagger-plugin/src/main/kotlin/io/javalin/openapi/plugin/swagger/SwaggerHandler.kt index a0361a2e..660ff471 100644 --- a/javalin-plugins/javalin-swagger-plugin/src/main/kotlin/io/javalin/openapi/plugin/swagger/SwaggerHandler.kt +++ b/javalin-plugins/javalin-swagger-plugin/src/main/kotlin/io/javalin/openapi/plugin/swagger/SwaggerHandler.kt @@ -13,7 +13,7 @@ class SwaggerEndpoint( method: HandlerType, path: String, roles: Set, - handler: Handler + handler: Handler, ) : Endpoint( method = method, path = path, @@ -40,7 +40,7 @@ class SwaggerHandler( private val tagsSorter: String, private val operationsSorter: String, private val customStylesheetFiles: List>, - private val customJavaScriptFiles: List> + private val customJavaScriptFiles: List>, ) : Handler, OpenApiPluginRouteHandler { private val swaggerUiHtml = createSwaggerUiHtml() diff --git a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/AnnotationProcessorContext.kt b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/AnnotationProcessorContext.kt index 116b3022..376c59d2 100644 --- a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/AnnotationProcessorContext.kt +++ b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/AnnotationProcessorContext.kt @@ -5,6 +5,8 @@ import io.javalin.introspection.AnnotationSet import io.javalin.introspection.EnumConstant import io.javalin.introspection.InternalIntrospectionApi import io.javalin.introspection.PropertyProjection +import io.javalin.introspection.ClassDefinition as RawType +import io.javalin.introspection.StructureType as RawStructureType import io.javalin.introspection.jap.JapTypeIntrospector import io.javalin.openapi.DiscriminatorMappingName import io.javalin.openapi.OpenApiName @@ -21,8 +23,6 @@ import javax.lang.model.type.TypeMirror import javax.lang.model.util.Types import javax.tools.Diagnostic.Kind.NOTE import javax.tools.Diagnostic.Kind.WARNING -import io.javalin.introspection.ClassDefinition as RawType -import io.javalin.introspection.StructureType as RawStructureType class AnnotationProcessorContext( val parameters: OpenApiAnnotationProcessorParameters, @@ -38,7 +38,12 @@ class AnnotationProcessorContext( override val simpleTypeMappings: Map get() = configuration.simpleTypeMappings override val embeddedTypeProcessors: List get() = configuration.embeddedTypeProcessors - private val japIntrospector: JapTypeIntrospector by lazy { JapTypeIntrospector(types, env.elementUtils) { roundEnv } } + private val japIntrospector: JapTypeIntrospector by lazy { + JapTypeIntrospector( + types = types, + elements = env.elementUtils, + ) { roundEnv } + } override fun isEnum(type: OpenApiType): Boolean = type.source.kind == ElementKind.ENUM @@ -58,7 +63,17 @@ class AnnotationProcessorContext( @OptIn(InternalIntrospectionApi::class) override fun toOpenApiType(raw: RawType): OpenApiType { val rawMirror = raw.source as TypeMirror - val mirror = if (rawMirror.kind.isPrimitive) types.boxedClass(rawMirror as PrimitiveType).asType() else rawMirror + val mirror = if (rawMirror.kind.isPrimitive) { + types.boxedClass(rawMirror as PrimitiveType).asType() + } else { + rawMirror + } + val source = if (raw.structureType == RawStructureType.DICTIONARY) { + mapType() + } else { + types.asElement(mirror) ?: objectType() + } + return OpenApiType( simpleName = mirror.getSimpleName(), fullName = mirror.getFullName(), @@ -66,8 +81,8 @@ class AnnotationProcessorContext( structureType = StructureType.valueOf(raw.structureType.name), handle = OpenApiTypeHandle( mirror = mirror, - source = if (raw.structureType == RawStructureType.DICTIONARY) mapType() else (types.asElement(mirror) ?: objectType()) - ) + source = source, + ), ) } @@ -87,7 +102,11 @@ class AnnotationProcessorContext( configuration.propertyInSchemeFilter?.filter(this, type, property.source as Element) != false override fun discriminatorSubtypes(type: OpenApiType): List> { - val subtypes = japIntrospector.typesAnnotatedWith(DiscriminatorMappingName::class.java, assignableTo = japIntrospector.introspect(type.mirror)) + val source = japIntrospector.introspect(type.mirror) + val subtypes = japIntrospector.typesAnnotatedWith( + annotationType = DiscriminatorMappingName::class.java, + assignableTo = source, + ) return subtypes.mapNotNull { subtype -> val name = subtype.getAnnotations().find(DiscriminatorMappingName::class.java)?.get("value")?.asString() if (name == null) null else name to toOpenApiType(subtype) @@ -129,8 +148,6 @@ class AnnotationProcessorContext( return element?.toString()?.substringBefore("<") ?: mirror.toString().substringBefore("<") } - /* Extension methods, should be replaced by context receivers in the future */ - fun TypeMirror.getSimpleName(): String = getFullName().substringAfterLast(".") diff --git a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/OpenApiAnnotationProcessorConfiguration.kt b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/OpenApiAnnotationProcessorConfiguration.kt index b85c89a2..63e54b10 100644 --- a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/OpenApiAnnotationProcessorConfiguration.kt +++ b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/OpenApiAnnotationProcessorConfiguration.kt @@ -19,7 +19,6 @@ class OpenApiAnnotationProcessorConfiguration { fun insertEmbeddedTypeProcessor(embeddedTypeProcessor: EmbeddedTypeProcessor) { embeddedTypeProcessors.add(0, embeddedTypeProcessor) } - } fun interface PropertyInSchemeFilter { diff --git a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/OpenApiTypeHandle.kt b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/OpenApiTypeHandle.kt index e093a4b2..4f8c47e2 100644 --- a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/OpenApiTypeHandle.kt +++ b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/OpenApiTypeHandle.kt @@ -5,7 +5,7 @@ import javax.lang.model.type.TypeMirror data class OpenApiTypeHandle( val mirror: TypeMirror, - val source: Element + val source: Element, ) @OptIn(InternalOpenApiTypeApi::class) diff --git a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/processor/shared/AnnotationProcessorExtensions.kt b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/processor/shared/AnnotationProcessorExtensions.kt index efbda4b1..37879a6f 100644 --- a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/processor/shared/AnnotationProcessorExtensions.kt +++ b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/processor/shared/AnnotationProcessorExtensions.kt @@ -41,8 +41,8 @@ fun Messager.printException(kind: Kind, throwable: Throwable) { printMessage(kind, error.toString()) - if (throwable.cause != null) { + throwable.cause?.let { cause -> printMessage(kind, "---") - printException(throwable.cause!!) + printException(cause) } } diff --git a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/processor/generators/JsonSchemaGenerator.kt b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/processor/generators/JsonSchemaGenerator.kt index 72c25fbc..07592453 100644 --- a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/processor/generators/JsonSchemaGenerator.kt +++ b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/processor/generators/JsonSchemaGenerator.kt @@ -8,17 +8,22 @@ import javax.lang.model.element.Element class JsonSchemaGenerator { - fun generate(roundEnvironment: RoundEnvironment) = - roundEnvironment.getElementsAnnotatedWith(JsonSchema::class.java) + fun generate(roundEnvironment: RoundEnvironment) { + val elements = roundEnvironment.getElementsAnnotatedWith(JsonSchema::class.java) .filter { it.getAnnotation(JsonSchema::class.java)!!.generateResource } - .onEach { context.env.filer.saveResource(context, "json-schemes/${it}", generate(it)) } - .run { context.env.filer.saveResource(context, "json-schemes/index", joinToString(separator = "\n")) } + + for (element in elements) { + context.env.filer.saveResource(context, "json-schemes/$element", generate(element)) + } + + context.env.filer.saveResource(context, "json-schemes/index", elements.joinToString(separator = "\n")) + } private fun generate(element: Element): String = with(context) { typeSchemaGenerator.createTypeSchema( type = element.asType().toOpenApiType(), - inlineRefs = true + inlineRefs = true, ).toJsonSchemaString() } diff --git a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/processor/generators/OpenApiGenerator.kt b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/processor/generators/OpenApiGenerator.kt index 31127df2..d744a3b3 100644 --- a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/processor/generators/OpenApiGenerator.kt +++ b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/processor/generators/OpenApiGenerator.kt @@ -20,37 +20,37 @@ internal class OpenApiGenerator { ) fun generate(roundEnvironment: RoundEnvironment) { - val routes = listOf( + val routeElements = listOf( roundEnvironment.getElementsAnnotatedWith(OpenApis::class.java), roundEnvironment.getElementsAnnotatedWith(OpenApi::class.java), - ) - .flatten() + ).flatten() + val routes = routeElements .flatMap { element -> context.annotationsOf(element).findAll(OpenApi::class.java).map { it.values } } - schemaGenerator.generateVersionedSchemas(routes) - .map { (version, generatedOpenApiSchema) -> - val resourceName = "openapi-${version.replace(" ", "-")}.json" - val resource = context.env.filer.saveResource(context, "openapi-plugin/$resourceName", generatedOpenApiSchema) - ?.toUri() - ?.toString() - ?: return + val resourceNames = mutableListOf() + for ((version, generatedOpenApiSchema) in schemaGenerator.generateVersionedSchemas(routes)) { + val resourceName = "openapi-${version.replace(" ", "-")}.json" + val resource = context.env.filer.saveResource(context, "openapi-plugin/$resourceName", generatedOpenApiSchema) + ?.toUri() + ?.toString() + ?: return - if (context.configuration.validateWithParser) { - val parsedSchema = OpenAPIV3Parser().readLocation(resource, emptyList(), ParseOptions()) + if (context.configuration.validateWithParser) { + val parsedSchema = OpenAPIV3Parser().readLocation(resource, emptyList(), ParseOptions()) - if (parsedSchema.messages.isNotEmpty()) { - context.env.messager.printMessage(Diagnostic.Kind.NOTE, "OpenApi Validation Warnings :: ${parsedSchema.messages.size}") - } - - parsedSchema.messages.forEach { - context.env.messager.printMessage(WARNING, it) - } + if (parsedSchema.messages.isNotEmpty()) { + context.env.messager.printMessage(Diagnostic.Kind.NOTE, "OpenApi Validation Warnings :: ${parsedSchema.messages.size}") } - resourceName + parsedSchema.messages.forEach { message -> + context.env.messager.printMessage(WARNING, message) + } } - .joinToString(separator = "\n") - .let { context.env.filer.saveResource(context, "openapi-plugin/.index", it) } + + resourceNames.add(resourceName) + } + + context.env.filer.saveResource(context, "openapi-plugin/.index", resourceNames.joinToString(separator = "\n")) } } diff --git a/openapi-annotation-processor/src/test/java/io/javalin/openapi/processor/RegressionFixtures.java b/openapi-annotation-processor/src/test/java/io/javalin/openapi/processor/RegressionTestSupport.java similarity index 100% rename from openapi-annotation-processor/src/test/java/io/javalin/openapi/processor/RegressionFixtures.java rename to openapi-annotation-processor/src/test/java/io/javalin/openapi/processor/RegressionTestSupport.java diff --git a/openapi-dynamic/src/main/kotlin/io/javalin/openapi/dynamic/ReflectionSchemaContext.kt b/openapi-dynamic/src/main/kotlin/io/javalin/openapi/dynamic/ReflectionSchemaContext.kt index ee440e61..47da4866 100644 --- a/openapi-dynamic/src/main/kotlin/io/javalin/openapi/dynamic/ReflectionSchemaContext.kt +++ b/openapi-dynamic/src/main/kotlin/io/javalin/openapi/dynamic/ReflectionSchemaContext.kt @@ -11,5 +11,4 @@ class ReflectionSchemaContext( ) : IntrospectorSchemaContext(simpleTypeMappings) { override val introspector: TypeIntrospector = ReflectionTypeIntrospector() - } diff --git a/openapi-dynamic/src/test/kotlin/io/javalin/openapi/dynamic/DynamicCompositionTest.kt b/openapi-dynamic/src/test/kotlin/io/javalin/openapi/dynamic/DynamicCompositionTest.kt index 0a6ec42f..c0c73683 100644 --- a/openapi-dynamic/src/test/kotlin/io/javalin/openapi/dynamic/DynamicCompositionTest.kt +++ b/openapi-dynamic/src/test/kotlin/io/javalin/openapi/dynamic/DynamicCompositionTest.kt @@ -13,7 +13,10 @@ class DynamicCompositionTest { @Test fun `emits oneOf with refs for composition annotations`() { - val refs = propertiesOf(Shape::class.java).path("animal").path("oneOf").map { it.path($$"$ref").asText() } + val refs = propertiesOf(Shape::class.java) + .path("animal") + .path("oneOf") + .map { it.path($$"$ref").asText() } assertThat(refs).containsExactlyInAnyOrder( "#/components/schemas/Dog", "#/components/schemas/Cat", diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/defaults/ArrayEmbeddedTypeProcessor.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/defaults/ArrayEmbeddedTypeProcessor.kt index 89dbd582..71d7344b 100644 --- a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/defaults/ArrayEmbeddedTypeProcessor.kt +++ b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/defaults/ArrayEmbeddedTypeProcessor.kt @@ -1,5 +1,6 @@ package io.javalin.openapi.experimental.defaults +import com.fasterxml.jackson.databind.JsonNode import io.javalin.openapi.experimental.EmbeddedTypeProcessor import io.javalin.openapi.experimental.EmbeddedTypeProcessorContext import io.javalin.openapi.experimental.StructureType.ARRAY @@ -7,23 +8,27 @@ import io.javalin.openapi.experimental.processor.shared.createObjectNode class ArrayEmbeddedTypeProcessor : EmbeddedTypeProcessor { - override fun process(context: EmbeddedTypeProcessorContext): Boolean = with(context) { - if (type.structureType == ARRAY) { - if (type.simpleName == "Byte") { - scheme.put("type", "string") - scheme.put("format", "binary") - } - else { - context.scheme.put("type", "array") - val items = createObjectNode() - context.parentContext.typeSchemaGenerator.addType(items, type, inlineRefs, references, requiresNonNulls) - context.scheme.set("items", items) - } + override fun process(context: EmbeddedTypeProcessorContext): Boolean { + if (context.type.structureType != ARRAY) { + return false + } + if (context.type.simpleName == "Byte") { + context.scheme.put("type", "string") + context.scheme.put("format", "binary") return true } - return false + context.scheme.put("type", "array") + val items = createObjectNode() + context.parentContext.typeSchemaGenerator.addType( + scheme = items, + type = context.type, + inlineRefs = context.inlineRefs, + references = context.references, + requiresNonNulls = context.requiresNonNulls, + ) + context.scheme.set("items", items) + return true } - } diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/defaults/CompositionEmbeddedTypeProcessor.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/defaults/CompositionEmbeddedTypeProcessor.kt index 22a23420..9cd7fee3 100644 --- a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/defaults/CompositionEmbeddedTypeProcessor.kt +++ b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/defaults/CompositionEmbeddedTypeProcessor.kt @@ -6,18 +6,17 @@ import io.javalin.openapi.experimental.processor.generators.createComposition class CompositionEmbeddedTypeProcessor : EmbeddedTypeProcessor { - override fun process(context: EmbeddedTypeProcessorContext): Boolean = - context.composition - ?.let { - context.scheme.createComposition( - context = context.parentContext, - type = context.type, - propertyComposition = it, - references = context.references, - inlineRefs = context.inlineRefs, - requiresNonNulls = context.requiresNonNulls - ) - true - } ?: false + override fun process(context: EmbeddedTypeProcessorContext): Boolean { + val composition = context.composition ?: return false + context.scheme.createComposition( + context = context.parentContext, + type = context.type, + propertyComposition = composition, + references = context.references, + inlineRefs = context.inlineRefs, + requiresNonNulls = context.requiresNonNulls, + ) + return true + } } diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/defaults/DictionaryEmbeddedTypeProcessor.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/defaults/DictionaryEmbeddedTypeProcessor.kt index 20be083a..0ccf2bb3 100644 --- a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/defaults/DictionaryEmbeddedTypeProcessor.kt +++ b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/defaults/DictionaryEmbeddedTypeProcessor.kt @@ -8,34 +8,33 @@ import io.javalin.openapi.experimental.processor.shared.createObjectNode class DictionaryEmbeddedTypeProcessor : EmbeddedTypeProcessor { - override fun process(context: EmbeddedTypeProcessorContext): Boolean = with (context) { - if (type.structureType == DICTIONARY) { - scheme.put("type", "object") - val additionalProperties = createObjectNode() - val additionalType = context.type.generics[1] + override fun process(context: EmbeddedTypeProcessorContext): Boolean { + if (context.type.structureType != DICTIONARY) { + return false + } - context.parentContext.embeddedTypeProcessors - .firstOrNull { - it.process( - context.copy( - scheme = additionalProperties, - type = additionalType - ) - ) - } - ?: parentContext.typeSchemaGenerator.addType( - scheme = additionalProperties, - type = additionalType, - inlineRefs = inlineRefs, - references = references, - requiresNonNulls = requiresNonNulls - ) + context.scheme.put("type", "object") + val additionalProperties = createObjectNode() + val additionalType = context.type.generics[1] + val additionalContext = context.copy( + scheme = additionalProperties, + type = additionalType, + ) + val handled = context.parentContext.embeddedTypeProcessors.any { + it.process(additionalContext) + } - scheme.set("additionalProperties", additionalProperties) - return true + if (!handled) { + context.parentContext.typeSchemaGenerator.addType( + scheme = additionalProperties, + type = additionalType, + inlineRefs = context.inlineRefs, + references = context.references, + requiresNonNulls = context.requiresNonNulls, + ) } - return false + context.scheme.set("additionalProperties", additionalProperties) + return true } - } diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/CompositionGenerator.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/CompositionGenerator.kt index 89a5139c..fc9c1f77 100644 --- a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/CompositionGenerator.kt +++ b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/CompositionGenerator.kt @@ -11,12 +11,12 @@ import io.javalin.openapi.Composition.ANY_OF import io.javalin.openapi.Composition.ONE_OF import io.javalin.openapi.NULL_STRING import io.javalin.openapi.OneOf -import io.javalin.openapi.experimental.OpenApiType import io.javalin.openapi.experimental.CustomProperty +import io.javalin.openapi.experimental.OpenApiType import io.javalin.openapi.experimental.SchemaGenerationContext +import io.javalin.openapi.experimental.processor.shared.createArrayNode import io.javalin.openapi.experimental.processor.shared.createJsonObjectOf import io.javalin.openapi.experimental.processor.shared.createObjectNode -import io.javalin.openapi.experimental.processor.shared.toJsonArray import io.javalin.openapi.experimental.processor.shared.toJsonObject import io.javalin.introspection.ClassDefinition as RawType @@ -25,18 +25,31 @@ fun findCompositionInElement(context: SchemaGenerationContext, annotations: Anno ?: compositionOf(context, annotations, AnyOf::class.java, ANY_OF) ?: compositionOf(context, annotations, AllOf::class.java, ALL_OF) -private fun compositionOf(context: SchemaGenerationContext, annotations: AnnotationSet, annotationType: Class, composition: Composition): PropertyComposition? { +private fun compositionOf( + context: SchemaGenerationContext, + annotations: AnnotationSet, + annotationType: Class, + composition: Composition, +): PropertyComposition? { val annotation = annotations.find(annotationType) ?: return null val references = annotation.get("value").asClassDefinitions().map { context.toOpenApiType(it) }.toSet() val discriminator = annotation.get("discriminator").asMap()?.let { discriminatorInfo(context, it) } - return PropertyComposition(composition, references, discriminator) + return PropertyComposition( + type = composition, + references = references, + discriminator = discriminator, + ) } private fun discriminatorInfo(context: SchemaGenerationContext, discriminator: Map<*, *>): DiscriminatorInfo { val property = discriminator["property"] as Map<*, *> val mapping = (discriminator["mapping"] as? List<*>).orEmpty() .filterIsInstance>() - .map { (it["name"] as String) to context.toOpenApiType(it["value"] as RawType) } + .map { entry -> + val name = entry["name"] as String + val type = entry["value"] as RawType + name to context.toOpenApiType(type) + } return DiscriminatorInfo( propertyName = property["name"] as String, propertyType = context.toOpenApiType(property["type"] as RawType), @@ -60,47 +73,50 @@ fun ObjectNode.createComposition( // an empty oneOf/anyOf/allOf is invalid OpenAPI, so skip when there are no refs/subtypes if (refs.isEmpty()) return - when (inlineRefs) { - true -> - refs - .map { context.typeSchemaGenerator.createTypeSchema(type = it, inlineRefs = true, requireNonNullsByDefault = requiresNonNulls) } - .onEach { (_, refs) -> references.addAll(refs) } - .map { (scheme, _) -> scheme } - .toJsonArray { add(it) } - .let { set(propertyComposition.type.propertyName, it) } - - false -> - refs - .onEach { references.add(it) } - .map { createJsonObjectOf($$"$ref", "#/components/schemas/${it.simpleName}") } - .toJsonArray { add(it) } - .let { set(propertyComposition.type.propertyName, it) } + val compositionValues = createArrayNode() + if (inlineRefs) { + for (ref in refs) { + val result = context.typeSchemaGenerator.createTypeSchema( + type = ref, + inlineRefs = true, + requireNonNullsByDefault = requiresNonNulls, + ) + references.addAll(result.references) + compositionValues.add(result.json) + } + } else { + for (ref in refs) { + references.add(ref) + compositionValues.add(createJsonObjectOf($$"$ref", "#/components/schemas/${ref.simpleName}")) + } } + set(propertyComposition.type.propertyName, compositionValues) - propertyComposition.discriminator + val discriminator = propertyComposition.discriminator ?.takeIf { it.propertyName != NULL_STRING } - ?.also { discriminator -> - val discriminatorObject = createObjectNode() - set("discriminator", discriminatorObject) - discriminatorObject.put("propertyName", discriminator.propertyName) + ?: return - val mapping = discriminator.mapping.ifEmpty { subtypes } + val discriminatorObject = createObjectNode() + set("discriminator", discriminatorObject) + discriminatorObject.put("propertyName", discriminator.propertyName) - if (discriminator.injectInMappings) { - val customProperty = CustomProperty( - name = discriminator.propertyName, - type = discriminator.propertyType - ) + val mapping = discriminator.mapping.ifEmpty { subtypes } + if (discriminator.injectInMappings) { + val customProperty = CustomProperty( + name = discriminator.propertyName, + type = discriminator.propertyType, + ) - mapping.forEach { (_, mappedClass) -> - mappedClass.extra.add(customProperty) - } - } + mapping.forEach { (_, mappedClass) -> + mappedClass.extra.add(customProperty) + } + } - mapping - .onEach { (_, mappedClass) -> references.add(mappedClass) } - .associate { (name, mappedClass) -> name to "#/components/schemas/${mappedClass.simpleName}" } - .takeIf { it.isNotEmpty() } - ?.also { discriminatorObject.set("mapping", it.toJsonObject()) } + if (mapping.isNotEmpty()) { + mapping.forEach { (_, mappedClass) -> references.add(mappedClass) } + val mappings = mapping.associate { (name, mappedClass) -> + name to "#/components/schemas/${mappedClass.simpleName}" } + discriminatorObject.set("mapping", mappings.toJsonObject()) + } } diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/ExampleGenerator.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/ExampleGenerator.kt index 2b286483..1d71340e 100644 --- a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/ExampleGenerator.kt +++ b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/ExampleGenerator.kt @@ -76,9 +76,17 @@ object ExampleGenerator { } private fun List.isObjectList(): Boolean = - this.isNotEmpty() && this.all { it.name == null && it.value == null && it.objects?.isNotEmpty() ?: false } + isNotEmpty() && all { example -> + example.name == null && + example.value == null && + example.objects?.isNotEmpty() == true + } private fun List.isRawList(): Boolean = - this.isNotEmpty() && this.all { it.name == null && it.value != null && it.objects?.isEmpty() ?: true } + isNotEmpty() && all { example -> + example.name == null && + example.value != null && + example.objects.isNullOrEmpty() + } } diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/ResultScheme.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/ResultScheme.kt index c390d587..e59064de 100644 --- a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/ResultScheme.kt +++ b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/ResultScheme.kt @@ -9,7 +9,7 @@ import java.math.BigDecimal data class ResultScheme( val json: ObjectNode, - val references: Set + val references: Set, ) { fun toJsonSchemaString(): String { val scheme = createObjectNode() @@ -54,7 +54,7 @@ fun translatePropertyName(strategy: OpenApiNamingStrategy, name: String): String OpenApiNamingStrategy.KEBAB_CASE -> splitCamelCase(name).joinToString("-") { it.lowercase() } } -fun ObjectNode.addExtra(extra: Map): ObjectNode = also { +fun ObjectNode.addExtra(extra: Map): ObjectNode { extra .filterValues { it != null } .forEach { (key, value) -> @@ -71,4 +71,5 @@ fun ObjectNode.addExtra(extra: Map): ObjectNode = also { else -> put(key, value.toString()) } } + return this } diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/TypeSchemaGenerator.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/TypeSchemaGenerator.kt index 4a902329..c403f73e 100644 --- a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/TypeSchemaGenerator.kt +++ b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/TypeSchemaGenerator.kt @@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.JsonNode import com.fasterxml.jackson.databind.node.ArrayNode import com.fasterxml.jackson.databind.node.ObjectNode import io.javalin.introspection.Accessor +import io.javalin.introspection.AnnotationValue import io.javalin.introspection.AnnotationSet import io.javalin.introspection.ClassDefinition as RawType import io.javalin.introspection.InternalIntrospectionApi @@ -37,7 +38,11 @@ class TypeSchemaGenerator(val context: SchemaGenerationContext) { val definedBy = type.definedBy() if (definedBy != null && !isEnum) { - return createTypeSchema(definedBy, inlineRefs, requireNonNullsByDefault) + return createTypeSchema( + type = definedBy, + inlineRefs = inlineRefs, + requireNonNullsByDefault = requireNonNullsByDefault, + ) } val schema = createObjectNode() @@ -46,7 +51,14 @@ class TypeSchemaGenerator(val context: SchemaGenerationContext) { when { composition != null -> { - schema.createComposition(context, type, composition, references, inlineRefs, requireNonNullsByDefault) + schema.createComposition( + context = context, + type = type, + propertyComposition = composition, + references = references, + inlineRefs = inlineRefs, + requiresNonNulls = requireNonNullsByDefault, + ) } isEnum -> { val enumType = definedBy @@ -56,25 +68,22 @@ class TypeSchemaGenerator(val context: SchemaGenerationContext) { val values = createArrayNode() val descriptions = createArrayNode() - context.enumConstantsOf(type) - .map { constant -> - val customName = constant.annotations.find(OpenApiName::class.java)?.get("value")?.asString() - val description = constant.annotations.find(OpenApiDescription::class.java)?.get("value")?.asString() - val name = when { - customName != null -> customName - namingStrategy != null -> translatePropertyName(namingStrategy, constant.name) - else -> constant.name - } - name to (description ?: "") + for (constant in context.enumConstantsOf(type)) { + val customName = constant.annotations.find(OpenApiName::class.java)?.get("value")?.asString() + val description = constant.annotations.find(OpenApiDescription::class.java)?.get("value")?.asString() + val name = when { + customName != null -> customName + namingStrategy != null -> translatePropertyName(namingStrategy, constant.name) + else -> constant.name } - .forEach { (name, description) -> - if (enumType != null && enumType.type != "string") { - values.add(jsonMapper.readTree(name)) - } else { - values.add(name) - } - descriptions.add(description) + + if (enumType != null && enumType.type != "string") { + values.add(jsonMapper.readTree(name)) + } else { + values.add(name) } + descriptions.add(description ?: "") + } schema.put("type", enumType?.type ?: "string") enumType?.format?.also { schema.put("format", it) } @@ -100,22 +109,16 @@ class TypeSchemaGenerator(val context: SchemaGenerationContext) { val properties = context.findAllProperties(type, requireNonNulls) properties.forEach { property -> - val result = - when { - processedProperties.contains(property) -> - processedProperties[property]!! - else -> - createEmbeddedTypeDescription( - type = property.type, - inlineRefs = inlineRefs, - requiresNonNulls = requireNonNulls, - composition = property.composition, - extra = property.extra, - nullable = property.nullable, - ).also { - processedProperties[property] = it - } - } + val result = processedProperties.getOrPut(property) { + createEmbeddedTypeDescription( + type = property.type, + inlineRefs = inlineRefs, + requiresNonNulls = requireNonNulls, + composition = property.composition, + extra = property.extra, + nullable = property.nullable, + ) + } propertiesObject.set(property.name, result.json) references.addAll(result.references) } @@ -128,7 +131,7 @@ class TypeSchemaGenerator(val context: SchemaGenerationContext) { } } - return ResultScheme(schema, references) + return ResultScheme(json = schema, references = references) } fun createEmbeddedTypeDescription( @@ -142,34 +145,52 @@ class TypeSchemaGenerator(val context: SchemaGenerationContext) { val definedBy = type.definedBy() if (definedBy != null && !context.isEnum(type)) { - return createEmbeddedTypeDescription(definedBy, inlineRefs, requiresNonNulls, composition, extra, nullable) + return createEmbeddedTypeDescription( + type = definedBy, + inlineRefs = inlineRefs, + requiresNonNulls = requiresNonNulls, + composition = composition, + extra = extra, + nullable = nullable, + ) } val scheme = createObjectNode() val references = mutableSetOf() - val handledByCustomProcessor = - context.embeddedTypeProcessors.firstOrNull { - it.process( - EmbeddedTypeProcessorContext( - parentContext = context, - scheme = scheme, - references = references, - type = type, - inlineRefs = inlineRefs, - requiresNonNulls = requiresNonNulls, - composition = composition, - extra = extra - ) - ) - } + val processorContext = EmbeddedTypeProcessorContext( + parentContext = context, + scheme = scheme, + references = references, + type = type, + inlineRefs = inlineRefs, + requiresNonNulls = requiresNonNulls, + composition = composition, + extra = extra, + ) + val handled = context.embeddedTypeProcessors.any { processor -> + processor.process(processorContext) + } - if (handledByCustomProcessor == null) { + if (!handled) { if (type.fullName == "java.util.Optional" && type.generics.size == 1) { - return createEmbeddedTypeDescription(type.generics.first(), inlineRefs, requiresNonNulls, composition, extra, nullable = true) + return createEmbeddedTypeDescription( + type = type.generics.first(), + inlineRefs = inlineRefs, + requiresNonNulls = requiresNonNulls, + composition = composition, + extra = extra, + nullable = true, + ) } - addType(scheme, type, inlineRefs, references, requiresNonNulls) + addType( + scheme = scheme, + type = type, + inlineRefs = inlineRefs, + references = references, + requiresNonNulls = requiresNonNulls, + ) } scheme.addExtra(extra) @@ -207,7 +228,7 @@ class TypeSchemaGenerator(val context: SchemaGenerationContext) { } } - return ResultScheme(scheme, references) + return ResultScheme(json = scheme, references = references) } fun addType( @@ -220,7 +241,11 @@ class TypeSchemaGenerator(val context: SchemaGenerationContext) { when (val nonRefType = context.simpleTypeMappings[type.fullName]) { null -> { if (inlineRefs) { - val (subScheme, subReferences) = createTypeSchema(type, true, requiresNonNulls) + val (subScheme, subReferences) = createTypeSchema( + type = type, + inlineRefs = true, + requireNonNullsByDefault = requiresNonNulls, + ) subScheme.properties().forEach { (key, value) -> scheme.set(key, value) } references.addAll(subReferences) } else { @@ -261,8 +286,9 @@ internal fun SchemaGenerationContext.findAllProperties(type: OpenApiType, requir val name = customName ?: property.name val finalName = if (customName == null && namingStrategy != null) translatePropertyName(namingStrategy, name) else name - val nullability = property.annotations.find(OpenApiPropertyType::class.java)?.get("nullability")?.asString() - val redirect = property.annotations.find(OpenApiPropertyType::class.java)?.get("definedBy")?.asClassDefinition() + val propertyType = property.annotations.find(OpenApiPropertyType::class.java) + val nullability = propertyType?.get("nullability")?.asString() + val redirect = propertyType?.get("definedBy")?.asClassDefinition() val treatedAsNotNull = (redirect == null && !property.nullable) || redirect?.hasPrimitiveSource() == true val isNotNull = when { @@ -302,7 +328,7 @@ internal fun SchemaGenerationContext.findAllProperties(type: OpenApiType, requir Property( name = extraProperty.name, type = extraProperty.type, - required = requireNonNulls + required = requireNonNulls, ) ) } @@ -320,10 +346,10 @@ private fun AnnotationSet.findExtra(): Map { "description" to find(OpenApiDescription::class.java)?.get("value")?.asString() ) - find(OpenApiExample::class.java)?.values?.also { example -> - val value = example.notNull("value") - val raw = example.notNull("raw") - val objects = (example["objects"] as? List<*>)?.filterIsInstance>().orEmpty() + find(OpenApiExample::class.java)?.also { example -> + val value = example["value"].notNullString() + val raw = example["raw"].notNullString() + val objects = example["objects"].asList().filterIsInstance>() when { value != null -> extra["example"] = value raw != null -> extra["example"] = jsonMapper.readTree(raw) @@ -334,30 +360,30 @@ private fun AnnotationSet.findExtra(): Map { } } - find(OpenApiNumberValidation::class.java)?.values?.also { - extra["minimum"] = it.notNull("minimum")?.toBigDecimal() - extra["maximum"] = it.notNull("maximum")?.toBigDecimal() - extra["exclusiveMinimum"] = it.notNull("exclusiveMinimum")?.toBigDecimal() - extra["exclusiveMaximum"] = it.notNull("exclusiveMaximum")?.toBigDecimal() - extra["multipleOf"] = it.notNull("multipleOf")?.toBigDecimal() + find(OpenApiNumberValidation::class.java)?.also { validation -> + extra["minimum"] = validation["minimum"].notNullString()?.toBigDecimal() + extra["maximum"] = validation["maximum"].notNullString()?.toBigDecimal() + extra["exclusiveMinimum"] = validation["exclusiveMinimum"].notNullString()?.toBigDecimal() + extra["exclusiveMaximum"] = validation["exclusiveMaximum"].notNullString()?.toBigDecimal() + extra["multipleOf"] = validation["multipleOf"].notNullString()?.toBigDecimal() } - find(OpenApiStringValidation::class.java)?.values?.also { - extra["minLength"] = it.notNull("minLength")?.toInt() - extra["maxLength"] = it.notNull("maxLength")?.toInt() - extra["format"] = it.notNull("format") - extra["pattern"] = it.notNull("pattern") + find(OpenApiStringValidation::class.java)?.also { validation -> + extra["minLength"] = validation["minLength"].notNullString()?.toInt() + extra["maxLength"] = validation["maxLength"].notNullString()?.toInt() + extra["format"] = validation["format"].notNullString() + extra["pattern"] = validation["pattern"].notNullString() } - find(OpenApiArrayValidation::class.java)?.values?.also { - extra["minItems"] = it.notNull("minItems")?.toInt() - extra["maxItems"] = it.notNull("maxItems")?.toInt() - extra["uniqueItems"] = (it["uniqueItems"] as? Boolean)?.takeIf { unique -> unique } + find(OpenApiArrayValidation::class.java)?.also { validation -> + extra["minItems"] = validation["minItems"].notNullString()?.toInt() + extra["maxItems"] = validation["maxItems"].notNullString()?.toInt() + extra["uniqueItems"] = validation["uniqueItems"].asBoolean()?.takeIf { unique -> unique } } - find(OpenApiObjectValidation::class.java)?.values?.also { - extra["minProperties"] = it.notNull("minProperties")?.toInt() - extra["maxProperties"] = it.notNull("maxProperties")?.toInt() + find(OpenApiObjectValidation::class.java)?.also { validation -> + extra["minProperties"] = validation["minProperties"].notNullString()?.toInt() + extra["maxProperties"] = validation["maxProperties"].notNullString()?.toInt() } findAll(Custom::class.java).forEach { custom -> @@ -380,8 +406,8 @@ private fun MemberVisibility.toOpenApi(): Visibility = MemberVisibility.PACKAGE_PRIVATE -> Visibility.DEFAULT } -private fun Map.notNull(key: String): String? = - (this[key] as? String)?.takeIf { it != NULL_STRING } +private fun AnnotationValue.notNullString(): String? = + asString()?.takeIf { it != NULL_STRING } private val primitiveSourceNames = setOf("boolean", "byte", "short", "int", "long", "float", "double", "char") diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/schema/OpenApiRouteDefinition.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/schema/OpenApiRouteDefinition.kt index 615525a2..b163eeec 100644 --- a/openapi-generator/src/main/kotlin/io/javalin/openapi/schema/OpenApiRouteDefinition.kt +++ b/openapi-generator/src/main/kotlin/io/javalin/openapi/schema/OpenApiRouteDefinition.kt @@ -27,7 +27,7 @@ internal data class OpenApiRouteDefinition( val security: List, ) { val formattedPath: String - get() = path.let { if (it.startsWith("/")) it else "/$it" } + get() = if (path.startsWith("/")) path else "/$path" companion object { fun from(values: Map): OpenApiRouteDefinition = @@ -154,8 +154,8 @@ internal data class OpenApiContentDefinition( val example: String?, val exampleObjects: List, ) { - val hasResolvedSource: Boolean - get() = from != null && from.fullName != NULL_CLASS::class.java.name + val resolvedSource: ClassDefinition? + get() = from?.takeUnless { it.fullName == NULL_CLASS::class.java.name } companion object { fun from(values: Map): OpenApiContentDefinition = @@ -178,11 +178,11 @@ internal data class OpenApiContentPropertyDefinition( val from: ClassDefinition?, val name: String, val isArray: Boolean, - val type: String, + val type: String?, val format: String?, ) { - val hasResolvedSource: Boolean - get() = from != null && from.fullName != NULL_CLASS::class.java.name + val resolvedSource: ClassDefinition? + get() = from?.takeUnless { it.fullName == NULL_CLASS::class.java.name } companion object { fun from(values: Map): OpenApiContentPropertyDefinition = @@ -190,7 +190,7 @@ internal data class OpenApiContentPropertyDefinition( from = values.classDefinition("from"), name = values.requiredString("name"), isArray = values.boolean("isArray"), - type = values.requiredString("type"), + type = values.text("type"), format = values.text("format"), ) } diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/schema/OpenApiSchemaBuilder.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/schema/OpenApiSchemaBuilder.kt index 3feb0ce9..4f5ccf83 100644 --- a/openapi-generator/src/main/kotlin/io/javalin/openapi/schema/OpenApiSchemaBuilder.kt +++ b/openapi-generator/src/main/kotlin/io/javalin/openapi/schema/OpenApiSchemaBuilder.kt @@ -63,58 +63,86 @@ class OpenApiSchemaBuilder { /** Add a named security scheme */ fun withSecurityScheme(name: String, scheme: SecurityScheme): OpenApiSchemaBuilder = apply { - val components = root.get("components") as? ObjectNode ?: createObjectNode().also { root.set("components", it) } - val schemes = components.get("securitySchemes") as? ObjectNode ?: createObjectNode().also { components.set("securitySchemes", it) } + val components = root.get("components") as? ObjectNode + ?: createObjectNode().also { root.set("components", it) } + val schemes = components.get("securitySchemes") as? ObjectNode + ?: createObjectNode().also { components.set("securitySchemes", it) } schemes.set(name, jsonMapper.convertValue(scheme, JsonNode::class.java)) } /** Add HTTP Basic authentication scheme */ @JvmOverloads - fun withBasicAuth(name: String = "BasicAuth", configure: Consumer = Consumer {}): OpenApiSchemaBuilder = + fun withBasicAuth( + name: String = "BasicAuth", + configure: Consumer = Consumer {}, + ): OpenApiSchemaBuilder = withSecurityScheme(name, BasicAuth().also { configure.accept(it) }) /** Add HTTP Bearer authentication scheme */ @JvmOverloads - fun withBearerAuth(name: String = "BearerAuth", configure: Consumer = Consumer {}): OpenApiSchemaBuilder = + fun withBearerAuth( + name: String = "BearerAuth", + configure: Consumer = Consumer {}, + ): OpenApiSchemaBuilder = withSecurityScheme(name, BearerAuth().also { configure.accept(it) }) /** Add API Key authentication scheme */ @JvmOverloads - fun withApiKeyAuth(name: String = "ApiKeyAuth", apiKeyName: String = "X-API-Key", configure: Consumer = Consumer {}): OpenApiSchemaBuilder = + fun withApiKeyAuth( + name: String = "ApiKeyAuth", + apiKeyName: String = "X-API-Key", + configure: Consumer = Consumer {}, + ): OpenApiSchemaBuilder = withSecurityScheme(name, ApiKeyAuth(name = apiKeyName).also { configure.accept(it) }) /** Add Cookie authentication scheme */ @JvmOverloads - fun withCookieAuth(name: String = "CookieAuth", sessionCookie: String = "JSESSIONID", configure: Consumer = Consumer {}): OpenApiSchemaBuilder = + fun withCookieAuth( + name: String = "CookieAuth", + sessionCookie: String = "JSESSIONID", + configure: Consumer = Consumer {}, + ): OpenApiSchemaBuilder = withSecurityScheme(name, CookieAuth(name = sessionCookie).also { configure.accept(it) }) /** Add OpenID Connect authentication scheme */ @JvmOverloads - fun withOpenID(name: String, openIdConnectUrl: String, configure: Consumer = Consumer {}): OpenApiSchemaBuilder = + fun withOpenID( + name: String, + openIdConnectUrl: String, + configure: Consumer = Consumer {}, + ): OpenApiSchemaBuilder = withSecurityScheme(name, OpenID(openIdConnectUrl = openIdConnectUrl).also { configure.accept(it) }) /** Add OAuth2 authentication scheme */ @JvmOverloads - fun withOAuth2(name: String, description: String, configure: Consumer = Consumer {}): OpenApiSchemaBuilder = + fun withOAuth2( + name: String, + description: String, + configure: Consumer = Consumer {}, + ): OpenApiSchemaBuilder = withSecurityScheme(name, OAuth2(description = description).also { configure.accept(it) }) /** Add a global security requirement */ @JvmOverloads - fun withGlobalSecurity(name: String, configure: Consumer = Consumer {}): OpenApiSchemaBuilder = apply { - val security = Security(name = name).also { configure.accept(it) } - val securityArray = root.get("security") as? ArrayNode ?: createArrayNode().also { root.set("security", it) } - val entry = createObjectNode() - val scopesArray = createArrayNode() - security.scopes.forEach { scopesArray.add(it) } - entry.set(security.name, scopesArray) - securityArray.add(entry) - } + fun withGlobalSecurity( + name: String, + configure: Consumer = Consumer {}, + ): OpenApiSchemaBuilder = + apply { + val security = Security(name = name).also { configure.accept(it) } + val securityArray = root.get("security") as? ArrayNode + ?: createArrayNode().also { root.set("security", it) } + val entry = createObjectNode() + val scopes = createArrayNode() + security.scopes.forEach { scopes.add(it) } + entry.set(security.name, scopes) + securityArray.add(entry) + } fun path(path: String): PathItemBuilder { - if (!paths.has(path)) { - paths.set(path, createObjectNode()) - } - return PathItemBuilder(paths.get(path) as ObjectNode, refCollector) + val pathItem = paths.get(path) as? ObjectNode + ?: createObjectNode().also { paths.set(path, it) } + return PathItemBuilder(pathItem = pathItem, refCollector = refCollector) } fun hasOperation(path: String, method: String): Boolean = @@ -130,7 +158,7 @@ class OpenApiSchemaBuilder { fun resolveComponentReferences(resolver: ComponentSchemaResolver) { val maxIterations = 1000 - val generatedComponents = TreeMap?> { a, b -> a.compareTo(b) } + val generatedComponents = TreeMap?>() var iteration = 0 while (generatedComponents.size < componentReferences.size) { @@ -162,21 +190,20 @@ class OpenApiSchemaBuilder { val simpleNameToFullName = mutableMapOf() - generatedComponents - .mapNotNull { it.value } - .forEach { (type, json) -> - val existing = simpleNameToFullName[type.simpleName] - if (existing != null && existing != type.fullName) { - throw IllegalStateException( - "Component schema name collision: '${type.simpleName}' maps to both '$existing' and '${type.fullName}'. " + - "Use @OpenApiName to provide a unique name for one of the conflicting types." - ) - } - simpleNameToFullName[type.simpleName] = type.fullName - if (!hasComponentSchema(type.simpleName)) { - componentSchemas.set(type.simpleName, json) - } + for ((_, component) in generatedComponents) { + val (type, json) = component ?: continue + val existing = simpleNameToFullName[type.simpleName] + if (existing != null && existing != type.fullName) { + throw IllegalStateException( + "Component schema name collision: '${type.simpleName}' maps to both '$existing' and '${type.fullName}'. " + + "Use @OpenApiName to provide a unique name for one of the conflicting types." + ) + } + simpleNameToFullName[type.simpleName] = type.fullName + if (!hasComponentSchema(type.simpleName)) { + componentSchemas.set(type.simpleName, json) } + } } private fun buildRoot(): ObjectNode { @@ -257,7 +284,7 @@ class PathItemBuilder( fun operation(method: String, configure: OperationBuilder.() -> Unit) { val existing = pathItem.get(method) as? ObjectNode - val builder = OperationBuilder(refCollector, existing) + val builder = OperationBuilder(refCollector = refCollector, existing = existing) builder.configure() pathItem.set(method, builder.build()) } @@ -324,13 +351,13 @@ class OperationBuilder( } fun parameters(configure: ParametersBuilder.() -> Unit) { - val builder = ParametersBuilder(refCollector, parametersArray) + val builder = ParametersBuilder(refCollector = refCollector, existing = parametersArray) builder.configure() parametersArray = builder.build() } fun requestBody(configure: RequestBodyBuilder.() -> Unit) { - val builder = RequestBodyBuilder(refCollector, requestBodyObject) + val builder = RequestBodyBuilder(refCollector = refCollector, existing = requestBodyObject) builder.configure() val built = builder.build() if (built.size() > 0) { @@ -339,13 +366,13 @@ class OperationBuilder( } fun responses(configure: ResponsesBuilder.() -> Unit) { - val builder = ResponsesBuilder(refCollector, responsesObject) + val builder = ResponsesBuilder(refCollector = refCollector, existing = responsesObject) builder.configure() responsesObject = builder.build() } fun callbacks(configure: CallbacksBuilder.() -> Unit) { - val builder = CallbacksBuilder(refCollector, callbacksObject) + val builder = CallbacksBuilder(refCollector = refCollector, existing = callbacksObject) builder.configure() val built = builder.build() if (built.size() > 0) { @@ -467,7 +494,10 @@ class ParametersBuilder( parameter( name = name, location = location, - schema = ResultScheme(SchemaBuilder().apply(schema).build(), emptySet()), + schema = ResultScheme( + json = SchemaBuilder().apply(schema).build(), + references = emptySet(), + ), description = description, required = required, deprecated = deprecated, @@ -486,7 +516,15 @@ class ParametersBuilder( example: String?, schema: Consumer, ) = - parameter(name, location, description, required, deprecated, allowEmptyValue, example) { schema.accept(this) } + parameter( + name = name, + location = location, + description = description, + required = required, + deprecated = deprecated, + allowEmptyValue = allowEmptyValue, + example = example, + ) { schema.accept(this) } internal fun build(): ArrayNode = parameters @@ -513,7 +551,7 @@ class RequestBodyBuilder( } fun content(configure: ContentBuilder.() -> Unit) { - val builder = ContentBuilder(refCollector, contentObject) + val builder = ContentBuilder(refCollector = refCollector, existing = contentObject) builder.configure() val built = builder.build() if (built.size() > 0) { @@ -559,7 +597,7 @@ class ContentBuilder( fun mediaType(mimeType: String, configure: MediaTypeBuilder.() -> Unit) { val existingMediaType = content.get(mimeType) as? ObjectNode - val builder = MediaTypeBuilder(refCollector, existingMediaType) + val builder = MediaTypeBuilder(refCollector = refCollector, existing = existingMediaType) builder.configure() content.set(mimeType, builder.build()) } @@ -748,7 +786,7 @@ class ResponsesBuilder( fun response(status: String, configure: ResponseBuilder.() -> Unit) { val existingResponse = responses.get(status) as? ObjectNode - val builder = ResponseBuilder(refCollector, existingResponse) + val builder = ResponseBuilder(refCollector = refCollector, existing = existingResponse) builder.configure() responses.set(status, builder.build()) } @@ -777,7 +815,7 @@ class ResponseBuilder( } fun content(configure: ContentBuilder.() -> Unit) { - val builder = ContentBuilder(refCollector, contentObject) + val builder = ContentBuilder(refCollector = refCollector, existing = contentObject) builder.configure() val built = builder.build() if (built.size() > 0) { @@ -786,7 +824,7 @@ class ResponseBuilder( } fun headers(configure: HeadersBuilder.() -> Unit) { - val builder = HeadersBuilder(refCollector, headersObject) + val builder = HeadersBuilder(refCollector = refCollector, existing = headersObject) builder.configure() val built = builder.build() if (built.size() > 0) { @@ -858,7 +896,10 @@ class HeadersBuilder( ) { header( name = name, - schema = ResultScheme(SchemaBuilder().apply(schema).build(), emptySet()), + schema = ResultScheme( + json = SchemaBuilder().apply(schema).build(), + references = emptySet(), + ), description = description, required = required, deprecated = deprecated, @@ -876,7 +917,14 @@ class HeadersBuilder( example: String?, schema: Consumer, ) = - header(name, description, required, deprecated, allowEmptyValue, example) { schema.accept(this) } + header( + name = name, + description = description, + required = required, + deprecated = deprecated, + allowEmptyValue = allowEmptyValue, + example = example, + ) { schema.accept(this) } internal fun build(): ObjectNode = headers @@ -904,13 +952,13 @@ class CallbacksBuilder( } val existingOp = urlObject.get(method) as? ObjectNode - val builder = CallbackOperationBuilder(refCollector, existingOp) + val builder = CallbackOperationBuilder(refCollector = refCollector, existing = existingOp) builder.configure() urlObject.set(method, builder.build()) } fun callback(name: String, url: String, method: String, configure: Consumer) = - callback(name, url, method) { configure.accept(this) } + callback(name = name, url = url, method = method) { configure.accept(this) } internal fun build(): ObjectNode = callbacks @@ -943,7 +991,7 @@ class CallbackOperationBuilder( } fun requestBody(configure: RequestBodyBuilder.() -> Unit) { - val builder = RequestBodyBuilder(refCollector, requestBodyObject) + val builder = RequestBodyBuilder(refCollector = refCollector, existing = requestBodyObject) builder.configure() val built = builder.build() if (built.size() > 0) { @@ -952,7 +1000,7 @@ class CallbackOperationBuilder( } fun responses(configure: ResponsesBuilder.() -> Unit) { - val builder = ResponsesBuilder(refCollector, responsesObject) + val builder = ResponsesBuilder(refCollector = refCollector, existing = responsesObject) builder.configure() responsesObject = builder.build() } diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/schema/OpenApiSchemaGenerator.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/schema/OpenApiSchemaGenerator.kt index d1ddedb9..94799f3e 100644 --- a/openapi-generator/src/main/kotlin/io/javalin/openapi/schema/OpenApiSchemaGenerator.kt +++ b/openapi-generator/src/main/kotlin/io/javalin/openapi/schema/OpenApiSchemaGenerator.kt @@ -19,9 +19,8 @@ class OpenApiSchemaGenerator( private val defaultStatusDescription: (String) -> String? = { OpenApiStatus.reasonPhrase(it) }, ) { - fun generateSchema(routes: List>): String { - return generateRouteSchema(routes.map(OpenApiRouteDefinition::from)) - } + fun generateSchema(routes: List>): String = + generateRouteSchema(routes.map(OpenApiRouteDefinition::from)) private fun generateRouteSchema(routes: List): String { val schema = @@ -64,7 +63,9 @@ class OpenApiSchemaGenerator( } } - schema.resolveComponentReferences { type -> context.typeSchemaGenerator.createTypeSchema(type, false) } + schema.resolveComponentReferences { type -> + context.typeSchemaGenerator.createTypeSchema(type = type, inlineRefs = false) + } return schema.toJson() } @@ -110,7 +111,9 @@ class OpenApiSchemaGenerator( requestBody { description(requestBody.description) content { addResolvedContent(requestBody.content) } - if (requestBody.required) { required(true) } + if (requestBody.required) { + required(true) + } } } @@ -148,7 +151,7 @@ class OpenApiSchemaGenerator( callback( name = callback.name, url = callback.url, - method = callback.method.lowercase() + method = callback.method.lowercase(), ) { summary(callback.summary) description(callback.description) @@ -156,7 +159,9 @@ class OpenApiSchemaGenerator( requestBody { description(callbackBody?.description) content { addResolvedContent(callbackBody?.content.orEmpty()) } - if (callbackBody?.required == true) { required(true) } + if (callbackBody?.required == true) { + required(true) + } } responses { for (response in callback.responses.sortedBy { it.status }) { @@ -189,44 +194,43 @@ class OpenApiSchemaGenerator( COOKIE("cookie"), } - private fun generateOperationId(method: String, route: OpenApiRouteDefinition, pathParamPrefix: String = "By"): String = - when (val operationId = route.operationId) { - AUTO_GENERATE -> - method.lowercase() + route.path.split('/') - .map { pathPart -> - if (pathPart.startsWith('{') || pathPart.startsWith('<')) { - val pathParam = pathPart - .drop(1) - .dropLast(1) - .split('-') - .joinToString(separator = "") { it.capitalise() } - pathParamPrefix + pathParam - } else { - pathPart.capitalise() - } - } - .joinToString(separator = "") { - it.split('-').joinToString(separator = "") { it.capitalise() } - } - else -> operationId + private fun generateOperationId(method: String, route: OpenApiRouteDefinition, pathParamPrefix: String = "By"): String { + if (route.operationId != AUTO_GENERATE) { + return route.operationId + } + + val path = route.path.split('/').joinToString(separator = "") { pathPart -> + if (pathPart.startsWith('{') || pathPart.startsWith('<')) { + val parameterName = pathPart + .drop(1) + .dropLast(1) + .split('-') + .joinToString(separator = "") { it.capitalise() } + pathParamPrefix + parameterName + } else { + pathPart + .split('-') + .joinToString(separator = "") { it.capitalise() } + } } + return method.lowercase() + path + } private fun String.capitalise(): String = this.replaceFirstChar { it.titlecase(Locale.getDefault()) } private fun resolveMediaType(content: OpenApiContentDefinition): Pair Unit>? { - val from = content.from - val fromIsNull = !content.hasResolvedSource + val source = content.resolvedSource var type = content.type var mimeType = content.mimeType if (mimeType == null) { - if (fromIsNull) { + if (source == null) { mimeType = type type = null } else { - mimeType = detectContentType(from!!) + mimeType = detectContentType(source) } } @@ -248,8 +252,8 @@ class OpenApiSchemaGenerator( val configure: MediaTypeBuilder.() -> Unit = { when { - properties == null && additionalProperties == null && !fromIsNull -> - schema(createTypeDescriptionWithReferences(from!!)) + properties == null && additionalProperties == null && source != null -> + schema(createTypeDescriptionWithReferences(source)) properties == null && additionalProperties == null -> schema { @@ -280,29 +284,29 @@ class OpenApiSchemaGenerator( private fun ObjectSchemaBuilder.buildProperties(properties: List) { for (property in properties) { - val from = property.from?.takeIf { property.hasResolvedSource } + val source = property.resolvedSource if (property.isArray) { - if (from != null) { - arrayProperty(property.name, createTypeDescriptionWithReferences(from)) + if (source != null) { + arrayProperty(property.name, createTypeDescriptionWithReferences(source)) } else { - arrayProperty(property.name, property.type, property.format) + arrayProperty(property.name, requireNotNull(property.type), property.format) } } else { - if (from != null) { - property(property.name, createTypeDescriptionWithReferences(from)) + if (source != null) { + property(property.name, createTypeDescriptionWithReferences(source)) } else { - property(property.name, property.type, property.format) + property(property.name, requireNotNull(property.type), property.format) } } } } private fun ObjectSchemaBuilder.buildAdditionalProperties(additionalProperties: OpenApiContentDefinition) { - val from = additionalProperties.from?.takeIf { additionalProperties.hasResolvedSource } + val source = additionalProperties.resolvedSource - if (from != null) { - additionalProperties(createTypeDescriptionWithReferences(from)) + if (source != null) { + additionalProperties(createTypeDescriptionWithReferences(source)) } else { additionalProperties(additionalProperties.type, additionalProperties.format) } diff --git a/openapi-generator/src/test/kotlin/io/javalin/openapi/schema/OpenApiRouteDefinitionTest.kt b/openapi-generator/src/test/kotlin/io/javalin/openapi/schema/OpenApiRouteDefinitionTest.kt new file mode 100644 index 00000000..b47f9bda --- /dev/null +++ b/openapi-generator/src/test/kotlin/io/javalin/openapi/schema/OpenApiRouteDefinitionTest.kt @@ -0,0 +1,107 @@ +package io.javalin.openapi.schema + +import io.javalin.introspection.AnnotationSet +import io.javalin.introspection.AnnotationProjection +import io.javalin.introspection.ClassDefinition +import io.javalin.introspection.EnumConstant +import io.javalin.introspection.InternalIntrospectionApi +import io.javalin.introspection.PropertyProjection +import io.javalin.openapi.experimental.EmbeddedTypeProcessor +import io.javalin.openapi.experimental.OpenApiType +import io.javalin.openapi.experimental.SchemaGenerationContext +import io.javalin.openapi.experimental.SimpleType +import io.javalin.openapi.experimental.StructureType +import io.javalin.openapi.experimental.processor.generators.TypeSchemaGenerator +import io.javalin.openapi.experimental.processor.shared.jsonMapper +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +internal class OpenApiRouteDefinitionTest { + + @Test + fun `schemas resolved content properties without an unused fallback type`() { + val source = StubClassDefinition() + + val json = OpenApiSchemaGenerator(TestSchemaContext(), "API", "1.0").generateSchema( + listOf( + mapOf( + "path" to "/owners", + "methods" to listOf("GET"), + "operationId" to "getOwners", + "responses" to listOf( + mapOf( + "status" to "200", + "content" to listOf( + mapOf( + "mimeType" to "application/json", + "properties" to listOf( + mapOf( + "from" to source, + "name" to "owner", + "isArray" to false, + ) + ), + ) + ), + ) + ), + ) + ) + ) + + val document = jsonMapper.readTree(json) + val ownerSchema = document + .path("paths") + .path("/owners") + .path("get") + .path("responses") + .path("200") + .path("content") + .path("application/json") + .path("schema") + .path("properties") + .path("owner") + + assertThat(ownerSchema.path("\$ref").asText()) + .isEqualTo("#/components/schemas/Owner") + } + + private class StubClassDefinition : ClassDefinition( + simpleName = "Owner", + fullName = "example.Owner", + ) { + @OptIn(InternalIntrospectionApi::class) + override val source: Any = Unit + + override fun isEnum(): Boolean = false + override fun getEnumConstants(): List = emptyList() + override fun getProperties(): List = emptyList() + override fun getAnnotations(): AnnotationSet = error("Not used by route definition decoding") + } + + private class TestSchemaContext : SchemaGenerationContext { + override val typeSchemaGenerator: TypeSchemaGenerator = TypeSchemaGenerator(this) + override val simpleTypeMappings: Map = emptyMap() + override val embeddedTypeProcessors: List = emptyList() + + override fun isEnum(type: OpenApiType): Boolean = false + override fun annotationsOf(type: OpenApiType): AnnotationSet = EmptyAnnotations + override fun propertiesOf(type: OpenApiType): List = emptyList() + override fun enumConstantsOf(type: OpenApiType): List = emptyList() + + override fun toOpenApiType(raw: ClassDefinition): OpenApiType = + OpenApiType( + simpleName = raw.simpleName, + fullName = raw.fullName, + generics = raw.generics.map(::toOpenApiType), + structureType = StructureType.valueOf(raw.structureType.name), + ) + } + + private object EmptyAnnotations : AnnotationSet { + override fun all(): List = emptyList() + override fun find(type: Class): AnnotationProjection? = null + override fun findAll(type: Class): List = emptyList() + override fun contains(simpleName: String): Boolean = false + } +} diff --git a/openapi-ksp/src/main/kotlin/io/javalin/openapi/ksp/KspSchemaContext.kt b/openapi-ksp/src/main/kotlin/io/javalin/openapi/ksp/KspSchemaContext.kt index d9a2d9ba..1774cca0 100644 --- a/openapi-ksp/src/main/kotlin/io/javalin/openapi/ksp/KspSchemaContext.kt +++ b/openapi-ksp/src/main/kotlin/io/javalin/openapi/ksp/KspSchemaContext.kt @@ -1,7 +1,7 @@ package io.javalin.openapi.ksp -import com.google.devtools.ksp.processing.Resolver import com.google.devtools.ksp.processing.KSPLogger +import com.google.devtools.ksp.processing.Resolver import com.google.devtools.ksp.symbol.KSAnnotated import io.javalin.introspection.AnnotationSet import io.javalin.introspection.PropertyProjection diff --git a/openapi-ksp/src/main/kotlin/io/javalin/openapi/ksp/OpenApiSymbolProcessor.kt b/openapi-ksp/src/main/kotlin/io/javalin/openapi/ksp/OpenApiSymbolProcessor.kt index 77ad6f4a..5871d901 100644 --- a/openapi-ksp/src/main/kotlin/io/javalin/openapi/ksp/OpenApiSymbolProcessor.kt +++ b/openapi-ksp/src/main/kotlin/io/javalin/openapi/ksp/OpenApiSymbolProcessor.kt @@ -26,35 +26,51 @@ class OpenApiSymbolProcessor( override fun process(resolver: Resolver): List { // JsonSchema inlines references; its cache must not leak into OpenAPI component references. - generateJsonSchemes(resolver, KspSchemaContext(resolver, logger)) - generateOpenApiDocuments(resolver, KspSchemaContext(resolver, logger)) + generateJsonSchemes( + resolver = resolver, + context = KspSchemaContext(resolver = resolver, logger = logger), + ) + generateOpenApiDocuments( + resolver = resolver, + context = KspSchemaContext(resolver = resolver, logger = logger), + ) return emptyList() } private fun generateJsonSchemes(resolver: Resolver, context: KspSchemaContext) { - val generated = resolver.getSymbolsWithAnnotation(JsonSchema::class.qualifiedName!!) + val generated = mutableListOf() + val declarations = resolver.getSymbolsWithAnnotation(JsonSchema::class.qualifiedName!!) .filterIsInstance() - .mapNotNull { declaration -> - val type = context.introspect(declaration.asStarProjectedType()) - if (context.annotationsOf(type).find(JsonSchema::class.java)?.get("generateResource")?.asBoolean() == false) { - return@mapNotNull null - } - val json = context.typeSchemaGenerator.createTypeSchema(type, inlineRefs = true).toJsonSchemaString() - writeResource("json-schemes/${type.fullName}", json) - type.fullName - } .toList() + for (declaration in declarations) { + val type = context.introspect(declaration.asStarProjectedType()) + val generateResource = context.annotationsOf(type) + .find(JsonSchema::class.java) + ?.get("generateResource") + ?.asBoolean() + if (generateResource == false) { + continue + } + + val json = context.typeSchemaGenerator.createTypeSchema(type, inlineRefs = true).toJsonSchemaString() + writeResource("json-schemes/${type.fullName}", json) + generated.add(type.fullName) + } + if (generated.isNotEmpty()) { writeResource("json-schemes/index", generated.joinToString(separator = "\n")) } } private fun generateOpenApiDocuments(resolver: Resolver, context: KspSchemaContext) { - val routes = (resolver.getSymbolsWithAnnotation(OpenApi::class.qualifiedName!!) + resolver.getSymbolsWithAnnotation(OpenApis::class.qualifiedName!!)) + val annotatedSymbols = resolver.getSymbolsWithAnnotation(OpenApi::class.qualifiedName!!) + + resolver.getSymbolsWithAnnotation(OpenApis::class.qualifiedName!!) + val symbols = annotatedSymbols .distinct() - .flatMap { annotated -> context.annotationsOf(annotated).findAll(OpenApi::class.java).map { it.values } } .toList() + val routes = symbols + .flatMap { annotated -> context.annotationsOf(annotated).findAll(OpenApi::class.java).map { it.values } } if (routes.isEmpty()) { return @@ -66,14 +82,14 @@ class OpenApiSymbolProcessor( version = options[OPENAPI_INFO_VERSION] ?: "", ) - val generatedVersions = generator.generateVersionedSchemas(routes) - .map { (version, json) -> - val resourceName = "openapi-${version.replace(" ", "-")}.json" - writeResource("openapi-plugin/$resourceName", json) - resourceName - } + val resourceNames = mutableListOf() + for ((version, json) in generator.generateVersionedSchemas(routes)) { + val resourceName = "openapi-${version.replace(" ", "-")}.json" + writeResource("openapi-plugin/$resourceName", json) + resourceNames.add(resourceName) + } - writeResource("openapi-plugin/.index", generatedVersions.joinToString(separator = "\n")) + writeResource("openapi-plugin/.index", resourceNames.joinToString(separator = "\n")) } private fun writeResource(path: String, content: String) { @@ -90,5 +106,9 @@ class OpenApiSymbolProcessor( class OpenApiSymbolProcessorProvider : SymbolProcessorProvider { override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor = - OpenApiSymbolProcessor(environment.codeGenerator, environment.logger, environment.options) + OpenApiSymbolProcessor( + codeGenerator = environment.codeGenerator, + logger = environment.logger, + options = environment.options, + ) } diff --git a/openapi-ksp/src/test/kotlin/io/javalin/openapi/ksp/KspSchemaContextTest.kt b/openapi-ksp/src/test/kotlin/io/javalin/openapi/ksp/KspSchemaContextTest.kt index e5ade95b..a9974b06 100644 --- a/openapi-ksp/src/test/kotlin/io/javalin/openapi/ksp/KspSchemaContextTest.kt +++ b/openapi-ksp/src/test/kotlin/io/javalin/openapi/ksp/KspSchemaContextTest.kt @@ -44,7 +44,10 @@ class KspSchemaContextTest { fun `generates a component schema end-to-end through the shared generator`() { withResolver { resolver, logger -> val context = KspSchemaContext(resolver, logger) - val user = resolver.getClassDeclarationByName(resolver.getKSNameFromString("io.javalin.openapi.ksp.User"))!! + val declaration = resolver.getClassDeclarationByName( + resolver.getKSNameFromString("io.javalin.openapi.ksp.User") + )!! + val user = declaration .asStarProjectedType() val schema = context.componentSchema(context.introspect(user)).json diff --git a/openapi-ksp/src/test/kotlin/io/javalin/openapi/ksp/OpenApiSymbolProcessorTest.kt b/openapi-ksp/src/test/kotlin/io/javalin/openapi/ksp/OpenApiSymbolProcessorTest.kt index 5272e9a2..4d0011ce 100644 --- a/openapi-ksp/src/test/kotlin/io/javalin/openapi/ksp/OpenApiSymbolProcessorTest.kt +++ b/openapi-ksp/src/test/kotlin/io/javalin/openapi/ksp/OpenApiSymbolProcessorTest.kt @@ -23,31 +23,20 @@ class OpenApiSymbolProcessorTest { @Test fun `writes a json-scheme resource for a JsonSchema type via the shared generator`() { - val compilation = KotlinCompilation().apply { - useKsp2() - sources = listOf( - SourceFile.kotlin( - "Widget.kt", - """ - package app - import io.javalin.openapi.JsonSchema - @JsonSchema - class Widget(val name: String, val size: Int) - """.trimIndent() - ) + val (compilation, result) = compileWithKsp( + SourceFile.kotlin( + "Widget.kt", + """ + package app + import io.javalin.openapi.JsonSchema + @JsonSchema + class Widget(val name: String, val size: Int) + """.trimIndent() ) - symbolProcessorProviders = mutableListOf(OpenApiSymbolProcessorProvider()) - inheritClassPath = true - messageOutputStream = System.out - } - - val result = compilation.compile() + ) check(result.exitCode == KotlinCompilation.ExitCode.OK) { "KSP compilation failed: ${result.messages}" } - val scheme = compilation.kspSourcesDir.walkTopDown().firstOrNull { it.name == "app.Widget" } - ?: error("json-scheme not generated. Output tree:\n" + compilation.kspSourcesDir.walkTopDown().joinToString("\n")) - - val doc = jsonMapper.readTree(scheme.readText()) + val doc = compilation.generatedJson("app.Widget") assertThat(doc.path("type").asText()).isEqualTo("object") assertThat(doc.path("properties").path("name").path("type").asText()).isEqualTo("string") assertThat(doc.path("properties").path("size").path("type").asText()).isEqualTo("integer") @@ -56,44 +45,33 @@ class OpenApiSymbolProcessorTest { @Test fun `writes an openapi document for an OpenApi route via the shared generator`() { - val compilation = KotlinCompilation().apply { - useKsp2() - sources = listOf( - SourceFile.kotlin( - "Routes.kt", - """ - package app - import io.javalin.openapi.HttpMethod - import io.javalin.openapi.OpenApiStatus - import io.javalin.openapi.OpenApi - import io.javalin.openapi.OpenApiContent - import io.javalin.openapi.OpenApiResponse - - class Account(val id: String, val age: Int) - - class Routes { - @OpenApi( - path = "/account", - methods = [HttpMethod.GET], - responses = [OpenApiResponse(status = OpenApiStatus.OK, content = [OpenApiContent(from = Account::class)])] - ) - fun getAccount() {} - } - """.trimIndent() - ) - ) - symbolProcessorProviders = mutableListOf(OpenApiSymbolProcessorProvider()) - inheritClassPath = true - messageOutputStream = System.out - } + val (compilation, result) = compileWithKsp( + SourceFile.kotlin( + "Routes.kt", + """ + package app + import io.javalin.openapi.HttpMethod + import io.javalin.openapi.OpenApiStatus + import io.javalin.openapi.OpenApi + import io.javalin.openapi.OpenApiContent + import io.javalin.openapi.OpenApiResponse - val result = compilation.compile() - check(result.exitCode == KotlinCompilation.ExitCode.OK) { "KSP compilation failed: ${result.messages}" } + class Account(val id: String, val age: Int) - val document = compilation.kspSourcesDir.walkTopDown().firstOrNull { it.name == "openapi-default.json" } - ?: error("openapi document not generated. Output tree:\n" + compilation.kspSourcesDir.walkTopDown().joinToString("\n")) + class Routes { + @OpenApi( + path = "/account", + methods = [HttpMethod.GET], + responses = [OpenApiResponse(status = OpenApiStatus.OK, content = [OpenApiContent(from = Account::class)])] + ) + fun getAccount() {} + } + """.trimIndent() + ) + ) + check(result.exitCode == KotlinCompilation.ExitCode.OK) { "KSP compilation failed: ${result.messages}" } - val doc = jsonMapper.readTree(document.readText()) + val doc = compilation.generatedJson("openapi-default.json") assertThat(doc.path("openapi").asText()).isEqualTo("3.1.0") val operation = doc.path("paths").path("/account").path("get") @@ -112,55 +90,44 @@ class OpenApiSymbolProcessorTest { @Test fun `auto-discovers discriminator subtypes via DiscriminatorMappingName`() { - val compilation = KotlinCompilation().apply { - useKsp2() - sources = listOf( - SourceFile.kotlin( - "Shapes.kt", - """ - package app - import io.javalin.openapi.Discriminator - import io.javalin.openapi.DiscriminatorMappingName - import io.javalin.openapi.DiscriminatorProperty - import io.javalin.openapi.HttpMethod - import io.javalin.openapi.OneOf - import io.javalin.openapi.OpenApi - import io.javalin.openapi.OpenApiContent - import io.javalin.openapi.OpenApiResponse - import io.javalin.openapi.OpenApiStatus - - @OneOf(discriminator = Discriminator(property = DiscriminatorProperty(name = "type"))) - sealed interface Shape - - @DiscriminatorMappingName("circle") - data class Circle(val radius: Int) : Shape - - @DiscriminatorMappingName("square") - data class Square(val side: Int) : Shape - - class Shapes { - @OpenApi( - path = "/shape", - methods = [HttpMethod.GET], - responses = [OpenApiResponse(status = OpenApiStatus.OK, content = [OpenApiContent(from = Shape::class)])] - ) - fun shape() {} - } - """.trimIndent() - ) - ) - symbolProcessorProviders = mutableListOf(OpenApiSymbolProcessorProvider()) - inheritClassPath = true - messageOutputStream = System.out - } + val (compilation, result) = compileWithKsp( + SourceFile.kotlin( + "Shapes.kt", + """ + package app + import io.javalin.openapi.Discriminator + import io.javalin.openapi.DiscriminatorMappingName + import io.javalin.openapi.DiscriminatorProperty + import io.javalin.openapi.HttpMethod + import io.javalin.openapi.OneOf + import io.javalin.openapi.OpenApi + import io.javalin.openapi.OpenApiContent + import io.javalin.openapi.OpenApiResponse + import io.javalin.openapi.OpenApiStatus - val result = compilation.compile() - check(result.exitCode == KotlinCompilation.ExitCode.OK) { "KSP compilation failed: ${result.messages}" } + @OneOf(discriminator = Discriminator(property = DiscriminatorProperty(name = "type"))) + sealed interface Shape - val document = compilation.kspSourcesDir.walkTopDown().firstOrNull { it.name == "openapi-default.json" } - ?: error("openapi document not generated. Output tree:\n" + compilation.kspSourcesDir.walkTopDown().joinToString("\n")) + @DiscriminatorMappingName("circle") + data class Circle(val radius: Int) : Shape - val shape = jsonMapper.readTree(document.readText()).path("components").path("schemas").path("Shape") + @DiscriminatorMappingName("square") + data class Square(val side: Int) : Shape + + class Shapes { + @OpenApi( + path = "/shape", + methods = [HttpMethod.GET], + responses = [OpenApiResponse(status = OpenApiStatus.OK, content = [OpenApiContent(from = Shape::class)])] + ) + fun shape() {} + } + """.trimIndent() + ) + ) + check(result.exitCode == KotlinCompilation.ExitCode.OK) { "KSP compilation failed: ${result.messages}" } + + val shape = compilation.generatedJson("openapi-default.json").path("components").path("schemas").path("Shape") val refs = shape.path("oneOf").map { it.path("\$ref").asText() } assertThat(refs).containsExactlyInAnyOrder( @@ -175,50 +142,39 @@ class OpenApiSymbolProcessorTest { @Test fun `does not leak inlined sub-schemas from the JsonSchema pass into OpenApi refs`() { - val compilation = KotlinCompilation().apply { - useKsp2() - sources = listOf( - SourceFile.kotlin( - "Shared.kt", - """ - package app - import io.javalin.openapi.HttpMethod - import io.javalin.openapi.JsonSchema - import io.javalin.openapi.OpenApi - import io.javalin.openapi.OpenApiContent - import io.javalin.openapi.OpenApiResponse - import io.javalin.openapi.OpenApiStatus - - data class Address(val city: String) - - @JsonSchema - class Profile(val address: Address) - - data class Account(val address: Address) - - class Routes { - @OpenApi( - path = "/account", - methods = [HttpMethod.GET], - responses = [OpenApiResponse(status = OpenApiStatus.OK, content = [OpenApiContent(from = Account::class)])] - ) - fun getAccount() {} - } - """.trimIndent() - ) - ) - symbolProcessorProviders = mutableListOf(OpenApiSymbolProcessorProvider()) - inheritClassPath = true - messageOutputStream = System.out - } + val (compilation, result) = compileWithKsp( + SourceFile.kotlin( + "Shared.kt", + """ + package app + import io.javalin.openapi.HttpMethod + import io.javalin.openapi.JsonSchema + import io.javalin.openapi.OpenApi + import io.javalin.openapi.OpenApiContent + import io.javalin.openapi.OpenApiResponse + import io.javalin.openapi.OpenApiStatus - val result = compilation.compile() - check(result.exitCode == KotlinCompilation.ExitCode.OK) { "KSP compilation failed: ${result.messages}" } + data class Address(val city: String) + + @JsonSchema + class Profile(val address: Address) - val document = compilation.kspSourcesDir.walkTopDown().firstOrNull { it.name == "openapi-default.json" } - ?: error("openapi document not generated. Output tree:\n" + compilation.kspSourcesDir.walkTopDown().joinToString("\n")) + data class Account(val address: Address) + + class Routes { + @OpenApi( + path = "/account", + methods = [HttpMethod.GET], + responses = [OpenApiResponse(status = OpenApiStatus.OK, content = [OpenApiContent(from = Account::class)])] + ) + fun getAccount() {} + } + """.trimIndent() + ) + ) + check(result.exitCode == KotlinCompilation.ExitCode.OK) { "KSP compilation failed: ${result.messages}" } - val schemas = jsonMapper.readTree(document.readText()).path("components").path("schemas") + val schemas = compilation.generatedJson("openapi-default.json").path("components").path("schemas") val addressProperty = schemas.path("Account").path("properties").path("address") assertThat(addressProperty.path("\$ref").asText()).isEqualTo("#/components/schemas/Address") @@ -435,12 +391,12 @@ class OpenApiSymbolProcessorTest { private fun compileWithKsp( vararg sources: SourceFile, - providers: MutableList = mutableListOf(OpenApiSymbolProcessorProvider()), + providers: List = listOf(OpenApiSymbolProcessorProvider()), ): Pair { val compilation = KotlinCompilation().apply { useKsp2() this.sources = sources.toList() - symbolProcessorProviders = providers + symbolProcessorProviders = providers.toMutableList() inheritClassPath = true messageOutputStream = System.out } From bcd2f2c263e47a194b1c1f4022f46cba23bbd8b6 Mon Sep 17 00:00:00 2001 From: dzikoysk Date: Tue, 21 Jul 2026 19:51:46 +0200 Subject: [PATCH 32/43] chore: cleanup --- .../javalin/introspection/ClassDefinition.kt | 5 +- .../introspection/PropertyProjection.kt | 6 +- .../introspection/jap/JapTypeIntrospector.kt | 20 +++---- .../introspection/ksp/KspTypeIntrospector.kt | 16 +++--- .../runtime/ReflectionTypeIntrospector.kt | 54 +++++++++--------- .../plugin/redoc/ReDocWebJarHandler.kt | 2 +- .../openapi/plugin/swagger/SwaggerHandler.kt | 7 ++- .../AnnotationProcessorContext.kt | 28 ++++++---- .../experimental/IntrospectorSchemaContext.kt | 8 ++- .../openapi/experimental/OpenApiType.kt | 6 +- .../processor/generators/ExampleGenerator.kt | 7 ++- .../generators/TypeSchemaGenerator.kt | 42 ++++++++------ .../openapi/schema/OpenApiRouteDefinition.kt | 6 +- .../openapi/schema/OpenApiSchemaBuilder.kt | 51 +++++++++-------- .../openapi/schema/OpenApiSchemaGenerator.kt | 56 +++++++++---------- 15 files changed, 171 insertions(+), 143 deletions(-) diff --git a/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/ClassDefinition.kt b/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/ClassDefinition.kt index fac96929..69a52214 100644 --- a/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/ClassDefinition.kt +++ b/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/ClassDefinition.kt @@ -19,7 +19,10 @@ abstract class ClassDefinition( abstract fun getAnnotations(): AnnotationSet override fun toString(): String = - if (generics.isEmpty()) fullName else "$fullName<${generics.joinToString(", ")}>" + when { + generics.isEmpty() -> fullName + else -> "$fullName<${generics.joinToString(", ")}>" + } } enum class StructureType { diff --git a/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/PropertyProjection.kt b/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/PropertyProjection.kt index 043ddd56..6b28e6d4 100644 --- a/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/PropertyProjection.kt +++ b/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/PropertyProjection.kt @@ -20,5 +20,7 @@ fun isGetterName(name: String): Boolean = (name.startsWith("is") && name.length > 2 && name[2].isUpperCase()) fun propertyName(getterName: String): String = - (if (getterName.startsWith("get")) getterName.removePrefix("get") else getterName.removePrefix("is")) - .replaceFirstChar { it.lowercase() } + when { + getterName.startsWith("get") -> getterName.removePrefix("get") + else -> getterName.removePrefix("is") + }.replaceFirstChar { it.lowercase() } diff --git a/introspection/introspection-jap/src/main/kotlin/io/javalin/introspection/jap/JapTypeIntrospector.kt b/introspection/introspection-jap/src/main/kotlin/io/javalin/introspection/jap/JapTypeIntrospector.kt index a7903df4..c24f7c0f 100644 --- a/introspection/introspection-jap/src/main/kotlin/io/javalin/introspection/jap/JapTypeIntrospector.kt +++ b/introspection/introspection-jap/src/main/kotlin/io/javalin/introspection/jap/JapTypeIntrospector.kt @@ -71,20 +71,17 @@ class JapTypeIntrospector( when (mirror) { is TypeVariable -> { val key = mirror.asElement()?.toString() ?: mirror.toString() - if (key in visitingTypeVariables) { - objectDefinition(structureType) - } else { - val bound = mirror.upperBound ?: mirror.lowerBound - if (bound != null) { + val bound = mirror.upperBound ?: mirror.lowerBound + when { + key in visitingTypeVariables -> objectDefinition(structureType) + bound != null -> resolve( mirror = bound, generics = generics, structureType = structureType, visitingTypeVariables = visitingTypeVariables + key, ) - } else { - objectDefinition(structureType) - } + else -> objectDefinition(structureType) } } is WildcardType -> @@ -121,7 +118,7 @@ class JapTypeIntrospector( mirror = it, generics = generics, structureType = structureType, - visitingTypeVariables = visitingTypeVariables + visitingTypeVariables = visitingTypeVariables, ) } ?: objectDefinition(structureType) @@ -210,7 +207,10 @@ class JapTypeIntrospector( override fun defaultAction(value: Any?, p: Nothing?): Any? = value override fun visitType(type: TypeMirror, p: Nothing?): Any = - if (type is PrimitiveType) primitiveDefinition(type) else resolve(type) + when { + type is PrimitiveType -> primitiveDefinition(type) + else -> resolve(type) + } override fun visitEnumConstant(constant: VariableElement, p: Nothing?): Any = constant.simpleName.toString() diff --git a/introspection/introspection-ksp/src/main/kotlin/io/javalin/introspection/ksp/KspTypeIntrospector.kt b/introspection/introspection-ksp/src/main/kotlin/io/javalin/introspection/ksp/KspTypeIntrospector.kt index 6aa524da..301d9125 100644 --- a/introspection/introspection-ksp/src/main/kotlin/io/javalin/introspection/ksp/KspTypeIntrospector.kt +++ b/introspection/introspection-ksp/src/main/kotlin/io/javalin/introspection/ksp/KspTypeIntrospector.kt @@ -82,14 +82,14 @@ class KspTypeIntrospector(private val resolver: Resolver) : CompileTimeIntrospec return objectDefinition(structureType) } val bound = declaration.bounds.firstOrNull()?.resolve() - return if (bound != null) { - resolve( - type = bound, - structureType = structureType, - visitingTypeParameters = visitingTypeParameters + key, - ) - } else { - objectDefinition(structureType) + return when { + bound != null -> + resolve( + type = bound, + structureType = structureType, + visitingTypeParameters = visitingTypeParameters + key, + ) + else -> objectDefinition(structureType) } } val qualifiedName = declaration.qualifiedName?.asString() ?: return objectDefinition(structureType) diff --git a/introspection/introspection-runtime/src/main/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospector.kt b/introspection/introspection-runtime/src/main/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospector.kt index 3d64ba16..d6a1dbf7 100644 --- a/introspection/introspection-runtime/src/main/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospector.kt +++ b/introspection/introspection-runtime/src/main/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospector.kt @@ -56,15 +56,15 @@ private fun reflect( visitingTypeVariables = visitingTypeVariables, ) is TypeVariable<*> -> - if (type in visitingTypeVariables) { - objectDefinition(structureType) - } else { - reflect( - type = type.bounds.firstOrNull() ?: Any::class.java, - generics = generics, - structureType = structureType, - visitingTypeVariables = visitingTypeVariables + type, - ) + when { + type in visitingTypeVariables -> objectDefinition(structureType) + else -> + reflect( + type = type.bounds.firstOrNull() ?: Any::class.java, + generics = generics, + structureType = structureType, + visitingTypeVariables = visitingTypeVariables + type, + ) } is ParameterizedType -> parameterized( type = type, @@ -188,7 +188,10 @@ private class ReflectionClassDefinition( override fun getProperties(): List = collectMembers(erasure).map { member -> PropertyProjection( - name = if (member.accessor == Accessor.GETTER) propertyName(member.name) else member.name, + name = when { + member.accessor == Accessor.GETTER -> propertyName(member.name) + else -> member.name + }, type = reflect(member.genericType), accessor = member.accessor, nullable = (member.genericType as? Class<*>)?.isPrimitive != true, @@ -226,30 +229,14 @@ private fun collectMembers(clazz: Class<*>): List { if (method.parameterCount != 0 || method.declaringClass == Any::class.java) continue if (method.returnType == Void.TYPE || !isGetterName(method.name)) continue if (getterNames.add(method.name)) { - members += Member( - name = method.name, - genericType = method.genericReturnType, - accessor = Accessor.GETTER, - visibility = visibilityOf(method.modifiers), - transient = false, - source = method, - sources = listOf(method), - ) + members += method.toMember() } } // clazz.methods is public-only, so a second pass picks up inherited protected/package-private getters (jap parity) for (method in nonPublicGettersHierarchy(clazz)) { if (getterNames.add(method.name)) { - members += Member( - name = method.name, - genericType = method.genericReturnType, - accessor = Accessor.GETTER, - visibility = visibilityOf(method.modifiers), - transient = false, - source = method, - sources = listOf(method), - ) + members += method.toMember() } } @@ -279,6 +266,17 @@ private class Member( val sources: List, ) +private fun Method.toMember(): Member = + Member( + name = name, + genericType = genericReturnType, + accessor = Accessor.GETTER, + visibility = visibilityOf(modifiers), + transient = false, + source = this, + sources = listOf(this), + ) + private fun visibilityOf(modifiers: Int): MemberVisibility = when { Modifier.isPublic(modifiers) -> MemberVisibility.PUBLIC diff --git a/javalin-plugins/javalin-redoc-plugin/src/main/kotlin/io/javalin/openapi/plugin/redoc/ReDocWebJarHandler.kt b/javalin-plugins/javalin-redoc-plugin/src/main/kotlin/io/javalin/openapi/plugin/redoc/ReDocWebJarHandler.kt index 318c5024..1e107266 100644 --- a/javalin-plugins/javalin-redoc-plugin/src/main/kotlin/io/javalin/openapi/plugin/redoc/ReDocWebJarHandler.kt +++ b/javalin-plugins/javalin-redoc-plugin/src/main/kotlin/io/javalin/openapi/plugin/redoc/ReDocWebJarHandler.kt @@ -9,7 +9,7 @@ import org.eclipse.jetty.http.MimeTypes internal class ReDocWebJarHandler( private val redocWebJarPath: String, private val classLoader: ClassLoader = ReDocWebJarHandler::class.java.classLoader, - ) : Handler, OpenApiPluginRouteHandler { +) : Handler, OpenApiPluginRouteHandler { override fun handle(context: Context) { val resourceRootPath = "META-INF/resources$redocWebJarPath" diff --git a/javalin-plugins/javalin-swagger-plugin/src/main/kotlin/io/javalin/openapi/plugin/swagger/SwaggerHandler.kt b/javalin-plugins/javalin-swagger-plugin/src/main/kotlin/io/javalin/openapi/plugin/swagger/SwaggerHandler.kt index 660ff471..1960b9ab 100644 --- a/javalin-plugins/javalin-swagger-plugin/src/main/kotlin/io/javalin/openapi/plugin/swagger/SwaggerHandler.kt +++ b/javalin-plugins/javalin-swagger-plugin/src/main/kotlin/io/javalin/openapi/plugin/swagger/SwaggerHandler.kt @@ -68,6 +68,11 @@ class SwaggerHandler( .joinToString(separator = "\n") { "" } val allCustomJavaScripts = customJavaScriptFiles .joinToString(separator = "\n") { " diff --git a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/AnnotationProcessorContext.kt b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/AnnotationProcessorContext.kt index 376c59d2..f2e83f3e 100644 --- a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/AnnotationProcessorContext.kt +++ b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/AnnotationProcessorContext.kt @@ -63,16 +63,16 @@ class AnnotationProcessorContext( @OptIn(InternalIntrospectionApi::class) override fun toOpenApiType(raw: RawType): OpenApiType { val rawMirror = raw.source as TypeMirror - val mirror = if (rawMirror.kind.isPrimitive) { - types.boxedClass(rawMirror as PrimitiveType).asType() - } else { - rawMirror - } - val source = if (raw.structureType == RawStructureType.DICTIONARY) { - mapType() - } else { - types.asElement(mirror) ?: objectType() - } + val mirror = + when { + rawMirror.kind.isPrimitive -> types.boxedClass(rawMirror as PrimitiveType).asType() + else -> rawMirror + } + val source = + when { + raw.structureType == RawStructureType.DICTIONARY -> mapType() + else -> types.asElement(mirror) ?: objectType() + } return OpenApiType( simpleName = mirror.getSimpleName(), @@ -108,8 +108,12 @@ class AnnotationProcessorContext( assignableTo = source, ) return subtypes.mapNotNull { subtype -> - val name = subtype.getAnnotations().find(DiscriminatorMappingName::class.java)?.get("value")?.asString() - if (name == null) null else name to toOpenApiType(subtype) + subtype + .getAnnotations() + .find(DiscriminatorMappingName::class.java) + ?.get("value") + ?.asString() + ?.let { name -> name to toOpenApiType(subtype) } } } diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/IntrospectorSchemaContext.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/IntrospectorSchemaContext.kt index 4d8fc854..7895f396 100644 --- a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/IntrospectorSchemaContext.kt +++ b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/IntrospectorSchemaContext.kt @@ -63,8 +63,12 @@ abstract class IntrospectorSchemaContext( val scanner = introspector as? CompileTimeIntrospector ?: return emptyList() val subtypes = scanner.typesAnnotatedWith(DiscriminatorMappingName::class.java, assignableTo = type.raw) return subtypes.mapNotNull { subtype -> - val name = subtype.getAnnotations().find(DiscriminatorMappingName::class.java)?.get("value")?.asString() - if (name == null) null else name to toOpenApiType(subtype) + subtype + .getAnnotations() + .find(DiscriminatorMappingName::class.java) + ?.get("value") + ?.asString() + ?.let { name -> name to toOpenApiType(subtype) } } } } diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/OpenApiType.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/OpenApiType.kt index 97bdf834..5c60b247 100644 --- a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/OpenApiType.kt +++ b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/OpenApiType.kt @@ -16,7 +16,7 @@ class OpenApiType( val generics: List = emptyList(), val structureType: StructureType = DEFAULT, val extra: MutableList = mutableListOf(), - @property:InternalOpenApiTypeApi @JvmField val handle: Any? = null + @property:InternalOpenApiTypeApi @JvmField val handle: Any? = null, ) { override fun equals(other: Any?): Boolean = @@ -47,12 +47,12 @@ class OpenApiType( enum class StructureType { DEFAULT, ARRAY, - DICTIONARY + DICTIONARY, } interface Extra class CustomProperty( val name: String, - val type: OpenApiType + val type: OpenApiType, ) : Extra diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/ExampleGenerator.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/ExampleGenerator.kt index 1d71340e..187bed62 100644 --- a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/ExampleGenerator.kt +++ b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/ExampleGenerator.kt @@ -11,7 +11,7 @@ data class ExampleProperty( val name: String?, val value: String?, val raw: String?, - val objects: List? + val objects: List?, ) fun Map.toExampleProperty(): ExampleProperty = @@ -19,7 +19,10 @@ fun Map.toExampleProperty(): ExampleProperty = name = (this["name"] as? String)?.takeIf { it != NULL_STRING }, value = (this["value"] as? String)?.takeIf { it != NULL_STRING }, raw = (this["raw"] as? String)?.takeIf { it != NULL_STRING }, - objects = (this["objects"] as? List<*>)?.filterIsInstance>()?.map { it.toExampleProperty() }?.takeIf { it.isNotEmpty() }, + objects = (this["objects"] as? List<*>) + ?.filterIsInstance>() + ?.map { it.toExampleProperty() } + ?.takeIf { it.isNotEmpty() }, ) object ExampleGenerator { diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/TypeSchemaGenerator.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/TypeSchemaGenerator.kt index c403f73e..e4569dc9 100644 --- a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/TypeSchemaGenerator.kt +++ b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/TypeSchemaGenerator.kt @@ -29,7 +29,7 @@ class TypeSchemaGenerator(val context: SchemaGenerationContext) { fun createTypeSchema( type: OpenApiType, inlineRefs: Boolean = false, - requireNonNullsByDefault: Boolean = true + requireNonNullsByDefault: Boolean = true, ): ResultScheme { context.reportDebug("OpenApi | Generating schema for ${type.fullName}") @@ -77,10 +77,9 @@ class TypeSchemaGenerator(val context: SchemaGenerationContext) { else -> constant.name } - if (enumType != null && enumType.type != "string") { - values.add(jsonMapper.readTree(name)) - } else { - values.add(name) + when { + enumType != null && enumType.type != "string" -> values.add(jsonMapper.readTree(name)) + else -> values.add(name) } descriptions.add(description ?: "") } @@ -236,21 +235,24 @@ class TypeSchemaGenerator(val context: SchemaGenerationContext) { type: OpenApiType, inlineRefs: Boolean, references: MutableSet, - requiresNonNulls: Boolean + requiresNonNulls: Boolean, ) { when (val nonRefType = context.simpleTypeMappings[type.fullName]) { null -> { - if (inlineRefs) { - val (subScheme, subReferences) = createTypeSchema( - type = type, - inlineRefs = true, - requireNonNullsByDefault = requiresNonNulls, - ) - subScheme.properties().forEach { (key, value) -> scheme.set(key, value) } - references.addAll(subReferences) - } else { - references.add(type) - scheme.put($$"$ref", "#/components/schemas/${type.simpleName}") + when { + inlineRefs -> { + val (subScheme, subReferences) = createTypeSchema( + type = type, + inlineRefs = true, + requireNonNullsByDefault = requiresNonNulls, + ) + subScheme.properties().forEach { (key, value) -> scheme.set(key, value) } + references.addAll(subReferences) + } + else -> { + references.add(type) + scheme.put($$"$ref", "#/components/schemas/${type.simpleName}") + } } } else -> { @@ -284,7 +286,11 @@ internal fun SchemaGenerationContext.findAllProperties(type: OpenApiType, requir val customName = property.annotations.find(OpenApiName::class.java)?.get("value")?.asString() val name = customName ?: property.name - val finalName = if (customName == null && namingStrategy != null) translatePropertyName(namingStrategy, name) else name + val finalName = + when { + customName == null && namingStrategy != null -> translatePropertyName(namingStrategy, name) + else -> name + } val propertyType = property.annotations.find(OpenApiPropertyType::class.java) val nullability = propertyType?.get("nullability")?.asString() diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/schema/OpenApiRouteDefinition.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/schema/OpenApiRouteDefinition.kt index b163eeec..cfb3875f 100644 --- a/openapi-generator/src/main/kotlin/io/javalin/openapi/schema/OpenApiRouteDefinition.kt +++ b/openapi-generator/src/main/kotlin/io/javalin/openapi/schema/OpenApiRouteDefinition.kt @@ -27,7 +27,11 @@ internal data class OpenApiRouteDefinition( val security: List, ) { val formattedPath: String - get() = if (path.startsWith("/")) path else "/$path" + get() = + when { + path.startsWith("/") -> path + else -> "/$path" + } companion object { fun from(values: Map): OpenApiRouteDefinition = diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/schema/OpenApiSchemaBuilder.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/schema/OpenApiSchemaBuilder.kt index 4f5ccf83..5f513659 100644 --- a/openapi-generator/src/main/kotlin/io/javalin/openapi/schema/OpenApiSchemaBuilder.kt +++ b/openapi-generator/src/main/kotlin/io/javalin/openapi/schema/OpenApiSchemaBuilder.kt @@ -45,10 +45,9 @@ class OpenApiSchemaBuilder { val infoJson = jsonMapper.convertValue(OpenApiInfo().also { configure.accept(it) }, JsonNode::class.java) val existingInfo = root.get("info") val updatedInfo: JsonNode = - if (existingInfo != null) { - jsonMapper.readerForUpdating(existingInfo).readValue(infoJson) - } else { - infoJson + when { + existingInfo != null -> jsonMapper.readerForUpdating(existingInfo).readValue(infoJson) + else -> infoJson } root.set("info", updatedInfo) } @@ -456,14 +455,15 @@ class ParametersBuilder( if (required) param.put("required", true) if (deprecated) param.put("deprecated", true) if (allowEmptyValue) param.put("allowEmptyValue", true) - if (schema.references.isNotEmpty()) { - val mediaTypeNode = createObjectNode() - mediaTypeNode.set("schema", schemaJson) - val contentNode = createObjectNode() - contentNode.set("application/json", mediaTypeNode) - param.set("content", contentNode) - } else { - param.set("schema", schemaJson) + when { + schema.references.isNotEmpty() -> { + val mediaTypeNode = createObjectNode() + mediaTypeNode.set("schema", schemaJson) + val contentNode = createObjectNode() + contentNode.set("application/json", mediaTypeNode) + param.set("content", contentNode) + } + else -> param.set("schema", schemaJson) } if (example != null) { param.put("example", example) @@ -474,10 +474,9 @@ class ParametersBuilder( val existing = parameters.get(i) as? ObjectNode existing?.get("name")?.asText() == name && existing?.get("in")?.asText() == location } - if (existingIndex != null) { - parameters.set(existingIndex, param) - } else { - parameters.add(param) + when { + existingIndex != null -> parameters.set(existingIndex, param) + else -> parameters.add(param) } } @@ -939,17 +938,17 @@ class CallbacksBuilder( private val callbacks = existing ?: createObjectNode() fun callback(name: String, url: String, method: String, configure: CallbackOperationBuilder.() -> Unit) { - val eventObject = if (callbacks.has(name)) { - callbacks.get(name) as ObjectNode - } else { - createObjectNode().also { callbacks.set(name, it) } - } + val eventObject = + when { + callbacks.has(name) -> callbacks.get(name) as ObjectNode + else -> createObjectNode().also { callbacks.set(name, it) } + } - val urlObject = if (eventObject.has(url)) { - eventObject.get(url) as ObjectNode - } else { - createObjectNode().also { eventObject.set(url, it) } - } + val urlObject = + when { + eventObject.has(url) -> eventObject.get(url) as ObjectNode + else -> createObjectNode().also { eventObject.set(url, it) } + } val existingOp = urlObject.get(method) as? ObjectNode val builder = CallbackOperationBuilder(refCollector = refCollector, existing = existingOp) diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/schema/OpenApiSchemaGenerator.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/schema/OpenApiSchemaGenerator.kt index 94799f3e..7b808724 100644 --- a/openapi-generator/src/main/kotlin/io/javalin/openapi/schema/OpenApiSchemaGenerator.kt +++ b/openapi-generator/src/main/kotlin/io/javalin/openapi/schema/OpenApiSchemaGenerator.kt @@ -200,17 +200,20 @@ class OpenApiSchemaGenerator( } val path = route.path.split('/').joinToString(separator = "") { pathPart -> - if (pathPart.startsWith('{') || pathPart.startsWith('<')) { - val parameterName = pathPart - .drop(1) - .dropLast(1) - .split('-') - .joinToString(separator = "") { it.capitalise() } - pathParamPrefix + parameterName - } else { - pathPart - .split('-') - .joinToString(separator = "") { it.capitalise() } + when { + pathPart.startsWith('{') || pathPart.startsWith('<') -> { + val parameterName = pathPart + .drop(1) + .dropLast(1) + .split('-') + .joinToString(separator = "") { it.capitalise() } + pathParamPrefix + parameterName + } + else -> { + pathPart + .split('-') + .joinToString(separator = "") { it.capitalise() } + } } } return method.lowercase() + path @@ -226,11 +229,12 @@ class OpenApiSchemaGenerator( var mimeType = content.mimeType if (mimeType == null) { - if (source == null) { - mimeType = type - type = null - } else { - mimeType = detectContentType(source) + when { + source == null -> { + mimeType = type + type = null + } + else -> mimeType = detectContentType(source) } } @@ -286,18 +290,15 @@ class OpenApiSchemaGenerator( for (property in properties) { val source = property.resolvedSource - if (property.isArray) { - if (source != null) { + when { + property.isArray && source != null -> arrayProperty(property.name, createTypeDescriptionWithReferences(source)) - } else { + property.isArray -> arrayProperty(property.name, requireNotNull(property.type), property.format) - } - } else { - if (source != null) { + source != null -> property(property.name, createTypeDescriptionWithReferences(source)) - } else { + else -> property(property.name, requireNotNull(property.type), property.format) - } } } } @@ -305,10 +306,9 @@ class OpenApiSchemaGenerator( private fun ObjectSchemaBuilder.buildAdditionalProperties(additionalProperties: OpenApiContentDefinition) { val source = additionalProperties.resolvedSource - if (source != null) { - additionalProperties(createTypeDescriptionWithReferences(source)) - } else { - additionalProperties(additionalProperties.type, additionalProperties.format) + when { + source != null -> additionalProperties(createTypeDescriptionWithReferences(source)) + else -> additionalProperties(additionalProperties.type, additionalProperties.format) } applyExample(additionalProperties) From 01325d68b7df8f7cadfdcfd19744294cb0f03369 Mon Sep 17 00:00:00 2001 From: dzikoysk Date: Sun, 26 Jul 2026 19:57:23 +0200 Subject: [PATCH 33/43] chore: another round of cleanups --- docs/introduction/json-schema-setup.md | 58 ++++++- docs/introduction/setup.md | 70 +++++++- .../introspection/jap/JapTypeIntrospector.kt | 4 +- .../introspection/ksp/KspTypeIntrospector.kt | 15 +- .../runtime/ReflectionTypeIntrospector.kt | 19 ++- .../dynamic/hook/RegisteredRoutesHook.kt | 4 +- .../dynamic/hook/DynamicOpenApiHookTest.kt | 5 +- .../openapi/plugin/OpenApiConfiguration.kt | 4 +- .../javalin/openapi/plugin/OpenApiHandler.kt | 4 +- .../javalin/openapi/plugin/OpenApiPlugin.kt | 13 +- .../openapi/plugin/swagger/SwaggerHandler.kt | 32 ++-- .../plugin/swagger/SwaggerWebJarHandler.kt | 3 +- .../AnnotationProcessorContext.kt | 29 ++-- .../generators/JsonSchemaGenerator.kt | 6 +- .../processor/generators/OpenApiGenerator.kt | 64 +++++--- .../openapi/processor/CompositionTest.kt | 21 ++- .../io/javalin/openapi/dynamic/Validated.java | 7 + .../openapi/dynamic/DynamicCompositionTest.kt | 11 ++ .../openapi/experimental/OpenApiType.kt | 16 +- .../generators/CompositionGenerator.kt | 22 +-- .../processor/generators/ExampleGenerator.kt | 16 +- .../processor/generators/ResultScheme.kt | 8 + .../generators/TypeSchemaGenerator.kt | 38 +++-- .../openapi/schema/OpenApiRouteDefinition.kt | 25 ++- .../openapi/schema/OpenApiSchemaBuilder.kt | 152 +++++++++--------- .../openapi/schema/OpenApiSchemaGenerator.kt | 22 +-- .../schema/OpenApiSchemaBuilderTest.kt | 37 +++++ .../openapi/ksp/OpenApiSymbolProcessor.kt | 77 +++++---- .../openapi/ksp/OpenApiSymbolProcessorTest.kt | 73 ++++++++- 29 files changed, 606 insertions(+), 249 deletions(-) diff --git a/docs/introduction/json-schema-setup.md b/docs/introduction/json-schema-setup.md index 67761fee..0b700982 100644 --- a/docs/introduction/json-schema-setup.md +++ b/docs/introduction/json-schema-setup.md @@ -58,7 +58,7 @@ dependencies { } ``` -```xml [Maven] +```xml [Maven (Java)] io.javalin.community.openapi @@ -86,6 +86,62 @@ dependencies { ``` +```xml [Maven (Kotlin)] + + 2.1.0 + + + + + org.jetbrains.kotlin + kotlin-stdlib + ${kotlin.version} + + + io.javalin.community.openapi + openapi-specification + 7.3.0-RC.1 + + + + + + + org.jetbrains.kotlin + kotlin-maven-plugin + ${kotlin.version} + + + kapt + + kapt + + + + src/main/kotlin + + + + io.javalin.community.openapi + openapi-annotation-processor + 7.3.0-RC.1 + + + + + + compile + compile + + compile + + + + + + +``` + ::: ## Annotate Your Types diff --git a/docs/introduction/setup.md b/docs/introduction/setup.md index 0c1cc07f..5429dd1f 100644 --- a/docs/introduction/setup.md +++ b/docs/introduction/setup.md @@ -90,7 +90,7 @@ ksp { } ``` -```xml [Maven] +```xml [Maven (Java)] io.javalin.community.openapi @@ -130,6 +130,74 @@ ksp { ``` +```xml [Maven (Kotlin)] + + 2.1.0 + + + + + org.jetbrains.kotlin + kotlin-stdlib + ${kotlin.version} + + + io.javalin.community.openapi + javalin-openapi-plugin + 7.3.0-RC.1 + + + + io.javalin.community.openapi + javalin-swagger-plugin + 7.3.0-RC.1 + + + + io.javalin.community.openapi + javalin-redoc-plugin + 7.3.0-RC.1 + + + + + + + org.jetbrains.kotlin + kotlin-maven-plugin + ${kotlin.version} + + + kapt + + kapt + + + + src/main/kotlin + + + + io.javalin.community.openapi + openapi-annotation-processor + 7.3.0-RC.1 + + + + + + compile + compile + + compile + + + + + + +``` + ::: ## Choosing a Processor diff --git a/introspection/introspection-jap/src/main/kotlin/io/javalin/introspection/jap/JapTypeIntrospector.kt b/introspection/introspection-jap/src/main/kotlin/io/javalin/introspection/jap/JapTypeIntrospector.kt index c24f7c0f..6c92e3a9 100644 --- a/introspection/introspection-jap/src/main/kotlin/io/javalin/introspection/jap/JapTypeIntrospector.kt +++ b/introspection/introspection-jap/src/main/kotlin/io/javalin/introspection/jap/JapTypeIntrospector.kt @@ -354,7 +354,9 @@ class JapTypeIntrospector( Modifier.STATIC in modifiers -> false parameters.isNotEmpty() || enclosingElement.toString() == "java.lang.Object" -> false returnType.kind == TypeKind.VOID -> false - else -> isGetterName(simpleName.toString()) || annotationMirrors.any { it.annotationType.asElement().simpleName.contentEquals("OpenApiName") } + else -> + isGetterName(simpleName.toString()) || + annotationMirrors.any { it.annotationType.asElement().simpleName.contentEquals("OpenApiName") } } private fun Element.isInstanceField(): Boolean = diff --git a/introspection/introspection-ksp/src/main/kotlin/io/javalin/introspection/ksp/KspTypeIntrospector.kt b/introspection/introspection-ksp/src/main/kotlin/io/javalin/introspection/ksp/KspTypeIntrospector.kt index 301d9125..4219387f 100644 --- a/introspection/introspection-ksp/src/main/kotlin/io/javalin/introspection/ksp/KspTypeIntrospector.kt +++ b/introspection/introspection-ksp/src/main/kotlin/io/javalin/introspection/ksp/KspTypeIntrospector.kt @@ -122,9 +122,11 @@ class KspTypeIntrospector(private val resolver: Resolver) : CompileTimeIntrospec visitingTypeParameters = visitingTypeParameters, ) else -> { - val generics = type.arguments - .mapNotNull { it.type?.resolve() } - .map { resolve(it, visitingTypeParameters = visitingTypeParameters) } + val generics = + type + .arguments + .mapNotNull { it.type?.resolve() } + .map { resolve(it, visitingTypeParameters = visitingTypeParameters) } definition(type = type, structureType = structureType, generics = generics) } } @@ -239,9 +241,10 @@ class KspTypeIntrospector(private val resolver: Resolver) : CompileTimeIntrospec override fun findAll(type: Class): List { val annotations = annotations() - val direct = annotations - .filter { it.named(type) } - .map { KspAnnotationProjection(it) } + val direct = + annotations + .filter { it.named(type) } + .map { KspAnnotationProjection(it) } val containerName = type.getAnnotation(JavaRepeatable::class.java)?.value?.java?.canonicalName val repeated = containerName diff --git a/introspection/introspection-runtime/src/main/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospector.kt b/introspection/introspection-runtime/src/main/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospector.kt index d6a1dbf7..83362a5b 100644 --- a/introspection/introspection-runtime/src/main/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospector.kt +++ b/introspection/introspection-runtime/src/main/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospector.kt @@ -94,7 +94,13 @@ private fun raw( structureType = ARRAY, visitingTypeVariables = visitingTypeVariables, ) - clazz.isPrimitive -> definition(erasure = clazz.kotlin.javaObjectType, generics = generics, structureType = structureType) + clazz.isPrimitive -> + definition( + erasure = clazz.kotlin.javaObjectType, + generics = generics, + structureType = structureType, + source = clazz, + ) Map::class.java.isAssignableFrom(clazz) -> definition( erasure = clazz, @@ -139,13 +145,19 @@ private fun parameterized( } } -private fun definition(erasure: Class<*>, generics: List, structureType: StructureType): ClassDefinition = +private fun definition( + erasure: Class<*>, + generics: List, + structureType: StructureType, + source: Class<*> = erasure, +): ClassDefinition = ReflectionClassDefinition( simpleName = erasure.simpleName.ifEmpty { erasure.name.substringAfterLast('.') }, fullName = erasure.canonicalName ?: erasure.name, generics = generics, structureType = structureType, erasure = erasure, + sourceType = source, ) private fun objectDefinition(structureType: StructureType = DEFAULT): ClassDefinition = @@ -157,6 +169,7 @@ private class ReflectionClassDefinition( generics: List, structureType: StructureType, private val erasure: Class<*>, + private val sourceType: Class<*>, ) : ClassDefinition( simpleName = simpleName, fullName = fullName, @@ -165,7 +178,7 @@ private class ReflectionClassDefinition( ) { @InternalIntrospectionApi - override val source: Any = erasure + override val source: Any = sourceType override fun isEnum(): Boolean = erasure.isEnum diff --git a/javalin-plugins/javalin-openapi-dynamic-hook/src/main/kotlin/io/javalin/openapi/dynamic/hook/RegisteredRoutesHook.kt b/javalin-plugins/javalin-openapi-dynamic-hook/src/main/kotlin/io/javalin/openapi/dynamic/hook/RegisteredRoutesHook.kt index 27ac7e9c..5aeb6f6c 100644 --- a/javalin-plugins/javalin-openapi-dynamic-hook/src/main/kotlin/io/javalin/openapi/dynamic/hook/RegisteredRoutesHook.kt +++ b/javalin-plugins/javalin-openapi-dynamic-hook/src/main/kotlin/io/javalin/openapi/dynamic/hook/RegisteredRoutesHook.kt @@ -12,7 +12,7 @@ class RegisteredRoutesHookConfiguration { private val ignoredPathPrefixes = linkedSetOf() fun clearDefaultIgnoredRoutes(): RegisteredRoutesHookConfiguration = apply { - ignoreDefaultRoutes = false + this.ignoreDefaultRoutes = false } fun withIgnoredPathPrefix(prefix: String): RegisteredRoutesHookConfiguration = @@ -46,7 +46,7 @@ class RegisteredRoutesHook @JvmOverloads constructor( override fun apply(context: OpenApiHookContext) { val schemaContext = ReflectionSchemaContext() // document-scoped: owns the generator + its memo cache - context.builder.openApiVersion("3.1.0") + context.builder.openApiVersion("3.1.0").ensureInfo() for (handler in context.state.internalRouter.allHttpHandlers()) { val endpoint = handler.endpoint diff --git a/javalin-plugins/javalin-openapi-dynamic-hook/src/test/kotlin/io/javalin/openapi/dynamic/hook/DynamicOpenApiHookTest.kt b/javalin-plugins/javalin-openapi-dynamic-hook/src/test/kotlin/io/javalin/openapi/dynamic/hook/DynamicOpenApiHookTest.kt index 851d230a..35395442 100644 --- a/javalin-plugins/javalin-openapi-dynamic-hook/src/test/kotlin/io/javalin/openapi/dynamic/hook/DynamicOpenApiHookTest.kt +++ b/javalin-plugins/javalin-openapi-dynamic-hook/src/test/kotlin/io/javalin/openapi/dynamic/hook/DynamicOpenApiHookTest.kt @@ -25,8 +25,11 @@ class DynamicOpenApiHookTest { try { val body = Unirest.get("http://localhost:${app.port()}/openapi").asString().body - val paths = jsonMapper.readTree(body).path("paths") + val document = jsonMapper.readTree(body) + val paths = document.path("paths") + assertThat(document.path("info").has("title")).isTrue() + assertThat(document.path("info").has("version")).isTrue() assertThat(paths.has("/openapi")).isFalse() assertThat(paths.path("/users").has("get")).isTrue() assertThat(paths.path("/users").has("post")).isTrue() diff --git a/javalin-plugins/javalin-openapi-plugin/src/main/kotlin/io/javalin/openapi/plugin/OpenApiConfiguration.kt b/javalin-plugins/javalin-openapi-plugin/src/main/kotlin/io/javalin/openapi/plugin/OpenApiConfiguration.kt index 52617a2f..ff05f969 100644 --- a/javalin-plugins/javalin-openapi-plugin/src/main/kotlin/io/javalin/openapi/plugin/OpenApiConfiguration.kt +++ b/javalin-plugins/javalin-openapi-plugin/src/main/kotlin/io/javalin/openapi/plugin/OpenApiConfiguration.kt @@ -58,7 +58,9 @@ class OpenApiPluginConfiguration @JvmOverloads constructor( } /** Dynamically apply custom changes to generated OpenApi specifications */ - fun withDefinitionConfiguration(definitionConfigurationConfigurer: BiConsumer): OpenApiPluginConfiguration = apply { + fun withDefinitionConfiguration( + definitionConfigurationConfigurer: BiConsumer, + ): OpenApiPluginConfiguration = apply { this.definitionConfiguration = definitionConfigurationConfigurer } diff --git a/javalin-plugins/javalin-openapi-plugin/src/main/kotlin/io/javalin/openapi/plugin/OpenApiHandler.kt b/javalin-plugins/javalin-openapi-plugin/src/main/kotlin/io/javalin/openapi/plugin/OpenApiHandler.kt index 1e0d9a0e..7f5c28ac 100644 --- a/javalin-plugins/javalin-openapi-plugin/src/main/kotlin/io/javalin/openapi/plugin/OpenApiHandler.kt +++ b/javalin-plugins/javalin-openapi-plugin/src/main/kotlin/io/javalin/openapi/plugin/OpenApiHandler.kt @@ -9,11 +9,13 @@ import io.javalin.openapi.OpenApiPluginRouteHandler internal class OpenApiHandler(private val documentation: Lazy>) : Handler, OpenApiPluginRouteHandler { override fun handle(context: Context) { + val version = context.queryParamMap()["v"]?.firstOrNull() ?: "default" + context .header(Header.ACCESS_CONTROL_ALLOW_ORIGIN, "*") .header(Header.ACCESS_CONTROL_ALLOW_METHODS, "GET") .contentType(ContentType.JSON) - .result(documentation.value[context.queryParamMap()["v"]?.firstOrNull() ?: "default"] ?: "{}") + .result(documentation.value[version] ?: "{}") } } diff --git a/javalin-plugins/javalin-openapi-plugin/src/main/kotlin/io/javalin/openapi/plugin/OpenApiPlugin.kt b/javalin-plugins/javalin-openapi-plugin/src/main/kotlin/io/javalin/openapi/plugin/OpenApiPlugin.kt index 9e1e4b2e..21d92325 100644 --- a/javalin-plugins/javalin-openapi-plugin/src/main/kotlin/io/javalin/openapi/plugin/OpenApiPlugin.kt +++ b/javalin-plugins/javalin-openapi-plugin/src/main/kotlin/io/javalin/openapi/plugin/OpenApiPlugin.kt @@ -8,7 +8,9 @@ import io.javalin.openapi.schema.OpenApiSchemaBuilder import io.javalin.plugin.Plugin import java.util.function.Consumer -open class OpenApiPlugin(userConfig: Consumer) : Plugin(userConfig, OpenApiPluginConfiguration()) { +open class OpenApiPlugin( + userConfig: Consumer, +) : Plugin(userConfig, OpenApiPluginConfiguration()) { override fun repeatable(): Boolean = true @@ -30,11 +32,10 @@ open class OpenApiPlugin(userConfig: Consumer) : Plu pluginConfig.hooks.forEach { it.apply(context) } pluginConfig.definitionConfiguration?.accept(version, builder) - val json = - when { - pluginConfig.prettyOutputEnabled -> builder.toJson() - else -> builder.toCompactJson() - } + val json = when { + pluginConfig.prettyOutputEnabled -> builder.toJson() + else -> builder.toCompactJson() + } when (val processor = pluginConfig.definitionProcessor) { null -> json diff --git a/javalin-plugins/javalin-swagger-plugin/src/main/kotlin/io/javalin/openapi/plugin/swagger/SwaggerHandler.kt b/javalin-plugins/javalin-swagger-plugin/src/main/kotlin/io/javalin/openapi/plugin/swagger/SwaggerHandler.kt index 1960b9ab..b44ca027 100644 --- a/javalin-plugins/javalin-swagger-plugin/src/main/kotlin/io/javalin/openapi/plugin/swagger/SwaggerHandler.kt +++ b/javalin-plugins/javalin-swagger-plugin/src/main/kotlin/io/javalin/openapi/plugin/swagger/SwaggerHandler.kt @@ -57,22 +57,24 @@ class SwaggerHandler( val publicSwaggerAssetsPath = "$rootPath/webjars/swagger-ui/$swaggerVersion".removedDoubledPathOperators() val publicDocumentationPath = (rootPath + documentationPath).removedDoubledPathOperators() - val allDocumentations = versions - .joinToString(separator = ",\n") { - when (it) { - is SwaggerVersionMapping.OpenApiLoader -> "{ name: '${it.name}', url: '$publicDocumentationPath?v=${it.name}' }" - is SwaggerVersionMapping.Custom -> "{ name: '${it.name}', url: '${it.url}' }" + val allDocumentations = + versions + .joinToString(separator = ",\n") { + when (it) { + is SwaggerVersionMapping.OpenApiLoader -> "{ name: '${it.name}', url: '$publicDocumentationPath?v=${it.name}' }" + is SwaggerVersionMapping.Custom -> "{ name: '${it.name}', url: '${it.url}' }" + } } - } - val allCustomStylesheets = customStylesheetFiles - .joinToString(separator = "\n") { "" } - val allCustomJavaScripts = customJavaScriptFiles - .joinToString(separator = "\n") { "" } val resolvedValidatorUrl = when { validatorUrl != null -> "\"$validatorUrl\"" else -> "null" diff --git a/javalin-plugins/javalin-swagger-plugin/src/test/kotlin/io/javalin/openapi/plugin/swagger/SwaggerPluginTest.kt b/javalin-plugins/javalin-swagger-plugin/src/test/kotlin/io/javalin/openapi/plugin/swagger/SwaggerPluginTest.kt index 744952e0..9aa0a9cb 100644 --- a/javalin-plugins/javalin-swagger-plugin/src/test/kotlin/io/javalin/openapi/plugin/swagger/SwaggerPluginTest.kt +++ b/javalin-plugins/javalin-swagger-plugin/src/test/kotlin/io/javalin/openapi/plugin/swagger/SwaggerPluginTest.kt @@ -65,7 +65,7 @@ internal class SwaggerPluginTest { assertThat(response).contains("""link href='/swagger.css' rel='stylesheet' media='screen' type='text/css'""") assertThat(response).contains("""link href='/swagger-the-print.css' rel='stylesheet' media='print' type='text/css'""") - assertThat(response).contains("""script src='/script.js' type='text/javascript'""") + assertThat(response).contains("""""") assertThat(response).contains("{ name: 'custom', url: '/openapi.yaml' }") } finally { app.stop() From a885fffe1b0f39da56dfe1c3e96d43f8acaea43f Mon Sep 17 00:00:00 2001 From: dzikoysk Date: Sat, 1 Aug 2026 22:26:12 +0200 Subject: [PATCH 38/43] chore: one more cleanup I promise --- .gitignore | 1 + docs/introduction/json-schema-setup.md | 24 ++--- docs/introduction/setup.md | 48 +++------- .../introspection/ksp/KspTypeIntrospector.kt | 2 - .../ksp/KspTypeIntrospectorTest.kt | 80 ++++++++++------- .../runtime/ReflectionTypeIntrospectorTest.kt | 32 ++++++- .../test/sub/PackagePrivateBase.java | 1 - .../test/AnnotationProcessing.kt | 8 -- .../test/IntrospectionParityTest.kt | 1 - .../dynamic/hook/DynamicOpenApiHookTest.kt | 2 +- .../src/test/kotlin/OpenApiPluginTest.kt | 4 +- .../plugin/swagger/SwaggerPluginTest.kt | 57 +++++++----- .../src/test/compile/openapi.groovy | 13 +-- .../openapi/processor/CompositionTest.kt | 4 - .../openapi/processor/NamingStrategyTest.kt | 4 - .../openapi/processor/TypeMappersTest.kt | 8 +- .../io/javalin/openapi/dynamic/NotNull.java | 1 - .../io/javalin/openapi/dynamic/Shape.java | 1 - .../dynamic/DynamicSchemaGeneratorTest.kt | 90 ++++++++++++++++--- .../dynamic/ReflectionSchemaContextTest.kt | 56 ++++++++++-- .../schema/OpenApiSchemaBuilderTest.kt | 5 -- 21 files changed, 261 insertions(+), 181 deletions(-) diff --git a/.gitignore b/.gitignore index d9af3164..2fe296bf 100644 --- a/.gitignore +++ b/.gitignore @@ -145,3 +145,4 @@ gradle-app.setting **/build/ # End of https://www.toptal.com/developers/gitignore/api/gradle,java,maven,intellij +.gstack/ diff --git a/docs/introduction/json-schema-setup.md b/docs/introduction/json-schema-setup.md index 0b700982..96dde8f1 100644 --- a/docs/introduction/json-schema-setup.md +++ b/docs/introduction/json-schema-setup.md @@ -18,12 +18,8 @@ You only need the annotation processor and the specification module - no Javalin dependencies { val openapi = "7.3.0-RC.1" - annotationProcessor( - "io.javalin.community.openapi:openapi-annotation-processor:$openapi" - ) - implementation( - "io.javalin.community.openapi:openapi-specification:$openapi" - ) + annotationProcessor("io.javalin.community.openapi:openapi-annotation-processor:$openapi") + implementation("io.javalin.community.openapi:openapi-specification:$openapi") } ``` @@ -31,12 +27,8 @@ dependencies { dependencies { val openapi = "7.3.0-RC.1" - kapt( - "io.javalin.community.openapi:openapi-annotation-processor:$openapi" - ) - implementation( - "io.javalin.community.openapi:openapi-specification:$openapi" - ) + kapt("io.javalin.community.openapi:openapi-annotation-processor:$openapi") + implementation("io.javalin.community.openapi:openapi-specification:$openapi") } ``` @@ -49,12 +41,8 @@ plugins { dependencies { val openapi = "7.3.0-RC.1" - ksp( - "io.javalin.community.openapi:openapi-ksp:$openapi" - ) - implementation( - "io.javalin.community.openapi:openapi-specification:$openapi" - ) + ksp("io.javalin.community.openapi:openapi-ksp:$openapi") + implementation("io.javalin.community.openapi:openapi-specification:$openapi") } ``` diff --git a/docs/introduction/setup.md b/docs/introduction/setup.md index 5429dd1f..8613ff87 100644 --- a/docs/introduction/setup.md +++ b/docs/introduction/setup.md @@ -21,20 +21,12 @@ repositories { dependencies { val openapi = "7.3.0-RC.1" - annotationProcessor( - "io.javalin.community.openapi:openapi-annotation-processor:$openapi" - ) - implementation( - "io.javalin.community.openapi:javalin-openapi-plugin:$openapi" - ) + annotationProcessor("io.javalin.community.openapi:openapi-annotation-processor:$openapi") + implementation("io.javalin.community.openapi:javalin-openapi-plugin:$openapi") // Optional: Swagger UI - implementation( - "io.javalin.community.openapi:javalin-swagger-plugin:$openapi" - ) + implementation("io.javalin.community.openapi:javalin-swagger-plugin:$openapi") // Optional: ReDoc - implementation( - "io.javalin.community.openapi:javalin-redoc-plugin:$openapi" - ) + implementation("io.javalin.community.openapi:javalin-redoc-plugin:$openapi") } ``` @@ -46,18 +38,10 @@ plugins { dependencies { val openapi = "7.3.0-RC.1" - kapt( - "io.javalin.community.openapi:openapi-annotation-processor:$openapi" - ) - implementation( - "io.javalin.community.openapi:javalin-openapi-plugin:$openapi" - ) - implementation( - "io.javalin.community.openapi:javalin-swagger-plugin:$openapi" - ) - implementation( - "io.javalin.community.openapi:javalin-redoc-plugin:$openapi" - ) + kapt("io.javalin.community.openapi:openapi-annotation-processor:$openapi") + implementation("io.javalin.community.openapi:javalin-openapi-plugin:$openapi") + implementation("io.javalin.community.openapi:javalin-swagger-plugin:$openapi") + implementation("io.javalin.community.openapi:javalin-redoc-plugin:$openapi") } ``` @@ -70,18 +54,10 @@ plugins { dependencies { val openapi = "7.3.0-RC.1" - ksp( - "io.javalin.community.openapi:openapi-ksp:$openapi" - ) - implementation( - "io.javalin.community.openapi:javalin-openapi-plugin:$openapi" - ) - implementation( - "io.javalin.community.openapi:javalin-swagger-plugin:$openapi" - ) - implementation( - "io.javalin.community.openapi:javalin-redoc-plugin:$openapi" - ) + ksp("io.javalin.community.openapi:openapi-ksp:$openapi") + implementation("io.javalin.community.openapi:javalin-openapi-plugin:$openapi") + implementation("io.javalin.community.openapi:javalin-swagger-plugin:$openapi") + implementation("io.javalin.community.openapi:javalin-redoc-plugin:$openapi") } ksp { diff --git a/introspection/introspection-ksp/src/main/kotlin/io/javalin/introspection/ksp/KspTypeIntrospector.kt b/introspection/introspection-ksp/src/main/kotlin/io/javalin/introspection/ksp/KspTypeIntrospector.kt index 1f3afe47..26d77a45 100644 --- a/introspection/introspection-ksp/src/main/kotlin/io/javalin/introspection/ksp/KspTypeIntrospector.kt +++ b/introspection/introspection-ksp/src/main/kotlin/io/javalin/introspection/ksp/KspTypeIntrospector.kt @@ -29,8 +29,6 @@ import java.lang.annotation.Inherited import java.lang.annotation.Repeatable as JavaRepeatable import com.google.devtools.ksp.symbol.Visibility as KspVisibility -// KSP can't materialize JVM annotation instances (annotation access is name/value only) and names builtins with -// Kotlin FQNs (`kotlin.Int`), which `canonicalName` normalizes to their JVM equivalents to match jap/reflection. class KspTypeIntrospector(private val resolver: Resolver) : CompileTimeIntrospector { private val mapType = builtin("kotlin.collections.Map") diff --git a/introspection/introspection-ksp/src/test/kotlin/io/javalin/introspection/ksp/KspTypeIntrospectorTest.kt b/introspection/introspection-ksp/src/test/kotlin/io/javalin/introspection/ksp/KspTypeIntrospectorTest.kt index 293070b5..51bdd9da 100644 --- a/introspection/introspection-ksp/src/test/kotlin/io/javalin/introspection/ksp/KspTypeIntrospectorTest.kt +++ b/introspection/introspection-ksp/src/test/kotlin/io/javalin/introspection/ksp/KspTypeIntrospectorTest.kt @@ -41,32 +41,16 @@ class KspTypeIntrospectorTest { } @Test - fun `KSP backend resolves structure, enums and name-based annotations`() { - data class Snapshot( - val properties: Map, - val isEnum: Boolean, - val enumConstants: List?, - val hasRef: Boolean, - val refValue: String?, - val scannedLabel: String?, - ) - - val snapshot = withKsp { ksp -> - val pkg = "io.javalin.introspection.ksp" - val account = ksp.introspect("$pkg.Account") - val color = ksp.introspect("$pkg.Color") - Snapshot( - properties = account.getProperties().associate { it.name to "${it.type.fullName}:${it.type.structureType}" }, - isEnum = color.isEnum(), - enumConstants = color.getEnumConstants().map { it.name }, - hasRef = account.getAnnotations().contains("Ref"), - refValue = account.getAnnotations().find(Ref::class.java)?.get("value")?.asClassDefinition()?.fullName, - scannedLabel = account.getAnnotations().all().first { it.metadata.contains("MetaMarker") }["label"].asString(), - ) - } - - // Structure + collection/map/nullability resolve, and Kotlin builtins normalize to JVM names (== jap/reflection) - assertThat(snapshot.properties).containsAllEntriesOf( + fun `KSP backend resolves property types`() { + val properties = + withKsp { ksp -> + ksp + .introspect("$REF_PKG.Account") + .getProperties() + .associate { property -> property.name to "${property.type.fullName}:${property.type.structureType}" } + } + + assertThat(properties).containsAllEntriesOf( mapOf( "id" to "java.lang.String:DEFAULT", "age" to "java.lang.Integer:DEFAULT", @@ -76,17 +60,45 @@ class KspTypeIntrospectorTest { "meta" to "java.util.Map:DICTIONARY", ) ) + } - // Enum - assertThat(snapshot.isEnum).isTrue() - assertThat(snapshot.enumConstants).containsExactly("RED", "GREEN") + @Test + fun `KSP backend resolves enum constants`() { + val color = + withKsp { ksp -> + ksp.introspect("$REF_PKG.Color") + } + + assertThat(color.isEnum()).isTrue() + assertThat(color.getEnumConstants().map { it.name }).containsExactly("RED", "GREEN") + } - // Name-based annotation access (the only kind KSP can do): presence + class-valued member resolved to a ClassDefinition - assertThat(snapshot.hasRef).isTrue() - assertThat(snapshot.refValue).isEqualTo("$REF_PKG.Address") + @Test + fun `KSP backend reads annotations by name`() { + val annotations = + withKsp { ksp -> + ksp + .introspect("$REF_PKG.Account") + .getAnnotations() + } + + assertThat(annotations.contains("Ref")).isTrue() + assertThat(annotations.find(Ref::class.java)?.get("value")?.asClassDefinition()?.fullName) + .isEqualTo("$REF_PKG.Address") + } - // Enumerate-all + meta-annotation scan (the custom-annotation path) works on KSP too - assertThat(snapshot.scannedLabel).isEqualTo("x") + @Test + fun `KSP backend finds annotations by meta-annotation`() { + val annotated = + withKsp { ksp -> + ksp + .introspect("$REF_PKG.Account") + .getAnnotations() + .all() + .first { it.metadata.contains("MetaMarker") } + } + + assertThat(annotated["label"].asString()).isEqualTo("x") } private companion object { diff --git a/introspection/introspection-runtime/src/test/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospectorTest.kt b/introspection/introspection-runtime/src/test/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospectorTest.kt index 39a34a3d..98cc1e28 100644 --- a/introspection/introspection-runtime/src/test/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospectorTest.kt +++ b/introspection/introspection-runtime/src/test/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospectorTest.kt @@ -14,6 +14,9 @@ class ReflectionTypeIntrospectorTest { private fun props(type: Class<*>): Map = introspector.introspect(type).getProperties().associateBy { it.name } + private fun accountProperties(): Map = + props(Account::class.java) + @Test fun `resolves a class into the shared model`() { val account = introspector.introspect(Account::class.java) @@ -23,27 +26,48 @@ class ReflectionTypeIntrospectorTest { } @Test - fun `exposes getter members under logical names with boxed primitives and structural nullability`() { - val props = props(Account::class.java) + fun `exposes getter members under logical names`() { + val props = accountProperties() assertThat(props.keys).contains("id", "age", "name", "color", "address", "tags", "meta") assertThat(props.getValue("id").accessor).isEqualTo(Accessor.GETTER) + } + + @Test + fun `boxes primitive getter types`() { + val props = accountProperties() assertThat(props.getValue("age").type.fullName).isEqualTo("java.lang.Integer") + } + + @Test + fun `reads getter nullability`() { + val props = accountProperties() + assertThat(props.getValue("age").nullable).isFalse() assertThat(props.getValue("name").nullable).isTrue() } @Test - fun `resolves collections, maps and nested types`() { - val props = props(Account::class.java) + fun `resolves collection types`() { + val props = accountProperties() assertThat(props.getValue("tags").type.structureType).isEqualTo(StructureType.ARRAY) assertThat(props.getValue("tags").type.fullName).isEqualTo("java.lang.String") + } + + @Test + fun `resolves map types`() { + val props = accountProperties() val meta = props.getValue("meta").type assertThat(meta.structureType).isEqualTo(StructureType.DICTIONARY) assertThat(meta.generics.map { it.fullName }).containsExactly("java.lang.String", "java.lang.Integer") + } + + @Test + fun `resolves nested types`() { + val props = accountProperties() assertThat(props.getValue("address").type.fullName).isEqualTo(Address::class.java.name) } diff --git a/introspection/introspection-test/src/test/java/io/javalin/introspection/test/sub/PackagePrivateBase.java b/introspection/introspection-test/src/test/java/io/javalin/introspection/test/sub/PackagePrivateBase.java index 31507eda..92794b92 100644 --- a/introspection/introspection-test/src/test/java/io/javalin/introspection/test/sub/PackagePrivateBase.java +++ b/introspection/introspection-test/src/test/java/io/javalin/introspection/test/sub/PackagePrivateBase.java @@ -1,6 +1,5 @@ package io.javalin.introspection.test.sub; -/** Has a package-private field (not inherited across packages) and a public one (inherited everywhere). */ public class PackagePrivateBase { String packagePrivateField = ""; public String publicField = ""; diff --git a/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/AnnotationProcessing.kt b/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/AnnotationProcessing.kt index dfdc00db..20e91003 100644 --- a/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/AnnotationProcessing.kt +++ b/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/AnnotationProcessing.kt @@ -12,14 +12,6 @@ import javax.tools.SimpleJavaFileObject import javax.tools.ToolProvider import kotlin.reflect.KClass -/** - * Runs the annotation-processing backend inside a real in-process `javac` run, so it can be tested like reflection. - * - * The backend reads types via `javax.lang.model`, which the compiler only exposes to a running processor - there is - * no such environment at plain runtime. So [introspect] compiles a throwaway source with a processor attached, - * resolves [type] from inside it, and passes the result to [block]. The compiler discards its model once the run - * ends, so [block] must finish all introspection (e.g. [ClassDefinition.getProperties]) before returning. - */ object AnnotationProcessing { fun introspect(type: KClass<*>, block: (ClassDefinition) -> R): R { diff --git a/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/IntrospectionParityTest.kt b/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/IntrospectionParityTest.kt index a6c5e037..2bd801de 100644 --- a/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/IntrospectionParityTest.kt +++ b/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/IntrospectionParityTest.kt @@ -209,7 +209,6 @@ private data class PropertyShape( val transient: Boolean, ) -/** Normalized, order-independent structural view of a type, so the two backends can be compared by value. */ private fun ClassDefinition.toShape(): TypeShape = TypeShape( fullName = fullName, diff --git a/javalin-plugins/javalin-openapi-dynamic-hook/src/test/kotlin/io/javalin/openapi/dynamic/hook/DynamicOpenApiHookTest.kt b/javalin-plugins/javalin-openapi-dynamic-hook/src/test/kotlin/io/javalin/openapi/dynamic/hook/DynamicOpenApiHookTest.kt index 35395442..e1e3535c 100644 --- a/javalin-plugins/javalin-openapi-dynamic-hook/src/test/kotlin/io/javalin/openapi/dynamic/hook/DynamicOpenApiHookTest.kt +++ b/javalin-plugins/javalin-openapi-dynamic-hook/src/test/kotlin/io/javalin/openapi/dynamic/hook/DynamicOpenApiHookTest.kt @@ -123,7 +123,7 @@ class DynamicOpenApiHookTest { } @Test - fun `enriches a route from OpenApiMetadata, reusing the operation builder and schema engine`() { + fun `enriches a route from OpenApiMetadata`() { val app = Javalin.start { config -> config.jetty.port = 0 config.registerPlugin(OpenApiPlugin { it.withHook(RegisteredRoutesHook()) }) diff --git a/javalin-plugins/javalin-openapi-plugin/src/test/kotlin/OpenApiPluginTest.kt b/javalin-plugins/javalin-openapi-plugin/src/test/kotlin/OpenApiPluginTest.kt index da454dcf..66467532 100644 --- a/javalin-plugins/javalin-openapi-plugin/src/test/kotlin/OpenApiPluginTest.kt +++ b/javalin-plugins/javalin-openapi-plugin/src/test/kotlin/OpenApiPluginTest.kt @@ -51,9 +51,7 @@ class OpenApiPluginTest { config.registerPlugin( OpenApiPlugin { - it.withDefinitionConfiguration { _, _ -> - /* do nothing */ - } + it.withDefinitionConfiguration { _, _ -> } } ) } diff --git a/javalin-plugins/javalin-swagger-plugin/src/test/kotlin/io/javalin/openapi/plugin/swagger/SwaggerPluginTest.kt b/javalin-plugins/javalin-swagger-plugin/src/test/kotlin/io/javalin/openapi/plugin/swagger/SwaggerPluginTest.kt index 9aa0a9cb..c0e5c79e 100644 --- a/javalin-plugins/javalin-swagger-plugin/src/test/kotlin/io/javalin/openapi/plugin/swagger/SwaggerPluginTest.kt +++ b/javalin-plugins/javalin-swagger-plugin/src/test/kotlin/io/javalin/openapi/plugin/swagger/SwaggerPluginTest.kt @@ -7,6 +7,21 @@ import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test internal class SwaggerPluginTest { + + private fun swaggerPage(configure: SwaggerConfiguration.() -> Unit): String { + val app = Javalin.start { + it.jetty.port = 0 + it.registerPlugin(SwaggerPlugin { swagger -> swagger.configure() }) + } + + return try { + Unirest.get("http://localhost:${app.port()}/swagger") + .asString() + .body + } finally { + app.stop() + } + } @Test fun `should properly host swagger ui`() { val app = Javalin.start { @@ -46,30 +61,32 @@ internal class SwaggerPluginTest { } @Test - fun `should have custom version, css and js injected`() { - val app = Javalin.start { - it.jetty.port = 0 - it.registerPlugin(SwaggerPlugin { swagger -> - swagger - .injectStylesheet("/swagger.css") - .injectStylesheet("/swagger-the-print.css", "print") - .injectJavaScript("/script.js") - .injectCustomVersion("custom", "/openapi.yaml") - }) + fun `injects custom stylesheets`() { + val response = swaggerPage { + injectStylesheet("/swagger.css") + injectStylesheet("/swagger-the-print.css", "print") } - try { - val response = Unirest.get("http://localhost:${app.port()}/swagger") - .asString() - .body + assertThat(response).contains("""link href='/swagger.css' rel='stylesheet' media='screen' type='text/css'""") + assertThat(response).contains("""link href='/swagger-the-print.css' rel='stylesheet' media='print' type='text/css'""") + } - assertThat(response).contains("""link href='/swagger.css' rel='stylesheet' media='screen' type='text/css'""") - assertThat(response).contains("""link href='/swagger-the-print.css' rel='stylesheet' media='print' type='text/css'""") - assertThat(response).contains("""""") - assertThat(response).contains("{ name: 'custom', url: '/openapi.yaml' }") - } finally { - app.stop() + @Test + fun `injects custom JavaScript`() { + val response = swaggerPage { + injectJavaScript("/script.js") } + + assertThat(response).contains("""""") + } + + @Test + fun `injects custom documentation versions`() { + val response = swaggerPage { + injectCustomVersion("custom", "/openapi.yaml") + } + + assertThat(response).contains("{ name: 'custom', url: '/openapi.yaml' }") } @Test diff --git a/openapi-annotation-processor/src/test/compile/openapi.groovy b/openapi-annotation-processor/src/test/compile/openapi.groovy index 089acdb5..6156eb91 100644 --- a/openapi-annotation-processor/src/test/compile/openapi.groovy +++ b/openapi-annotation-processor/src/test/compile/openapi.groovy @@ -17,26 +17,17 @@ class OpenApiConfiguration implements OpenApiAnnotationProcessorConfigurer { void configure(OpenApiAnnotationProcessorConfiguration configuration) { configuration.validateWithParser = false - // Used by TypeMappersTest configuration.simpleTypeMappings['io.javalin.openapi.processor.TypeMappersTest.CustomType'] = new SimpleType("string") - // Used by PropertySelectionTest configuration.propertyInSchemeFilter = { AnnotationProcessorContext ctx, OpenApiType type, Element property -> TypeElement filteredRecord = ctx.forTypeElement('io.javalin.openapi.processor.PropertySelectionTest.FilteredRecord') TypeElement filteredRecordBase = ctx.forTypeElement('io.javalin.openapi.processor.PropertySelectionTest.FilteredRecordBase') - if (ctx.isAssignable(OpenApiTypeHandleKt.getMirror(type), filteredRecord.asType()) && ctx.hasElement(filteredRecord, property)) { - return false // exclude + return [filteredRecord, filteredRecordBase].every { filteredType -> + !ctx.isAssignable(OpenApiTypeHandleKt.getMirror(type), filteredType.asType()) || !ctx.hasElement(filteredType, property) } - - if (ctx.isAssignable(OpenApiTypeHandleKt.getMirror(type), filteredRecordBase.asType()) && ctx.hasElement(filteredRecordBase, property)) { - return false // exclude - } - - return true // include } - // Used by CustomTypeMappingsTest - unwrap AtomicReference to T configuration.insertEmbeddedTypeProcessor({ EmbeddedTypeProcessorContext context -> if (context.type.simpleName == 'AtomicReference' && context.type.generics.size() == 1) { context.parentContext.typeSchemaGenerator.addType(context.scheme, context.type.generics[0], context.inlineRefs, context.references, false) diff --git a/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/CompositionTest.kt b/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/CompositionTest.kt index dde50440..e3eaf123 100644 --- a/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/CompositionTest.kt +++ b/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/CompositionTest.kt @@ -79,8 +79,6 @@ internal class CompositionTest : OpenApiAnnotationProcessorSpecification() { """)) } - // Nullable composition types - @JsonSchema class NullableOneOfConfig( @get:OpenApiNullable @@ -226,8 +224,6 @@ internal class CompositionTest : OpenApiAnnotationProcessorSpecification() { """)) } - // Discriminator tests - @OneOf( discriminator = Discriminator( property = DiscriminatorProperty( diff --git a/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/NamingStrategyTest.kt b/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/NamingStrategyTest.kt index 570694c8..a80cc707 100644 --- a/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/NamingStrategyTest.kt +++ b/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/NamingStrategyTest.kt @@ -86,8 +86,6 @@ internal class NamingStrategyTest : OpenApiAnnotationProcessorSpecification() { .doesNotContainKey("lastName") } - // Enum naming tests - @OpenApiNaming(OpenApiNamingStrategy.SNAKE_CASE) private enum class SnakeCaseEnum { MyValue, @@ -148,8 +146,6 @@ internal class NamingStrategyTest : OpenApiAnnotationProcessorSpecification() { .isEqualTo(json("""["my-value", "customName"]""")) } - // Property naming override tests - @OpenApiNaming(OpenApiNamingStrategy.SNAKE_CASE) private class NamingWithOverrideEntity( val firstName: String, diff --git a/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/TypeMappersTest.kt b/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/TypeMappersTest.kt index 1e56e0bb..3edfa491 100644 --- a/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/TypeMappersTest.kt +++ b/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/TypeMappersTest.kt @@ -25,7 +25,7 @@ import java.util.UUID internal class TypeMappersTest : OpenApiAnnotationProcessorSpecification() { - class CustomType // mapped by openapi.groovy + class CustomType enum class StandardEnum { VALUE_1, @@ -100,7 +100,6 @@ internal class TypeMappersTest : OpenApiAnnotationProcessorSpecification() { .inPath("$.components.schemas.SimpleTypesList.properties") .isObject .isEqualTo(json( - // language=json """ { "customType": { @@ -281,7 +280,6 @@ internal class TypeMappersTest : OpenApiAnnotationProcessorSpecification() { .inPath("$.paths['/dictionary-structure'].get.responses.200.content['application/map-string-string'].schema") .isObject .isEqualTo(json( - // language=json """ { "type": "object", @@ -335,7 +333,6 @@ internal class TypeMappersTest : OpenApiAnnotationProcessorSpecification() { .inPath("$.paths['/nested-list-example'].get.responses.200.content['text/plain'].example") .isObject .isEqualTo(json( - // language=json """ { "name": "document", @@ -406,7 +403,6 @@ internal class TypeMappersTest : OpenApiAnnotationProcessorSpecification() { .inPath("$.components.schemas.IntegerEnum") .isObject .isEqualTo(json( - // language=json """ { "type": "integer", @@ -440,7 +436,6 @@ internal class TypeMappersTest : OpenApiAnnotationProcessorSpecification() { .inPath("$.components.schemas.XEnumDescriptionsEnum") .isObject .isEqualTo(json( - // language=json """ { "type": "string", @@ -468,7 +463,6 @@ internal class TypeMappersTest : OpenApiAnnotationProcessorSpecification() { .inPath("$.components.schemas.DescribedStringEnum") .isObject .isEqualTo(json( - // language=json """ { "type": "string", diff --git a/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/NotNull.java b/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/NotNull.java index 24b281d5..ccddbc24 100644 --- a/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/NotNull.java +++ b/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/NotNull.java @@ -5,7 +5,6 @@ import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; -/** Stand-in for a runtime-retained {@code @NotNull}, matched by simple name. */ @Retention(RetentionPolicy.RUNTIME) @Target({ElementType.METHOD, ElementType.FIELD}) public @interface NotNull { diff --git a/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/Shape.java b/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/Shape.java index 2f54f16f..871e32e0 100644 --- a/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/Shape.java +++ b/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/Shape.java @@ -9,7 +9,6 @@ public Object getAnimal() { return null; } - /** Composition with no explicit refs - reflection cannot discover subtypes, so this must NOT emit `oneOf: []`. */ @OneOf({}) public Object getEmpty() { return null; diff --git a/openapi-dynamic/src/test/kotlin/io/javalin/openapi/dynamic/DynamicSchemaGeneratorTest.kt b/openapi-dynamic/src/test/kotlin/io/javalin/openapi/dynamic/DynamicSchemaGeneratorTest.kt index d8500176..59517c4d 100644 --- a/openapi-dynamic/src/test/kotlin/io/javalin/openapi/dynamic/DynamicSchemaGeneratorTest.kt +++ b/openapi-dynamic/src/test/kotlin/io/javalin/openapi/dynamic/DynamicSchemaGeneratorTest.kt @@ -13,8 +13,7 @@ class DynamicSchemaGeneratorTest { private fun JsonNode.ref(): String = path($$"$ref").asText() private fun JsonNode.stringArray(): List = map { it.asText() } - @Test - fun `renders a full component graph through OpenApiSchemaBuilder via reflection`() { + private fun accountDocument(): JsonNode { val builder = OpenApiSchemaBuilder().openApiVersion("3.1.0") builder.path("/account").operation("get") { responses { @@ -29,51 +28,114 @@ class DynamicSchemaGeneratorTest { } } builder.resolveComponentReferences { type -> schemaContext.componentSchema(type) } + return jsonMapper.readTree(builder.toJson()) + } + + private fun accountSchema(): JsonNode = + accountDocument() + .path("components") + .path("schemas") + .path("Account") - val document = jsonMapper.readTree(builder.toJson()) - val schemas = document.path("components").path("schemas") + private fun accountProperties(): JsonNode = + accountSchema().path("properties") + + @Test + fun `discovers component schemas reachable from an account response`() { + val schemas = accountDocument().path("components").path("schemas") - // The whole ref graph was discovered and resolved to a fixpoint from a single root type. assertThat(schemas.fieldNames().asSequence().toList()) .containsExactlyInAnyOrder("Account", "Address", "Role") + } + + @Test + fun `references the account component from a response`() { + val document = accountDocument() - // Root response is a $ref into the component graph. assertThat( document.path("paths").path("/account").path("get") .path("responses").path("200").path("content") .path("application/json").path("schema").ref() ).isEqualTo("#/components/schemas/Account") + } - val account_ = schemas.path("Account") - assertThat(account_.path("type").asText()).isEqualTo("object") - assertThat(account_.path("required").stringArray()).containsExactlyInAnyOrder("id", "age") + @Test + fun `renders the account component with required properties`() { + val account = accountSchema() + val properties = account.path("properties") - val properties = account_.path("properties") + assertThat(account.path("type").asText()).isEqualTo("object") + assertThat(account.path("required").stringArray()).containsExactlyInAnyOrder("id", "age") assertThat(properties.path("id").path("type").asText()).isEqualTo("string") assertThat(properties.path("age").path("type").asText()).isEqualTo("integer") assertThat(properties.path("age").path("format").asText()).isEqualTo("int32") + } + + @Test + fun `honors renamed account properties`() { + val properties = accountProperties() + assertThat(properties.path("e_mail").path("type").asText()).isEqualTo("string") assertThat(properties.has("email")).isFalse() + } + + @Test + fun `omits ignored account properties`() { + val properties = accountProperties() + assertThat(properties.has("secret")).isFalse() + } + + @Test + fun `renders account property descriptions`() { + val properties = accountProperties() + assertThat(properties.path("label").path("description").asText()).isEqualTo("Human readable label") + } + + @Test + fun `references object component properties`() { + val properties = accountProperties() - // Nested object -> $ref assertThat(properties.path("address").ref()).isEqualTo("#/components/schemas/Address") - // Enum -> $ref (resolved to a string enum component) + } + + @Test + fun `references enum component properties`() { + val properties = accountProperties() + assertThat(properties.path("role").ref()).isEqualTo("#/components/schemas/Role") + } + + @Test + fun `renders collection properties`() { + val properties = accountProperties() - // Collection -> array of items assertThat(properties.path("tags").path("type").asText()).isEqualTo("array") assertThat(properties.path("tags").path("items").path("type").asText()).isEqualTo("string") + } + + @Test + fun `renders map properties`() { + val properties = accountProperties() - // Map -> object with additionalProperties assertThat(properties.path("meta").path("type").asText()).isEqualTo("object") assertThat(properties.path("meta").path("additionalProperties").path("type").asText()).isEqualTo("integer") + } + + @Test + fun `renders object components`() { + val schemas = accountDocument().path("components").path("schemas") val address = schemas.path("Address") assertThat(address.path("type").asText()).isEqualTo("object") assertThat(address.path("properties").fieldNames().asSequence().toList()) .containsExactlyInAnyOrder("city", "zip") + } + + @Test + fun `renders enum components`() { + val schemas = accountDocument().path("components").path("schemas") val role = schemas.path("Role") assertThat(role.path("type").asText()).isEqualTo("string") diff --git a/openapi-dynamic/src/test/kotlin/io/javalin/openapi/dynamic/ReflectionSchemaContextTest.kt b/openapi-dynamic/src/test/kotlin/io/javalin/openapi/dynamic/ReflectionSchemaContextTest.kt index a3d9f66b..89b851e5 100644 --- a/openapi-dynamic/src/test/kotlin/io/javalin/openapi/dynamic/ReflectionSchemaContextTest.kt +++ b/openapi-dynamic/src/test/kotlin/io/javalin/openapi/dynamic/ReflectionSchemaContextTest.kt @@ -15,6 +15,12 @@ class ReflectionSchemaContextTest { private fun propertyNames(type: Class<*>): List = schemaOf(type).path("properties").fieldNames().asSequence().toList() + private fun accountSchema(): JsonNode = + schemaOf(Account::class.java) + + private fun accountProperties(): JsonNode = + accountSchema().path("properties") + @Test fun `resolves a class into the shared OpenApiType model`() { val account = schemaContext.introspect(Account::class.java) @@ -25,28 +31,66 @@ class ReflectionSchemaContextTest { } @Test - fun `applies renames, ignores, required flags and descriptions through the shared generator`() { - val account = schemaOf(Account::class.java) - val properties = account.path("properties") + fun `honors renamed account properties`() { + val properties = accountProperties() - assertThat(properties.fieldNames().asSequence().toList()).contains("id", "age", "name", "e_mail") + assertThat(properties.has("e_mail")).isTrue() assertThat(properties.has("email")).isFalse() + } + + @Test + fun `omits ignored account properties`() { + val properties = accountProperties() + assertThat(properties.has("secret")).isFalse() + } + + @Test + fun `omits synthetic account properties`() { + val properties = accountProperties() + assertThat(properties.has("class")).isFalse() + } + + @Test + fun `marks required account properties`() { + val account = accountSchema() assertThat(account.path("required").map { it.asText() }).containsExactlyInAnyOrder("id", "age") + } + + @Test + fun `renders account property descriptions`() { + val properties = accountProperties() + assertThat(properties.path("label").path("description").asText()).isEqualTo("Human readable label") } @Test - fun `resolves collections, maps and nested objects`() { - val properties = schemaOf(Account::class.java).path("properties") + fun `renders collection properties`() { + val properties = accountProperties() assertThat(properties.path("tags").path("type").asText()).isEqualTo("array") assertThat(properties.path("tags").path("items").path("type").asText()).isEqualTo("string") + } + + @Test + fun `renders map properties`() { + val properties = accountProperties() + assertThat(properties.path("meta").path("type").asText()).isEqualTo("object") assertThat(properties.path("meta").path("additionalProperties").path("type").asText()).isEqualTo("integer") + } + + @Test + fun `references nested object properties`() { + val properties = accountProperties() + assertThat(properties.path("address").path($$"$ref").asText()).isEqualTo("#/components/schemas/Address") + } + + @Test + fun `renders nested object properties`() { assertThat(propertyNames(Address::class.java)).containsExactlyInAnyOrder("city", "zip") } diff --git a/openapi-generator/src/test/kotlin/io/javalin/openapi/schema/OpenApiSchemaBuilderTest.kt b/openapi-generator/src/test/kotlin/io/javalin/openapi/schema/OpenApiSchemaBuilderTest.kt index 07a29b9d..9d965f0b 100644 --- a/openapi-generator/src/test/kotlin/io/javalin/openapi/schema/OpenApiSchemaBuilderTest.kt +++ b/openapi-generator/src/test/kotlin/io/javalin/openapi/schema/OpenApiSchemaBuilderTest.kt @@ -733,7 +733,6 @@ internal class OpenApiSchemaBuilderTest { }) }, emptySet()) - // Add a path that references Address via $ref schema.path("/users").operation("get") { responses { response("200") { @@ -788,7 +787,6 @@ internal class OpenApiSchemaBuilderTest { val userDef = OpenApiType(simpleName = "User", fullName = "com.example.User") val addressDef = OpenApiType(simpleName = "Address", fullName = "com.example.Address") - // User references Address val userSchema = ResultScheme(createObjectNode().apply { put("type", "object") set("properties", createObjectNode().apply { @@ -916,7 +914,6 @@ internal class OpenApiSchemaBuilderTest { } } - // A -> B -> C (chain of 3) val bDef = OpenApiType(simpleName = "B", fullName = "com.example.B") val cDef = OpenApiType(simpleName = "C", fullName = "com.example.C") @@ -1544,7 +1541,6 @@ internal class OpenApiSchemaBuilderTest { val json = schema.toJson() - // Compile-time data preserved assertThatJson(json).inPath("$.paths['/users'].get.tags").isArray.containsExactly("users") assertThatJson(json).inPath("$.paths['/users'].get.summary").isEqualTo("Get users") assertThatJson(json).inPath("$.paths['/users'].get.operationId").isEqualTo("getUsers") @@ -1553,7 +1549,6 @@ internal class OpenApiSchemaBuilderTest { assertThatJson(json).inPath("$.paths['/users'].get.responses['200'].content['application/json'].schema.\$ref") .isEqualTo("#/components/schemas/User") - // Runtime additions assertThatJson(json).inPath("$.servers[0].url").isEqualTo("https://api.example.com") assertThatJson(json).inPath("$.components.securitySchemes.BearerAuth.scheme").isEqualTo("bearer") assertThatJson(json).inPath("$.paths['/users'].get.security[0].BearerAuth").isArray.isEmpty() From f1d614f21794d23d1c67f95482fc7e8b0962174d Mon Sep 17 00:00:00 2001 From: dzikoysk Date: Sat, 1 Aug 2026 23:17:33 +0200 Subject: [PATCH 39/43] chore: cleanup --- .gitignore | 1 - .../kotlin/io/javalin/openapi/processor/TypeMappersTest.kt | 6 ++++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 2fe296bf..d9af3164 100644 --- a/.gitignore +++ b/.gitignore @@ -145,4 +145,3 @@ gradle-app.setting **/build/ # End of https://www.toptal.com/developers/gitignore/api/gradle,java,maven,intellij -.gstack/ diff --git a/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/TypeMappersTest.kt b/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/TypeMappersTest.kt index 3edfa491..2ab54a52 100644 --- a/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/TypeMappersTest.kt +++ b/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/TypeMappersTest.kt @@ -100,6 +100,7 @@ internal class TypeMappersTest : OpenApiAnnotationProcessorSpecification() { .inPath("$.components.schemas.SimpleTypesList.properties") .isObject .isEqualTo(json( + // language=json """ { "customType": { @@ -280,6 +281,7 @@ internal class TypeMappersTest : OpenApiAnnotationProcessorSpecification() { .inPath("$.paths['/dictionary-structure'].get.responses.200.content['application/map-string-string'].schema") .isObject .isEqualTo(json( + // language=json """ { "type": "object", @@ -333,6 +335,7 @@ internal class TypeMappersTest : OpenApiAnnotationProcessorSpecification() { .inPath("$.paths['/nested-list-example'].get.responses.200.content['text/plain'].example") .isObject .isEqualTo(json( + // language=json """ { "name": "document", @@ -403,6 +406,7 @@ internal class TypeMappersTest : OpenApiAnnotationProcessorSpecification() { .inPath("$.components.schemas.IntegerEnum") .isObject .isEqualTo(json( + // language=json """ { "type": "integer", @@ -436,6 +440,7 @@ internal class TypeMappersTest : OpenApiAnnotationProcessorSpecification() { .inPath("$.components.schemas.XEnumDescriptionsEnum") .isObject .isEqualTo(json( + // language=json """ { "type": "string", @@ -463,6 +468,7 @@ internal class TypeMappersTest : OpenApiAnnotationProcessorSpecification() { .inPath("$.components.schemas.DescribedStringEnum") .isObject .isEqualTo(json( + // language=json """ { "type": "string", From 0a412ed3beb75daced03c5868f2b97186bc3829a Mon Sep 17 00:00:00 2001 From: dzikoysk Date: Sun, 2 Aug 2026 00:42:40 +0200 Subject: [PATCH 40/43] chore: bump --- .../openapi/experimental/OpenApiType.kt | 31 +++++-------------- .../experimental/SchemaGenerationContext.kt | 1 + 2 files changed, 8 insertions(+), 24 deletions(-) diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/OpenApiType.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/OpenApiType.kt index 3387685f..9ff682dc 100644 --- a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/OpenApiType.kt +++ b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/OpenApiType.kt @@ -1,6 +1,7 @@ package io.javalin.openapi.experimental import io.javalin.openapi.experimental.StructureType.DEFAULT +import java.util.Objects @RequiresOptIn( level = RequiresOptIn.Level.ERROR, @@ -16,32 +17,15 @@ class OpenApiType( val generics: List = emptyList(), val structureType: StructureType = DEFAULT, val extra: MutableList = mutableListOf(), - @property:InternalOpenApiTypeApi @JvmField val handle: Any? = null, + @property:InternalOpenApiTypeApi val handle: Any? = null, ) { - override fun equals(other: Any?): Boolean = - when { - this === other -> true - other is OpenApiType -> - fullName == other.fullName - && generics == other.generics - && structureType == other.structureType - else -> false - } - - override fun hashCode(): Int { - var result = fullName.hashCode() - result = 31 * result + generics.hashCode() - result = 31 * result + structureType.hashCode() - return result - } - - override fun toString(): String = - when { - generics.isEmpty() -> fullName - else -> "$fullName<${generics.joinToString(", ")}>" - } + other is OpenApiType + && fullName == other.fullName + && generics == other.generics + && structureType == other.structureType + override fun hashCode(): Int = Objects.hash(fullName, generics, structureType) } enum class StructureType { @@ -60,7 +44,6 @@ data class CustomProperty( internal fun OpenApiType.mergeExtraFrom(other: OpenApiType): Boolean { val missingExtra = other.extra.filterNot(extra::contains) if (missingExtra.isEmpty()) return false - extra.addAll(missingExtra) return true } diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/SchemaGenerationContext.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/SchemaGenerationContext.kt index 84ec2878..09e25819 100644 --- a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/SchemaGenerationContext.kt +++ b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/SchemaGenerationContext.kt @@ -29,4 +29,5 @@ interface SchemaGenerationContext { fun reportWarning(message: String) {} fun reportDebug(message: String) {} + } From 21550c27f932ab23f21a2baa406acb52a4baafff Mon Sep 17 00:00:00 2001 From: dzikoysk Date: Sun, 2 Aug 2026 02:32:29 +0200 Subject: [PATCH 41/43] chore: code style --- .../src/main/compile/openapi.groovy | 2 +- .../openapi/plugin/test/JavalinTest.java | 1 - .../introspection/ksp/KspTypeIntrospector.kt | 6 ++- .../ksp/KspTypeIntrospectorTest.kt | 26 +++++++------ .../runtime/ReflectionTypeIntrospector.kt | 35 +++++++++-------- .../dynamic/hook/RegisteredRoutesHook.kt | 3 +- .../plugin/swagger/SwaggerWebJarHandler.kt | 4 +- .../plugin/swagger/SwaggerPluginTest.kt | 3 +- .../AnnotationProcessorContext.kt | 7 +++- .../shared/AnnotationProcessorExtensions.kt | 1 - .../processor/ComponentAnnotationsTest.kt | 6 --- .../openapi/processor/ComponentSchemaTest.kt | 2 - .../openapi/processor/CompositionTest.kt | 2 - .../processor/CustomAnnotationsTest.kt | 2 - .../processor/CustomTypeMappingsTest.kt | 4 -- .../processor/OpenApiAnnotationTest.kt | 2 - .../processor/ProcessorDiagnosticsTest.kt | 38 +++++++++++++++++++ .../openapi/processor/TypeMappersTest.kt | 12 ------ .../generators/CompositionGenerator.kt | 8 +++- .../openapi/schema/OpenApiSchemaBuilder.kt | 20 +++++----- .../openapi/schema/OpenApiSchemaGenerator.kt | 8 ++-- openapi-ksp/build.gradle.kts | 2 +- .../openapi/ksp/OpenApiSymbolProcessor.kt | 28 ++++++-------- settings.gradle.kts | 2 +- 24 files changed, 119 insertions(+), 105 deletions(-) diff --git a/examples/javalin-gradle-kotlin/src/main/compile/openapi.groovy b/examples/javalin-gradle-kotlin/src/main/compile/openapi.groovy index 9883c70a..2d3b04ed 100644 --- a/examples/javalin-gradle-kotlin/src/main/compile/openapi.groovy +++ b/examples/javalin-gradle-kotlin/src/main/compile/openapi.groovy @@ -9,4 +9,4 @@ class OpenApiConfiguration implements OpenApiAnnotationProcessorConfigurer { void configure(OpenApiAnnotationProcessorConfiguration openApiAnnotationProcessorConfiguration) { } -} \ No newline at end of file +} diff --git a/examples/javalin-gradle-kotlin/src/main/java/io/javalin/openapi/plugin/test/JavalinTest.java b/examples/javalin-gradle-kotlin/src/main/java/io/javalin/openapi/plugin/test/JavalinTest.java index 3d861a45..8fca8b89 100644 --- a/examples/javalin-gradle-kotlin/src/main/java/io/javalin/openapi/plugin/test/JavalinTest.java +++ b/examples/javalin-gradle-kotlin/src/main/java/io/javalin/openapi/plugin/test/JavalinTest.java @@ -216,7 +216,6 @@ public static void main(String[] args) { @OpenApiContent(from = EntityDto[].class), // array @OpenApiContent(from = File.class), // file @OpenApiContent(type = "application/json"), // empty - @OpenApiContent(), // empty @OpenApiContent(mimeType = "image/png", type = "string", format = "base64"), // single file upload, @OpenApiContent(mimeType = "multipart/form-data", properties = { @OpenApiContentProperty(name = "form-element", type = "integer"), // random element in form-data diff --git a/introspection/introspection-ksp/src/main/kotlin/io/javalin/introspection/ksp/KspTypeIntrospector.kt b/introspection/introspection-ksp/src/main/kotlin/io/javalin/introspection/ksp/KspTypeIntrospector.kt index 26d77a45..09222990 100644 --- a/introspection/introspection-ksp/src/main/kotlin/io/javalin/introspection/ksp/KspTypeIntrospector.kt +++ b/introspection/introspection-ksp/src/main/kotlin/io/javalin/introspection/ksp/KspTypeIntrospector.kt @@ -11,6 +11,7 @@ import com.google.devtools.ksp.symbol.KSDeclaration import com.google.devtools.ksp.symbol.KSType import com.google.devtools.ksp.symbol.KSTypeParameter import com.google.devtools.ksp.symbol.Modifier +import com.google.devtools.ksp.symbol.Visibility as KspVisibility import io.javalin.introspection.Accessor import io.javalin.introspection.AnnotationProjection import io.javalin.introspection.AnnotationSet @@ -27,7 +28,6 @@ import io.javalin.introspection.StructureType.DICTIONARY import io.javalin.introspection.MemberVisibility import java.lang.annotation.Inherited import java.lang.annotation.Repeatable as JavaRepeatable -import com.google.devtools.ksp.symbol.Visibility as KspVisibility class KspTypeIntrospector(private val resolver: Resolver) : CompileTimeIntrospector { @@ -278,7 +278,9 @@ class KspTypeIntrospector(private val resolver: Resolver) : CompileTimeIntrospec } override fun all(): List = - annotations().distinctBy { it.annotationType.resolve().declaration }.map { KspAnnotationProjection(it) } + annotations() + .distinctBy { it.annotationType.resolve().declaration } + .map { KspAnnotationProjection(it) } private fun KSAnnotation.named(type: Class): Boolean { val qualifiedName = qualifiedName() diff --git a/introspection/introspection-ksp/src/test/kotlin/io/javalin/introspection/ksp/KspTypeIntrospectorTest.kt b/introspection/introspection-ksp/src/test/kotlin/io/javalin/introspection/ksp/KspTypeIntrospectorTest.kt index 51bdd9da..7c67ba4c 100644 --- a/introspection/introspection-ksp/src/test/kotlin/io/javalin/introspection/ksp/KspTypeIntrospectorTest.kt +++ b/introspection/introspection-ksp/src/test/kotlin/io/javalin/introspection/ksp/KspTypeIntrospectorTest.kt @@ -64,41 +64,45 @@ class KspTypeIntrospectorTest { @Test fun `KSP backend resolves enum constants`() { - val color = + val (isEnum, constants) = withKsp { ksp -> - ksp.introspect("$REF_PKG.Color") + val color = ksp.introspect("$REF_PKG.Color") + color.isEnum() to color.getEnumConstants().map { it.name } } - assertThat(color.isEnum()).isTrue() - assertThat(color.getEnumConstants().map { it.name }).containsExactly("RED", "GREEN") + assertThat(isEnum).isTrue() + assertThat(constants).containsExactly("RED", "GREEN") } @Test fun `KSP backend reads annotations by name`() { - val annotations = + val (hasRef, refValue) = withKsp { ksp -> - ksp + val annotations = + ksp .introspect("$REF_PKG.Account") .getAnnotations() + annotations.contains("Ref") to annotations.find(Ref::class.java)?.get("value")?.asClassDefinition()?.fullName } - assertThat(annotations.contains("Ref")).isTrue() - assertThat(annotations.find(Ref::class.java)?.get("value")?.asClassDefinition()?.fullName) - .isEqualTo("$REF_PKG.Address") + assertThat(hasRef).isTrue() + assertThat(refValue).isEqualTo("$REF_PKG.Address") } @Test fun `KSP backend finds annotations by meta-annotation`() { - val annotated = + val label = withKsp { ksp -> ksp .introspect("$REF_PKG.Account") .getAnnotations() .all() .first { it.metadata.contains("MetaMarker") } + .get("label") + .asString() } - assertThat(annotated["label"].asString()).isEqualTo("x") + assertThat(label).isEqualTo("x") } private companion object { diff --git a/introspection/introspection-runtime/src/main/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospector.kt b/introspection/introspection-runtime/src/main/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospector.kt index 422e6958..849b714b 100644 --- a/introspection/introspection-runtime/src/main/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospector.kt +++ b/introspection/introspection-runtime/src/main/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospector.kt @@ -246,7 +246,6 @@ private fun collectMembers(clazz: Class<*>): List { } } - // clazz.methods is public-only, so a second pass picks up inherited protected/package-private getters (jap parity) for (method in nonPublicGettersHierarchy(clazz)) { if (getterNames.add(method.name)) { members += method.toMember() @@ -305,16 +304,12 @@ private fun declaredFieldsHierarchy(clazz: Class<*>): List { val fields = mutableListOf() var current: Class<*>? = clazz while (current != null && current != Any::class.java) { - // only members [clazz] actually inherits, matching JAP's getAllMembers: private stays in the leaf, - // package-private is inherited only within the same package current.declaredFields.filterTo(fields) { field -> - val modifiers = field.modifiers - when { - current == clazz -> true - Modifier.isPrivate(modifiers) -> false - Modifier.isPublic(modifiers) || Modifier.isProtected(modifiers) -> true - else -> field.declaringClass.packageName == clazz.packageName - } + memberIsInheritedBy( + type = clazz, + declaringClass = field.declaringClass, + modifiers = field.modifiers, + ) } current = current.superclass } @@ -329,19 +324,27 @@ private fun nonPublicGettersHierarchy(clazz: Class<*>): List { val modifiers = method.modifiers if (Modifier.isStatic(modifiers) || Modifier.isPublic(modifiers) || method.isBridge || method.isSynthetic) continue if (method.parameterCount != 0 || method.returnType == Void.TYPE || !method.isPropertyGetter()) continue - val inherited = when { - current == clazz -> true - Modifier.isPrivate(modifiers) -> false - Modifier.isProtected(modifiers) -> true - else -> method.declaringClass.packageName == clazz.packageName + if (memberIsInheritedBy(clazz, method.declaringClass, modifiers)) { + getters += method } - if (inherited) getters += method } current = current.superclass } return getters } +private fun memberIsInheritedBy( + type: Class<*>, + declaringClass: Class<*>, + modifiers: Int, +): Boolean = + when { + declaringClass == type -> true + Modifier.isPrivate(modifiers) -> false + Modifier.isPublic(modifiers) || Modifier.isProtected(modifiers) -> true + else -> declaringClass.packageName == type.packageName + } + private class ReflectionAnnotations(private val sources: List) : AnnotationSet { override fun contains(simpleName: String): Boolean = diff --git a/javalin-plugins/javalin-openapi-dynamic-hook/src/main/kotlin/io/javalin/openapi/dynamic/hook/RegisteredRoutesHook.kt b/javalin-plugins/javalin-openapi-dynamic-hook/src/main/kotlin/io/javalin/openapi/dynamic/hook/RegisteredRoutesHook.kt index 5aeb6f6c..495512ec 100644 --- a/javalin-plugins/javalin-openapi-dynamic-hook/src/main/kotlin/io/javalin/openapi/dynamic/hook/RegisteredRoutesHook.kt +++ b/javalin-plugins/javalin-openapi-dynamic-hook/src/main/kotlin/io/javalin/openapi/dynamic/hook/RegisteredRoutesHook.kt @@ -45,7 +45,7 @@ class RegisteredRoutesHook @JvmOverloads constructor( private val config = RegisteredRoutesHookConfiguration().also(userConfig::accept) override fun apply(context: OpenApiHookContext) { - val schemaContext = ReflectionSchemaContext() // document-scoped: owns the generator + its memo cache + val schemaContext = ReflectionSchemaContext() context.builder.openApiVersion("3.1.0").ensureInfo() for (handler in context.state.internalRouter.allHttpHandlers()) { @@ -83,7 +83,6 @@ class RegisteredRoutesHook @JvmOverloads constructor( context.builder.resolveComponentReferences { type -> schemaContext.componentSchema(type) } } - /** Javalin `` -> OpenAPI `{slashParam}`; `{param}` kept as-is. */ private fun toOpenApiPath(path: String): String = path.replace(ANGLE_PARAM) { "{${it.groupValues[1]}}" } diff --git a/javalin-plugins/javalin-swagger-plugin/src/main/kotlin/io/javalin/openapi/plugin/swagger/SwaggerWebJarHandler.kt b/javalin-plugins/javalin-swagger-plugin/src/main/kotlin/io/javalin/openapi/plugin/swagger/SwaggerWebJarHandler.kt index 2e55c029..98a729ef 100644 --- a/javalin-plugins/javalin-swagger-plugin/src/main/kotlin/io/javalin/openapi/plugin/swagger/SwaggerWebJarHandler.kt +++ b/javalin-plugins/javalin-swagger-plugin/src/main/kotlin/io/javalin/openapi/plugin/swagger/SwaggerWebJarHandler.kt @@ -5,7 +5,6 @@ import io.javalin.http.Handler import io.javalin.openapi.OpenApiPluginRouteHandler import org.eclipse.jetty.http.HttpStatus import org.eclipse.jetty.http.MimeTypes -import java.io.InputStream internal class SwaggerWebJarHandler( private val swaggerWebJarPath: String, @@ -19,7 +18,7 @@ internal class SwaggerWebJarHandler( .replaceFirst(context.contextPath(), "") .replaceFirst(swaggerWebJarPath, "") - val resource: InputStream? = classLoader.getResourceAsStream(resourceRootPath + requestedResource) + val resource = classLoader.getResourceAsStream(resourceRootPath + requestedResource) if (resource == null) { context.status(HttpStatus.NOT_FOUND_404) @@ -29,7 +28,6 @@ internal class SwaggerWebJarHandler( context.result(resource) context.res().characterEncoding = "UTF-8" - // Swagger returns non-standard assets such as .js.map, which Jetty does not otherwise recognize. MimeTypes.DEFAULTS.getMimeByExtension(context.path())?.let { context.contentType(it) } diff --git a/javalin-plugins/javalin-swagger-plugin/src/test/kotlin/io/javalin/openapi/plugin/swagger/SwaggerPluginTest.kt b/javalin-plugins/javalin-swagger-plugin/src/test/kotlin/io/javalin/openapi/plugin/swagger/SwaggerPluginTest.kt index c0e5c79e..d8e70831 100644 --- a/javalin-plugins/javalin-swagger-plugin/src/test/kotlin/io/javalin/openapi/plugin/swagger/SwaggerPluginTest.kt +++ b/javalin-plugins/javalin-swagger-plugin/src/test/kotlin/io/javalin/openapi/plugin/swagger/SwaggerPluginTest.kt @@ -22,6 +22,7 @@ internal class SwaggerPluginTest { app.stop() } } + @Test fun `should properly host swagger ui`() { val app = Javalin.start { @@ -130,7 +131,7 @@ internal class SwaggerPluginTest { } @Test - fun `should not fail if second swagger plugin is registered with routes`(){ + fun `should not fail if second swagger plugin is registered with routes`() { val app = Javalin.start { it.jetty.port = 0 it.registerPlugin(SwaggerPlugin()) diff --git a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/AnnotationProcessorContext.kt b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/AnnotationProcessorContext.kt index 09ff051e..c485a56d 100644 --- a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/AnnotationProcessorContext.kt +++ b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/AnnotationProcessorContext.kt @@ -146,10 +146,13 @@ class AnnotationProcessorContext( fun getFullName(mirror: TypeMirror): String { val element = env.typeUtils.asElement(mirror) + val fullName = element?.toString()?.substringBefore("<") ?: mirror.toString().substringBefore("<") val customName = element?.getAnnotation(OpenApiName::class.java)?.value + val packageName = fullName.substringBeforeLast('.', "") return when { - customName != null -> mirror.toString().substringBeforeLast(".") + "." + customName - else -> element?.toString()?.substringBefore("<") ?: mirror.toString().substringBefore("<") + customName == null -> fullName + packageName.isEmpty() -> customName + else -> "$packageName.$customName" } } diff --git a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/processor/shared/AnnotationProcessorExtensions.kt b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/processor/shared/AnnotationProcessorExtensions.kt index 37879a6f..4d491afc 100644 --- a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/processor/shared/AnnotationProcessorExtensions.kt +++ b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/processor/shared/AnnotationProcessorExtensions.kt @@ -18,7 +18,6 @@ fun Filer.saveResource(context: AnnotationProcessorContext, name: String, conten } resource } catch (_: FilerException) { - // file has been created during previous compilation phase null } catch (throwable: Throwable) { context.env.messager.printException(throwable) diff --git a/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/ComponentAnnotationsTest.kt b/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/ComponentAnnotationsTest.kt index 72416b3a..c1499c88 100644 --- a/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/ComponentAnnotationsTest.kt +++ b/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/ComponentAnnotationsTest.kt @@ -30,8 +30,6 @@ internal class ComponentAnnotationsTest : OpenApiAnnotationProcessorSpecificatio ) @Test fun should_include_openapi_description() = withOpenApi("should_include_openapi_description") { - println(it) - assertThatJson(it) .inPath("$.components.schemas.ClassWithOpenApiDescription") .isObject @@ -55,8 +53,6 @@ internal class ComponentAnnotationsTest : OpenApiAnnotationProcessorSpecificatio ) @Test fun should_change_property_type() = withOpenApi("should_change_property_type") { - println(it) - assertThatJson(it) .inPath("$.components.schemas.ClassWithOpenApiType") .isObject @@ -87,8 +83,6 @@ internal class ComponentAnnotationsTest : OpenApiAnnotationProcessorSpecificatio ) @Test fun should_add_nullable_property() = withOpenApi("should_control_nullability") { - println(it) - assertThatJson(it) .inPath("$.components.schemas.ClassWithNullableProperties.properties.testProperty.type") .isEqualTo(json("""["string", "null"]""")) diff --git a/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/ComponentSchemaTest.kt b/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/ComponentSchemaTest.kt index de85bbe7..3d5fd797 100644 --- a/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/ComponentSchemaTest.kt +++ b/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/ComponentSchemaTest.kt @@ -37,8 +37,6 @@ internal class ComponentSchemaTest : OpenApiAnnotationProcessorSpecification() { ) @Test fun should_generate_reference_with_inherited_properties() = withOpenApi("should_generate_reference_with_inherited_properties") { - println(it) - assertThatJson(it) .inPath("$.components.schemas.FinalClass.properties") .isObject diff --git a/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/CompositionTest.kt b/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/CompositionTest.kt index e3eaf123..72bb0183 100644 --- a/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/CompositionTest.kt +++ b/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/CompositionTest.kt @@ -254,8 +254,6 @@ internal class CompositionTest : OpenApiAnnotationProcessorSpecification() { ) @Test fun should_resolve_subtypes_as_mapping() = withOpenApi("should_resolve_subtypes_as_mapping") { - println(it) - assertThatJson(it) .inPath("$.components.schemas.Union") .isObject diff --git a/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/CustomAnnotationsTest.kt b/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/CustomAnnotationsTest.kt index b7be2c71..8b9873a3 100644 --- a/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/CustomAnnotationsTest.kt +++ b/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/CustomAnnotationsTest.kt @@ -50,8 +50,6 @@ internal class CustomAnnotationsTest : OpenApiAnnotationProcessorSpecification() ) @Test fun should_include_custom_annotation_in_type_scheme() = withOpenApi("should_include_custom_annotation_in_type_scheme") { - println(it) - assertThatJson(it) .inPath("$.paths['/custom'].get.responses.200.content['application/json'].schema") .isObject diff --git a/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/CustomTypeMappingsTest.kt b/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/CustomTypeMappingsTest.kt index 66574c2c..745e263c 100644 --- a/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/CustomTypeMappingsTest.kt +++ b/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/CustomTypeMappingsTest.kt @@ -26,8 +26,6 @@ internal class CustomTypeMappingsTest : OpenApiAnnotationProcessorSpecification( ) @Test fun should_unwrap_atomic_reference_via_custom_processor() = withOpenApi("should_unwrap_atomic_reference_via_custom_processor") { - println(it) - assertThatJson(it) .inPath("$.components.schemas.EntityWithAtomicReference") .isObject @@ -63,8 +61,6 @@ internal class CustomTypeMappingsTest : OpenApiAnnotationProcessorSpecification( ) @Test fun should_unwrap_optional_as_nullable() = withOpenApi("should_unwrap_optional_as_nullable") { - println(it) - assertThatJson(it) .inPath("$.components.schemas.EntityWithOptional") .isObject diff --git a/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/OpenApiAnnotationTest.kt b/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/OpenApiAnnotationTest.kt index fc9dd06c..f44f6e03 100644 --- a/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/OpenApiAnnotationTest.kt +++ b/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/OpenApiAnnotationTest.kt @@ -155,8 +155,6 @@ internal class OpenApiAnnotationTest : OpenApiAnnotationProcessorSpecification() ) @Test fun should_generate_callback() = withOpenApi("should_generate_callback") { - println(it) - assertThatJson(it) .inPath("$.paths['/callback'].get.callbacks") .isObject diff --git a/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/ProcessorDiagnosticsTest.kt b/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/ProcessorDiagnosticsTest.kt index e5f68a4f..74d3f707 100644 --- a/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/ProcessorDiagnosticsTest.kt +++ b/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/ProcessorDiagnosticsTest.kt @@ -92,6 +92,44 @@ internal class ProcessorDiagnosticsTest { ) } + @Test + fun should_apply_custom_type_names_without_a_package() { + val diagnostics = compile( + sourceCode = + """ + import io.javalin.openapi.OpenApi; + import io.javalin.openapi.OpenApiContent; + import io.javalin.openapi.OpenApiName; + import io.javalin.openapi.OpenApiResponse; + + @OpenApiName("Renamed") + class Original { + } + + class DefaultPackageRoute { + @OpenApi( + path = "/renamed", + responses = { + @OpenApiResponse(status = "200", content = { @OpenApiContent(from = Original.class) }) + } + ) + public void route() { + } + } + """.trimIndent(), + processor = + object : OpenApiAnnotationProcessor() { + override fun init(processingEnv: ProcessingEnvironment) { + super.init(processingEnv) + context.configuration.debug = true + } + }, + ) + + assertThat(diagnostics.map { it.getMessage(null) }) + .contains("OpenApi | Generating schema for Renamed") + } + private fun compile( sourceCode: String, processor: Processor = OpenApiAnnotationProcessor(), diff --git a/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/TypeMappersTest.kt b/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/TypeMappersTest.kt index 2ab54a52..fde5021a 100644 --- a/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/TypeMappersTest.kt +++ b/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/TypeMappersTest.kt @@ -94,8 +94,6 @@ internal class TypeMappersTest : OpenApiAnnotationProcessorSpecification() { ) @Test fun should_map_all_simple_types() = withOpenApi("should_map_all_simple_types") { - println(it) - assertThatJson(it) .inPath("$.components.schemas.SimpleTypesList.properties") .isObject @@ -275,8 +273,6 @@ internal class TypeMappersTest : OpenApiAnnotationProcessorSpecification() { ) @Test fun should_output_dictionary_structure() = withOpenApi("should_output_dictionary_structure") { - println(it) - assertThatJson(it) .inPath("$.paths['/dictionary-structure'].get.responses.200.content['application/map-string-string'].schema") .isObject @@ -329,8 +325,6 @@ internal class TypeMappersTest : OpenApiAnnotationProcessorSpecification() { ) @Test fun should_support_nested_lists_in_example_objects() = withOpenApi("should_support_nested_lists_in_example_objects") { - println(it) - assertThatJson(it) .inPath("$.paths['/nested-list-example'].get.responses.200.content['text/plain'].example") .isObject @@ -367,8 +361,6 @@ internal class TypeMappersTest : OpenApiAnnotationProcessorSpecification() { ) @Test fun should_support_raw_examples() = withOpenApi("should_support_raw_examples") { - println(it) - assertThatJson(it) .inPath("$.components.schemas.RawExampleEntity.properties.intField.example") .isEqualTo(1234) @@ -400,8 +392,6 @@ internal class TypeMappersTest : OpenApiAnnotationProcessorSpecification() { ) @Test fun should_support_integer_enum() = withOpenApi("should_support_integer_enum") { - println(it) - assertThatJson(it) .inPath("$.components.schemas.IntegerEnum") .isObject @@ -434,8 +424,6 @@ internal class TypeMappersTest : OpenApiAnnotationProcessorSpecification() { ) @Test fun should_support_x_enum_descriptions_enum() = withOpenApi("should_support_x_enum_descriptions_enum") { - println(it) - assertThatJson(it) .inPath("$.components.schemas.XEnumDescriptionsEnum") .isObject diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/CompositionGenerator.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/CompositionGenerator.kt index 48898e20..21c3a62b 100644 --- a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/CompositionGenerator.kt +++ b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/CompositionGenerator.kt @@ -32,7 +32,12 @@ private fun compositionOf( composition: Composition, ): PropertyComposition? { val annotation = annotations.find(annotationType) ?: return null - val references = annotation.get("value").asClassDefinitions().map { context.toOpenApiType(it) }.toSet() + val references = + annotation + .get("value") + .asClassDefinitions() + .map(context::toOpenApiType) + .toSet() val discriminator = annotation.get("discriminator").asMap()?.let { discriminatorInfo(context, it) } return PropertyComposition( type = composition, @@ -72,7 +77,6 @@ fun ObjectNode.createComposition( val refs = propertyComposition.references.ifEmpty { subtypes.map { it.second } } - // an empty oneOf/anyOf/allOf is invalid OpenAPI, so skip when there are no refs/subtypes if (refs.isEmpty()) return val compositionValues = createArrayNode() diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/schema/OpenApiSchemaBuilder.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/schema/OpenApiSchemaBuilder.kt index bd962dc5..062046a1 100644 --- a/openapi-generator/src/main/kotlin/io/javalin/openapi/schema/OpenApiSchemaBuilder.kt +++ b/openapi-generator/src/main/kotlin/io/javalin/openapi/schema/OpenApiSchemaBuilder.kt @@ -52,16 +52,17 @@ class OpenApiSchemaBuilder { root.set("info", updatedInfo) } - fun ensureInfo(title: String = "", version: String = ""): OpenApiSchemaBuilder = apply { - val info = root.get("info") as? ObjectNode - ?: createObjectNode().also { root.set("info", it) } - if (!info.has("title")) { - info.put("title", title) - } - if (!info.has("version")) { - info.put("version", version) + fun ensureInfo(title: String = "", version: String = ""): OpenApiSchemaBuilder = + apply { + val info = root.get("info") as? ObjectNode + ?: createObjectNode().also { root.set("info", it) } + if (!info.has("title")) { + info.put("title", title) + } + if (!info.has("version")) { + info.put("version", version) + } } - } fun server(configure: Consumer): OpenApiSchemaBuilder = apply { @@ -259,7 +260,6 @@ class OpenApiSchemaBuilder { schemas?.properties()?.forEach { (schemaName, schemaValue) -> builder.componentSchemas.set(schemaName, schemaValue.deepCopy()) } - // Preserve non-schema component entries (securitySchemes, etc.) componentsNode.properties() .filter { it.key != "schemas" } .forEach { (compKey, compValue) -> diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/schema/OpenApiSchemaGenerator.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/schema/OpenApiSchemaGenerator.kt index 9d49fb5c..d807b70d 100644 --- a/openapi-generator/src/main/kotlin/io/javalin/openapi/schema/OpenApiSchemaGenerator.kt +++ b/openapi-generator/src/main/kotlin/io/javalin/openapi/schema/OpenApiSchemaGenerator.kt @@ -220,9 +220,8 @@ class OpenApiSchemaGenerator( return method.lowercase() + path } - private fun String.capitalise(): String = replaceFirstChar { - it.titlecase(Locale.getDefault()) - } + private fun String.capitalise(): String = + replaceFirstChar { it.titlecase(Locale.getDefault()) } private fun resolveMediaType(content: OpenApiContentDefinition): Pair Unit>? { val source = content.resolvedSource @@ -335,7 +334,8 @@ class OpenApiSchemaGenerator( return context.typeSchemaGenerator.createEmbeddedTypeDescription(model) } - private fun descriptionOf(response: OpenApiResponseDefinition): String = response.description + private fun descriptionOf(response: OpenApiResponseDefinition): String = + response.description ?: defaultStatusDescription(response.status) ?: "" diff --git a/openapi-ksp/build.gradle.kts b/openapi-ksp/build.gradle.kts index 3b3188f4..d97d3671 100644 --- a/openapi-ksp/build.gradle.kts +++ b/openapi-ksp/build.gradle.kts @@ -3,7 +3,7 @@ description = "Javalin OpenAPI KSP | Kotlin Symbol Processing backend for OpenAP dependencies { api(project(":openapi-generator")) api(project(":introspection:introspection-ksp")) - api(libs.ksp.symbol.processing.api) // OpenApiSymbolProcessorProvider/Processor are public KSP SymbolProcessor types (ServiceLoader entry point) + api(libs.ksp.symbol.processing.api) testImplementation(libs.kctfork.core) testImplementation(libs.kctfork.ksp) diff --git a/openapi-ksp/src/main/kotlin/io/javalin/openapi/ksp/OpenApiSymbolProcessor.kt b/openapi-ksp/src/main/kotlin/io/javalin/openapi/ksp/OpenApiSymbolProcessor.kt index d3476fee..aae0f902 100644 --- a/openapi-ksp/src/main/kotlin/io/javalin/openapi/ksp/OpenApiSymbolProcessor.kt +++ b/openapi-ksp/src/main/kotlin/io/javalin/openapi/ksp/OpenApiSymbolProcessor.kt @@ -25,19 +25,13 @@ class OpenApiSymbolProcessor( private val writtenResources = mutableSetOf() override fun process(resolver: Resolver): List { - // JsonSchema inlines references; its cache must not leak into OpenAPI component references. - generateJsonSchemes( - resolver = resolver, - context = KspSchemaContext(resolver = resolver, logger = logger), - ) - generateOpenApiDocuments( - resolver = resolver, - context = KspSchemaContext(resolver = resolver, logger = logger), - ) + generateJsonSchemes(resolver) + generateOpenApiDocuments(resolver) return emptyList() } - private fun generateJsonSchemes(resolver: Resolver, context: KspSchemaContext) { + private fun generateJsonSchemes(resolver: Resolver) { + val context = KspSchemaContext(resolver = resolver, logger = logger) val generated = resolver .getSymbolsWithAnnotation(JsonSchema::class.qualifiedName!!) @@ -60,17 +54,18 @@ class OpenApiSymbolProcessor( .createTypeSchema(type, inlineRefs = true) .toJsonSchemaString() val resourceName = declaration.qualifiedName?.asString() ?: type.fullName - writeResource("json-schemes/$resourceName", json) + writeResourceOnce("json-schemes/$resourceName", json) resourceName } .toList() if (generated.isNotEmpty()) { - writeResource("json-schemes/index", generated.joinToString(separator = "\n")) + writeResourceOnce("json-schemes/index", generated.joinToString(separator = "\n")) } } - private fun generateOpenApiDocuments(resolver: Resolver, context: KspSchemaContext) { + private fun generateOpenApiDocuments(resolver: Resolver) { + val context = KspSchemaContext(resolver = resolver, logger = logger) val routes = (resolver.getSymbolsWithAnnotation(OpenApi::class.qualifiedName!!) + resolver.getSymbolsWithAnnotation(OpenApis::class.qualifiedName!!)) @@ -98,16 +93,15 @@ class OpenApiSymbolProcessor( .generateVersionedSchemas(routes) .map { (version, json) -> val resourceName = "openapi-${version.replace(" ", "-")}.json" - writeResource("openapi-plugin/$resourceName", json) + writeResourceOnce("openapi-plugin/$resourceName", json) resourceName } - writeResource("openapi-plugin/.index", resourceNames.joinToString(separator = "\n")) + writeResourceOnce("openapi-plugin/.index", resourceNames.joinToString(separator = "\n")) } - private fun writeResource(path: String, content: String) { + private fun writeResourceOnce(path: String, content: String) { if (!writtenResources.add(path)) { - // KSP resources are immutable; routes another processor adds in later rounds are intentionally omitted. return } diff --git a/settings.gradle.kts b/settings.gradle.kts index 226ccba7..5734f5ff 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -20,4 +20,4 @@ include( "examples", "examples:javalin-gradle-kotlin", "examples:javalin-ksp-kotlin" -) \ No newline at end of file +) From c5822fd4a1daee20cbe8dfae2a50e17b7df0fc8d Mon Sep 17 00:00:00 2001 From: dzikoysk Date: Wed, 23 Sep 2026 02:30:05 +0200 Subject: [PATCH 42/43] chore: cleanup & fixes --- .../introspection/jap/JapTypeIntrospector.kt | 72 ++--- .../introspection/ksp/KspTypeIntrospector.kt | 73 +++-- .../runtime/ReflectionTypeIntrospector.kt | 28 +- .../introspection-test/build.gradle.kts | 10 + .../test/IntrospectionParityTest.kt | 9 - .../introspection/test/SchemaParityTest.kt | 289 ++++++++++++++++++ .../dynamic/hook/RegisteredRoutesHook.kt | 6 +- .../dynamic/hook/DynamicOpenApiHookTest.kt | 53 ++++ .../generators/TypeSchemaGenerator.kt | 241 ++++++--------- .../openapi/schema/OpenApiRouteDefinition.kt | 9 +- .../openapi/schema/OpenApiSchemaBuilder.kt | 220 +++++-------- .../openapi/schema/OpenApiSchemaGenerator.kt | 6 +- .../schema/OpenApiRouteDefinitionTest.kt | 18 +- .../schema/OpenApiSchemaBuilderTest.kt | 54 ++++ 14 files changed, 700 insertions(+), 388 deletions(-) create mode 100644 introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/SchemaParityTest.kt diff --git a/introspection/introspection-jap/src/main/kotlin/io/javalin/introspection/jap/JapTypeIntrospector.kt b/introspection/introspection-jap/src/main/kotlin/io/javalin/introspection/jap/JapTypeIntrospector.kt index 56c2a102..9720a70b 100644 --- a/introspection/introspection-jap/src/main/kotlin/io/javalin/introspection/jap/JapTypeIntrospector.kt +++ b/introspection/introspection-jap/src/main/kotlin/io/javalin/introspection/jap/JapTypeIntrospector.kt @@ -64,7 +64,6 @@ class JapTypeIntrospector( private fun resolve( mirror: TypeMirror, - generics: List = emptyList(), structureType: StructureType = DEFAULT, visitingTypeVariables: Set = emptySet(), ): ClassDefinition = @@ -77,7 +76,6 @@ class JapTypeIntrospector( bound != null -> resolve( mirror = bound, - generics = generics, structureType = structureType, visitingTypeVariables = visitingTypeVariables + key, ) @@ -87,24 +85,21 @@ class JapTypeIntrospector( is WildcardType -> resolve( mirror = mirror.extendsBound ?: objectMirror(), - generics = generics, structureType = structureType, visitingTypeVariables = visitingTypeVariables, ) is ArrayType -> resolve( mirror = mirror.componentType, - generics = generics, structureType = ARRAY, visitingTypeVariables = visitingTypeVariables, ) is PrimitiveType -> definition( mirror = types.boxedClass(mirror).asType(), - generics = generics, + generics = emptyList(), structureType = structureType, ) is DeclaredType -> declared( mirror = mirror, - generics = generics, structureType = structureType, visitingTypeVariables = visitingTypeVariables, ) @@ -116,7 +111,6 @@ class JapTypeIntrospector( ?.let { resolve( mirror = it, - generics = generics, structureType = structureType, visitingTypeVariables = visitingTypeVariables, ) @@ -126,7 +120,6 @@ class JapTypeIntrospector( private fun declared( mirror: DeclaredType, - generics: List, structureType: StructureType, visitingTypeVariables: Set, ): ClassDefinition { @@ -150,7 +143,6 @@ class JapTypeIntrospector( types.isAssignable(erasure, erasureOf("java.util.Collection")) -> resolve( mirror = mirror.typeArguments.getOrElse(0) { objectMirror() }, - generics = generics, structureType = ARRAY, visitingTypeVariables = visitingTypeVariables, ) @@ -271,51 +263,32 @@ class JapTypeIntrospector( override fun getProperties(): List { val element = typeElement() ?: return emptyList() - if (element.kind == ElementKind.RECORD) { - val recordProperties = element.recordComponents.map { component -> - PropertyProjection( - name = component.simpleName.toString(), - type = resolve(component.asType()), - accessor = Accessor.RECORD_COMPONENT, - nullable = component.asType().nullable(), - visibility = MemberVisibility.PUBLIC, - transient = false, - source = component, - annotations = AnnotationsView(listOf(component)), - ) - } - val recordPropertyNames = recordProperties.mapTo(mutableSetOf()) { it.name } - val extraGetters = elements.getAllMembers(element).mapNotNull { member -> - if (!member.isGetter()) { - return@mapNotNull null - } - - val getter = member as ExecutableElement - val name = propertyName(getter.simpleName.toString()) - if (name in recordPropertyNames) { - return@mapNotNull null - } - - PropertyProjection( - name = name, - type = resolve(getter.returnType), - accessor = Accessor.GETTER, - nullable = getter.returnType.nullable(), - visibility = getter.visibility(), - transient = false, - source = getter, - annotations = AnnotationsView(listOf(getter)), - ) + val recordProperties = element.recordComponents.map { component -> + val backingField = element.enclosedElements.firstOrNull { + it.kind == ElementKind.FIELD && it.simpleName == component.simpleName } - - return recordProperties + extraGetters + PropertyProjection( + name = component.simpleName.toString(), + type = resolve(component.asType()), + accessor = Accessor.RECORD_COMPONENT, + nullable = component.asType().nullable(), + visibility = MemberVisibility.PUBLIC, + transient = false, + source = component.accessor, + annotations = AnnotationsView(listOfNotNull(component.accessor, backingField, component)), + ) } + val recordPropertyNames = recordProperties.map { it.name } - return elements.getAllMembers(element).mapNotNull { member -> + val otherProperties = elements.getAllMembers(element).mapNotNull { member -> when { member.isGetter() -> (member as ExecutableElement).let { getter -> + val name = propertyName(getter.simpleName.toString()) + if (name in recordPropertyNames) { + return@mapNotNull null + } PropertyProjection( - name = propertyName(getter.simpleName.toString()), + name = name, type = resolve(getter.returnType), accessor = Accessor.GETTER, nullable = getter.returnType.nullable(), @@ -325,7 +298,7 @@ class JapTypeIntrospector( annotations = AnnotationsView(listOf(getter)), ) } - member.isInstanceField() -> (member as VariableElement).let { field -> + element.kind != ElementKind.RECORD && member.isInstanceField() -> (member as VariableElement).let { field -> PropertyProjection( name = field.simpleName.toString(), type = resolve(field.asType()), @@ -340,6 +313,7 @@ class JapTypeIntrospector( else -> null } } + return recordProperties + otherProperties } private fun typeElement(): TypeElement? = diff --git a/introspection/introspection-ksp/src/main/kotlin/io/javalin/introspection/ksp/KspTypeIntrospector.kt b/introspection/introspection-ksp/src/main/kotlin/io/javalin/introspection/ksp/KspTypeIntrospector.kt index 09222990..e8e491f3 100644 --- a/introspection/introspection-ksp/src/main/kotlin/io/javalin/introspection/ksp/KspTypeIntrospector.kt +++ b/introspection/introspection-ksp/src/main/kotlin/io/javalin/introspection/ksp/KspTypeIntrospector.kt @@ -26,6 +26,8 @@ import io.javalin.introspection.StructureType.ARRAY import io.javalin.introspection.StructureType.DEFAULT import io.javalin.introspection.StructureType.DICTIONARY import io.javalin.introspection.MemberVisibility +import io.javalin.introspection.isGetterName +import io.javalin.introspection.propertyName import java.lang.annotation.Inherited import java.lang.annotation.Repeatable as JavaRepeatable @@ -201,25 +203,62 @@ class KspTypeIntrospector(private val resolver: Resolver) : CompileTimeIntrospec override fun getAnnotations(): AnnotationSet = KspAnnotations(declaration) + @OptIn(KspExperimental::class) override fun getProperties(): List { val declaration = declaration ?: return emptyList() - return declaration - .getAllProperties() - .filter { property -> property.getVisibility() !in setOf(KspVisibility.PRIVATE, KspVisibility.LOCAL) } - .map { property -> - val propertyType = property.type.resolve() - PropertyProjection( - name = property.simpleName.asString(), - type = resolve(propertyType), - accessor = Accessor.GETTER, - nullable = propertyType.isMarkedNullable, - visibility = visibilityOf(property.getVisibility()), - transient = Modifier.JAVA_TRANSIENT in property.modifiers, - source = property, - annotations = KspAnnotations(elements = listOfNotNull(property, property.getter)), - ) - } - .toList() + val properties = + declaration + .getAllProperties() + .filter { property -> property.getVisibility() !in setOf(KspVisibility.PRIVATE, KspVisibility.LOCAL) } + .map { property -> + val propertyType = property.type.resolve() + PropertyProjection( + name = property.simpleName.asString(), + type = resolve(propertyType), + accessor = Accessor.GETTER, + nullable = propertyType.isMarkedNullable, + visibility = visibilityOf(property.getVisibility()), + transient = Modifier.JAVA_TRANSIENT in property.modifiers, + source = property, + annotations = KspAnnotations(elements = listOfNotNull(property, property.getter)), + ) + } + .toList() + + val getters = + declaration + .getAllFunctions() + .filter { it.getVisibility() != KspVisibility.PRIVATE || it.parentDeclaration == declaration } + .filter { Modifier.SUSPEND !in it.modifiers && Modifier.JAVA_STATIC !in it.modifiers } + .filter { it.parameters.isEmpty() && it.extensionReceiver == null } + .filterNot { function -> + declaration.classKind == ClassKind.OBJECT && !declaration.isCompanionObject && + function.annotations.any { it.annotationType.resolve().declaration.qualifiedName?.asString() == "kotlin.jvm.JvmStatic" } + } + .mapNotNull { function -> + val name = resolver.getJvmName(function) ?: function.simpleName.asString() + val annotations = KspAnnotations(function) + if (!isGetterName(name) && !annotations.contains("OpenApiName")) { + return@mapNotNull null + } + val returnType = function.returnType?.resolve() ?: return@mapNotNull null + if (returnType.declaration.qualifiedName?.asString() == "kotlin.Unit" && !returnType.isMarkedNullable) { + return@mapNotNull null + } + PropertyProjection( + name = propertyName(name), + type = resolve(returnType), + accessor = Accessor.GETTER, + nullable = returnType.isMarkedNullable, + visibility = visibilityOf(function.getVisibility()), + transient = false, + source = function, + annotations = annotations, + ) + } + .toList() + + return properties + getters } } diff --git a/introspection/introspection-runtime/src/main/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospector.kt b/introspection/introspection-runtime/src/main/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospector.kt index 849b714b..162924ac 100644 --- a/introspection/introspection-runtime/src/main/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospector.kt +++ b/introspection/introspection-runtime/src/main/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospector.kt @@ -36,7 +36,6 @@ class ReflectionTypeIntrospector : TypeIntrospector { private fun reflect( type: Type, - generics: List = emptyList(), structureType: StructureType = DEFAULT, visitingTypeVariables: Set> = emptySet(), ): ClassDefinition = @@ -44,14 +43,12 @@ private fun reflect( is GenericArrayType -> reflect( type = type.genericComponentType, - generics = generics, structureType = ARRAY, visitingTypeVariables = visitingTypeVariables, ) is WildcardType -> reflect( type = type.upperBounds.firstOrNull() ?: Any::class.java, - generics = generics, structureType = structureType, visitingTypeVariables = visitingTypeVariables, ) @@ -61,20 +58,17 @@ private fun reflect( else -> reflect( type = type.bounds.firstOrNull() ?: Any::class.java, - generics = generics, structureType = structureType, visitingTypeVariables = visitingTypeVariables + type, ) } is ParameterizedType -> parameterized( type = type, - generics = generics, structureType = structureType, visitingTypeVariables = visitingTypeVariables, ) is Class<*> -> raw( clazz = type, - generics = generics, structureType = structureType, visitingTypeVariables = visitingTypeVariables, ) @@ -83,21 +77,19 @@ private fun reflect( private fun raw( clazz: Class<*>, - generics: List, structureType: StructureType, visitingTypeVariables: Set>, ): ClassDefinition = when { clazz.isArray -> reflect( type = clazz.componentType, - generics = generics, structureType = ARRAY, visitingTypeVariables = visitingTypeVariables, ) clazz.isPrimitive -> definition( erasure = clazz.kotlin.javaObjectType, - generics = generics, + generics = emptyList(), structureType = structureType, source = clazz, ) @@ -108,12 +100,11 @@ private fun raw( structureType = DICTIONARY, ) Collection::class.java.isAssignableFrom(clazz) -> objectDefinition(ARRAY) - else -> definition(erasure = clazz, generics = generics, structureType = structureType) + else -> definition(erasure = clazz, generics = emptyList(), structureType = structureType) } private fun parameterized( type: ParameterizedType, - generics: List, structureType: StructureType, visitingTypeVariables: Set>, ): ClassDefinition { @@ -132,7 +123,6 @@ private fun parameterized( Collection::class.java.isAssignableFrom(erasure) -> reflect( type = arguments.getOrElse(0) { Any::class.java }, - generics = generics, structureType = ARRAY, visitingTypeVariables = visitingTypeVariables, ) @@ -219,8 +209,12 @@ private class ReflectionClassDefinition( } private fun collectMembers(clazz: Class<*>): List { + val members = mutableListOf() + val getterNames = mutableSetOf() + if (clazz.isRecord) { - return clazz.recordComponents.map { component -> + clazz.recordComponents.mapTo(members) { component -> + getterNames.add(component.accessor.name) val backingField = runCatching { clazz.getDeclaredField(component.name) }.getOrNull() Member( name = component.name, @@ -234,9 +228,6 @@ private fun collectMembers(clazz: Class<*>): List { } } - val members = mutableListOf() - val getterNames = mutableSetOf() - for (method in clazz.methods) { if (Modifier.isStatic(method.modifiers) || method.isBridge || method.isSynthetic) continue if (method.parameterCount != 0 || method.declaringClass == Any::class.java) continue @@ -252,6 +243,11 @@ private fun collectMembers(clazz: Class<*>): List { } } + if (clazz.isRecord) { + val componentNames = clazz.recordComponents.map { it.name } + return members.filter { it.accessor == Accessor.RECORD_COMPONENT || propertyName(it.name) !in componentNames } + } + for (field in declaredFieldsHierarchy(clazz)) { if (Modifier.isStatic(field.modifiers) || field.isSynthetic) continue members += Member( diff --git a/introspection/introspection-test/build.gradle.kts b/introspection/introspection-test/build.gradle.kts index 18ed34a5..b4964029 100644 --- a/introspection/introspection-test/build.gradle.kts +++ b/introspection/introspection-test/build.gradle.kts @@ -1,4 +1,7 @@ dependencies { + testImplementation(project(":openapi-annotation-processor")) + testImplementation(project(":openapi-ksp")) + testImplementation(project(":openapi-dynamic")) testImplementation(project(":introspection:introspection-api")) testImplementation(project(":introspection:introspection-runtime")) testImplementation(project(":introspection:introspection-jap")) @@ -12,3 +15,10 @@ dependencies { testRuntimeOnly(libs.junit.platform.launcher) testImplementation(libs.assertj.core) } + +tasks.test { + jvmArgs( + listOf("api", "code", "comp", "file", "jvm", "main", "model", "parser", "processing", "tree", "util") + .map { "--add-opens=jdk.compiler/com.sun.tools.javac.$it=ALL-UNNAMED" } + ) +} diff --git a/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/IntrospectionParityTest.kt b/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/IntrospectionParityTest.kt index 2bd801de..f603ea08 100644 --- a/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/IntrospectionParityTest.kt +++ b/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/IntrospectionParityTest.kt @@ -115,15 +115,6 @@ class IntrospectionParityTest { assertThat(processedRefs).isEqualTo(runtimeRefs).containsExactly(Address::class.java.name, Color::class.java.name) } - @Test - fun `annotation value maps match across backends`() { - val runtimeValue = runtime.introspect(Holder::class.java).getAnnotations().find(Ref::class.java)!!.get("value").asClassDefinition() - val processedValue = AnnotationProcessing.introspect(Holder::class) { it.getAnnotations().find(Ref::class.java)!!.get("value").asClassDefinition() } - assertThat(processedValue?.fullName) - .isEqualTo(runtimeValue?.fullName) - .isEqualTo(Address::class.java.name) - } - @Test fun `primitive array annotation members normalize identically across backends`() { val runtimeInts = runtime.introspect(Flagged::class.java).getAnnotations().find(Flags::class.java)!!.get("ints").asList() diff --git a/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/SchemaParityTest.kt b/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/SchemaParityTest.kt new file mode 100644 index 00000000..010d4524 --- /dev/null +++ b/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/SchemaParityTest.kt @@ -0,0 +1,289 @@ +package io.javalin.introspection.test + +import com.fasterxml.jackson.databind.JsonNode +import com.tschuchort.compiletesting.KotlinCompilation +import com.tschuchort.compiletesting.SourceFile +import com.tschuchort.compiletesting.kspSourcesDir +import com.tschuchort.compiletesting.symbolProcessorProviders +import com.tschuchort.compiletesting.useKsp2 +import io.javalin.openapi.OpenApi +import io.javalin.openapi.dynamic.ReflectionSchemaContext +import io.javalin.openapi.experimental.processor.shared.jsonMapper +import io.javalin.openapi.ksp.OpenApiSymbolProcessorProvider +import io.javalin.openapi.processor.OpenApiAnnotationProcessor +import io.javalin.openapi.schema.OpenApiSchemaGenerator +import org.assertj.core.api.Assertions.assertThat +import org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.api.TestInstance.Lifecycle.PER_CLASS +import java.io.File + +@OptIn(ExperimentalCompilerApi::class) +@TestInstance(PER_CLASS) +class SchemaParityTest { + + private val source = SourceFile.kotlin("Models.kt", """ + package parity + + import io.javalin.openapi.* + + @JsonSchema(requireNonNulls = false) + class Properties( + @get:OpenApiName("display_name") + @get:OpenApiRequired + val name: String, + @get:OpenApiIgnore + val secret: String, + @get:OpenApiNullable + val label: String?, + val values: List, + val mapping: Map, + val child: Child, + ) + + class Child(val id: Int) + + @OpenApi( + path = "/users/{id}", + pathParams = [OpenApiParam(name = "id", type = Int::class)], + responses = [OpenApiResponse(status = "200", content = [OpenApiContent(Properties::class)])], + ) + class Endpoint + + @JsonSchema + enum class Role { + @OpenApiName("administrator") + @OpenApiDescription("Administrator") + ADMIN, + USER, + } + + @JsonSchema(requireNonNulls = false) + class FunctionGetters : Parent() { + fun getName(): String = + "" + + @OpenApiName("age") + fun ageValue(): Int = + 1 + + @OpenApiIgnore + fun getIgnored(): String = + "" + + fun issue(): String = + "" + + fun getNeedsArgument(argument: String): String = + argument + + fun getNothing() {} + + @OpenApiNullable + fun getUnit(): Unit? = + null + + suspend fun getSuspended(): String = + "" + + private fun getPrivate(): String = + "" + + @JvmName("getAlias") + fun readAlias(): String = + "" + + @JvmName("readRenamed") + fun getRenamed(): String = + "" + } + + open class Parent { + fun getInherited(): Boolean = + true + + private fun getHidden(): String = + "" + } + + @JsonSchema(requireNonNulls = false) + object StaticGetters { + @JvmStatic + fun getIgnored(): String = + "" + + fun getName(): String = + "" + } + + class CompanionOwner { + @JsonSchema(requireNonNulls = false) + companion object { + @JvmStatic + fun getName(): String = + "" + } + } + + @JsonSchema(requireNonNulls = false) + class RecursiveBranches(val first: Node, val second: Node) + + class Node( + @get:OpenApiNullable + val next: Node?, + ) + """.trimIndent()) + + private val record = SourceFile.java("AnnotatedRecord.java", """ + package parity; + + import io.javalin.openapi.*; + + @JsonSchema(requireNonNulls = false) + public record AnnotatedRecord( + @OpenApiIgnore + String secret, + @OpenApiName("display_name") + @OpenApiRequired + String name + ) { + public String getLabel() { return ""; } + public String getName() { return name; } + public String getSecret() { return secret; } + } + """.trimIndent()) + + private val ap by lazy { compile(useKsp = false) } + private val ksp by lazy { compile(useKsp = true) } + + private class CompiledSources(val resources: File, val classLoader: ClassLoader) + + private fun compile(useKsp: Boolean): CompiledSources { + val compilation = KotlinCompilation().apply { + sources = listOf(source, record) + inheritClassPath = true + jvmTarget = "17" + when { + useKsp -> { + useKsp2() + symbolProcessorProviders = mutableListOf(OpenApiSymbolProcessorProvider()) + } + else -> annotationProcessors = listOf(OpenApiAnnotationProcessor()) + } + } + val result = compilation.compile() + check(result.exitCode == KotlinCompilation.ExitCode.OK) { result.messages } + val resources = when { + useKsp -> compilation.kspSourcesDir.resolve("resources") + else -> result.outputDirectory + } + return CompiledSources(resources = resources, classLoader = result.classLoader) + } + + private fun schema(name: String): JsonNode { + val resource = "json-schemes/parity.$name" + val apSchema = jsonMapper.readTree(ap.resources.resolve(resource)) + val kspSchema = jsonMapper.readTree(ksp.resources.resolve(resource)) + assertThat(kspSchema).describedAs("KSP schema for %s", name).isEqualTo(apSchema) + assertThat(runtimeSchema(name)).describedAs("Reflection schema for %s", name).isEqualTo(apSchema) + return apSchema + } + + private fun runtimeSchema(name: String): JsonNode { + val context = ReflectionSchemaContext() + return jsonMapper.readTree( + context.typeSchemaGenerator.createTypeSchema( + type = context.introspect(ap.classLoader.loadClass("parity.${name.replace('.', '$')}")), + inlineRefs = true, + ).toJsonSchemaString() + ) + } + + @Test + fun `route annotations and referenced components produce the same document`() { + val resource = "openapi-plugin/openapi-default.json" + val apDocument = jsonMapper.readTree(ap.resources.resolve(resource)) + val kspDocument = jsonMapper.readTree(ksp.resources.resolve(resource)) + val context = ReflectionSchemaContext() + val endpoint = context.introspect(ap.classLoader.loadClass("parity.Endpoint")) + val routes = context.annotationsOf(endpoint).findAll(OpenApi::class.java).map { it.values } + val runtimeDocument = jsonMapper.readTree( + OpenApiSchemaGenerator(context = context, title = "", version = "").generateSchema(routes) + ) + + assertThat(kspDocument).isEqualTo(apDocument) + assertThat(runtimeDocument).isEqualTo(apDocument) + val operation = apDocument.path("paths").path("/users/{id}").path("get") + assertThat(operation.path("parameters")[0].path("required").asBoolean()).isTrue() + val responseSchema = operation.path("responses").path("200").path("content").path("application/json").path("schema") + assertThat(responseSchema.path($$"$ref").asText()) + .isEqualTo("#/components/schemas/Properties") + assertThat(apDocument.path("components").path("schemas").has("Child")).isTrue() + } + + @Test + fun `Java record annotations and extra getters agree between AP and reflection`() { + val apSchema = jsonMapper.readTree(ap.resources.resolve("json-schemes/parity.AnnotatedRecord")) + assertThat(runtimeSchema("AnnotatedRecord")).isEqualTo(apSchema) + assertThat(apSchema.path("properties").fieldNames().asSequence().toList()) + .containsExactlyInAnyOrder("display_name", "label") + assertThat(apSchema.path("required").map { it.asText() }).containsExactly("display_name") + } + + @Test + fun `properties annotations and nested types produce the same schema`() { + val schema = schema("Properties") + val expected = jsonMapper.readTree( + // language=json + """ + { + "display_name": {"type": "string"}, + "label": {"type": ["string", "null"]}, + "values": {"type": "array", "items": {"type": "integer", "format": "int32"}}, + "mapping": {"type": "object", "additionalProperties": {"type": "integer", "format": "int32"}}, + "child": {"type": "object", "properties": {"id": {"type": "integer", "format": "int32"}}} + } + """ + ) + assertThat(schema.path("properties")).isEqualTo(expected) + assertThat(schema.path("required").map { it.asText() }).containsExactly("display_name") + } + + @Test + fun `enum names and descriptions produce the same schema`() { + val schema = schema("Role") + assertThat(schema.path("enum").map { it.asText() }).containsExactly("administrator", "USER") + assertThat(schema.path("x-enum-descriptions").map { it.asText() }).containsExactly("Administrator", "") + } + + @Test + fun `explicit getters produce the same schema`() { + val properties = schema("FunctionGetters").path("properties") + assertThat(properties.fieldNames().asSequence().toList()).containsExactlyInAnyOrder("name", "age", "inherited", "private", "alias", "unit") + assertThat(properties.path("name").path("type").asText()).isEqualTo("string") + assertThat(properties.path("age").path("type").asText()).isEqualTo("integer") + assertThat(properties.path("inherited").path("type").asText()).isEqualTo("boolean") + } + + @Test + fun `static getters are excluded by every backend`() { + val properties = schema("StaticGetters").path("properties") + assertThat(properties.fieldNames().asSequence().toList()).containsExactly("name") + } + + @Test + fun `companion getters retain their instance methods`() { + val properties = schema("CompanionOwner.Companion").path("properties") + assertThat(properties.fieldNames().asSequence().toList()).containsExactly("name") + } + + @Test + fun `recursive branches have unique reference targets`() { + val schema = schema("RecursiveBranches") + val anchors = schema.findValuesAsText("\$anchor") + val references = schema.findValuesAsText("\$ref") + assertThat(anchors).hasSize(2).doesNotHaveDuplicates() + assertThat(references).containsExactlyInAnyOrderElementsOf(anchors.map { "#$it" }) + } +} diff --git a/javalin-plugins/javalin-openapi-dynamic-hook/src/main/kotlin/io/javalin/openapi/dynamic/hook/RegisteredRoutesHook.kt b/javalin-plugins/javalin-openapi-dynamic-hook/src/main/kotlin/io/javalin/openapi/dynamic/hook/RegisteredRoutesHook.kt index 495512ec..4cd4b810 100644 --- a/javalin-plugins/javalin-openapi-dynamic-hook/src/main/kotlin/io/javalin/openapi/dynamic/hook/RegisteredRoutesHook.kt +++ b/javalin-plugins/javalin-openapi-dynamic-hook/src/main/kotlin/io/javalin/openapi/dynamic/hook/RegisteredRoutesHook.kt @@ -73,10 +73,10 @@ class RegisteredRoutesHook @JvmOverloads constructor( } } - when (metadata) { - null -> responses { response("200") { description("OK") } } - else -> metadata.configure(this) + if (!operationAlreadyDocumented) { + responses { response("200") { description("OK") } } } + metadata?.configure(this) } } diff --git a/javalin-plugins/javalin-openapi-dynamic-hook/src/test/kotlin/io/javalin/openapi/dynamic/hook/DynamicOpenApiHookTest.kt b/javalin-plugins/javalin-openapi-dynamic-hook/src/test/kotlin/io/javalin/openapi/dynamic/hook/DynamicOpenApiHookTest.kt index e1e3535c..972006c7 100644 --- a/javalin-plugins/javalin-openapi-dynamic-hook/src/test/kotlin/io/javalin/openapi/dynamic/hook/DynamicOpenApiHookTest.kt +++ b/javalin-plugins/javalin-openapi-dynamic-hook/src/test/kotlin/io/javalin/openapi/dynamic/hook/DynamicOpenApiHookTest.kt @@ -122,6 +122,58 @@ class DynamicOpenApiHookTest { } } + @Test + fun `summary metadata preserves the generated response`() { + val app = Javalin.start { config -> + config.jetty.port = 0 + config.registerPlugin(OpenApiPlugin { it.withHook(RegisteredRoutesHook()) }) + config.routes.addEndpoint( + Endpoint.create(HandlerType.GET, "/users") + .addMetadata(OpenApiMetadata { summary("List users") }) + .handler { } + ) + } + + try { + val document = jsonMapper.readTree(Unirest.get("http://localhost:${app.port()}/openapi").asString().body) + val operation = document.path("paths").path("/users").path("get") + assertThat(operation.path("summary").asText()).isEqualTo("List users") + assertThat(operation.path("responses").path("200").path("description").asText()).isEqualTo("OK") + } finally { + app.stop() + } + } + + @Test + fun `metadata preserves responses from existing documentation`() { + val app = Javalin.start { config -> + config.jetty.port = 0 + config.registerPlugin(OpenApiPlugin { plugin -> + plugin.withHook { context -> + context.builder.path("/users").operation("post") { + responses { response("201") { description("Created") } } + } + } + plugin.withHook(RegisteredRoutesHook()) + }) + config.routes.addEndpoint( + Endpoint.create(HandlerType.POST, "/users") + .addMetadata(OpenApiMetadata { summary("Create a user") }) + .handler { } + ) + } + + try { + val document = jsonMapper.readTree(Unirest.get("http://localhost:${app.port()}/openapi").asString().body) + val operation = document.path("paths").path("/users").path("post") + assertThat(operation.path("summary").asText()).isEqualTo("Create a user") + assertThat(operation.path("responses").fieldNames().asSequence().toList()).containsExactly("201") + assertThat(operation.path("responses").path("201").path("description").asText()).isEqualTo("Created") + } finally { + app.stop() + } + } + @Test fun `enriches a route from OpenApiMetadata`() { val app = Javalin.start { config -> @@ -147,6 +199,7 @@ class DynamicOpenApiHookTest { val get = document.path("paths").path("/users/{id}").path("get") assertThat(get.path("summary").asText()).isEqualTo("Get a user") + assertThat(get.path("responses").path("200").path("description").asText()).isEqualTo("The user") assertThat(get.path("parameters")[0].path("name").asText()).isEqualTo("id") val schema = get.path("responses").path("200").path("content").path("application/json").path("schema") diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/TypeSchemaGenerator.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/TypeSchemaGenerator.kt index 094029d8..a4243c7e 100644 --- a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/TypeSchemaGenerator.kt +++ b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/TypeSchemaGenerator.kt @@ -15,21 +15,14 @@ import io.javalin.openapi.experimental.processor.shared.* class TypeSchemaGenerator(val context: SchemaGenerationContext) { - private val processedProperties = mutableMapOf() private val activeInlineTypes = mutableMapOf() - private val inlineTypeAnchors = mutableMapOf() - private var inlineSchemaDepth = 0 + private var nextInlineAnchor = 0 private data class InlineType( val fullName: String, val structureType: StructureType, ) - private data class ProcessedProperty( - val property: Property, - val requiresNonNulls: Boolean, - ) - private fun OpenApiType.definedBy(): OpenApiType? = context.annotationsOf(this) .find(OpenApiPropertyType::class.java) @@ -42,159 +35,128 @@ class TypeSchemaGenerator(val context: SchemaGenerationContext) { inlineRefs: Boolean = false, requireNonNullsByDefault: Boolean = true, ): ResultScheme { - val isStandaloneJsonSchema = inlineRefs - val isRootJsonSchema = isStandaloneJsonSchema && inlineSchemaDepth == 0 + if (inlineRefs) { + if (activeInlineTypes.isEmpty()) { + this.nextInlineAnchor = 0 + } + activeInlineTypes[type.toInlineType()]?.let { activeSchema -> + val anchor = activeSchema.get($$"$anchor")?.asText() + ?: "javalin-${this.nextInlineAnchor++}".also { activeSchema.put($$"$anchor", it) } + return ResultScheme( + json = createObjectNode().put($$"$ref", "#$anchor"), + references = emptySet(), + ) + } + } + + context.reportDebug("OpenApi | Generating schema for ${type.fullName}") + + val annotations = context.annotationsOf(type) + val isEnum = context.isEnum(type) + val definedBy = type.definedBy() + + if (definedBy != null && !isEnum) { + return createTypeSchema( + type = definedBy, + inlineRefs = inlineRefs, + requireNonNullsByDefault = requireNonNullsByDefault, + ) + } - if (isStandaloneJsonSchema) { - inlineSchemaDepth++ + val schema = createObjectNode() + val references = mutableSetOf() + val composition = findCompositionInElement(context, annotations) + + if (inlineRefs) { + activeInlineTypes[type.toInlineType()] = schema } try { - if (isStandaloneJsonSchema) { - val inlineType = type.toInlineType() - activeInlineTypes[inlineType]?.let { activeSchema -> - activeSchema.put($$"$anchor", inlineAnchorFor(inlineType)) - return ResultScheme( - json = createObjectNode().put($$"$ref", "#${inlineAnchorFor(inlineType)}"), - references = emptySet(), + when { + composition != null -> { + schema.createComposition( + context = context, + type = type, + propertyComposition = composition, + references = references, + inlineRefs = inlineRefs, + requiresNonNulls = requireNonNullsByDefault, ) } - } + isEnum -> { + val enumType = definedBy + ?.let { context.simpleTypeMappings[it.fullName] } + + val namingStrategy = annotations.namingStrategy() + val values = createArrayNode() + val descriptions = createArrayNode() + + for (constant in context.enumConstantsOf(type)) { + val customName = constant.annotations.find(OpenApiName::class.java)?.get("value")?.asString() + val description = constant.annotations.find(OpenApiDescription::class.java)?.get("value")?.asString() + val name = when { + customName != null -> customName + namingStrategy != null -> translatePropertyName(namingStrategy, constant.name) + else -> constant.name + } - context.reportDebug("OpenApi | Generating schema for ${type.fullName}") + when { + enumType != null && enumType.type != "string" -> values.add(jsonMapper.readTree(name)) + else -> values.add(name) + } + descriptions.add(description ?: "") + } - val annotations = context.annotationsOf(type) - val isEnum = context.isEnum(type) - val definedBy = type.definedBy() + schema.put("type", enumType?.type ?: "string") + enumType?.format?.also { schema.put("format", it) } + schema.set("enum", values) - if (definedBy != null && !isEnum) { - return createTypeSchema( - type = definedBy, - inlineRefs = inlineRefs, - requireNonNullsByDefault = requireNonNullsByDefault, - ) - } + if (descriptions.any { it.isTextual && it.asText().isNotEmpty() }) { + schema.set("x-enum-descriptions", descriptions) + } - val schema = createObjectNode() - val references = mutableSetOf() - val composition = findCompositionInElement(context, annotations) + schema.addExtra(annotations.findExtra()) + } + else -> { + schema.put("type", "object") - if (isStandaloneJsonSchema) { - activeInlineTypes[type.toInlineType()] = schema - } + schema.addExtra(annotations.findExtra()) - try { - when { - composition != null -> { - schema.createComposition( - context = context, - type = type, - propertyComposition = composition, - references = references, - inlineRefs = inlineRefs, - requiresNonNulls = requireNonNullsByDefault, - ) - } - isEnum -> { - val enumType = definedBy - ?.let { context.simpleTypeMappings[it.fullName] } - - val namingStrategy = annotations.namingStrategy() - val values = createArrayNode() - val descriptions = createArrayNode() - - for (constant in context.enumConstantsOf(type)) { - val customName = constant.annotations.find(OpenApiName::class.java)?.get("value")?.asString() - val description = constant.annotations.find(OpenApiDescription::class.java)?.get("value")?.asString() - val name = when { - customName != null -> customName - namingStrategy != null -> translatePropertyName(namingStrategy, constant.name) - else -> constant.name - } - - when { - enumType != null && enumType.type != "string" -> values.add(jsonMapper.readTree(name)) - else -> values.add(name) - } - descriptions.add(description ?: "") - } + val propertiesObject = createObjectNode() + schema.set("properties", propertiesObject) - schema.put("type", enumType?.type ?: "string") - enumType?.format?.also { schema.put("format", it) } - schema.set("enum", values) + val requireNonNulls = (annotations.find(JsonSchema::class.java)?.get("requireNonNulls")?.asBoolean()) + ?: requireNonNullsByDefault - if (descriptions.any { it.isTextual && it.asText().isNotEmpty() }) { - schema.set("x-enum-descriptions", descriptions) - } + val properties = context.findAllProperties(type, requireNonNulls) - schema.addExtra(annotations.findExtra()) + properties.forEach { property -> + val result = createEmbeddedTypeDescription( + type = property.type, + inlineRefs = inlineRefs, + requiresNonNulls = requireNonNulls, + composition = property.composition, + extra = property.extra, + nullable = property.nullable, + ) + propertiesObject.set(property.name, result.json) + result.references.forEach { references.addReference(it) } } - else -> { - schema.put("type", "object") - - schema.addExtra(annotations.findExtra()) - - val propertiesObject = createObjectNode() - schema.set("properties", propertiesObject) - - val requireNonNulls = (annotations.find(JsonSchema::class.java)?.get("requireNonNulls")?.asBoolean()) - ?: requireNonNullsByDefault - - val properties = context.findAllProperties(type, requireNonNulls) - - properties.forEach { property -> - val result = - when { - inlineRefs -> createEmbeddedTypeDescription( - type = property.type, - inlineRefs = true, - requiresNonNulls = requireNonNulls, - composition = property.composition, - extra = property.extra, - nullable = property.nullable, - ) - else -> processedProperties.getOrPut( - ProcessedProperty( - property = property, - requiresNonNulls = requireNonNulls, - ) - ) { - createEmbeddedTypeDescription( - type = property.type, - inlineRefs = false, - requiresNonNulls = requireNonNulls, - composition = property.composition, - extra = property.extra, - nullable = property.nullable, - ) - } - } - propertiesObject.set(property.name, result.json) - result.references.forEach { references.addReference(it) } - } - if (properties.any { it.required }) { - val required = createArrayNode() - properties.filter { it.required }.forEach { required.add(it.name) } - schema.set("required", required) - } + if (properties.any { it.required }) { + val required = createArrayNode() + properties.filter { it.required }.forEach { required.add(it.name) } + schema.set("required", required) } } - } finally { - if (isStandaloneJsonSchema) { - activeInlineTypes.remove(type.toInlineType()) - } } - - return ResultScheme(json = schema, references = references) } finally { - if (isStandaloneJsonSchema) { - inlineSchemaDepth-- - } - if (isRootJsonSchema) { - inlineTypeAnchors.clear() + if (inlineRefs) { + activeInlineTypes.remove(type.toInlineType()) } } + + return ResultScheme(json = schema, references = references) } private fun OpenApiType.toInlineType(): InlineType = @@ -203,9 +165,6 @@ class TypeSchemaGenerator(val context: SchemaGenerationContext) { structureType = structureType, ) - private fun inlineAnchorFor(type: InlineType): String = - inlineTypeAnchors.getOrPut(type) { "javalin-${inlineTypeAnchors.size}" } - fun createEmbeddedTypeDescription( type: OpenApiType, inlineRefs: Boolean = false, diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/schema/OpenApiRouteDefinition.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/schema/OpenApiRouteDefinition.kt index b9f17ad2..99efbd8d 100644 --- a/openapi-generator/src/main/kotlin/io/javalin/openapi/schema/OpenApiRouteDefinition.kt +++ b/openapi-generator/src/main/kotlin/io/javalin/openapi/schema/OpenApiRouteDefinition.kt @@ -157,9 +157,6 @@ internal data class OpenApiContentDefinition( val example: String?, val exampleObjects: List, ) { - val resolvedSource: ClassDefinition? - get() = from?.takeUnless { it.fullName == NULL_CLASS::class.java.name } - companion object { fun from(values: Map): OpenApiContentDefinition = OpenApiContentDefinition( @@ -184,9 +181,6 @@ internal data class OpenApiContentPropertyDefinition( val type: String?, val format: String?, ) { - val resolvedSource: ClassDefinition? - get() = from?.takeUnless { it.fullName == NULL_CLASS::class.java.name } - companion object { fun from(values: Map): OpenApiContentPropertyDefinition = OpenApiContentPropertyDefinition( @@ -211,7 +205,8 @@ private fun Map.nonEmptyString(key: String): String? = string(key) private fun Map.boolean(key: String): Boolean = get(key) as? Boolean ?: false -private fun Map.classDefinition(key: String): ClassDefinition? = get(key) as? ClassDefinition +private fun Map.classDefinition(key: String): ClassDefinition? = + (get(key) as? ClassDefinition)?.takeUnless { it.fullName == NULL_CLASS::class.java.name } private fun Map.strings(key: String): List = (get(key) as? List<*>)?.filterIsInstance().orEmpty() diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/schema/OpenApiSchemaBuilder.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/schema/OpenApiSchemaBuilder.kt index 062046a1..7b8f02e2 100644 --- a/openapi-generator/src/main/kotlin/io/javalin/openapi/schema/OpenApiSchemaBuilder.kt +++ b/openapi-generator/src/main/kotlin/io/javalin/openapi/schema/OpenApiSchemaBuilder.kt @@ -245,33 +245,13 @@ class OpenApiSchemaBuilder { fun fromJson(json: String): OpenApiSchemaBuilder { val builder = OpenApiSchemaBuilder() val parsed = jsonMapper.readTree(json) as ObjectNode - - parsed.properties().forEach { (key, value) -> - when (key) { - "paths" -> { - val pathsNode = value as? ObjectNode ?: return@forEach - pathsNode.properties().forEach { (pathKey, pathValue) -> - builder.paths.set(pathKey, pathValue.deepCopy()) - } - } - "components" -> { - val componentsNode = value as? ObjectNode ?: return@forEach - val schemas = componentsNode.get("schemas") as? ObjectNode - schemas?.properties()?.forEach { (schemaName, schemaValue) -> - builder.componentSchemas.set(schemaName, schemaValue.deepCopy()) - } - componentsNode.properties() - .filter { it.key != "schemas" } - .forEach { (compKey, compValue) -> - val rootComponents = builder.root.get("components") as? ObjectNode - ?: createObjectNode().also { builder.root.set("components", it) } - rootComponents.set(compKey, compValue.deepCopy()) - } - } - else -> builder.root.set(key, value.deepCopy()) - } + (parsed.remove("paths") as? ObjectNode)?.let { builder.paths.setAll(it) } + val components = parsed.get("components") as? ObjectNode + (components?.remove("schemas") as? ObjectNode)?.let { builder.componentSchemas.setAll(it) } + if (components == null || components.isEmpty) { + parsed.remove("components") } - + builder.root.setAll(parsed) return builder } } @@ -317,29 +297,14 @@ class OperationBuilder( existing: ObjectNode? = null, ) { - private val operation = createObjectNode().also { op -> - existing?.properties()?.forEach { (key, value) -> - if (key !in MANAGED_FIELDS) op.set(key, value.deepCopy()) - } - } - private var tagsArray = (existing?.get("tags") as? ArrayNode)?.deepCopy() ?: createArrayNode() - private var parametersArray = (existing?.get("parameters") as? ArrayNode)?.deepCopy() ?: createArrayNode() - private var responsesObject = (existing?.get("responses") as? ObjectNode)?.deepCopy() - private var callbacksObject = (existing?.get("callbacks") as? ObjectNode)?.deepCopy() - private var securityArray = (existing?.get("security") as? ArrayNode)?.deepCopy() ?: createArrayNode() - private var requestBodyObject = (existing?.get("requestBody") as? ObjectNode)?.deepCopy() - private var deprecatedValue = existing?.get("deprecated")?.asBoolean() - - companion object { - private val MANAGED_FIELDS = setOf("tags", "parameters", "requestBody", "responses", "callbacks", "security", "deprecated") - } + private val operation = existing?.deepCopy() ?: createObjectNode() fun tags(vararg tags: String) { tags(tags.asList()) } fun tags(tags: Collection) { - this.tagsArray = createArrayNode() + val tagsArray = operation.putArray("tags") tags.forEach { tagsArray.add(it) } } @@ -356,51 +321,51 @@ class OperationBuilder( } fun deprecated(value: Boolean) { - this.deprecatedValue = value + operation.put("deprecated", value) } - fun addTag(tag: String) { - tagsArray.add(tag) - } + fun addTag(tag: String) = + addTags(listOf(tag)) fun addTags(tags: Collection) { + val tagsArray = operation.get("tags") as? ArrayNode ?: operation.putArray("tags") tags.forEach { tagsArray.add(it) } } fun parameters(configure: ParametersBuilder.() -> Unit) { - val builder = ParametersBuilder(refCollector = refCollector, existing = parametersArray) + val builder = ParametersBuilder(refCollector = refCollector, existing = operation.get("parameters") as? ArrayNode) builder.configure() - this.parametersArray = builder.build() + operation.set("parameters", builder.build()) } fun requestBody(configure: RequestBodyBuilder.() -> Unit) { - val builder = RequestBodyBuilder(refCollector = refCollector, existing = requestBodyObject) + val builder = RequestBodyBuilder(refCollector = refCollector, existing = operation.get("requestBody") as? ObjectNode) builder.configure() val built = builder.build() if (built.size() > 0) { - this.requestBodyObject = built + operation.set("requestBody", built) } } fun responses(configure: ResponsesBuilder.() -> Unit) { - val builder = ResponsesBuilder(refCollector = refCollector, existing = responsesObject) + val builder = ResponsesBuilder(refCollector = refCollector, existing = operation.get("responses") as? ObjectNode) builder.configure() - this.responsesObject = builder.build() + operation.set("responses", builder.build()) } fun callbacks(configure: CallbacksBuilder.() -> Unit) { - val builder = CallbacksBuilder(refCollector = refCollector, existing = callbacksObject) + val builder = CallbacksBuilder(refCollector = refCollector, existing = operation.get("callbacks") as? ObjectNode) builder.configure() val built = builder.build() if (built.size() > 0) { - this.callbacksObject = built + operation.set("callbacks", built) } } fun security(configure: SecurityBuilder.() -> Unit) { - val builder = SecurityBuilder(securityArray) + val builder = SecurityBuilder(operation.get("security") as? ArrayNode) builder.configure() - this.securityArray = builder.build() + operation.set("security", builder.build()) } fun parameters(configure: Consumer) = @@ -417,30 +382,32 @@ class OperationBuilder( internal fun build(): ObjectNode { val result = createObjectNode() - if (tagsArray.size() > 0) { - result.set("tags", tagsArray) - } - - for (entry in operation.properties()) { - result.set(entry.key, entry.value) - } + (operation.get("tags") as? ArrayNode) + ?.takeIf { !it.isEmpty } + ?.let { result.set("tags", it) } - if (parametersArray.size() > 0) { - result.set("parameters", parametersArray) + val managedFields = setOf("tags", "parameters", "requestBody", "responses", "callbacks", "security", "deprecated") + for ((key, value) in operation.properties()) { + if (key !in managedFields) { + result.set(key, value) + } } - requestBodyObject?.let { result.set("requestBody", it) } - - result.set("responses", responsesObject ?: createObjectNode()) - - callbacksObject?.let { result.set("callbacks", it) } + (operation.get("parameters") as? ArrayNode) + ?.takeIf { !it.isEmpty } + ?.let { result.set("parameters", it) } - if (deprecatedValue == true) { result.put("deprecated", true) } - - if (securityArray.size() > 0) { - result.set("security", securityArray) + (operation.get("requestBody") as? ObjectNode)?.let { result.set("requestBody", it) } + result.set("responses", operation.get("responses") as? ObjectNode ?: createObjectNode()) + (operation.get("callbacks") as? ObjectNode)?.let { result.set("callbacks", it) } + if (operation.get("deprecated")?.asBoolean() == true) { + result.put("deprecated", true) } + (operation.get("security") as? ArrayNode) + ?.takeIf { !it.isEmpty } + ?.let { result.set("security", it) } + return result } } @@ -550,11 +517,7 @@ class RequestBodyBuilder( existing: ObjectNode? = null, ) { - private val requestBody = createObjectNode().also { rb -> - existing?.get("description")?.let { rb.set("description", it.deepCopy()) } - existing?.get("required")?.let { rb.set("required", it.deepCopy()) } - } - private var contentObject = (existing?.get("content") as? ObjectNode)?.deepCopy() + private val requestBody = existing?.deepCopy() ?: createObjectNode() fun description(value: String?) { value?.let { requestBody.put("description", it) } @@ -565,11 +528,11 @@ class RequestBodyBuilder( } fun content(configure: ContentBuilder.() -> Unit) { - val builder = ContentBuilder(refCollector = refCollector, existing = contentObject) + val builder = ContentBuilder(refCollector = refCollector, existing = requestBody.get("content") as? ObjectNode) builder.configure() val built = builder.build() if (built.size() > 0) { - this.contentObject = built + requestBody.set("content", built) } } @@ -583,7 +546,7 @@ class RequestBodyBuilder( result.set("description", requestBody.get("description")) } - contentObject?.let { result.set("content", it) } + (requestBody.get("content") as? ObjectNode)?.let { result.set("content", it) } if (result.size() == 0) { return result @@ -629,18 +592,15 @@ class MediaTypeBuilder( existing: ObjectNode? = null, ) : ExampleHolder { - private val mediaType = createObjectNode().also { mt -> - existing?.get("example")?.let { mt.set("example", it.deepCopy()) } - } - private var schemaObject = (existing?.get("schema") as? ObjectNode)?.deepCopy() + private val mediaType = existing?.deepCopy() ?: createObjectNode() fun schema(resolved: ResultScheme) { refCollector(resolved.references) - this.schemaObject = resolved.json + mediaType.set("schema", resolved.json) } fun schema(configure: SchemaBuilder.() -> Unit) { - this.schemaObject = SchemaBuilder().apply(configure).build() + mediaType.set("schema", SchemaBuilder().apply(configure).build()) } fun schema(configure: Consumer) = @@ -649,7 +609,7 @@ class MediaTypeBuilder( fun objectSchema(configure: ObjectSchemaBuilder.() -> Unit) { val builder = ObjectSchemaBuilder(refCollector) builder.configure() - this.schemaObject = builder.build() + mediaType.set("schema", builder.build()) } fun objectSchema(configure: Consumer) = @@ -666,15 +626,13 @@ class MediaTypeBuilder( internal fun build(): ObjectNode { val result = createObjectNode() - schemaObject?.let { schema -> + (mediaType.get("schema") as? ObjectNode)?.let { schema -> if (schema.size() > 0) { result.set("schema", schema) } } - for (entry in mediaType.properties()) { - result.set(entry.key, entry.value) - } + mediaType.get("example")?.let { result.set("example", it) } return result } @@ -685,10 +643,8 @@ class ObjectSchemaBuilder( private val refCollector: (Set) -> Unit = {}, ) : ExampleHolder { - private val properties = createObjectNode() - private var additionalPropertiesObject: ObjectNode? = null - private var exampleValue: String? = null - private var exampleJsonValue: JsonNode? = null + private val schema = createObjectNode() + private val properties = schema.putObject("properties") fun property(name: String, schema: ResultScheme) { refCollector(schema.references) @@ -739,11 +695,11 @@ class ObjectSchemaBuilder( fun additionalProperties(schema: ResultScheme) { refCollector(schema.references) - this.additionalPropertiesObject = schema.json + this.schema.set("additionalProperties", schema.json) } fun additionalProperties(schema: SchemaBuilder.() -> Unit) { - this.additionalPropertiesObject = SchemaBuilder().apply(schema).build() + this.schema.set("additionalProperties", SchemaBuilder().apply(schema).build()) } fun additionalProperties(schema: Consumer) = @@ -753,17 +709,15 @@ class ObjectSchemaBuilder( val schema = createObjectNode() type?.let { schema.put("type", it) } format?.let { schema.put("format", it) } - this.additionalPropertiesObject = schema + this.schema.set("additionalProperties", schema) } override fun example(value: String) { - this.exampleValue = value - this.exampleJsonValue = null + schema.put("example", value) } override fun exampleJson(value: JsonNode) { - this.exampleJsonValue = value - this.exampleValue = null + schema.set("example", value) } internal fun build(): ObjectNode { @@ -774,12 +728,8 @@ class ObjectSchemaBuilder( result.set("properties", properties) } - additionalPropertiesObject?.let { result.set("additionalProperties", it) } - - when { - exampleValue != null -> result.put("example", exampleValue) - exampleJsonValue != null -> result.set("example", exampleJsonValue) - } + schema.get("additionalProperties")?.let { result.set("additionalProperties", it) } + schema.get("example")?.let { result.set("example", it) } return result } @@ -812,31 +762,27 @@ class ResponseBuilder( existing: ObjectNode? = null, ) { - private val response = createObjectNode().also { r -> - existing?.get("description")?.let { r.set("description", it.deepCopy()) } - } - private var contentObject = (existing?.get("content") as? ObjectNode)?.deepCopy() - private var headersObject = (existing?.get("headers") as? ObjectNode)?.deepCopy() + private val response = existing?.deepCopy() ?: createObjectNode() fun description(value: String?) { value?.let { response.put("description", it) } } fun content(configure: ContentBuilder.() -> Unit) { - val builder = ContentBuilder(refCollector = refCollector, existing = contentObject) + val builder = ContentBuilder(refCollector = refCollector, existing = response.get("content") as? ObjectNode) builder.configure() val built = builder.build() if (built.size() > 0) { - this.contentObject = built + response.set("content", built) } } fun headers(configure: HeadersBuilder.() -> Unit) { - val builder = HeadersBuilder(refCollector = refCollector, existing = headersObject) + val builder = HeadersBuilder(refCollector = refCollector, existing = response.get("headers") as? ObjectNode) builder.configure() val built = builder.build() if (built.size() > 0) { - this.headersObject = built + response.set("headers", built) } } @@ -849,8 +795,8 @@ class ResponseBuilder( val result = createObjectNode() result.put("description", response.get("description")?.asText() ?: "") - contentObject?.let { result.set("content", it) } - headersObject?.let { result.set("headers", it) } + (response.get("content") as? ObjectNode)?.let { result.set("content", it) } + (response.get("headers") as? ObjectNode)?.let { result.set("headers", it) } return result } @@ -974,17 +920,7 @@ class CallbackOperationBuilder( existing: ObjectNode? = null, ) { - private val operation = createObjectNode().also { op -> - existing?.properties()?.forEach { (key, value) -> - if (key !in MANAGED_FIELDS) op.set(key, value.deepCopy()) - } - } - private var requestBodyObject = (existing?.get("requestBody") as? ObjectNode)?.deepCopy() - private var responsesObject = (existing?.get("responses") as? ObjectNode)?.deepCopy() - - companion object { - private val MANAGED_FIELDS = setOf("requestBody", "responses") - } + private val operation = existing?.deepCopy() ?: createObjectNode() fun summary(value: String?) { value?.let { operation.put("summary", it) } @@ -995,18 +931,18 @@ class CallbackOperationBuilder( } fun requestBody(configure: RequestBodyBuilder.() -> Unit) { - val builder = RequestBodyBuilder(refCollector = refCollector, existing = requestBodyObject) + val builder = RequestBodyBuilder(refCollector = refCollector, existing = operation.get("requestBody") as? ObjectNode) builder.configure() val built = builder.build() if (built.size() > 0) { - this.requestBodyObject = built + operation.set("requestBody", built) } } fun responses(configure: ResponsesBuilder.() -> Unit) { - val builder = ResponsesBuilder(refCollector = refCollector, existing = responsesObject) + val builder = ResponsesBuilder(refCollector = refCollector, existing = operation.get("responses") as? ObjectNode) builder.configure() - this.responsesObject = builder.build() + operation.set("responses", builder.build()) } fun requestBody(configure: Consumer) = @@ -1017,12 +953,14 @@ class CallbackOperationBuilder( internal fun build(): ObjectNode { val result = createObjectNode() - for (entry in operation.properties()) { - result.set(entry.key, entry.value) + for ((key, value) in operation.properties()) { + if (key != "requestBody" && key != "responses") { + result.set(key, value) + } } - requestBodyObject?.let { result.set("requestBody", it) } - responsesObject?.let { result.set("responses", it) } + (operation.get("requestBody") as? ObjectNode)?.let { result.set("requestBody", it) } + (operation.get("responses") as? ObjectNode)?.let { result.set("responses", it) } return result } diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/schema/OpenApiSchemaGenerator.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/schema/OpenApiSchemaGenerator.kt index d807b70d..532a3b92 100644 --- a/openapi-generator/src/main/kotlin/io/javalin/openapi/schema/OpenApiSchemaGenerator.kt +++ b/openapi-generator/src/main/kotlin/io/javalin/openapi/schema/OpenApiSchemaGenerator.kt @@ -224,7 +224,7 @@ class OpenApiSchemaGenerator( replaceFirstChar { it.titlecase(Locale.getDefault()) } private fun resolveMediaType(content: OpenApiContentDefinition): Pair Unit>? { - val source = content.resolvedSource + val source = content.from var type = content.type var mimeType = content.mimeType @@ -288,7 +288,7 @@ class OpenApiSchemaGenerator( private fun ObjectSchemaBuilder.buildProperties(properties: List) { for (property in properties) { - val source = property.resolvedSource + val source = property.from when { property.isArray && source != null -> @@ -304,7 +304,7 @@ class OpenApiSchemaGenerator( } private fun ObjectSchemaBuilder.buildAdditionalProperties(additionalProperties: OpenApiContentDefinition) { - val source = additionalProperties.resolvedSource + val source = additionalProperties.from when { source != null -> additionalProperties(createTypeDescriptionWithReferences(source)) diff --git a/openapi-generator/src/test/kotlin/io/javalin/openapi/schema/OpenApiRouteDefinitionTest.kt b/openapi-generator/src/test/kotlin/io/javalin/openapi/schema/OpenApiRouteDefinitionTest.kt index b47f9bda..668b0a14 100644 --- a/openapi-generator/src/test/kotlin/io/javalin/openapi/schema/OpenApiRouteDefinitionTest.kt +++ b/openapi-generator/src/test/kotlin/io/javalin/openapi/schema/OpenApiRouteDefinitionTest.kt @@ -6,6 +6,7 @@ import io.javalin.introspection.ClassDefinition import io.javalin.introspection.EnumConstant import io.javalin.introspection.InternalIntrospectionApi import io.javalin.introspection.PropertyProjection +import io.javalin.openapi.NULL_CLASS import io.javalin.openapi.experimental.EmbeddedTypeProcessor import io.javalin.openapi.experimental.OpenApiType import io.javalin.openapi.experimental.SchemaGenerationContext @@ -18,6 +19,19 @@ import org.junit.jupiter.api.Test internal class OpenApiRouteDefinitionTest { + @Test + fun `absent content sources are normalized while decoding annotations`() { + val content = OpenApiContentDefinition.from( + mapOf( + "from" to StubClassDefinition(NULL_CLASS::class.java.name), + "properties" to listOf(mapOf("name" to "value", "from" to StubClassDefinition(NULL_CLASS::class.java.name))), + ) + ) + + assertThat(content.from).isNull() + assertThat(content.properties.single().from).isNull() + } + @Test fun `schemas resolved content properties without an unused fallback type`() { val source = StubClassDefinition() @@ -66,9 +80,9 @@ internal class OpenApiRouteDefinitionTest { .isEqualTo("#/components/schemas/Owner") } - private class StubClassDefinition : ClassDefinition( + private class StubClassDefinition(fullName: String = "example.Owner") : ClassDefinition( simpleName = "Owner", - fullName = "example.Owner", + fullName = fullName, ) { @OptIn(InternalIntrospectionApi::class) override val source: Any = Unit diff --git a/openapi-generator/src/test/kotlin/io/javalin/openapi/schema/OpenApiSchemaBuilderTest.kt b/openapi-generator/src/test/kotlin/io/javalin/openapi/schema/OpenApiSchemaBuilderTest.kt index 9d965f0b..7f4893eb 100644 --- a/openapi-generator/src/test/kotlin/io/javalin/openapi/schema/OpenApiSchemaBuilderTest.kt +++ b/openapi-generator/src/test/kotlin/io/javalin/openapi/schema/OpenApiSchemaBuilderTest.kt @@ -8,6 +8,8 @@ import io.javalin.openapi.experimental.processor.shared.createArrayNode import io.javalin.openapi.experimental.processor.shared.createObjectNode import net.javacrumbs.jsonunit.assertj.JsonAssertions.json import net.javacrumbs.jsonunit.assertj.assertThatJson +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test @@ -663,6 +665,18 @@ internal class OpenApiSchemaBuilderTest { .containsEntry("type", "object") .containsEntry("example", "example value") } + + @Test + fun `last object example replaces the previous representation`() { + val schema = ObjectSchemaBuilder() + val example = createObjectNode().put("name", "Ada") + schema.example("first") + schema.exampleJson(example) + assertThatJson(schema.build()).inPath("$.example").isEqualTo(example) + + schema.example("last") + assertThatJson(schema.build()).inPath("$.example").isEqualTo("last") + } } @Nested @@ -990,6 +1004,46 @@ internal class OpenApiSchemaBuilderTest { @Nested inner class MergeBehavior { + @Test + fun `failed operation edit leaves the published document unchanged`() { + val schema = builder() + schema.path("/users").operation("get") { + tags("users") + parameters { parameter("limit", "query") { type("integer") } } + responses { response("200") { description("Original") } } + } + val before = schema.toJson() + + assertThatThrownBy { + schema.path("/users").operation("get") { + addTag("admin") + parameters { parameter("name", "query") { type("string") } } + responses { response("200") { description("Changed") } } + error("Edit failed") + } + }.isInstanceOf(IllegalStateException::class.java) + + assertThat(schema.toJson()).isEqualTo(before) + } + + @Test + fun `operation field order is independent of configuration order`() { + val operation = OperationBuilder().apply { + security { securityRequirement("auth") } + deprecated(true) + callbacks { callback("event", "{url}", "post") { summary("Callback") } } + responses { response("200") { description("OK") } } + requestBody { description("Body") } + parameters { parameter("id", "path") { type("string") } } + summary("Summary") + tags("users") + } + + assertThat(operation.build().fieldNames().asSequence().toList()).containsExactly( + "tags", "summary", "parameters", "requestBody", "responses", "callbacks", "deprecated", "security" + ) + } + @Test fun `should preserve all fields when reopening operation with empty lambda`() { val schema = builder() From 23ff57147ba6e00966d61defea88fb9a3e157579 Mon Sep 17 00:00:00 2001 From: dzikoysk Date: Fri, 25 Sep 2026 21:25:48 +0200 Subject: [PATCH 43/43] chore: refactor --- .../runtime/ReflectionTypeIntrospector.kt | 131 +++++++----------- .../introspection/test/SchemaParityTest.kt | 39 ++++++ .../processor/generators/ExampleGenerator.kt | 48 +++---- .../generators/TypeSchemaGenerator.kt | 26 ++-- .../generators/ExampleGeneratorTest.kt | 58 ++++++++ 5 files changed, 170 insertions(+), 132 deletions(-) create mode 100644 openapi-generator/src/test/kotlin/io/javalin/openapi/experimental/processor/generators/ExampleGeneratorTest.kt diff --git a/introspection/introspection-runtime/src/main/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospector.kt b/introspection/introspection-runtime/src/main/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospector.kt index 162924ac..cf94a834 100644 --- a/introspection/introspection-runtime/src/main/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospector.kt +++ b/introspection/introspection-runtime/src/main/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospector.kt @@ -72,7 +72,7 @@ private fun reflect( structureType = structureType, visitingTypeVariables = visitingTypeVariables, ) - else -> definition(erasure = Any::class.java, generics = emptyList(), structureType = structureType) + else -> objectDefinition(structureType) } private fun raw( @@ -87,20 +87,20 @@ private fun raw( visitingTypeVariables = visitingTypeVariables, ) clazz.isPrimitive -> - definition( + ReflectionClassDefinition( erasure = clazz.kotlin.javaObjectType, generics = emptyList(), structureType = structureType, - source = clazz, + sourceType = clazz, ) Map::class.java.isAssignableFrom(clazz) -> - definition( + ReflectionClassDefinition( erasure = clazz, generics = listOf(objectDefinition(), objectDefinition()), structureType = DICTIONARY, ) Collection::class.java.isAssignableFrom(clazz) -> objectDefinition(ARRAY) - else -> definition(erasure = clazz, generics = emptyList(), structureType = structureType) + else -> ReflectionClassDefinition(erasure = clazz, generics = emptyList(), structureType = structureType) } private fun parameterized( @@ -114,7 +114,7 @@ private fun parameterized( Map::class.java.isAssignableFrom(erasure) -> { val keyType = reflect(arguments.getOrElse(0) { Any::class.java }, visitingTypeVariables = visitingTypeVariables) val valueType = reflect(arguments.getOrElse(1) { Any::class.java }, visitingTypeVariables = visitingTypeVariables) - definition( + ReflectionClassDefinition( erasure = erasure, generics = listOf(keyType, valueType), structureType = DICTIONARY, @@ -130,39 +130,22 @@ private fun parameterized( val resolvedGenerics = arguments.map { reflect(it, visitingTypeVariables = visitingTypeVariables) } - definition(erasure = erasure, generics = resolvedGenerics, structureType = structureType) + ReflectionClassDefinition(erasure = erasure, generics = resolvedGenerics, structureType = structureType) } } } -private fun definition( - erasure: Class<*>, - generics: List, - structureType: StructureType, - source: Class<*> = erasure, -): ClassDefinition = - ReflectionClassDefinition( - simpleName = erasure.simpleName.ifEmpty { erasure.name.substringAfterLast('.') }, - fullName = erasure.canonicalName ?: erasure.name, - generics = generics, - structureType = structureType, - erasure = erasure, - sourceType = source, - ) - private fun objectDefinition(structureType: StructureType = DEFAULT): ClassDefinition = - definition(erasure = Any::class.java, generics = emptyList(), structureType = structureType) + ReflectionClassDefinition(erasure = Any::class.java, generics = emptyList(), structureType = structureType) private class ReflectionClassDefinition( - simpleName: String, - fullName: String, + private val erasure: Class<*>, generics: List, structureType: StructureType, - private val erasure: Class<*>, - private val sourceType: Class<*>, + sourceType: Class<*> = erasure, ) : ClassDefinition( - simpleName = simpleName, - fullName = fullName, + simpleName = erasure.simpleName.ifEmpty { erasure.name.substringAfterLast('.') }, + fullName = erasure.canonicalName ?: erasure.name, generics = generics, structureType = structureType, ) { @@ -189,100 +172,80 @@ private class ReflectionClassDefinition( } override fun getProperties(): List = - collectMembers(erasure).map { member -> - PropertyProjection( - name = when { - member.accessor == Accessor.GETTER -> propertyName(member.name) - else -> member.name - }, - type = reflect(member.genericType), - accessor = member.accessor, - nullable = (member.genericType as? Class<*>)?.isPrimitive != true, - visibility = member.visibility, - transient = member.transient, - source = member.source, - annotations = ReflectionAnnotations(member.sources), - ) - } + collectProperties(erasure) override fun getAnnotations(): AnnotationSet = ReflectionAnnotations(listOf(erasure)) } -private fun collectMembers(clazz: Class<*>): List { - val members = mutableListOf() +private fun collectProperties(clazz: Class<*>): List { + val properties = mutableListOf() val getterNames = mutableSetOf() - - if (clazz.isRecord) { - clazz.recordComponents.mapTo(members) { component -> - getterNames.add(component.accessor.name) - val backingField = runCatching { clazz.getDeclaredField(component.name) }.getOrNull() - Member( - name = component.name, - genericType = component.genericType, - accessor = Accessor.RECORD_COMPONENT, - visibility = MemberVisibility.PUBLIC, - transient = false, - source = component.accessor, - sources = listOfNotNull(component.accessor, backingField, component), - ) - } + val components = clazz.recordComponents.orEmpty() + val componentNames = components.map { it.name } + + components.mapTo(properties) { component -> + getterNames.add(component.accessor.name) + val backingField = runCatching { clazz.getDeclaredField(component.name) }.getOrNull() + PropertyProjection( + name = component.name, + type = reflect(component.genericType), + accessor = Accessor.RECORD_COMPONENT, + nullable = !component.type.isPrimitive, + visibility = MemberVisibility.PUBLIC, + transient = false, + source = component.accessor, + annotations = ReflectionAnnotations(listOfNotNull(component.accessor, backingField, component)), + ) } for (method in clazz.methods) { if (Modifier.isStatic(method.modifiers) || method.isBridge || method.isSynthetic) continue if (method.parameterCount != 0 || method.declaringClass == Any::class.java) continue if (method.returnType == Void.TYPE || !method.isPropertyGetter()) continue + if (propertyName(method.name) in componentNames) continue if (getterNames.add(method.name)) { - members += method.toMember() + properties += method.toProperty() } } for (method in nonPublicGettersHierarchy(clazz)) { + if (propertyName(method.name) in componentNames) continue if (getterNames.add(method.name)) { - members += method.toMember() + properties += method.toProperty() } } if (clazz.isRecord) { - val componentNames = clazz.recordComponents.map { it.name } - return members.filter { it.accessor == Accessor.RECORD_COMPONENT || propertyName(it.name) !in componentNames } + return properties } for (field in declaredFieldsHierarchy(clazz)) { if (Modifier.isStatic(field.modifiers) || field.isSynthetic) continue - members += Member( + properties += PropertyProjection( name = field.name, - genericType = field.genericType, + type = reflect(field.genericType), accessor = Accessor.FIELD, + nullable = !field.type.isPrimitive, visibility = visibilityOf(field.modifiers), transient = Modifier.isTransient(field.modifiers), source = field, - sources = listOf(field), + annotations = ReflectionAnnotations(listOf(field)), ) } - return members + return properties } -private class Member( - val name: String, - val genericType: Type, - val accessor: Accessor, - val visibility: MemberVisibility, - val transient: Boolean, - val source: AnnotatedElement, - val sources: List, -) - -private fun Method.toMember(): Member = - Member( - name = name, - genericType = genericReturnType, +private fun Method.toProperty(): PropertyProjection = + PropertyProjection( + name = propertyName(name), + type = reflect(genericReturnType), accessor = Accessor.GETTER, + nullable = !returnType.isPrimitive, visibility = visibilityOf(modifiers), transient = false, source = this, - sources = listOf(this), + annotations = ReflectionAnnotations(listOf(this)), ) private fun Method.isPropertyGetter(): Boolean = diff --git a/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/SchemaParityTest.kt b/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/SchemaParityTest.kt index 010d4524..043d2ee3 100644 --- a/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/SchemaParityTest.kt +++ b/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/SchemaParityTest.kt @@ -27,6 +27,28 @@ class SchemaParityTest { package parity import io.javalin.openapi.* + import kotlin.reflect.KClass + + @CustomAnnotation + annotation class CustomSchema(val nested: NestedValues, val entries: Array) + + annotation class NestedValues( + val enabled: Boolean = true, + val byteValue: Byte = 1, + val shortValue: Short = 2, + val intValue: Int = 3, + val longValue: Long = 4, + val floatValue: Float = 1.5f, + val doubleValue: Double = 2.5, + val letter: Char = 'x', + val text: String = " label", + val type: KClass<*> = Child::class, + val numbers: IntArray = [1, 2], + ) + + @JsonSchema + @CustomSchema(nested = NestedValues(), entries = [NestedValues()]) + class CustomValues @JsonSchema(requireNonNulls = false) class Properties( @@ -200,6 +222,23 @@ class SchemaParityTest { ) } + @Test + fun `nested custom annotation values retain their JSON types across backends`() { + val schema = schema("CustomValues") + val expected = jsonMapper.readTree( + // language=json + """ + { + "enabled": true, "byteValue": 1, "shortValue": 2, "intValue": 3, "longValue": 4, + "floatValue": 1.5, "doubleValue": 2.5, "letter": "x", "text": "label", + "type": "parity.Child", "numbers": [1, 2] + } + """ + ) + assertThat(schema.path("nested")).isEqualTo(expected) + assertThat(schema.path("entries")).containsExactly(expected) + } + @Test fun `route annotations and referenced components produce the same document`() { val resource = "openapi-plugin/openapi-default.json" diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/ExampleGenerator.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/ExampleGenerator.kt index d1f3a628..35946e1a 100644 --- a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/ExampleGenerator.kt +++ b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/ExampleGenerator.kt @@ -1,7 +1,7 @@ package io.javalin.openapi.experimental.processor.generators import com.fasterxml.jackson.databind.JsonNode -import com.fasterxml.jackson.databind.node.ObjectNode +import com.fasterxml.jackson.databind.node.TextNode import io.javalin.openapi.NULL_STRING import io.javalin.openapi.experimental.processor.shared.createArrayNode import io.javalin.openapi.experimental.processor.shared.createObjectNode @@ -39,44 +39,30 @@ object ExampleGenerator { } } - fun generateFromExamples(examples: List): GeneratorResult { - if (examples.isRawList()) { - val jsonArray = createArrayNode() - examples.forEach { jsonArray.add(it.value) } - return GeneratorResult(null, jsonArray) - } - - if (examples.isObjectList()) { - val jsonArray = createArrayNode() - examples.forEach { jsonArray.add(it.toSimpleExampleValue().jsonElement!!) } - return GeneratorResult(null, jsonArray) - } - - return GeneratorResult(null, examples.toJsonObject()) - } + fun generateFromExamples(examples: List): GeneratorResult = + GeneratorResult(simpleValue = null, jsonElement = examples.toJson()) - private fun ExampleProperty.toSimpleExampleValue(): GeneratorResult = + private fun ExampleProperty.toJson(): JsonNode = when { - value != null -> GeneratorResult(value, null) - objects?.isNotEmpty() == true -> generateFromExamples(objects) - raw != null -> GeneratorResult(null, jsonMapper.readTree(raw)) + value != null -> TextNode.valueOf(value) + objects?.isNotEmpty() == true -> objects.toJson() + raw != null -> jsonMapper.readTree(raw) else -> throw IllegalArgumentException("Example object must have value, raw value or objects ($this)") } - private fun List.toJsonObject(): ObjectNode { - val jsonObject = createObjectNode() - forEach { - val result = it.toSimpleExampleValue() - if (it.name == null) { - throw IllegalArgumentException("Example object must have a name ($it)") + private fun List.toJson(): JsonNode = + when { + isRawList() || isObjectList() -> createArrayNode().also { array -> + forEach { array.add(it.toJson()) } } - when { - result.simpleValue != null -> jsonObject.put(it.name, result.simpleValue) - result.jsonElement != null -> jsonObject.set(it.name, result.jsonElement) + else -> createObjectNode().also { objectNode -> + forEach { example -> + val value = example.toJson() + require(example.name != null) { "Example object must have a name ($example)" } + objectNode.set(example.name, value) + } } } - return jsonObject - } private fun List.isObjectList(): Boolean = isNotEmpty() && all { example -> diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/TypeSchemaGenerator.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/TypeSchemaGenerator.kt index a4243c7e..e2e0c0da 100644 --- a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/TypeSchemaGenerator.kt +++ b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/TypeSchemaGenerator.kt @@ -461,23 +461,15 @@ private fun customAnnotationValue(value: Any?): Any? = when (value) { is String -> value.trimIndent() is RawType -> value.fullName - is Map<*, *> -> createObjectNode().also { node -> - value.forEach { (key, nestedValue) -> - val field = key as? String ?: return@forEach - when (val resolved = customAnnotationValue(nestedValue)) { - is Boolean -> node.put(field, resolved) - is Int -> node.put(field, resolved) - is Long -> node.put(field, resolved) - is Double -> node.put(field, resolved) - is Float -> node.put(field, resolved) - is Short -> node.put(field, resolved.toInt()) - is Byte -> node.put(field, resolved.toInt()) - is String -> node.put(field, resolved) - is JsonNode -> node.set(field, resolved) - null -> {} - else -> node.put(field, resolved.toString()) - } - } + is Map<*, *> -> { + val fields = + value + .entries + .mapNotNull { (key, nestedValue) -> + (key as? String)?.let { it to customAnnotationValue(nestedValue) } + } + .toMap() + createObjectNode().addExtra(fields) } is List<*> -> createArrayNode().also { array -> value.forEach { diff --git a/openapi-generator/src/test/kotlin/io/javalin/openapi/experimental/processor/generators/ExampleGeneratorTest.kt b/openapi-generator/src/test/kotlin/io/javalin/openapi/experimental/processor/generators/ExampleGeneratorTest.kt new file mode 100644 index 00000000..f56c4e8b --- /dev/null +++ b/openapi-generator/src/test/kotlin/io/javalin/openapi/experimental/processor/generators/ExampleGeneratorTest.kt @@ -0,0 +1,58 @@ +package io.javalin.openapi.experimental.processor.generators + +import io.javalin.openapi.experimental.processor.shared.jsonMapper +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.junit.jupiter.api.Test + +internal class ExampleGeneratorTest { + + private fun generate(vararg examples: Map): ExampleGenerator.GeneratorResult = + ExampleGenerator.generateFromExamples(examples.map { it.toExampleProperty() }) + + @Test + fun `named examples preserve text raw JSON and nested objects`() { + val result = generate( + mapOf("name" to "text", "value" to "true"), + mapOf("name" to "enabled", "raw" to "true"), + mapOf("name" to "count", "raw" to "12"), + mapOf("name" to "absent", "raw" to "null"), + mapOf("name" to "nested", "objects" to listOf(mapOf("name" to "id", "value" to "42"))), + ) + + assertThat(result.simpleValue).isNull() + assertThat(result.jsonElement).isEqualTo(jsonMapper.readTree( + // language=json + """{"text":"true","enabled":true,"count":12,"absent":null,"nested":{"id":"42"}}""" + )) + } + + @Test + fun `unnamed text examples produce an array of strings`() { + val result = generate(mapOf("value" to "1"), mapOf("value" to "true")) + + assertThat(result.jsonElement).isEqualTo(jsonMapper.readTree("""["1","true"]""")) + } + + @Test + fun `unnamed object examples produce an array of objects`() { + val result = generate( + mapOf("objects" to listOf(mapOf("name" to "id", "raw" to "1"))), + mapOf("objects" to listOf(mapOf("name" to "id", "raw" to "2"))), + ) + + assertThat(result.jsonElement).isEqualTo(jsonMapper.readTree("""[{"id":1},{"id":2}]""")) + } + + @Test + fun `empty examples produce an empty object`() { + assertThat(generate().jsonElement).isEqualTo(jsonMapper.createObjectNode()) + } + + @Test + fun `object entries require names`() { + assertThatThrownBy { generate(mapOf("raw" to "true")) } + .isInstanceOf(IllegalArgumentException::class.java) + .hasMessageContaining("must have a name") + } +}