diff --git a/spring-ai-commons/src/main/java/org/springframework/ai/content/Media.java b/spring-ai-commons/src/main/java/org/springframework/ai/content/Media.java
index d9e23b162c4..50094592c54 100644
--- a/spring-ai-commons/src/main/java/org/springframework/ai/content/Media.java
+++ b/spring-ai-commons/src/main/java/org/springframework/ai/content/Media.java
@@ -1,5 +1,5 @@
/*
- * Copyright 2023-2024 the original author or authors.
+ * Copyright 2023-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -17,6 +17,7 @@
package org.springframework.ai.content;
import java.io.IOException;
+import java.net.URI;
import java.net.URL;
import org.springframework.core.io.Resource;
@@ -64,6 +65,7 @@
*
* @author Christian Tzolov
* @author Mark Pollack
+ * @author Thomas Vitale
* @since 1.0.0
*/
public class Media {
@@ -75,7 +77,7 @@ public class Media {
* media it has been passed.
*/
@Nullable
- private String id;
+ private final String id;
private final MimeType mimeType;
@@ -98,13 +100,29 @@ public class Media {
*
Square brackets
*
*/
- private String name;
+ private final String name;
+
+ /**
+ * Create a new Media instance.
+ * @param mimeType the media MIME type
+ * @param uri the URI for the media data
+ */
+ public Media(MimeType mimeType, URI uri) {
+ Assert.notNull(mimeType, "MimeType must not be null");
+ Assert.notNull(uri, "URI must not be null");
+ this.mimeType = mimeType;
+ this.id = null;
+ this.data = uri.toString();
+ this.name = generateDefaultName(mimeType);
+ }
/**
* Create a new Media instance.
* @param mimeType the media MIME type
* @param url the URL for the media data
+ * @deprecated in favour of {@link #Media(MimeType, URI)}
*/
+ @Deprecated
public Media(MimeType mimeType, URL url) {
Assert.notNull(mimeType, "MimeType must not be null");
Assert.notNull(url, "URL must not be null");
@@ -138,7 +156,7 @@ public Media(MimeType mimeType, Resource resource) {
* Creates a new Media builder.
* @return a new Media builder instance
*/
- public static final Builder builder() {
+ public static Builder builder() {
return new Builder();
}
@@ -148,7 +166,7 @@ public static final Builder builder() {
* @param data the media data
* @param id the media id
*/
- private Media(MimeType mimeType, Object data, String id, String name) {
+ private Media(MimeType mimeType, Object data, @Nullable String id, @Nullable String name) {
Assert.notNull(mimeType, "MimeType must not be null");
Assert.notNull(data, "Data must not be null");
this.mimeType = mimeType;
@@ -171,7 +189,7 @@ public MimeType getMimeType() {
/**
* Get the media data object
- * @return a java.net.URL.toString() or a byte[]
+ * @return a java.net.URI.toString() or a byte[]
*/
public Object getData() {
return this.data;
@@ -194,6 +212,7 @@ public byte[] getDataAsByteArray() {
* Get the media id
* @return the media id
*/
+ @Nullable
public String getId() {
return this.id;
}
@@ -260,12 +279,26 @@ public Builder data(Object data) {
return this;
}
+ /**
+ * Sets the media data from a URI.
+ * @param uri the media URI, must not be null
+ * @return the builder instance
+ * @throws IllegalArgumentException if URI is null
+ */
+ public Builder data(URI uri) {
+ Assert.notNull(uri, "URI must not be null");
+ this.data = uri.toString();
+ return this;
+ }
+
/**
* Sets the media data from a URL.
* @param url the media URL, must not be null
* @return the builder instance
* @throws IllegalArgumentException if url is null
+ * @deprecated in favour of {@link #data(URI)}
*/
+ @Deprecated
public Builder data(URL url) {
Assert.notNull(url, "URL must not be null");
this.data = url.toString();
diff --git a/spring-ai-commons/src/main/java/org/springframework/ai/content/package-info.java b/spring-ai-commons/src/main/java/org/springframework/ai/content/package-info.java
new file mode 100644
index 00000000000..fb923a84098
--- /dev/null
+++ b/spring-ai-commons/src/main/java/org/springframework/ai/content/package-info.java
@@ -0,0 +1,20 @@
+/*
+ * Copyright 2023-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Core observation abstractions.
+ */
+package org.springframework.ai.content;
diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/mistralai-chat.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/mistralai-chat.adoc
index 088df589c69..f94113048eb 100644
--- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/mistralai-chat.adoc
+++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/mistralai-chat.adoc
@@ -191,7 +191,7 @@ or the image URL equivalent:
----
var userMessage = new UserMessage("Explain what do you see on this picture?",
new Media(MimeTypeUtils.IMAGE_PNG,
- "https://docs.spring.io/spring-ai/reference/_images/multimodal.test.png"));
+ URI.create("https://docs.spring.io/spring-ai/reference/_images/multimodal.test.png")));
ChatResponse response = chatModel.call(new Prompt(this.userMessage,
ChatOptions.builder().model(MistralAiApi.ChatModel.PIXTRAL_LARGE.getValue()).build()));
diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/openai-chat.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/openai-chat.adoc
index a1d7a359adc..f7ae5dc752e 100644
--- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/openai-chat.adoc
+++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/openai-chat.adoc
@@ -221,7 +221,7 @@ or the image URL equivalent using the `gpt-4o` model:
----
var userMessage = new UserMessage("Explain what do you see on this picture?",
new Media(MimeTypeUtils.IMAGE_PNG,
- "https://docs.spring.io/spring-ai/reference/_images/multimodal.test.png"));
+ URI.create("https://docs.spring.io/spring-ai/reference/_images/multimodal.test.png")));
ChatResponse response = chatModel.call(new Prompt(this.userMessage,
OpenAiChatOptions.builder().model(OpenAiApi.ChatModel.GPT_4_O.getValue()).build()));
diff --git a/spring-ai-model/src/test/java/org/springframework/ai/model/MediaTests.java b/spring-ai-model/src/test/java/org/springframework/ai/model/MediaTests.java
index a8fc1139db6..79576f23980 100644
--- a/spring-ai-model/src/test/java/org/springframework/ai/model/MediaTests.java
+++ b/spring-ai-model/src/test/java/org/springframework/ai/model/MediaTests.java
@@ -1,5 +1,5 @@
/*
- * Copyright 2024-2024 the original author or authors.
+ * Copyright 2024-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -18,6 +18,7 @@
import java.io.IOException;
import java.net.MalformedURLException;
+import java.net.URI;
import java.net.URL;
import java.util.UUID;
@@ -49,6 +50,22 @@ void testMediaBuilderWithByteArrayResource() {
assertThat(media.getName()).isEqualTo(name);
}
+ @Test
+ void testMediaBuilderWithUri() {
+ MimeType mimeType = MimeType.valueOf("image/png");
+ URI uri = URI.create("http://example.com/image.png");
+ String id = "123";
+ String name = "test-media";
+
+ Media media = Media.builder().mimeType(mimeType).data(uri).id(id).name(name).build();
+
+ assertThat(media.getMimeType()).isEqualTo(mimeType);
+ assertThat(media.getData()).isInstanceOf(String.class);
+ assertThat(media.getData()).isEqualTo(uri.toString());
+ assertThat(media.getId()).isEqualTo(id);
+ assertThat(media.getName()).isEqualTo(name);
+ }
+
@Test
void testMediaBuilderWithURL() throws MalformedURLException {
MimeType mimeType = MimeType.valueOf("image/png");
@@ -91,6 +108,13 @@ void testGetDataAsByteArrayWithInvalidData() {
.hasMessageContaining("Media data is not a byte[]");
}
+ @Test
+ void testMediaBuilderWithNullUri() {
+ assertThatThrownBy(() -> Media.builder().mimeType(MimeType.valueOf("image/png")).data((URI) null).build())
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("URI must not be null");
+ }
+
@Test
void testMediaBuilderWithNullURL() {
assertThatThrownBy(() -> Media.builder().mimeType(MimeType.valueOf("image/png")).data((URL) null).build())
@@ -162,6 +186,20 @@ void testLastDataMethodWins() throws MalformedURLException {
assertThat(media.getData()).isSameAs(bytes);
}
+ @Test
+ void testMediaConstructorWithUri() {
+ MimeType mimeType = MimeType.valueOf("image/png");
+ URI uri = URI.create("http://example.com/image.png");
+
+ Media media = new Media(mimeType, uri);
+
+ assertThat(media.getMimeType()).isEqualTo(mimeType);
+ assertThat(media.getData()).isInstanceOf(String.class);
+ assertThat(media.getData()).isEqualTo(uri.toString());
+ assertThat(media.getId()).isNull();
+ assertValidMediaName(media.getName(), "png");
+ }
+
@Test
void testMediaConstructorWithUrl() throws MalformedURLException {
MimeType mimeType = MimeType.valueOf("image/png");
@@ -195,7 +233,7 @@ private void assertValidMediaName(String name, String expectedMimeSubtype) {
}
@Test
- void testMediaConstructorWithResource() throws IOException {
+ void testMediaConstructorWithResource() {
MimeType mimeType = MimeType.valueOf("image/png");
byte[] data = new byte[] { 1, 2, 3 };
Resource resource = new ByteArrayResource(data);
@@ -210,7 +248,7 @@ void testMediaConstructorWithResource() throws IOException {
}
@Test
- void testMediaConstructorWithResourceAndId() throws IOException {
+ void testMediaConstructorWithResourceAndId() {
MimeType mimeType = MimeType.valueOf("image/png");
byte[] data = new byte[] { 1, 2, 3 };
Resource resource = new ByteArrayResource(data);
@@ -256,6 +294,30 @@ public byte[] getContentAsByteArray() throws IOException {
/// Tests to ensure two arg ctors behave identically to the builder
+ @Test
+ void testUriConstructorMatchesBuilder() {
+ // Given
+ MimeType mimeType = MimeType.valueOf("image/png");
+ URI uri = URI.create("http://example.com/image.png");
+
+ // When
+ Media mediaFromCtor = new Media(mimeType, uri);
+ Media mediaFromBuilder = Media.builder().mimeType(mimeType).data(uri).build();
+
+ // Then - verify all properties match
+ assertThat(mediaFromCtor.getMimeType()).isEqualTo(mediaFromBuilder.getMimeType());
+ assertThat(mediaFromCtor.getData()).isEqualTo(mediaFromBuilder.getData());
+ assertThat(mediaFromCtor.getId()).isEqualTo(mediaFromBuilder.getId());
+
+ // Verify name structure for both instances
+ assertValidMediaName(mediaFromCtor.getName(), "png");
+ assertValidMediaName(mediaFromBuilder.getName(), "png");
+
+ // Data type consistency
+ assertThat(mediaFromCtor.getData()).isInstanceOf(String.class);
+ assertThat(mediaFromBuilder.getData()).isInstanceOf(String.class);
+ }
+
@Test
void testURLConstructorMatchesBuilder() throws MalformedURLException {
// Given
@@ -305,6 +367,30 @@ void testResourceConstructorMatchesBuilder() throws IOException {
assertThat(mediaFromBuilder.getData()).isInstanceOf(byte[].class);
}
+ @Test
+ void testUriConstructorNullValidation() {
+ MimeType mimeType = MimeType.valueOf("image/png");
+
+ // Test null mimeType
+ assertThatThrownBy(() -> new Media(null, URI.create("http://example.com/image.png")))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("MimeType must not be null");
+
+ // Test null URL
+ assertThatThrownBy(() -> new Media(mimeType, (URI) null)).isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("URI must not be null");
+
+ // Compare with builder validation
+ assertThatThrownBy(
+ () -> Media.builder().mimeType(null).data(URI.create("http://example.com/image.png")).build())
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("MimeType must not be null");
+
+ assertThatThrownBy(() -> Media.builder().mimeType(mimeType).data((URI) null).build())
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("URI must not be null");
+ }
+
@Test
void testURLConstructorNullValidation() {
MimeType mimeType = MimeType.valueOf("image/png");
@@ -375,7 +461,7 @@ public byte[] getContentAsByteArray() throws IOException {
}
@Test
- void testDifferentMimeTypesNameFormat() throws IOException {
+ void testDifferentMimeTypesNameFormat() {
// Test constructor name generation
Media jpegMediaCtor = new Media(Media.Format.IMAGE_JPEG, new ByteArrayResource(new byte[] { 1, 2, 3 }));
assertValidMediaName(jpegMediaCtor.getName(), "jpeg");