diff --git a/adcp/build.gradle.kts b/adcp/build.gradle.kts index bf40152..6550046 100644 --- a/adcp/build.gradle.kts +++ b/adcp/build.gradle.kts @@ -22,3 +22,64 @@ dependencies { exclude(group = "com.networknt", module = "json-schema-validator") } } + +// -- Build-time SDK version constant ---------------------------------------- +// Reads ADCP_VERSION (e.g. "3.0.11") and generates AdcpSdkVersion.java with +// the major and release-precision (major.minor) constants. This lets callers +// do cross-major validation at config time without hardcoding a version number. +// Output lands in build/generated/ and is NOT checked in. + +val generateSdkVersion = tasks.register("generateSdkVersion") { + val versionFile = rootProject.file("ADCP_VERSION") + inputs.file(versionFile) + val outputDir = layout.buildDirectory.dir("generated/sources/sdk-version/main/java") + outputs.dir(outputDir) + + doLast { + val raw = versionFile.readText().trim() + val parts = raw.split(".") + require(parts.size >= 2) { "ADCP_VERSION must be in major.minor.patch format: $raw" } + val major = parts[0].toInt() + val release = "${parts[0]}.${parts[1]}" // release-precision, e.g. "3.0" + + val pkg = "org.adcontextprotocol.adcp" + val dir = outputDir.get().asFile.resolve(pkg.replace('.', '/')) + dir.mkdirs() + dir.resolve("AdcpSdkVersion.java").writeText( + """ + package $pkg; + + /** + * Build-time AdCP SDK version constants — generated from {@code ADCP_VERSION}. + * Do not edit manually; update {@code ADCP_VERSION} at the repo root instead. + * + *

