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");