Skip to content

Add a Gradle plugin for declarative cache control #43

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 7 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import com.gradleup.librarian.gradle.librarianRoot

plugins {
alias(libs.plugins.kotlin).apply(false)
alias(libs.plugins.kotlin.multiplatform).apply(false)
alias(libs.plugins.kotlin.jvm).apply(false)
alias(libs.plugins.android).apply(false)
alias(libs.plugins.librarian).apply(false)
alias(libs.plugins.atomicfu).apply(false)
Expand Down
2 changes: 2 additions & 0 deletions gradle.properties
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
org.gradle.jvmargs=-Xmx8g

android.useAndroidX=true

ksp.useKSP2=true
10 changes: 9 additions & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ apollo-mpp-utils = { group = "com.apollographql.apollo", name = "apollo-mpp-util
apollo-testing-support = { group = "com.apollographql.apollo", name = "apollo-testing-support", version.ref = "apollo" }
apollo-runtime = { group = "com.apollographql.apollo", name = "apollo-runtime", version.ref = "apollo" }
apollo-mockserver = "com.apollographql.mockserver:apollo-mockserver:0.0.1"
apollo-ast = { group = "com.apollographql.apollo", name = "apollo-ast", version.ref = "apollo" }
atomicfu-library = { group = "org.jetbrains.kotlinx", name = "atomicfu", version.ref = "atomicfu" }
kotlin-test = { group = "org.jetbrains.kotlin", name = "kotlin-test" } # the Kotlin plugin resolves the version
kotlin-test-junit = { group = "org.jetbrains.kotlin", name = "kotlin-test-junit" } # the Kotlin plugin resolves the version
Expand All @@ -28,11 +29,18 @@ slf4j-nop = "org.slf4j:slf4j-nop:2.0.13"
androidx-sqlite = { group = "androidx.sqlite", name = "sqlite", version.ref = "androidx-sqlite" }
androidx-sqlite-framework = { group = "androidx.sqlite", name = "sqlite-framework", version.ref = "androidx-sqlite" }
androidx-startup-runtime = "androidx.startup:startup-runtime:1.1.1"
kgp-min = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version = "1.9.0" }
gradle-api-min = { group = "dev.gradleplugins", name = "gradle-api", version = "8.0" }
kotlin-poet = { group = "com.squareup", name = "kotlinpoet", version = "1.18.1" }

[plugins]
kotlin = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin-plugin" }
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin-plugin" }
kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin-plugin" }
android = { id = "com.android.library", version.ref = "android-plugin" }
librarian = { id = "com.gradleup.librarian", version.ref = "librarian" }
atomicfu = { id = "org.jetbrains.kotlin.plugin.atomicfu", version.ref = "kotlin-plugin" }
sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" }
apollo = { id = "com.apollographql.apollo", version.ref = "apollo" }
apollo-cache = { id = "com.apollographql.cache" }
ksp = { id = "com.google.devtools.ksp", version = "2.0.0-1.0.24" }
gratatouille = { id = "com.gradleup.gratatouille", version = "0.0.4" }
1 change: 1 addition & 0 deletions normalized-cache-gradle-plugin/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Module normalized-cache-gradle-plugin
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
public abstract class com/apollographql/cache/gradle/ApolloCacheExtension {
public fun <init> (Lorg/gradle/api/Project;)V
public final fun getProject ()Lorg/gradle/api/Project;
public final fun service (Ljava/lang/String;Lorg/gradle/api/Action;)V
}

public abstract class com/apollographql/cache/gradle/ApolloCachePlugin : org/gradle/api/Plugin {
public fun <init> ()V
public synthetic fun apply (Ljava/lang/Object;)V
public fun apply (Lorg/gradle/api/Project;)V
}

public abstract class com/apollographql/cache/gradle/ApolloCacheService {
public fun <init> (Ljava/lang/String;Lorg/gradle/api/Project;)V
public abstract fun getPackageName ()Lorg/gradle/api/provider/Property;
public final fun srcDir (Ljava/lang/Object;)V
}

public final class com/apollographql/cache/gradle/VersionKt {
public static final field VERSION Ljava/lang/String;
}

public final class com/apollographql/cache/gradle/internal/GenerateApolloCacheSourcesEntryPoint {
public static final field Companion Lcom/apollographql/cache/gradle/internal/GenerateApolloCacheSourcesEntryPoint$Companion;
public fun <init> ()V
public static final fun run (Ljava/util/List;Ljava/lang/String;Ljava/io/File;)V
}

public final class com/apollographql/cache/gradle/internal/GenerateApolloCacheSourcesEntryPoint$Companion {
public final fun run (Ljava/util/List;Ljava/lang/String;Ljava/io/File;)V
}

29 changes: 29 additions & 0 deletions normalized-cache-gradle-plugin/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import com.gradleup.librarian.gradle.librarianModule

plugins {
alias(libs.plugins.kotlin.jvm)
id("java-gradle-plugin")
alias(libs.plugins.ksp)
alias(libs.plugins.gratatouille)
}

librarianModule(true)

dependencies {
compileOnly(libs.kgp.min)
compileOnly(libs.gradle.api.min)
implementation(libs.kotlin.poet)
implementation(libs.apollo.ast)
testImplementation(libs.kotlin.test)
}

gradlePlugin {
plugins {
create("com.apollographql.cache") {
id = "com.apollographql.cache"
displayName = "com.apollographql.cache"
description = "Apollo Normalized Cache Gradle plugin"
implementationClass = "com.apollographql.cache.gradle.ApolloCachePlugin"
}
}
}
1 change: 1 addition & 0 deletions normalized-cache-gradle-plugin/librarian.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
version.packageName=com.apollographql.cache.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.apollographql.cache.gradle

import com.apollographql.cache.gradle.internal.isKotlinMultiplatform
import com.apollographql.cache.gradle.internal.kotlinProjectExtensionOrThrow
import com.apollographql.cache.gradle.internal.registerGenerateApolloCacheSourcesTask
import org.gradle.api.Action
import org.gradle.api.Project
import javax.inject.Inject

abstract class ApolloCacheExtension @Inject constructor(val project: Project) {
fun service(serviceName: String, action: Action<ApolloCacheService>) {
val service = project.objects.newInstance(ApolloCacheService::class.java, serviceName)
action.execute(service)

val task = project.registerGenerateApolloCacheSourcesTask(
taskName = "generate${serviceName.replaceFirstChar(Char::uppercase)}ApolloCacheSources",
schemaFiles = service.graphqlSourceDirectorySet,
packageName = service.packageName
)
val mainSourceSetName = if (project.isKotlinMultiplatform) "commonMain" else "main"
project.kotlinProjectExtensionOrThrow.sourceSets.getByName(mainSourceSetName).kotlin.srcDir(task)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.apollographql.cache.gradle

import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.api.artifacts.ExternalDependency

abstract class ApolloCachePlugin : Plugin<Project> {
override fun apply(target: Project) {
target.extensions.create("apolloCache", ApolloCacheExtension::class.java, target)
target.configureDefaultVersionsResolutionStrategy()
}

private fun Project.configureDefaultVersionsResolutionStrategy() {
configurations.configureEach { configuration ->
configuration.withDependencies { dependencySet ->
val pluginVersion = VERSION
dependencySet.filterIsInstance<ExternalDependency>()
.filter { it.group == "com.apollographql.cache" && it.version.isNullOrEmpty() }
.forEach { it.version { constraint -> constraint.require(pluginVersion) } }
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.apollographql.cache.gradle

import org.gradle.api.Project
import org.gradle.api.provider.Property
import javax.inject.Inject

abstract class ApolloCacheService @Inject constructor(
private val name: String,
project: Project,
) {
abstract val packageName: Property<String>

internal val graphqlSourceDirectorySet = project.objects.sourceDirectorySet("graphql", "graphql")

fun srcDir(directory: Any) {
graphqlSourceDirectorySet.srcDir(directory)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package com.apollographql.cache.gradle.internal.codegen

import com.apollographql.cache.gradle.VERSION
import com.squareup.kotlinpoet.ClassName
import com.squareup.kotlinpoet.CodeBlock
import com.squareup.kotlinpoet.FileSpec
import com.squareup.kotlinpoet.MAP
import com.squareup.kotlinpoet.MemberName
import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy
import com.squareup.kotlinpoet.PropertySpec
import com.squareup.kotlinpoet.STRING
import com.squareup.kotlinpoet.TypeSpec
import com.squareup.kotlinpoet.asTypeName
import java.io.File
import kotlin.time.Duration

private object Symbols {
val MaxAge = ClassName("com.apollographql.cache.normalized.api", "MaxAge")
val MaxAgeInherit = MaxAge.nestedClass("Inherit")
val MaxAgeDuration = MaxAge.nestedClass("Duration")
val Seconds = MemberName(Duration.Companion::class.asTypeName(), "seconds", isExtension = true)
}

internal class Codegen(
private val packageName: String,
private val outputDirectory: File,
private val maxAges: Map<String, Int>,
) {
fun generate() {
generateCache()
}

private fun generateCache() {
val initializer = CodeBlock.builder().apply {
add("mapOf(\n")
indent()
maxAges.forEach { (field, duration) ->
if (duration == -1) {
addStatement("%S to %T,", field, Symbols.MaxAgeInherit)
} else {
addStatement("%S to %T(%L.%M),", field, Symbols.MaxAgeDuration, duration, Symbols.Seconds)
}
}
unindent()
add(")")
}
.build()
val file = FileSpec.builder(packageName, "Cache")
.addType(
TypeSpec.objectBuilder("Cache")
.addProperty(
PropertySpec.builder("maxAges", MAP
.parameterizedBy(STRING, Symbols.MaxAge)
)
.initializer(initializer)
.build()
)
.build()
)
.addFileComment(
"""

AUTO-GENERATED FILE. DO NOT MODIFY.

This class was automatically generated by Apollo GraphQL Cache version '$VERSION'.

""".trimIndent()
)
.build()

file.writeTo(outputDirectory)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
package com.apollographql.cache.gradle.internal.codegen

import com.apollographql.apollo.annotations.ApolloExperimental
import com.apollographql.apollo.ast.GQLBooleanValue
import com.apollographql.apollo.ast.GQLDirective
import com.apollographql.apollo.ast.GQLDocument
import com.apollographql.apollo.ast.GQLIntValue
import com.apollographql.apollo.ast.GQLInterfaceTypeDefinition
import com.apollographql.apollo.ast.GQLObjectTypeDefinition
import com.apollographql.apollo.ast.GQLSchemaDefinition
import com.apollographql.apollo.ast.GQLStringValue
import com.apollographql.apollo.ast.GQLTypeDefinition
import com.apollographql.apollo.ast.Schema
import com.apollographql.apollo.ast.SourceLocation
import com.apollographql.apollo.ast.pretty
import com.apollographql.apollo.ast.toGQLDocument
import com.apollographql.apollo.ast.validateAsSchema
import gratatouille.FileWithPath
import org.gradle.api.logging.Logging
import java.io.File

private const val CACHE_CONTROL = "cacheControl"
private const val CACHE_CONTROL_FIELD = "cacheControlField"

internal class SchemaReader(
private val schemaFiles: Collection<FileWithPath>,
) {
/*
* Taken from ApolloCompiler.buildCodegenSchema(), with error handling removed as errors will already be handled by it.
*/
private fun getSchema(): Schema {
val schemaDocuments = schemaFiles.map {
it.normalizedPath to it.file.toGQLDocument(allowJson = true)
}
// Locate the mainSchemaDocument. It's the one that contains the operation roots
val mainSchemaDocuments = mutableListOf<GQLDocument>()
val otherSchemaDocuments = mutableListOf<GQLDocument>()
schemaDocuments.forEach {
val document = it.second
if (
document.definitions.filterIsInstance<GQLSchemaDefinition>().isNotEmpty()
|| document.definitions.filterIsInstance<GQLTypeDefinition>().any { it.name == "Query" }
) {
mainSchemaDocuments.add(document)
} else {
otherSchemaDocuments.add(document)
}
}
val mainSchemaDocument = mainSchemaDocuments.single()

// Sort the other schema document as type extensions are order sensitive
val otherSchemaDocumentSorted = otherSchemaDocuments.sortedBy { it.sourceLocation?.filePath?.substringAfterLast(File.pathSeparator) }
val schemaDefinitions = (listOf(mainSchemaDocument) + otherSchemaDocumentSorted).flatMap { it.definitions }
val schemaDocument = GQLDocument(
definitions = schemaDefinitions,
sourceLocation = null
)

@OptIn(ApolloExperimental::class)
val result = schemaDocument.validateAsSchema()
return result.value!!
}

fun getMaxAge(): Map<String, Int> {
val schema = getSchema()
val typeDefinitions = schema.typeDefinitions
val issues = mutableListOf<Issue>()
fun GQLDirective.maxAgeAndInherit(): Pair<Int?, Boolean> {
val maxAge = (arguments.firstOrNull { it.name == "maxAge" }?.value as? GQLIntValue)?.value?.toIntOrNull()
if (maxAge != null && maxAge < 0) {
issues += Issue("`maxAge` must not be negative", sourceLocation)
return null to false
}
val inheritMaxAge = (arguments.firstOrNull { it.name == "inheritMaxAge" }?.value as? GQLBooleanValue)?.value == true
if (maxAge == null && !inheritMaxAge || maxAge != null && inheritMaxAge) {
issues += Issue("`@$name` must either provide a `maxAge` or an `inheritMaxAge` set to true", sourceLocation)
return null to false
}
return maxAge to inheritMaxAge
}

val maxAges = mutableMapOf<String, Int>()
for (typeDefinition in typeDefinitions.values) {
val typeCacheControlDirective = typeDefinition.directives.firstOrNull { schema.originalDirectiveName(it.name) == CACHE_CONTROL }
if (typeCacheControlDirective != null) {
val (maxAge, inheritMaxAge) = typeCacheControlDirective.maxAgeAndInherit()
if (maxAge != null) {
maxAges[typeDefinition.name] = maxAge
} else if (inheritMaxAge) {
maxAges[typeDefinition.name] = -1
}
}

val typeCacheControlFieldDirectives =
typeDefinition.directives.filter { schema.originalDirectiveName(it.name) == CACHE_CONTROL_FIELD }
for (fieldDirective in typeCacheControlFieldDirectives) {
val fieldName = (fieldDirective.arguments.first { it.name == "name" }.value as GQLStringValue).value
if (typeDefinition.fields.none { it.name == fieldName }) {
issues += Issue("Field `$fieldName` does not exist on type `${typeDefinition.name}`", fieldDirective.sourceLocation)
continue
}
val (maxAge, inheritMaxAge) = fieldDirective.maxAgeAndInherit()
if (maxAge != null) {
maxAges["${typeDefinition.name}.$fieldName"] = maxAge
} else if (inheritMaxAge) {
maxAges["${typeDefinition.name}.$fieldName"] = -1
}
}

for (field in typeDefinition.fields) {
val fieldCacheControlDirective = field.directives.firstOrNull { schema.originalDirectiveName(it.name) == CACHE_CONTROL }
if (fieldCacheControlDirective != null) {
val (maxAge, inheritMaxAge) = fieldCacheControlDirective.maxAgeAndInherit()
if (maxAge != null) {
maxAges["${typeDefinition.name}.${field.name}"] = maxAge
} else if (inheritMaxAge) {
maxAges["${typeDefinition.name}.${field.name}"] = -1
}
}
}
}
if (issues.isNotEmpty()) {
for (issue in issues) {
issue.log()
}
throw IllegalStateException("Found issues in the schema")
}
return maxAges
}
}

private class Issue(
val message: String,
val sourceLocation: SourceLocation?,
) {
fun log() {
Logging.getLogger("apollo").lifecycle("w: ${sourceLocation.pretty()}: Apollo: ${message}")
}
}

private val GQLTypeDefinition.fields
get() = when (this) {
is GQLObjectTypeDefinition -> fields
is GQLInterfaceTypeDefinition -> fields
else -> emptyList()
}
Loading