diff --git a/README.md b/README.md index 80607e1..06964aa 100644 --- a/README.md +++ b/README.md @@ -103,9 +103,9 @@ ArcadeClient client = ArcadeOkHttpClient.builder() See this table for the available options: | Setter | System property | Environment variable | Required | Default value | -| --------- | ---------------- | -------------------- | -------- | -------------------------- | +| --------- | ---------------- | -------------------- |----------| -------------------------- | | `apiKey` | `arcade.apiKey` | `ARCADE_API_KEY` | true | - | -| `baseUrl` | `arcade.baseUrl` | `ARCADE_BASE_URL` | true | `"https://api.arcade.dev"` | +| `baseUrl` | `arcade.baseUrl` | `ARCADE_BASE_URL` | false | `"https://api.arcade.dev"` | System properties take precedence over environment variables. @@ -721,6 +721,39 @@ ArcadeClient client = ArcadeOkHttpClient.builder() .build(); ``` +## Spring Boot Integration + +The `dev.arcade:arcade-spring-boot-starter` provides a configured `ArcadeClient` bean if the following configuration values are set: + +| Spring Property | Environment variable | Required | Default value | +|--------------------|----------------------|----------|--------------------------| +| `arcade.api-key` | `ARCADE_API_KEY` | true | - | +| `arcade.base-url` | `ARCADE_BASE_URL` | false | `"https://api.arcade.dev"` | + +Read the Spring Boot [Externalized Configuration](https://docs.spring.io/spring-boot/reference/features/external-config.html) docs for more ways to configure these properties. + +## Run the examples + +The examples in `arcade-java-example` can be run in your IDE or on the command line by running: + +```shell +# Configure the environment variables +export ARCADE_API_KEY="" +export ARCADE_USER_ID="arcade-userid-or-email" + +# run the example +./gradlew :arcade-java-example:run -Pexample= +``` + +| Example Name | Description | +|-------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `springboot.SpringBoot` | Calls an aArcade tool using Spring Boot | +| `springai.SpringAI` | Allows a Spring AI chat client to call Arcade Tools, you must configure an OpenAI key for this example: `export SPRING_AI_OPENAI_API_KEY=` | +| `Auth` | Demos how to handle an OAuth authorization response | +| `PlaySpotify` | Calls an Arcade tool without a plain Java application. | +> [!NOTE] +> The above Spring Boot (and Spring AI) examples are not web applications, but the demonstrated logic will work the same way in a web app. + ## FAQ ### Why don't you use plain `enum` classes? diff --git a/arcade-java-example/build.gradle.kts b/arcade-java-example/build.gradle.kts index cb30397..de456c8 100644 --- a/arcade-java-example/build.gradle.kts +++ b/arcade-java-example/build.gradle.kts @@ -1,5 +1,6 @@ plugins { id("arcade.java") + id("io.spring.dependency-management") version "1.1.7" // only needed for SpringBoot examples application } @@ -9,11 +10,26 @@ repositories { dependencies { implementation(project(":arcade-java")) + + // Only needed for SpringBootExample + implementation(project(":arcade-spring-boot-starter")) + implementation("org.springframework.boot:spring-boot-starter:3.5.10") + + // only needed for SpringAIExample + implementation("org.springframework.ai:spring-ai-starter-model-openai") + implementation("org.apache.httpcomponents.client5:httpclient5:5.6") +} + +// only needed for SpringAIExample +dependencyManagement { + imports { + mavenBom("org.springframework.ai:spring-ai-bom:1.1.2") + } } tasks.withType().configureEach { // Allow using more modern APIs, like `List.of` and `Map.of`, in examples. - options.release.set(9) + options.release.set(17) } application { diff --git a/arcade-java-example/src/main/java/dev/arcade/example/springai/SpringAIExample.java b/arcade-java-example/src/main/java/dev/arcade/example/springai/SpringAIExample.java new file mode 100644 index 0000000..edac092 --- /dev/null +++ b/arcade-java-example/src/main/java/dev/arcade/example/springai/SpringAIExample.java @@ -0,0 +1,227 @@ +package dev.arcade.example.springai; + +import dev.arcade.client.ArcadeClient; +import dev.arcade.core.JsonValue; +import dev.arcade.models.AuthorizationResponse; +import dev.arcade.models.tools.AuthorizeToolRequest; +import dev.arcade.models.tools.ExecuteToolRequest; +import dev.arcade.models.tools.ExecuteToolResponse; +import dev.arcade.models.tools.ToolAuthorizeParams; +import dev.arcade.models.tools.ToolExecuteParams; +import java.util.Optional; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.tool.annotation.Tool; +import org.springframework.ai.tool.annotation.ToolParam; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.ApplicationRunner; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.stereotype.Service; + +@SpringBootApplication +public class SpringAIExample { + + private static final String SYSTEM_PROMPT = + """ + You are a specialized Music Assistant with access to two MCP tools: get_spotify_state and play_song. + Your goal is to provide a seamless, proactive audio experience. Follow these operational guidelines: + Context Awareness: Before playing any music, always use get_spotify_state to see if music is already playing. If it is, acknowledge what is currently playing before switching to the new track. + Proactive Assistance: If the user asks for a song and Spotify is currently paused, inform the user you are resuming playback or starting the session for them. + Error Handling: If a song fails to play or the tool returns an error, check the state again to see if the player is disconnected or if the track simply wasn't found. + Tone: Be concise, upbeat, and music-focused. + Chain of Thought: When a user asks for a song, your internal logic should be: Check State -> Inform User -> Execute Play. + """; + + public static void main(String[] args) { + SpringApplication.run(SpringAIExample.class, args); + } + + @Bean + ApplicationRunner runner(ChatClient.Builder chatClientBuilder, ArcadeToolProvider arcadeToolProvider) { + return args -> { + ChatClient chatClient = chatClientBuilder + .defaultSystem(SYSTEM_PROMPT) + .defaultTools(arcadeToolProvider) // see below, each tool is annotated with @Tool + .build(); + + String summary = chatClient + .prompt() + .user("If my music is not currently playing, play something to get me in the mood to write code.") + .call() + .content(); + + System.out.println(summary); + }; + } + + /** + * Exposes Arcade Tools to Spring AI. + */ + @Service + public static class ArcadeToolProvider { + private final Logger log = LoggerFactory.getLogger(ArcadeToolProvider.class); + private final ArcadeClient client; + private final String userId; + + ArcadeToolProvider(ArcadeClient client, @Value("${arcade.user-id}") String userId) { + this.client = client; + this.userId = userId; + } + + /** + * Exposes an Arcade Tool call to Spotify.GetPlaybackState, using Spring AI annotations. + * + * @return A string object of the playback state. + */ + @Tool( + name = "play_song", + description = "Plays a song by an artist and queues four more songs by the same artist") + String play(@ToolParam(description = "The name of the artist to play") String name) { + return executeTool( + "Spotify.PlayArtistByName", + ExecuteToolRequest.Input.builder() + + // map the above input as "additional Properties" + .putAdditionalProperty("name", JsonValue.from(name)) + .build()); + } + + /** + * Exposes an Arcade Tool call to Spotify.GetPlaybackState, using Spring AI annotations. + * + * @return A string object of the playback state. + */ + @Tool( + name = "get_spotify_state", + description = + """ + Get information about the user's current playback state, + including track or episode, and active device. + This tool does not perform any actions. Use other tools to control playback. + """) + String playbackState() { + return executeTool( + "Spotify.GetPlaybackState", + ExecuteToolRequest.Input.builder().build()); // this tool has no inputs + } + + /** + * Executes the specified tool with the provided input. + * + * @param toolName the name of the tool to be executed + * @param input the input parameters required for tool execution + * @return the result of the tool execution as a string; the result may include + * the output of the tool, an error message, or an authorization requirement + */ + private String executeTool(String toolName, ExecuteToolRequest.Input input) { + log.debug("Executing tool {}, with input: {}", toolName, input); + try { + // call the tool + ExecuteToolResponse response = client.tools() + .execute(ToolExecuteParams.builder() + .executeToolRequest(ExecuteToolRequest.builder() + .toolName(toolName) + .userId(userId) + .input(input) + .build()) + .build()); + + log.debug( + "Tool {} executed, with a status of '{}'", + toolName, + response.status().orElse(null)); + + // process the result + if (response.success().orElse(false)) { + String result = + response.output().map(o -> o._value().toString()).orElse("{}"); + + log.debug("Tool {} returned", result); + return result; + } + + Optional error = + response.output().flatMap(ExecuteToolResponse.Output::error); + + if (error.isPresent()) { + String errorMessage = error.get().message(); + + if (errorMessage.contains("authorization required")) { + AuthorizationResult auth = requestAuthorization(toolName); + if (auth.url() != null) { + log.debug("Tool requires {} authorization, open a browser to {}", toolName, auth.url()); + return String.format( + "The '%s' tool requires authorization, open a browser to %s to continue.", + toolName, auth.url()); + } + } + log.warn("Tool {} returned an error: {}", toolName, errorMessage); + return "Error: " + errorMessage; + } + + return "Error: Tool execution failed"; + } catch (Exception e) { + String message = e.getMessage(); + if (message != null && message.contains("authorization")) { + AuthorizationResult auth = requestAuthorization(toolName); + if (auth.url() != null) { + log.debug("Tool requires {} authorization, open a browser to {}", toolName, auth.url()); + return String.format( + "The '%s' tool requires authorization, open a browser to %s to continue.", + toolName, auth.url()); + } + } + log.error("Tool execution failed for {}: {}", toolName, message); + return "Error: " + message; + } + } + + /** + * Requests authorization for a tool and returns the OAuth URL. + */ + public AuthorizationResult requestAuthorization(String toolName) { + try { + AuthorizeToolRequest authRequest = AuthorizeToolRequest.builder() + .toolName(toolName) + .userId(userId) + .build(); + + AuthorizationResponse response = client.tools() + .authorize(ToolAuthorizeParams.builder() + .authorizeToolRequest(authRequest) + .build()); + + Optional url = response.url(); + Optional status = response.status(); + String statusValue = status.map(s -> s.value().name()).orElse("unknown"); + + if ("PENDING".equalsIgnoreCase(statusValue) && url.isPresent()) { + return new AuthorizationResult(toolName, url.get(), statusValue); + } + return new AuthorizationResult(toolName, null, statusValue); + } catch (Exception e) { + log.error("Failed to request authorization for {}: {}", toolName, e.getMessage()); + return new AuthorizationResult(toolName, null, "error: " + e.getMessage()); + } + } + + public record AuthorizationResult(String toolName, String url, String status) { + public boolean requiresAction() { + return url != null && "PENDING".equalsIgnoreCase(status); + } + } + + public enum ResultType { + album, + artist, + playlist, + track, + show, + episode, + audiobook + } + } +} diff --git a/arcade-java-example/src/main/java/dev/arcade/example/springboot/SpringBootExample.java b/arcade-java-example/src/main/java/dev/arcade/example/springboot/SpringBootExample.java new file mode 100644 index 0000000..580e0d4 --- /dev/null +++ b/arcade-java-example/src/main/java/dev/arcade/example/springboot/SpringBootExample.java @@ -0,0 +1,53 @@ +package dev.arcade.example.springboot; + +import dev.arcade.client.ArcadeClient; +import dev.arcade.models.tools.ExecuteToolRequest; +import dev.arcade.models.tools.ExecuteToolResponse; +import dev.arcade.models.tools.ToolExecuteParams; +import org.springframework.boot.ApplicationRunner; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; + +/** + * Example of calling a tool using the Arcade Java SDK. + */ +@SpringBootApplication +public class SpringBootExample { + + /** + * Starts the Spring Boot application. + * @param args All args are passed into the SpringApplication + */ + public static void main(String[] args) { + SpringApplication.run(SpringBootExample.class, args); + } + + /** + * Injects an ArcadeClient, and returns an ApplicationRunner that makes a tool call. + * @param client Arcade Client is autoinjected if ARCADE_API_KEY, or equivalent application.properties var is set. + * @return Runs code on application start. + */ + @Bean + ApplicationRunner appRunner(ArcadeClient client) { + return args -> { + String userId = System.getenv("ARCADE_USER_ID"); // the Spotify tool require a userId + if (userId == null) { + throw new IllegalArgumentException("Missing ARCADE_USER_ID environment variable"); + } + + ToolExecuteParams params = ToolExecuteParams.builder() + .executeToolRequest(ExecuteToolRequest.builder() + .toolName("Spotify.ResumePlayback@1.0.2") + .userId(userId) + .build()) + .build(); + ExecuteToolResponse executeToolResponse = client.tools().execute(params); + executeToolResponse + .output() + .ifPresentOrElse( + output -> System.out.println("Tool output: " + output._value()), + () -> System.out.println("No output for this tool")); + }; + } +} diff --git a/arcade-java-example/src/main/resources/application-local.yml.template b/arcade-java-example/src/main/resources/application-local.yml.template new file mode 100644 index 0000000..16d01c1 --- /dev/null +++ b/arcade-java-example/src/main/resources/application-local.yml.template @@ -0,0 +1,15 @@ +# Local configuration - copy to application-local.yml and fill in values +# DO NOT commit application-local.yml to version control + +spring: + ai: + openai: + api-key: ${OPENAI_API_KEY:your-openai-api-key} + chat: + options: + model: gpt-4o + +arcade: + api-key: ${ARCADE_API_KEY:your-arcade-api-key} + user-id: ${ARCADE_USER_ID:your-email@example.com} + diff --git a/arcade-java-example/src/main/resources/application.yml b/arcade-java-example/src/main/resources/application.yml new file mode 100644 index 0000000..608bc63 --- /dev/null +++ b/arcade-java-example/src/main/resources/application.yml @@ -0,0 +1,8 @@ +spring: + main: + web-application-type: none + config: + import: optional:application-local.yml +logging: + level: + dev.arcade.example: DEBUG diff --git a/arcade-spring-boot-starter/build.gradle.kts b/arcade-spring-boot-starter/build.gradle.kts new file mode 100644 index 0000000..6212eba --- /dev/null +++ b/arcade-spring-boot-starter/build.gradle.kts @@ -0,0 +1,32 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + id("arcade.kotlin") + id("arcade.publish") +} + +dependencies { + api(project(":arcade-java")) + implementation("org.springframework.boot:spring-boot-starter:3.5.10") +} + +tasks.withType().configureEach { + // Allow using more modern APIs, like `List.of` and `Map.of`, in examples. + options.release.set(17) +} + +tasks.withType { + compilerOptions { + freeCompilerArgs = listOf( + "-Xjvm-default=all", + "-Xjdk-release=17", + // Suppress deprecation warnings because we may still reference and test deprecated members. + // TODO: Replace with `-Xsuppress-warning=DEPRECATION` once we use Kotlin compiler 2.1.0+. + "-nowarn", + ) + jvmTarget.set(JvmTarget.JVM_17) + } +} + + diff --git a/arcade-spring-boot-starter/src/main/java/dev/arcade/springboot/ArcadeAutoConfiguration.kt b/arcade-spring-boot-starter/src/main/java/dev/arcade/springboot/ArcadeAutoConfiguration.kt new file mode 100644 index 0000000..3522df1 --- /dev/null +++ b/arcade-spring-boot-starter/src/main/java/dev/arcade/springboot/ArcadeAutoConfiguration.kt @@ -0,0 +1,41 @@ +package dev.arcade.springboot + +import dev.arcade.client.ArcadeClient +import dev.arcade.client.okhttp.ArcadeOkHttpClient +import dev.arcade.core.ClientOptions +import org.springframework.boot.autoconfigure.AutoConfiguration +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.boot.context.properties.EnableConfigurationProperties +import org.springframework.context.annotation.Bean +import org.springframework.util.StringUtils + +@AutoConfiguration +@EnableConfigurationProperties(ArcadeAutoConfiguration.Config::class) +open class ArcadeAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty(prefix = "arcade", name = ["api-key"]) + open fun arcadeClient(config: Config): ArcadeClient { + val clientBuilder = ArcadeOkHttpClient.builder().fromEnv() + + if (config.apiKey != null && StringUtils.hasText(config.apiKey)) { + clientBuilder.apiKey(config.apiKey) + } + + if (config.baseUrl != null && StringUtils.hasText(config.baseUrl)) { + clientBuilder.baseUrl(config.baseUrl) + } + + return clientBuilder.build() + } + + @ConfigurationProperties(prefix = "arcade") + @JvmRecord + data class Config( + val apiKey: String? = System.getenv("ARCADE_API_KEY"), + val baseUrl: String? = ClientOptions.PRODUCTION_URL, + ) {} +} diff --git a/arcade-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/arcade-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000..ce3da62 --- /dev/null +++ b/arcade-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +dev.arcade.springboot.ArcadeAutoConfiguration \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index b06e706..62cdb99 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -9,6 +9,7 @@ val projectNames = rootDir.listFiles() file.listFiles()?.asSequence().orEmpty().any { it.name == "build.gradle.kts" } } .map { it.name } - .toList() + .toList() + + listOf("arcade-spring-boot-starter") println("projects: $projectNames") projectNames.forEach { include(it) }