Used for cross-major validation: if a caller pins + * {@code adcpVersion("X.Y")} and {@code X != SDK_MAJOR_VERSION}, + * a {@link org.adcontextprotocol.adcp.error.ConfigurationError} is thrown + * before any network request is made. + */ + public final class AdcpSdkVersion { + + private AdcpSdkVersion() {} + + /** Major protocol version this SDK was built for (e.g. {@code 3}). */ + public static final int SDK_MAJOR_VERSION = $major; + + /** + * Release-precision protocol version this SDK was built for + * (e.g. {@code "3.0"}). + */ + public static final String SDK_RELEASE_VERSION = "$release"; + } + """.trimIndent() + ) + } +} + +sourceSets.named("main") { + java.srcDir(generateSdkVersion.map { it.outputs.files.singleFile }) +} + +tasks.named("compileJava") { + dependsOn(generateSdkVersion) +} diff --git a/adcp/src/main/java/org/adcontextprotocol/adcp/AdcpClient.java b/adcp/src/main/java/org/adcontextprotocol/adcp/AdcpClient.java index 80ebe88..2407a1f 100644 --- a/adcp/src/main/java/org/adcontextprotocol/adcp/AdcpClient.java +++ b/adcp/src/main/java/org/adcontextprotocol/adcp/AdcpClient.java @@ -157,6 +157,18 @@ public Builder adcpVersion(AdcpVersion adcpVersion) { return this; } + /** + * Pin a specific AdCP protocol version by release-precision string + * (e.g. {@code "3.0"}, {@code "3.1"}). + * + *

Equivalent to {@code adcpVersion(AdcpVersion.of(releaseVersion))}. + * Throws {@link org.adcontextprotocol.adcp.error.ConfigurationError} at + * {@link #build()} time if the major version does not match the SDK. + */ + public Builder adcpVersion(String releaseVersion) { + return adcpVersion(AdcpVersion.of(releaseVersion)); + } + /** Override the Jackson ObjectMapper. */ public Builder objectMapper(ObjectMapper objectMapper) { this.objectMapper = Objects.requireNonNull(objectMapper); @@ -174,7 +186,25 @@ public Builder ssrfPolicy(SsrfPolicy ssrfPolicy) { /** Builds the client. */ public AdcpClient build() { + validateAdcpVersion(adcpVersion); return new AdcpClient(this); } + + /** + * Validates that the pinned version's major matches the SDK's built-in major. + * Cross-major pins (e.g. requesting "2.0" from a major-3 SDK) fail fast before + * any network request. + */ + private static void validateAdcpVersion(@Nullable AdcpVersion version) { + if (version == null) return; + if (version.majorVersion() != AdcpSdkVersion.SDK_MAJOR_VERSION) { + throw new ConfigurationError( + "adcpVersion major " + version.majorVersion() + + " does not match SDK major " + + AdcpSdkVersion.SDK_MAJOR_VERSION + + " (built for AdCP " + AdcpSdkVersion.SDK_RELEASE_VERSION + ")", + "adcpVersion"); + } + } } } diff --git a/adcp/src/main/java/org/adcontextprotocol/adcp/AdcpVersion.java b/adcp/src/main/java/org/adcontextprotocol/adcp/AdcpVersion.java index 38ce4f0..9ab8bf0 100644 --- a/adcp/src/main/java/org/adcontextprotocol/adcp/AdcpVersion.java +++ b/adcp/src/main/java/org/adcontextprotocol/adcp/AdcpVersion.java @@ -43,4 +43,27 @@ public record AdcpVersion(int majorVersion, @Nullable String minorVersion) { } } } + + /** + * Parses a release-precision version string (e.g. {@code "3.0"}, {@code "3.1"}) + * into an {@code AdcpVersion}. + * + *

This is the string-based convenience factory — pass the same value you + * would set in the Python SDK or TS SDK {@code adcpVersion} constructor option. + * + * @param releaseVersion release-precision version (major.minor, e.g. {@code "3.0"}) + * @return parsed {@code AdcpVersion} + * @throws IllegalArgumentException if the string is not in major.minor format + */ + public static AdcpVersion of(String releaseVersion) { + java.util.Objects.requireNonNull(releaseVersion, "releaseVersion"); + if (!MINOR_VERSION_PATTERN.matcher(releaseVersion).matches()) { + throw new IllegalArgumentException( + "releaseVersion must be in major.minor format (e.g. '3.0'): " + + releaseVersion); + } + int dotIndex = releaseVersion.indexOf('.'); + int major = Integer.parseInt(releaseVersion.substring(0, dotIndex)); + return new AdcpVersion(major, releaseVersion); + } } diff --git a/adcp/src/main/java/org/adcontextprotocol/adcp/AgentConfig.java b/adcp/src/main/java/org/adcontextprotocol/adcp/AgentConfig.java index bace3a1..0edd865 100644 --- a/adcp/src/main/java/org/adcontextprotocol/adcp/AgentConfig.java +++ b/adcp/src/main/java/org/adcontextprotocol/adcp/AgentConfig.java @@ -229,6 +229,17 @@ public Builder adcpVersion(@Nullable AdcpVersion adcpVersion) { return this; } + /** + * Pin a specific AdCP protocol version by release-precision string + * (e.g. {@code "3.0"}, {@code "3.1"}). + * + *

Equivalent to {@code adcpVersion(AdcpVersion.of(releaseVersion))}. + * Cross-major pins are rejected at {@link AdcpClient} build time. + */ + public Builder adcpVersion(String releaseVersion) { + return adcpVersion(AdcpVersion.of(releaseVersion)); + } + /** Extra headers injected into every request to this agent. */ public Builder extraHeaders(Map extraHeaders) { this.extraHeaders = Map.copyOf(extraHeaders); diff --git a/adcp/src/test/java/org/adcontextprotocol/adcp/AdcpClientTest.java b/adcp/src/test/java/org/adcontextprotocol/adcp/AdcpClientTest.java index 1ef0be4..126ef9c 100644 --- a/adcp/src/test/java/org/adcontextprotocol/adcp/AdcpClientTest.java +++ b/adcp/src/test/java/org/adcontextprotocol/adcp/AdcpClientTest.java @@ -110,4 +110,25 @@ void callTool_accepts_null_args_without_npe() { () -> client.callTool("get_products", null, java.util.Map.class)); } } + + @Test + void builder_accepts_string_version() { + try (AdcpClient client = AdcpClient.builder() + .agent(AgentConfig.mcp("test", AGENT_URI)) + .adcpVersion("3.0") + .build()) { + assertNotNull(client.adcpVersion()); + assertEquals(3, client.adcpVersion().majorVersion()); + assertEquals("3.0", client.adcpVersion().minorVersion()); + } + } + + @Test + void builder_rejects_cross_major_version() { + assertThrows(org.adcontextprotocol.adcp.error.ConfigurationError.class, + () -> AdcpClient.builder() + .agent(AgentConfig.mcp("test", AGENT_URI)) + .adcpVersion(new AdcpVersion(2, null)) + .build()); + } } diff --git a/adcp/src/test/java/org/adcontextprotocol/adcp/AdcpVersionTest.java b/adcp/src/test/java/org/adcontextprotocol/adcp/AdcpVersionTest.java index 36cb9f9..51ec487 100644 --- a/adcp/src/test/java/org/adcontextprotocol/adcp/AdcpVersionTest.java +++ b/adcp/src/test/java/org/adcontextprotocol/adcp/AdcpVersionTest.java @@ -73,4 +73,51 @@ void accepts_three_part_minor_version() { var v = new AdcpVersion(3, "3.1.2"); assertEquals("3.1.2", v.minorVersion()); } + + // -- AdcpVersion.of(String) -- + + @Test + void of_parses_release_precision_version() { + AdcpVersion v = AdcpVersion.of("3.0"); + assertEquals(3, v.majorVersion()); + assertEquals("3.0", v.minorVersion()); + } + + @Test + void of_parses_minor_version() { + AdcpVersion v = AdcpVersion.of("3.1"); + assertEquals(3, v.majorVersion()); + assertEquals("3.1", v.minorVersion()); + } + + @Test + void of_rejects_major_only_string() { + assertThrows(IllegalArgumentException.class, () -> AdcpVersion.of("3")); + } + + @Test + void of_rejects_non_numeric() { + assertThrows(IllegalArgumentException.class, () -> AdcpVersion.of("abc.def")); + } + + @Test + void of_rejects_null() { + assertThrows(NullPointerException.class, () -> AdcpVersion.of(null)); + } + + // -- AdcpSdkVersion constants (build-time generated) -- + + @Test + void sdk_major_version_is_positive() { + assertTrue(AdcpSdkVersion.SDK_MAJOR_VERSION > 0, + "SDK_MAJOR_VERSION must be a positive integer"); + } + + @Test + void sdk_release_version_matches_major() { + String release = AdcpSdkVersion.SDK_RELEASE_VERSION; + assertTrue(release.startsWith(AdcpSdkVersion.SDK_MAJOR_VERSION + "."), + "SDK_RELEASE_VERSION must start with SDK_MAJOR_VERSION: " + release); + } } +