diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ff1a4d08..1184ee82 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,12 +27,12 @@ jobs: run: ./gradlew ktlintCheck - name: Run tests with coverage - run: ./gradlew core:koverXmlReport + run: ./gradlew core:koverXmlReport plugin:koverXmlReport - name: Upload coverage to Codecov if: github.event_name == 'pull_request' uses: codecov/codecov-action@v5 with: - files: core/build/reports/kover/report.xml + files: core/build/reports/kover/report.xml,plugin/build/reports/kover/report.xml fail_ci_if_error: false token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..f4ad3507 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +build/ +.gradle/ +.kotlin/ +.idea/ diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/ApiClientBaseGenerator.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/ApiClientBaseGenerator.kt index 67dddf75..8c953e7c 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/ApiClientBaseGenerator.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/ApiClientBaseGenerator.kt @@ -54,7 +54,8 @@ object ApiClientBaseGenerator { private fun buildMapToResult(t: TypeVariableName): FunSpec = FunSpec .builder(MAP_TO_RESULT) - .addModifiers(KModifier.PRIVATE, KModifier.SUSPEND, KModifier.INLINE) + .addAnnotation(PublishedApi::class) + .addModifiers(KModifier.INTERNAL, KModifier.SUSPEND, KModifier.INLINE) .addTypeVariable(t) .receiver(HTTP_RESPONSE) .contextParameters(listOf(ContextParameter(RAISE.parameterizedBy(HTTP_ERROR)))) diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/ApiResponseGenerator.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/ApiResponseGenerator.kt index 3da798ce..f983258b 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/ApiResponseGenerator.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/ApiResponseGenerator.kt @@ -20,6 +20,8 @@ object ApiResponseGenerator { private const val MESSAGE = "message" private const val TYPE = "type" + fun generate(): List = listOf(generateHttpError(), generateHttpSuccess()) + fun generateHttpError(): FileSpec { val enumType = TypeSpec .enumBuilder(HTTP_ERROR_TYPE) diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/CodeGenerator.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/CodeGenerator.kt new file mode 100644 index 00000000..10c6715b --- /dev/null +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/CodeGenerator.kt @@ -0,0 +1,35 @@ +package com.avsystem.justworks.core.gen + +import com.avsystem.justworks.core.model.ApiSpec +import java.io.File + +/** + * Facade that orchestrates model and client code generation, + * writing the produced files to the given output directory. + */ +object CodeGenerator { + data class Result(val modelFiles: Int, val clientFiles: Int) + + fun generate( + spec: ApiSpec, + modelPackage: String, + apiPackage: String, + outputDir: File + ): Result { + val modelFiles = ModelGenerator(modelPackage).generate(spec) + modelFiles.forEach { it.writeTo(outputDir) } + + val hasPolymorphicTypes = modelFiles.any { it.name == SerializersModuleGenerator.FILE_NAME } + + val clientFiles = ClientGenerator(apiPackage, modelPackage).generate(spec, hasPolymorphicTypes) + clientFiles.forEach { it.writeTo(outputDir) } + + return Result(modelFiles.size, clientFiles.size) + } + + fun generateSharedTypes(outputDir: File): Int { + val files = ApiResponseGenerator.generate() + ApiClientBaseGenerator.generate() + files.forEach { it.writeTo(outputDir) } + return files.size + } +} diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/SerializersModuleGenerator.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/SerializersModuleGenerator.kt index de3780d5..255807c9 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/SerializersModuleGenerator.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/SerializersModuleGenerator.kt @@ -13,6 +13,10 @@ import com.squareup.kotlinpoet.PropertySpec * that registers each sealed interface with its subclass variants. */ class SerializersModuleGenerator(private val modelPackage: String) { + companion object { + const val FILE_NAME = "SerializersModule" + } + /** * Generates a [FileSpec] containing the SerializersModule registration. * Returns null if the hierarchy has no sealed types to register. @@ -48,7 +52,7 @@ class SerializersModuleGenerator(private val modelPackage: String) { .build() return FileSpec - .builder(modelPackage, "SerializersModule") + .builder(modelPackage, FILE_NAME) .addProperty(prop) .build() } diff --git a/plugin/build.gradle.kts b/plugin/build.gradle.kts new file mode 100644 index 00000000..4826a9a4 --- /dev/null +++ b/plugin/build.gradle.kts @@ -0,0 +1,69 @@ +plugins { + kotlin("jvm") + `java-gradle-plugin` + `maven-publish` + id("org.jetbrains.kotlinx.kover") version "0.9.1" +} + +kotlin { + jvmToolchain(21) +} + +dependencies { + implementation(project(":core")) + testImplementation(kotlin("test")) +} + +gradlePlugin { + plugins { + create("justworks") { + id = "com.avsystem.justworks" + implementationClass = "com.avsystem.justworks.gradle.JustworksPlugin" + } + } +} + +// Functional test source set +val functionalTest by sourceSets.creating { + compileClasspath += sourceSets["main"].output + runtimeClasspath += sourceSets["main"].output +} + +val functionalTestImplementation by configurations.getting { + extendsFrom(configurations["testImplementation"]) +} + +val functionalTestRuntimeOnly by configurations.getting { + extendsFrom(configurations["testRuntimeOnly"]) +} + +dependencies { + functionalTestImplementation(gradleTestKit()) +} + +val functionalTestTask = + tasks.register("functionalTest") { + testClassesDirs = functionalTest.output.classesDirs + classpath = functionalTest.runtimeClasspath + useJUnitPlatform() + } + +tasks.check { + dependsOn(functionalTestTask) +} + +kover { + currentProject { + sources { + excludedSourceSets.add("functionalTest") + } + } +} + +tasks.named("koverXmlReport") { + dependsOn(functionalTestTask) +} + +tasks.test { + useJUnitPlatform() +} diff --git a/plugin/src/functionalTest/kotlin/com/avsystem/justworks/gradle/JustworksPluginFunctionalTest.kt b/plugin/src/functionalTest/kotlin/com/avsystem/justworks/gradle/JustworksPluginFunctionalTest.kt new file mode 100644 index 00000000..c95eeb31 --- /dev/null +++ b/plugin/src/functionalTest/kotlin/com/avsystem/justworks/gradle/JustworksPluginFunctionalTest.kt @@ -0,0 +1,520 @@ +package com.avsystem.justworks.gradle + +import org.gradle.testkit.runner.GradleRunner +import org.gradle.testkit.runner.TaskOutcome +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.io.TempDir +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Functional tests for the Justworks Gradle plugin using Gradle TestKit. + * + * Each test creates a temporary Gradle project, applies the plugin, + * and verifies the full lifecycle from a consumer's perspective. + */ +class JustworksPluginFunctionalTest { + @TempDir + lateinit var projectDir: File + + private fun writeFile(path: String, content: String) { + val file = projectDir.resolve(path) + file.parentFile.mkdirs() + file.writeText(content) + } + + @BeforeEach + fun setup() { + writeFile( + "settings.gradle.kts", + """rootProject.name = "test-project"""", + ) + + writeFile( + "api/petstore.yaml", + """ + openapi: '3.0.0' + info: + title: Petstore + version: '1.0' + paths: + /pets: + get: + operationId: listPets + summary: List pets + tags: + - pets + responses: + '200': + description: A list of pets + content: + application/json: + schema: + type: array + items: + ${'$'}ref: '#/components/schemas/Pet' + components: + schemas: + Pet: + type: object + required: + - id + - name + properties: + id: + type: integer + format: int64 + name: + type: string + tag: + type: string + Status: + type: string + enum: + - available + - pending + - sold + """.trimIndent(), + ) + } + + private fun writeBuildFile(extraDsl: String = "") { + writeFile( + "build.gradle.kts", + """ + plugins { + kotlin("jvm") version "2.3.0" + kotlin("plugin.serialization") version "2.3.0" + id("com.avsystem.justworks") + } + + repositories { + mavenCentral() + } + + dependencies { + implementation("org.jetbrains.kotlinx:kotlinx-serialization-core:1.8.1") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.1") + implementation("io.ktor:ktor-client-core:3.1.1") + implementation("io.ktor:ktor-client-content-negotiation:3.1.1") + implementation("io.ktor:ktor-serialization-kotlinx-json:3.1.1") + implementation("io.arrow-kt:arrow-core:2.2.1.1") + } + + kotlin { + compilerOptions { + freeCompilerArgs.add("-Xcontext-parameters") + } + } + + justworks { + specs { + register("main") { + specFile = file("api/petstore.yaml") + packageName = "com.example" + $extraDsl + } + } + } + """.trimIndent(), + ) + } + + private fun runner(vararg args: String): GradleRunner = GradleRunner + .create() + .withProjectDir(projectDir) + .withPluginClasspath() + .withArguments(*args, "--stacktrace") + .forwardOutput() + + @Test + fun `plugin applies and justworksGenerate runs successfully`() { + writeBuildFile() + + val result = runner("justworksGenerateMain").build() + + assertEquals( + TaskOutcome.SUCCESS, + result.task(":justworksGenerateMain")?.outcome, + ) + } + + @Test + fun `generated output directory contains kt file under package directory`() { + writeBuildFile() + + runner("justworksGenerateMain").build() + + val outputDir = projectDir.resolve("build/generated/justworks/main") + assertTrue(outputDir.exists(), "Output directory should exist") + assertTrue(outputDir.isDirectory, "Output should be a directory") + + val modelDir = outputDir.resolve("com/example/model") + assertTrue(modelDir.exists(), "Model package directory com/example/model should exist") + + val ktFiles = modelDir.listFiles()?.filter { it.extension == "kt" } ?: emptyList() + assertTrue(ktFiles.isNotEmpty(), "Should contain at least one .kt file") + assertTrue(ktFiles.any { it.name == "Pet.kt" }, "Should contain Pet.kt") + + val apiDir = outputDir.resolve("com/example/api") + assertTrue(apiDir.exists(), "API package directory com/example/api should exist") + + val apiFiles = apiDir.listFiles()?.filter { it.extension == "kt" } ?: emptyList() + assertTrue(apiFiles.any { it.name == "PetsApi.kt" }, "Should contain PetsApi.kt") + + // Shared types generated to separate directory with fixed package + val sharedDir = projectDir.resolve("build/generated/justworks/shared/kotlin/com/avsystem/justworks") + assertTrue(sharedDir.exists(), "Shared types directory should exist") + val sharedFiles = sharedDir.listFiles()?.filter { it.extension == "kt" } ?: emptyList() + assertTrue(sharedFiles.any { it.name == "HttpError.kt" }, "Should contain HttpError.kt") + assertTrue(sharedFiles.any { it.name == "HttpSuccess.kt" }, "Should contain HttpSuccess.kt") + assertTrue(sharedFiles.any { it.name == "ApiClientBase.kt" }, "Should contain ApiClientBase.kt") + } + + @Test + fun `generated client file contains suspend functions`() { + writeBuildFile() + + runner("justworksGenerateMain").build() + + val clientFile = + projectDir.resolve( + "build/generated/justworks/main/com/example/api/PetsApi.kt", + ) + assertTrue(clientFile.exists(), "PetsApi.kt should exist") + + val content = clientFile.readText() + assertTrue(content.contains("suspend fun"), "PetsApi should contain suspend functions") + assertTrue(content.contains("class PetsApi"), "PetsApi should define PetsApi class") + } + + @Test + fun `second run is UP-TO-DATE when inputs unchanged`() { + writeBuildFile() + + runner("justworksGenerateMain").build() + val secondResult = runner("justworksGenerateMain").build() + + assertEquals( + TaskOutcome.UP_TO_DATE, + secondResult.task(":justworksGenerateMain")?.outcome, + ) + } + + @Test + fun `compileKotlin triggers justworksGenerate and compiles successfully`() { + writeBuildFile() + + val result = runner("compileKotlin").build() + + assertEquals( + TaskOutcome.SUCCESS, + result.task(":justworksGenerateMain")?.outcome, + "justworksGenerateMain should run when compileKotlin is executed", + ) + assertEquals( + TaskOutcome.SUCCESS, + result.task(":compileKotlin")?.outcome, + "compileKotlin should succeed with generated sources", + ) + } + + @Test + fun `generates polymorphic types from spec with oneOf and allOf`() { + // Write a polymorphic OpenAPI spec + writeFile( + "api/petstore.yaml", + """ + openapi: '3.0.0' + info: + title: Polymorphic Test + version: '1.0' + paths: + /shapes: + get: + operationId: listShapes + summary: List shapes + tags: + - shapes + responses: + '200': + description: A list of shapes + content: + application/json: + schema: + type: array + items: + ${'$'}ref: '#/components/schemas/Shape' + components: + schemas: + Shape: + oneOf: + - ${'$'}ref: '#/components/schemas/Circle' + - ${'$'}ref: '#/components/schemas/Square' + discriminator: + propertyName: shapeType + mapping: + circle: '#/components/schemas/Circle' + square: '#/components/schemas/Square' + Circle: + type: object + required: + - radius + - shapeType + properties: + radius: + type: number + shapeType: + type: string + Square: + type: object + required: + - sideLength + - shapeType + properties: + sideLength: + type: number + shapeType: + type: string + """.trimIndent(), + ) + + writeBuildFile() + + val result = runner("compileKotlin").build() + + assertEquals( + TaskOutcome.SUCCESS, + result.task(":justworksGenerateMain")?.outcome, + "justworksGenerateMain should succeed", + ) + assertEquals( + TaskOutcome.SUCCESS, + result.task(":compileKotlin")?.outcome, + "compileKotlin should succeed with polymorphic generated sources", + ) + + val outputDir = projectDir.resolve("build/generated/justworks/main/com/example/model") + + // Sealed interface file exists + val shapeFile = outputDir.resolve("Shape.kt") + assertTrue(shapeFile.exists(), "Shape.kt should exist") + val shapeContent = shapeFile.readText() + assertTrue(shapeContent.contains("sealed interface"), "Shape.kt should contain sealed interface") + assertTrue(shapeContent.contains("JsonClassDiscriminator"), "Shape.kt should contain @JsonClassDiscriminator") + + // Variant data class file exists and implements sealed interface + val circleFile = outputDir.resolve("Circle.kt") + assertTrue(circleFile.exists(), "Circle.kt should exist") + val circleContent = circleFile.readText() + assertTrue(circleContent.contains(": Shape"), "Circle.kt should implement Shape") + + // SerializersModule file exists + val moduleFile = outputDir.resolve("SerializersModule.kt") + assertTrue(moduleFile.exists(), "SerializersModule.kt should exist") + val moduleContent = moduleFile.readText() + assertTrue( + moduleContent.contains("generatedSerializersModule"), + "SerializersModule.kt should contain module property", + ) + } + + @Test + fun `DSL accepts optional apiPackage and modelPackage overrides`() { + writeBuildFile( + """ + apiPackage = "com.example.custom.api" + modelPackage = "com.example.custom.model" + """.trimIndent(), + ) + + val result = runner("justworksGenerateMain").build() + + assertEquals( + TaskOutcome.SUCCESS, + result.task(":justworksGenerateMain")?.outcome, + ) + } + + // Multi-spec tests + + private fun writeMultiSpecProject() { + writeFile( + "api/petstore.yaml", + """ + openapi: 3.0.0 + info: + title: Petstore + version: 1.0.0 + paths: {} + components: + schemas: + Pet: + type: object + properties: + name: + type: string + """.trimIndent(), + ) + + writeFile( + "api/payments.yaml", + """ + openapi: 3.0.0 + info: + title: Payments + version: 1.0.0 + paths: {} + components: + schemas: + Payment: + type: object + properties: + amount: + type: number + """.trimIndent(), + ) + + writeFile( + "build.gradle.kts", + """ + plugins { + kotlin("jvm") version "2.3.0" + kotlin("plugin.serialization") version "2.3.0" + id("com.avsystem.justworks") + } + repositories { mavenCentral() } + dependencies { + implementation("org.jetbrains.kotlinx:kotlinx-serialization-core:1.8.1") + implementation("io.ktor:ktor-client-core:3.1.1") + implementation("io.ktor:ktor-client-content-negotiation:3.1.1") + implementation("io.ktor:ktor-serialization-kotlinx-json:3.1.1") + } + justworks { + specs { + register("petstore") { + specFile = file("api/petstore.yaml") + packageName = "com.example.petstore" + } + register("payments") { + specFile = file("api/payments.yaml") + packageName = "com.example.payments" + } + } + } + """.trimIndent(), + ) + } + + @Test + fun `multiple specs generate independently`() { + writeMultiSpecProject() + + val result = runner("justworksGenerateAll").build() + + assertEquals(TaskOutcome.SUCCESS, result.task(":justworksGeneratePetstore")?.outcome) + assertEquals(TaskOutcome.SUCCESS, result.task(":justworksGeneratePayments")?.outcome) + + // Verify isolated output directories exist + assertTrue(projectDir.resolve("build/generated/justworks/petstore").exists()) + assertTrue(projectDir.resolve("build/generated/justworks/payments").exists()) + + // Verify generated files in correct packages + assertTrue(projectDir.resolve("build/generated/justworks/petstore/com/example/petstore/model/Pet.kt").exists()) + assertTrue( + projectDir.resolve("build/generated/justworks/payments/com/example/payments/model/Payment.kt").exists(), + ) + } + + @Test + fun `single spec task generates only that spec`() { + writeMultiSpecProject() + + val result = runner("justworksGeneratePetstore").build() + + assertEquals(TaskOutcome.SUCCESS, result.task(":justworksGeneratePetstore")?.outcome) + // Payments task should not have run + assertNull(result.task(":justworksGeneratePayments")) + + assertTrue(projectDir.resolve("build/generated/justworks/petstore").exists()) + // Payments directory shouldn't exist yet + assertFalse(projectDir.resolve("build/generated/justworks/payments").exists()) + } + + @Test + fun `missing required property fails with clear error`() { + writeFile( + "build.gradle.kts", + """ + plugins { + kotlin("jvm") version "2.3.0" + id("com.avsystem.justworks") + } + justworks { + specs { + register("petstore") { + specFile = file("api/petstore.yaml") + // packageName missing + } + } + } + """.trimIndent(), + ) + + val result = runner("justworksGenerateAll").buildAndFail() + + assertTrue(result.output.contains("Spec 'petstore': packageName is required but not set")) + } + + @Test + fun `invalid spec name fails with clear error`() { + writeFile( + "build.gradle.kts", + """ + plugins { + kotlin("jvm") version "2.3.0" + id("com.avsystem.justworks") + } + justworks { + specs { + register("pet-store") { + specFile = file("api/petstore.yaml") + packageName = "com.example" + } + } + } + """.trimIndent(), + ) + + val result = runner("justworksGenerateAll").buildAndFail() + + assertTrue(result.output.contains("Invalid spec name 'pet-store'")) + } + + @Test + fun `empty specs container logs warning`() { + writeFile( + "build.gradle.kts", + """ + plugins { + kotlin("jvm") version "2.3.0" + id("com.avsystem.justworks") + } + justworks { + specs { + // empty + } + } + """.trimIndent(), + ) + + val result = runner("justworksGenerateAll").build() + + assertEquals(TaskOutcome.UP_TO_DATE, result.task(":justworksGenerateAll")?.outcome) + assertTrue(result.output.contains("justworks: no specs configured")) + } +} diff --git a/plugin/src/main/kotlin/com/avsystem/justworks/gradle/JustworksExtension.kt b/plugin/src/main/kotlin/com/avsystem/justworks/gradle/JustworksExtension.kt new file mode 100644 index 00000000..b02f3576 --- /dev/null +++ b/plugin/src/main/kotlin/com/avsystem/justworks/gradle/JustworksExtension.kt @@ -0,0 +1,42 @@ +package com.avsystem.justworks.gradle + +import org.gradle.api.NamedDomainObjectContainer +import org.gradle.api.model.ObjectFactory +import javax.inject.Inject + +/** + * DSL extension for configuring the Justworks OpenAPI code generator. + * + * Supports multiple named specs, each with independent configuration. + * + * Usage in `build.gradle.kts`: + * ```kotlin + * justworks { + * specs { + * register("petstore") { + * specFile = file("api/petstore.yaml") + * packageName = "com.example.petstore" + * } + * register("payments") { + * specFile = file("api/payments.yaml") + * packageName = "com.example.payments" + * } + * } + * } + * ``` + * + * Each registered spec generates to an isolated output directory + * and has its own generation task: `justworksGenerate`. + */ +abstract class JustworksExtension + @Inject + constructor(objects: ObjectFactory) { + /** + * Container of named OpenAPI spec configurations. + * + * Each spec is independently configured and generates to its own + * build/generated/justworks/{specName}/ directory. + */ + val specs: NamedDomainObjectContainer = + objects.domainObjectContainer(JustworksSpecConfiguration::class.java) + } diff --git a/plugin/src/main/kotlin/com/avsystem/justworks/gradle/JustworksGenerateTask.kt b/plugin/src/main/kotlin/com/avsystem/justworks/gradle/JustworksGenerateTask.kt new file mode 100644 index 00000000..51c4f914 --- /dev/null +++ b/plugin/src/main/kotlin/com/avsystem/justworks/gradle/JustworksGenerateTask.kt @@ -0,0 +1,77 @@ +package com.avsystem.justworks.gradle + +import com.avsystem.justworks.core.gen.CodeGenerator +import com.avsystem.justworks.core.parser.ParseResult +import com.avsystem.justworks.core.parser.SpecParser +import org.gradle.api.DefaultTask +import org.gradle.api.GradleException +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.CacheableTask +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputFile +import org.gradle.api.tasks.OutputDirectory +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.TaskAction +import java.io.File + +/** + * Gradle task that generates Kotlin source files from an OpenAPI spec. + * + * Parses the OpenAPI spec via [SpecParser], feeds the result to [CodeGenerator], + * and writes the generated Kotlin source files to the output directory. + */ +@CacheableTask +abstract class JustworksGenerateTask : DefaultTask() { + /** Path to the OpenAPI spec file. */ + @get:InputFile + @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val specFile: RegularFileProperty + + /** Package for generated API client classes. */ + @get:Input + abstract val apiPackage: Property + + /** Package for generated model/data classes. */ + @get:Input + abstract val modelPackage: Property + + /** Output directory for generated Kotlin source files. */ + @get:OutputDirectory + abstract val outputDir: DirectoryProperty + + @TaskAction + fun generate() { + val outDir = outputDir.get().asFile.recreateDirectory() + + val spec = specFile.get().asFile + when (val result = SpecParser.parse(spec)) { + is ParseResult.Failure -> { + throw GradleException( + "Failed to parse spec (task: $name): ${spec.name}:\n${result.errors.joinToString("\n")}", + ) + } + + is ParseResult.Success -> { + val (modelCount, clientCount) = CodeGenerator.generate( + spec = result.apiSpec, + modelPackage = modelPackage.get(), + apiPackage = apiPackage.get(), + outputDir = outDir, + ) + + logger.lifecycle( + "Generated $modelCount model files, $clientCount client files from ${spec.name}", + ) + } + } + } +} + +internal fun File.recreateDirectory(): File { + if (exists()) deleteRecursively() + mkdirs() + return this +} diff --git a/plugin/src/main/kotlin/com/avsystem/justworks/gradle/JustworksPlugin.kt b/plugin/src/main/kotlin/com/avsystem/justworks/gradle/JustworksPlugin.kt new file mode 100644 index 00000000..707dbbdc --- /dev/null +++ b/plugin/src/main/kotlin/com/avsystem/justworks/gradle/JustworksPlugin.kt @@ -0,0 +1,104 @@ +package com.avsystem.justworks.gradle + +import org.gradle.api.GradleException +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.api.plugins.JavaPluginExtension + +/** + * Gradle plugin that registers the Justworks OpenAPI code generator. + * + * Applies the `justworks` DSL extension with multi-spec support, + * registers per-spec generation tasks dynamically, wires generated sources + * into Kotlin source sets, and hooks `compileKotlin` to depend on code generation. + */ +class JustworksPlugin : Plugin { + companion object { + private val SPEC_NAME_PATTERN = Regex("[a-zA-Z][a-zA-Z0-9]*") + + internal fun taskNameFor(specName: String): String = + "justworksGenerate${specName.replaceFirstChar { it.uppercase() }}" + } + + override fun apply(project: Project) { + // 1. Create extension + val extension = project.extensions.create("justworks", JustworksExtension::class.java) + + // 2. Register shared types task (HttpError, Success — generated once) + val sharedTypesTask = + project.tasks.register("justworksSharedTypes", JustworksSharedTypesTask::class.java) { task -> + task.outputDir.set(project.layout.buildDirectory.dir("generated/justworks/shared/kotlin")) + } + + // 3. Register aggregate task + val generateAllTask = + project.tasks.register("justworksGenerateAll") { task -> + task.group = "code generation" + task.description = "Generate Kotlin clients from all configured OpenAPI specs" + } + + // 4. Dynamic per-spec task registration + extension.specs.all { spec -> + // Validate spec name (alphanumeric, starts with letter) + if (!spec.name.matches(SPEC_NAME_PATTERN)) { + throw GradleException( + "Invalid spec name '${spec.name}': must start with a letter and contain only letters and numbers", + ) + } + + val taskName = taskNameFor(spec.name) + + // Register per-spec generate task + val specTask = + project.tasks.register(taskName, JustworksGenerateTask::class.java) { task -> + task.dependsOn(sharedTypesTask) + task.specFile.set(spec.specFile) + task.apiPackage.set(spec.apiPackage.orElse(spec.packageName.map { "$it.api" })) + task.modelPackage.set(spec.modelPackage.orElse(spec.packageName.map { "$it.model" })) + task.outputDir.set(project.layout.buildDirectory.dir("generated/justworks/${spec.name}")) + task.group = "code generation" + task.description = "Generate Kotlin client from '${spec.name}' OpenAPI spec" + } + + // Wire spec task into aggregate task + generateAllTask.configure { it.dependsOn(specTask) } + } + + // 5. Wire generated sources into Kotlin source sets lazily + project.plugins.withId("org.jetbrains.kotlin.jvm") { + val sourceSets = project.extensions.getByType(JavaPluginExtension::class.java).sourceSets + + sourceSets.named("main") { sourceSet -> + // Wire shared types output + sourceSet.java.srcDir(sharedTypesTask.flatMap { it.outputDir }) + + // Wire each spec's output directory + extension.specs.all { spec -> + val specTask = project.tasks.named(taskNameFor(spec.name), JustworksGenerateTask::class.java) + sourceSet.java.srcDir(specTask.flatMap { it.outputDir }) + } + } + + // Hook compileKotlin to depend on generateAllTask + project.tasks.named("compileKotlin") { task -> + task.dependsOn(generateAllTask) + } + } + + // 6. Configuration-time validation + project.afterEvaluate { + if (extension.specs.isEmpty()) { + project.logger.warn("justworks: no specs configured in justworks.specs { } block") + } + + extension.specs.all { spec -> + if (!spec.specFile.isPresent) { + throw GradleException("Spec '${spec.name}': specFile is required but not set") + } + if (!spec.packageName.isPresent) { + throw GradleException("Spec '${spec.name}': packageName is required but not set") + } + } + } + } +} diff --git a/plugin/src/main/kotlin/com/avsystem/justworks/gradle/JustworksSharedTypesTask.kt b/plugin/src/main/kotlin/com/avsystem/justworks/gradle/JustworksSharedTypesTask.kt new file mode 100644 index 00000000..a6b915e3 --- /dev/null +++ b/plugin/src/main/kotlin/com/avsystem/justworks/gradle/JustworksSharedTypesTask.kt @@ -0,0 +1,28 @@ +package com.avsystem.justworks.gradle + +import com.avsystem.justworks.core.gen.CodeGenerator +import org.gradle.api.DefaultTask +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.tasks.CacheableTask +import org.gradle.api.tasks.OutputDirectory +import org.gradle.api.tasks.TaskAction + +/** + * Gradle task that generates shared types (HttpError, Success) once + * to a fixed output directory shared across all spec configurations. + */ +@CacheableTask +abstract class JustworksSharedTypesTask : DefaultTask() { + /** Output directory for shared type files. */ + @get:OutputDirectory + abstract val outputDir: DirectoryProperty + + @TaskAction + fun generate() { + val outDir = outputDir.get().asFile.recreateDirectory() + + val count = CodeGenerator.generateSharedTypes(outDir) + + logger.lifecycle("Generated $count shared type files") + } +} diff --git a/plugin/src/main/kotlin/com/avsystem/justworks/gradle/JustworksSpecConfiguration.kt b/plugin/src/main/kotlin/com/avsystem/justworks/gradle/JustworksSpecConfiguration.kt new file mode 100644 index 00000000..6c3cacaa --- /dev/null +++ b/plugin/src/main/kotlin/com/avsystem/justworks/gradle/JustworksSpecConfiguration.kt @@ -0,0 +1,51 @@ +package com.avsystem.justworks.gradle + +import org.gradle.api.Named +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.Property +import javax.inject.Inject + +/** + * Configuration for a single OpenAPI spec in the Justworks multi-spec DSL. + * + * Each spec is independently configured with its own OpenAPI file path, + * package structure, and generates to an isolated output directory. + * + * Usage in `build.gradle.kts`: + * ```kotlin + * justworks { + * specs { + * register("petstore") { + * specFile = file("api/petstore.yaml") + * packageName = "com.example.petstore" + * } + * } + * } + * ``` + * + * This class implements [Named] to work with Gradle's [org.gradle.api.NamedDomainObjectContainer]. + * The name is injected via constructor and determines the task name and output directory. + */ +abstract class JustworksSpecConfiguration + @Inject + constructor(private val name: String) : Named { + /** Path to the OpenAPI spec file (.yaml or .json). */ + abstract val specFile: RegularFileProperty + + /** Base package name for generated code (e.g., "com.example.api"). */ + abstract val packageName: Property + + /** + * Package for generated API client classes. + * Defaults to `"$packageName.api"` if not set. + */ + abstract val apiPackage: Property + + /** + * Package for generated model/data classes. + * Defaults to `"$packageName.model"` if not set. + */ + abstract val modelPackage: Property + + override fun getName(): String = name + } diff --git a/plugin/src/test/kotlin/com/avsystem/justworks/gradle/RecreateDirectoryTest.kt b/plugin/src/test/kotlin/com/avsystem/justworks/gradle/RecreateDirectoryTest.kt new file mode 100644 index 00000000..4c3d7b93 --- /dev/null +++ b/plugin/src/test/kotlin/com/avsystem/justworks/gradle/RecreateDirectoryTest.kt @@ -0,0 +1,61 @@ +package com.avsystem.justworks.gradle + +import org.junit.jupiter.api.io.TempDir +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class RecreateDirectoryTest { + @TempDir + lateinit var tempDir: File + + @Test + fun `creates directory when it does not exist`() { + val dir = tempDir.resolve("new-dir") + assertFalse(dir.exists()) + + val result = dir.recreateDirectory() + + assertTrue(dir.exists()) + assertTrue(dir.isDirectory) + assertEquals(dir, result) + } + + @Test + fun `deletes existing contents and recreates empty directory`() { + val dir = tempDir.resolve("existing-dir") + dir.mkdirs() + dir.resolve("file.txt").writeText("content") + dir.resolve("subdir").mkdir() + dir.resolve("subdir/nested.txt").writeText("nested") + + dir.recreateDirectory() + + assertTrue(dir.exists()) + assertTrue(dir.isDirectory) + assertEquals(emptyList(), dir.listFiles()?.toList()) + } + + @Test + fun `creates nested directory structure`() { + val dir = tempDir.resolve("a/b/c") + assertFalse(dir.exists()) + + dir.recreateDirectory() + + assertTrue(dir.exists()) + assertTrue(dir.isDirectory) + } + + @Test + fun `returns the same file instance`() { + val dir = tempDir.resolve("dir") + + val result = dir.recreateDirectory() + + assertSame(result, dir) + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 14ce48e2..58a42897 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -2,4 +2,4 @@ plugins { id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" } rootProject.name = "justworks" -include("core") +include("core", "plugin")