Skip to content
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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Set up JDK ${{ matrix.java }}
uses: actions/setup-java@v3
uses: actions/setup-java@v4
with:
java-version: ${{ matrix.java }}
distribution: 'adopt'
Expand Down
27 changes: 26 additions & 1 deletion e2e-tests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ End-to-end tests for the Restate runtime. Tests are deployed as Docker container

## Requirements

- JVM >= 21
- JDK 25 (the Gradle toolchain can provision it)
- Docker

## Running locally
Expand All @@ -27,6 +27,31 @@ Run a single test class within a suite:
./gradlew :e2e-tests:run --args='run --test-suite=default --test-name=IngressTest'
```

## OpenAPI contract checks

Run the OpenAPI checks against a locally built candidate image (replace the image tag as needed):

```shell
./gradlew :e2e-tests:run --args='run --test-suite=default --test-name=OpenAPITest --restate-container-image=localhost/restatedev/restate:openapi'
```

Omit `--restate-container-image` to test `ghcr.io/restatedev/restate:main`.
Reports and generated files are saved under `<report-dir>/default/OpenAPITest/openapi/`.

### Updating the Admin baseline

To update [`infra/src/main/openapi/admin.json`](../infra/src/main/openapi/admin.json),
run from the **Restate** checkout with this repository at `../e2e`:

```shell
set -o pipefail
cargo xtask generate-rest-api-doc | jq . > ../e2e/infra/src/main/openapi/admin.json
```

Review the diff and rerun the checks before committing the updated baseline. The shared Admin
client regenerates automatically. Updating the baseline accepts those changes for future
compatibility comparisons.

## Available test suites

| Suite | Description |
Expand Down
16 changes: 15 additions & 1 deletion e2e-tests/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,16 @@ dependencies {
// Used at runtime by some e2e tests to interact with Kafka
implementation("org.apache.kafka:kafka-clients:3.5.0")
// Runtime dep for e2e-specific functionality
implementation("org.openapitools:openapi-generator:7.21.0")
implementation(libs.openapi.generator)

// javac compiles the candidate's generated client during the OpenAPI test.
runtimeOnly(libs.tomcat.annotations)
runtimeOnly(libs.google.findbugs.jsr305)
}

// Reuse the shared client's checked-in specification as the compatibility baseline.
tasks.processResources {
from(project(":infra").file("src/main/openapi/admin.json")) { into("openapi/baseline") }
}

allOpen {
Expand All @@ -60,6 +69,11 @@ allOpen {
application { mainClass = "dev.restate.sdktesting.MainKt" }

spotless {
java {
target("src/main/resources/openapi/*.java")
googleJavaFormat()
licenseHeaderFile("$rootDir/config/license-header")
}
kotlin {
ktfmt()
licenseHeaderFile("$rootDir/config/license-header")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,7 @@
// https://github.com/restatedev/sdk-test-suite/blob/main/LICENSE
package dev.restate.sdktesting.tests

import dev.restate.admin.api.DeploymentApi
import dev.restate.admin.client.ApiClient
import com.fasterxml.jackson.databind.ObjectMapper
import dev.restate.client.Client
import dev.restate.client.SendResponse
import dev.restate.client.kotlin.*
Expand All @@ -32,6 +31,9 @@ import dev.restate.sdk.kotlin.state
import dev.restate.sdktesting.infra.*
import dev.restate.serde.kotlinx.jsonSerde
import java.net.URI
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse.BodyHandlers
import java.nio.file.Files
import java.util.UUID
import java.util.concurrent.atomic.AtomicInteger
Expand Down Expand Up @@ -260,40 +262,40 @@ class ForwardCompatibilityTest {
@InjectAdminURI adminURI: URI,
@InjectLocalEndpointURI localEndpointURI: URI,
) {
// Create Admin API client with the provided admin URI
val adminClient = ApiClient().setHost(adminURI.host).setPort(adminURI.port)
val adminApi = DeploymentApi(adminClient)

// List all deployments
val deployments = adminApi.listDeployments()
// The Admin client is generated from the current specification and need not support older
// servers. In particular, v1.7 deployment responses lack the `type` discriminator, making
// HTTP/Lambda deserialization ambiguous. Although v1.7.3 supports PATCH, updateDeployment()
// also deserializes its response and would fail even if we ignored the return value.
// Keep both GET and PATCH raw until LAST_COMPATIBLE_RESTATE_SERVER_VERSION is raised to a
// server whose deployment responses include `type` and work with the generated client.
// Then replace these requests with DeploymentApi.listDeployments()/updateDeployment().
val httpClient = HttpClient.newHttpClient()
val mapper = ObjectMapper()
val listResponse =
httpClient.send(
HttpRequest.newBuilder(adminURI.resolve("deployments")).GET().build(),
BodyHandlers.ofString(),
)
check(listResponse.statusCode() == 200) {
"listDeployments call failed with: ${listResponse.statusCode()} - ${listResponse.body()}"
}
val deployments = mapper.readTree(listResponse.body()).required("deployments")
check(deployments.isArray && !deployments.isEmpty) { "Expected registered deployments" }

LOG.info("Patching all deployments to use endpoint URI: {}", localEndpointURI)

// For each deployment, update its URI.
// NOTE: We use a raw PUT request here instead of adminApi.updateDeployment() because
// the generated client uses PATCH (per the current OpenAPI spec), but this test runs
// against an OLDER server version that only supports PUT on this endpoint.
// When the minimum supported version includes PATCH support, replace this block with:
// adminApi.updateDeployment(deployment.httpDeploymentResponse.id, updateRequest)
val httpClient = java.net.http.HttpClient.newHttpClient()
for (deployment in deployments.deployments) {
val deploymentId = deployment.httpDeploymentResponse.id
val body = """{"uri":"$localEndpointURI"}"""
val body = mapper.writeValueAsString(mapOf("uri" to localEndpointURI.toString()))
for (deployment in deployments) {
val deploymentId = deployment.required("id").asText()
val request =
java.net.http.HttpRequest.newBuilder()
.uri(
URI.create(
"http://${adminURI.host}:${adminURI.port}/v2/deployments/$deploymentId"
)
)
HttpRequest.newBuilder(adminURI.resolve("deployments/$deploymentId"))
.header("Content-Type", "application/json")
.PUT(java.net.http.HttpRequest.BodyPublishers.ofString(body))
.method("PATCH", HttpRequest.BodyPublishers.ofString(body))
.build()

try {
val response =
httpClient.send(request, java.net.http.HttpResponse.BodyHandlers.ofString())
check(response.statusCode() / 100 == 2) {
val response = httpClient.send(request, BodyHandlers.ofString())
check(response.statusCode() == 200) {
"updateDeployment call failed with: ${response.statusCode()} - ${response.body()}"
}
LOG.info(
Expand Down
197 changes: 146 additions & 51 deletions e2e-tests/src/main/kotlin/dev/restate/sdktesting/tests/OpenAPITest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,86 +8,181 @@
// https://github.com/restatedev/sdk-test-suite/blob/main/LICENSE
package dev.restate.sdktesting.tests

import dev.restate.sdk.annotation.Handler
import dev.restate.sdk.annotation.Name
import dev.restate.sdk.annotation.Service
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.ObjectMapper
import dev.restate.sdk.annotation.*
import dev.restate.sdk.endpoint.Endpoint
import dev.restate.sdk.kotlin.endpoint.journalRetention
import dev.restate.sdktesting.infra.*
import dev.restate.sdktesting.tests.openapi.checkCompatibility
import dev.restate.sdktesting.tests.openapi.generateClient
import java.net.URI
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.StandardCopyOption
import java.time.Duration
import kotlin.time.Duration.Companion.days
import kotlinx.serialization.Serializable
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.BeforeAllCallback
import org.junit.jupiter.api.extension.RegisterExtension
import org.openapitools.codegen.DefaultGenerator
import org.openapitools.codegen.config.CodegenConfigurator

class OpenAPITest {

@Serializable data class Details(val language: String)

@Serializable data class Greeting(val name: String, val details: Details)

@Service
@Name("GreeterService")
class GreeterService {
@Handler
suspend fun greet(name: String): String {
return "Hello, $name!"
}
@Handler suspend fun greet(greeting: Greeting): Greeting = greeting
}

@VirtualObject
@Name("GreeterObject")
class GreeterObject {
@Handler suspend fun greet(greeting: Greeting): Greeting = greeting

@Shared suspend fun read(): String = "ready"
}

@Workflow
@Name("GreeterWorkflow")
class GreeterWorkflow {
@Workflow suspend fun run(greeting: Greeting): Greeting = greeting

@Shared suspend fun read(): String = "ready"
}

companion object {
private val mapper = ObjectMapper()
private val http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build()
private lateinit var artifacts: Path

@RegisterExtension
@JvmField
val reports = BeforeAllCallback { context ->
artifacts =
Files.createDirectories(
Path.of(
context
.getConfigurationParameter(
BaseRestateDeployerExtension.REPORT_DIR_PROPERTY_NAME
)
.orElse("test_report")
)
.resolve("OpenAPITest/openapi")
)
}

@RegisterExtension
@JvmField
val deployerExt: RestateDeployerExtension = RestateDeployerExtension {
withEndpoint(Endpoint.bind(GreeterService()))
withEndpoint(
Endpoint.bind(GreeterService()) { it.journalRetention = 1.days }
.bind(GreeterObject())
.bind(GreeterWorkflow())
)
}
}

@Test
fun shouldGenerateValidOpenAPI(@InjectAdminURI adminURI: URI) = runTest {
// Download OpenAPI spec from Admin API
val httpClient = HttpClient.newHttpClient()
val request =
HttpRequest.newBuilder()
.uri(URI.create("${adminURI}services/GreeterService/openapi"))
.GET()
.build()
fun adminClientWorksAgainstCandidate(
@InjectAdminURI adminURI: URI,
@InjectIngressURI ingressURI: URI,
) {
val spec = download(adminURI.resolve("openapi"), "admin.json")
val invocationId = completedInvocation(ingressURI)
generateClient(
spec,
artifacts.resolve("admin-client"),
"dev.restate.candidate",
"AdminSmoke.java",
)
.use { client ->
// The Java smoke test is compiled together with the freshly generated client. This checks
// generated method signatures as well as runtime serialization/deserialization.
client
.loadClass("AdminSmoke")
.getMethod("run", URI::class.java, String::class.java)
.invoke(null, adminURI, invocationId)
}
}

@Test
fun adminContractIsBackwardsCompatible(@InjectAdminURI adminURI: URI) {
val candidate = download(adminURI.resolve("openapi"), "admin-compatibility.json")
val baseline = artifacts.resolve("admin-baseline.json")
checkNotNull(javaClass.getResourceAsStream("/openapi/baseline/admin.json")).use {
Files.copy(it, baseline, StandardCopyOption.REPLACE_EXISTING)
}
checkCompatibility(baseline, candidate, artifacts.resolve("breaking-changes.txt"))
}

val response = httpClient.send(request, HttpResponse.BodyHandlers.ofString())
assertThat(response.statusCode()).isEqualTo(200)

// Save OpenAPI spec to a temporary file
val tempDir = Files.createTempDirectory("openapi-test")
val openApiFile = tempDir.resolve("openapi.json")
Files.writeString(openApiFile, response.body())

// Configure and run OpenAPI Generator
val configurator =
CodegenConfigurator().apply {
setInputSpec(openApiFile.toString())
setOutputDir(tempDir.resolve("generated").toString())
setGeneratorName("java")
setApiPackage("dev.restate.generated.api")
setModelPackage("dev.restate.generated.model")
// Using the latest Java client generator features
setAdditionalProperties(
mapOf(
"dateLibrary" to "java8",
"artifactId" to "restate-generated-client",
"artifactVersion" to "1.0.0",
)
@Test
fun serviceClientsWorkAgainstCandidate(
@InjectAdminURI adminURI: URI,
@InjectIngressURI ingressURI: URI,
) {
for (service in listOf("GreeterService", "GreeterObject", "GreeterWorkflow")) {
val spec = download(adminURI.resolve("services/$service/openapi"), "$service.json")
generateClient(
spec,
artifacts.resolve("$service-client"),
// Each client has its own classloader, so the common invocation smoke test can use
// the same package while compiling independently against all three documents.
"dev.restate.ingress",
"${service}Smoke.java",
"InvocationSmoke.java",
)
}
.use { client ->
client
.loadClass("${service}Smoke")
.getMethod("run", URI::class.java)
.invoke(null, ingressURI)
}
}
}

// Generate the client code
val generator = DefaultGenerator()
val files = generator.opts(configurator.toClientOptInput()).generate()
private fun completedInvocation(ingressURI: URI): String {
val result =
request(ingressURI.resolve("restate/send/GreeterService/greet"), "POST", payload(), 202)
val id = result.path("invocationId").asText()
assertThat(id).isNotBlank()
request(ingressURI.resolve("restate/attach/$id"), "GET", null, 200)
return id
}

// Verify that files were generated
assertThat(files).isNotEmpty()
assertThat(tempDir.resolve("generated/src/main/java")).exists()
private fun payload() = """{"name":"Ada","details":{"language":"en"}}"""

// Verify specific generated files that should exist
assertThat(tempDir.resolve("generated/src/main/java/dev/restate/generated/api")).exists()
assertThat(tempDir.resolve("generated/src/main/java/dev/restate/generated/model")).exists()
private fun download(uri: URI, name: String): Path {
val document = request(uri, "GET", null, 200)
val path = artifacts.resolve(name)
Files.writeString(path, mapper.writerWithDefaultPrettyPrinter().writeValueAsString(document))
return path
}

private fun request(uri: URI, method: String, body: String?, status: Int): JsonNode {
val request =
HttpRequest.newBuilder(uri)
.timeout(Duration.ofSeconds(30))
.header("accept", "application/json")
.apply { if (body != null) header("content-type", "application/json") }
.method(
method,
body?.let { HttpRequest.BodyPublishers.ofString(it) }
?: HttpRequest.BodyPublishers.noBody(),
)
.build()
val response = http.send(request, HttpResponse.BodyHandlers.ofString())
assertThat(response.statusCode())
.withFailMessage("%s %s: %s", method, uri, response.body())
.isEqualTo(status)
return mapper.readTree(response.body())
}
}
Loading
Loading