From fe9f23d46eb955814418c8cac809a00825d5052d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Tue, 9 Jun 2026 16:06:14 +0200 Subject: [PATCH] feat(core): generate @Deprecated for deprecated operations, schemas, properties Parser extracts the deprecated flag from operations, schemas, and schema properties. Generated suspend functions and model types (data classes, sealed hierarchies, sealed interfaces, typealiases) carry @Deprecated when the source element is deprecated. Deprecated schema properties get @Deprecated on the generated data class property. Kotlin's @Deprecated is not applicable to plain function value parameters, so deprecated operation parameters are not annotated; the flag is still parsed (Parameter.deprecated) for future use. Closes #39 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../com/avsystem/justworks/core/gen/Utils.kt | 8 +++ .../core/gen/client/ClientGenerator.kt | 5 ++ .../core/gen/model/ModelGenerator.kt | 23 ++++++++- .../avsystem/justworks/core/model/ApiSpec.kt | 4 ++ .../justworks/core/parser/SpecParser.kt | 4 ++ .../justworks/core/gen/ClientGeneratorTest.kt | 22 ++++++++ .../justworks/core/gen/ModelGeneratorTest.kt | 45 +++++++++++++++++ .../justworks/core/parser/SpecParserTest.kt | 50 +++++++++++++++++++ 8 files changed, 159 insertions(+), 2 deletions(-) diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/Utils.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/Utils.kt index 9d326ebb..ec8ffd13 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/Utils.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/Utils.kt @@ -5,8 +5,10 @@ import com.avsystem.justworks.core.model.PrimitiveType import com.avsystem.justworks.core.model.PropertyModel import com.avsystem.justworks.core.model.SchemaModel import com.avsystem.justworks.core.model.TypeRef +import com.squareup.kotlinpoet.AnnotationSpec import com.squareup.kotlinpoet.BOOLEAN import com.squareup.kotlinpoet.BYTE_ARRAY +import com.squareup.kotlinpoet.ClassName import com.squareup.kotlinpoet.DOUBLE import com.squareup.kotlinpoet.FLOAT import com.squareup.kotlinpoet.INT @@ -17,6 +19,12 @@ import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy import com.squareup.kotlinpoet.STRING import com.squareup.kotlinpoet.TypeName +internal val DEPRECATED = ClassName("kotlin", "Deprecated") + +/** Builds a `@Deprecated("")` annotation. */ +internal fun deprecatedAnnotation(message: String): AnnotationSpec = + AnnotationSpec.builder(DEPRECATED).addMember("%S", message).build() + internal val TypeRef.properties: List get() = when (this) { is TypeRef.Inline -> properties diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/client/ClientGenerator.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/client/ClientGenerator.kt index 88469a44..a0a51ca1 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/client/ClientGenerator.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/client/ClientGenerator.kt @@ -21,6 +21,7 @@ import com.avsystem.justworks.core.gen.TOKEN import com.avsystem.justworks.core.gen.client.BodyGenerator.buildFunctionBody import com.avsystem.justworks.core.gen.client.ParametersGenerator.buildBodyParams import com.avsystem.justworks.core.gen.client.ParametersGenerator.buildNullableParameter +import com.avsystem.justworks.core.gen.deprecatedAnnotation import com.avsystem.justworks.core.gen.invoke import com.avsystem.justworks.core.gen.shared.toAuthParam import com.avsystem.justworks.core.gen.toCamelCase @@ -231,6 +232,10 @@ internal object ClientGenerator { .addModifiers(KModifier.SUSPEND) .returns(returnType) + if (endpoint.deprecated) { + funBuilder.addAnnotation(deprecatedAnnotation("This operation is deprecated.")) + } + val params = endpoint.parameters.groupBy { it.location } val pathParams = params[ParameterLocation.PATH].orEmpty().map { param -> diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/model/ModelGenerator.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/model/ModelGenerator.kt index 23e1415f..b2a4df10 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/model/ModelGenerator.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/model/ModelGenerator.kt @@ -26,6 +26,7 @@ import com.avsystem.justworks.core.gen.SERIAL_NAME import com.avsystem.justworks.core.gen.USE_SERIALIZERS import com.avsystem.justworks.core.gen.UUID_SERIALIZER import com.avsystem.justworks.core.gen.UUID_TYPE +import com.avsystem.justworks.core.gen.deprecatedAnnotation import com.avsystem.justworks.core.gen.invoke import com.avsystem.justworks.core.gen.model.ModelGenerator.buildNestedVariant import com.avsystem.justworks.core.gen.model.ModelGenerator.generateDataClass @@ -195,6 +196,10 @@ internal object ModelGenerator { val parentBuilder = TypeSpec.interfaceBuilder(className).addModifiers(KModifier.SEALED) parentBuilder.addAnnotation(SERIALIZABLE) + if (schema.deprecated) { + parentBuilder.addAnnotation(deprecatedAnnotation("This schema is deprecated.")) + } + if (schema.discriminator != null) { parentBuilder.addAnnotation( AnnotationSpec @@ -307,8 +312,10 @@ internal object ModelGenerator { .builder(kotlinName, type) .initializer(kotlinName) .addAnnotation(AnnotationSpec.builder(SERIAL_NAME).addMember("%S", prop.name).build()) - .apply { prop.description?.let { addKdoc("%L", it) } } - .build() + .apply { + if (prop.deprecated) addAnnotation(deprecatedAnnotation("This property is deprecated.")) + prop.description?.let { addKdoc("%L", it) } + }.build() } builder.primaryConstructor(constructorBuilder.build()) @@ -337,6 +344,10 @@ internal object ModelGenerator { .build(), ) + if (schema.deprecated) { + typeSpec.addAnnotation(deprecatedAnnotation("This schema is deprecated.")) + } + if (schema.description != null) { typeSpec.addKdoc("%L", schema.description) } @@ -466,6 +477,10 @@ internal object ModelGenerator { .addAnnotation(SERIALIZABLE) .addSuperinterfaces(superinterfaces) + if (schema.deprecated) { + typeSpec.addAnnotation(deprecatedAnnotation("This schema is deprecated.")) + } + if (serialName != null) { typeSpec.addAnnotation( AnnotationSpec @@ -680,6 +695,10 @@ internal object ModelGenerator { val typeAlias = TypeAliasSpec.builder(schema.name, primitiveType) + if (schema.deprecated) { + typeAlias.addAnnotation(deprecatedAnnotation("This schema is deprecated.")) + } + if (schema.description != null) { typeAlias.addKdoc("%L", schema.description) } diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/model/ApiSpec.kt b/core/src/main/kotlin/com/avsystem/justworks/core/model/ApiSpec.kt index dba29e84..32e498d9 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/model/ApiSpec.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/model/ApiSpec.kt @@ -42,6 +42,7 @@ data class Endpoint( val parameters: List, val requestBody: RequestBody?, val responses: Map, + val deprecated: Boolean = false, ) enum class HttpMethod { @@ -61,6 +62,7 @@ data class Parameter( val required: Boolean, val schema: TypeRef, val description: String?, + val deprecated: Boolean = false, ) // todo: add cookie @@ -99,6 +101,7 @@ data class SchemaModel( val anyOf: List?, val discriminator: Discriminator?, val underlyingType: TypeRef? = null, + val deprecated: Boolean = false, ) data class PropertyModel( @@ -107,6 +110,7 @@ data class PropertyModel( val description: String?, val nullable: Boolean, val defaultValue: Any? = null, + val deprecated: Boolean = false, ) data class EnumModel( diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt b/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt index 535e9fc3..99fb7be9 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt @@ -296,6 +296,7 @@ object SpecParser { parameters = mergedParams, requestBody = requestBody, responses = responses, + deprecated = operation.deprecated ?: false, ) } }.toList() @@ -307,6 +308,7 @@ object SpecParser { required = required ?: false, schema = schema?.toTypeRef() ?: TypeRef.Primitive(PrimitiveType.STRING), description = description, + deprecated = deprecated ?: false, ) // --- Schema extraction --- @@ -360,6 +362,7 @@ object SpecParser { anyOf = anyOf?.let { it.map(TypeRef::Reference).ifEmpty { null } }, discriminator = discriminator, underlyingType = underlyingType, + deprecated = schema.deprecated ?: false, ) } @@ -509,6 +512,7 @@ object SpecParser { description = propSchema.description, nullable = propName !in required, defaultValue = propSchema.default, + deprecated = propSchema.deprecated == true, ) } diff --git a/core/src/test/kotlin/com/avsystem/justworks/core/gen/ClientGeneratorTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/gen/ClientGeneratorTest.kt index c98d642a..07b4126c 100644 --- a/core/src/test/kotlin/com/avsystem/justworks/core/gen/ClientGeneratorTest.kt +++ b/core/src/test/kotlin/com/avsystem/justworks/core/gen/ClientGeneratorTest.kt @@ -116,6 +116,28 @@ class ClientGeneratorTest { assertTrue(KModifier.SUSPEND in funSpec.modifiers, "Expected SUSPEND modifier") } + // -- Deprecated operations -- + + @Test + fun `deprecated operation gets Deprecated annotation`() { + val cls = clientClass(endpoint().copy(deprecated = true)) + val funSpec = cls.funSpecs.first { it.name == "listPets" } + assertTrue( + funSpec.annotations.any { it.typeName.toString() == "kotlin.Deprecated" }, + "Expected @Deprecated on deprecated operation", + ) + } + + @Test + fun `non-deprecated operation has no Deprecated annotation`() { + val cls = clientClass(endpoint()) + val funSpec = cls.funSpecs.first { it.name == "listPets" } + assertFalse( + funSpec.annotations.any { it.typeName.toString() == "kotlin.Deprecated" }, + "Did not expect @Deprecated on non-deprecated operation", + ) + } + // -- CLNT-03: All HTTP methods -- @Test diff --git a/core/src/test/kotlin/com/avsystem/justworks/core/gen/ModelGeneratorTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/gen/ModelGeneratorTest.kt index a43e7199..b30c7b05 100644 --- a/core/src/test/kotlin/com/avsystem/justworks/core/gen/ModelGeneratorTest.kt +++ b/core/src/test/kotlin/com/avsystem/justworks/core/gen/ModelGeneratorTest.kt @@ -68,6 +68,51 @@ class ModelGeneratorTest { assertTrue(KModifier.DATA in typeSpec.modifiers, "Expected DATA modifier") } + @Test + fun `deprecated schema gets Deprecated annotation on data class`() { + val files = generate(spec(schemas = listOf(petSchema.copy(deprecated = true)))) + val typeSpec = files[0].members.filterIsInstance()[0] + val annotations = typeSpec.annotations.map { it.typeName.toString() } + assertTrue("kotlin.Deprecated" in annotations, "Expected @Deprecated on deprecated schema") + } + + @Test + fun `non-deprecated schema has no Deprecated annotation`() { + val files = generate(spec(schemas = listOf(petSchema))) + val typeSpec = files[0].members.filterIsInstance()[0] + val annotations = typeSpec.annotations.map { it.typeName.toString() } + assertTrue("kotlin.Deprecated" !in annotations, "Did not expect @Deprecated") + } + + @Test + fun `deprecated property gets Deprecated annotation`() { + val schema = SchemaModel( + name = "Thing", + description = null, + properties = listOf( + PropertyModel("legacy", TypeRef.Primitive(PrimitiveType.STRING), null, false, deprecated = true), + PropertyModel("current", TypeRef.Primitive(PrimitiveType.STRING), null, false), + ), + requiredProperties = setOf("legacy", "current"), + allOf = null, + oneOf = null, + anyOf = null, + discriminator = null, + ) + val files = generate(spec(schemas = listOf(schema))) + val typeSpec = files[0].members.filterIsInstance()[0] + val legacy = typeSpec.propertySpecs.first { it.name == "legacy" } + val current = typeSpec.propertySpecs.first { it.name == "current" } + assertTrue( + legacy.annotations.any { it.typeName.toString() == "kotlin.Deprecated" }, + "Expected @Deprecated on deprecated property", + ) + assertTrue( + current.annotations.none { it.typeName.toString() == "kotlin.Deprecated" }, + "Did not expect @Deprecated on normal property", + ) + } + @Test fun `generates class with Serializable annotation`() { val files = generate(spec(schemas = listOf(petSchema))) diff --git a/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserTest.kt index 603e151e..c317f0fb 100644 --- a/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserTest.kt +++ b/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserTest.kt @@ -13,6 +13,7 @@ import java.io.File import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertIs import kotlin.test.assertNotNull import kotlin.test.assertTrue @@ -140,6 +141,55 @@ class SpecParserTest : SpecParserTestBase() { assertEquals("NewPet", bodyType.schemaName) } + @Test + fun `parses deprecated operations schemas and parameters`() { + val spec = parseSpec( + """ + openapi: 3.0.0 + info: + title: Deprecated API + version: 1.0.0 + paths: + /legacy: + get: + operationId: getLegacy + deprecated: true + parameters: + - name: oldParam + in: query + deprecated: true + schema: + type: string + responses: + '200': + description: ok + components: + schemas: + LegacyModel: + type: object + deprecated: true + properties: + id: + type: string + oldField: + type: string + deprecated: true + """.trimIndent().toTempFile(), + ) + + val op = spec.endpoints.find { it.operationId == "getLegacy" } ?: fail("getLegacy not found") + assertTrue(op.deprecated, "operation should be deprecated") + val param = op.parameters.find { it.name == "oldParam" } ?: fail("oldParam not found") + assertTrue(param.deprecated, "parameter should be deprecated") + + val model = spec.schemas.find { it.name == "LegacyModel" } ?: fail("LegacyModel not found") + assertTrue(model.deprecated, "schema should be deprecated") + val oldField = model.properties.find { it.name == "oldField" } ?: fail("oldField not found") + assertTrue(oldField.deprecated, "property should be deprecated") + val idField = model.properties.find { it.name == "id" } ?: fail("id not found") + assertFalse(idField.deprecated, "normal property should not be deprecated") + } + @Test fun `parsed endpoints have tags`() { val listPets = petstore.endpoints.find { it.operationId == "listPets" }!!