Skip to content

Commit 5397108

Browse files
ThomasVitalemarkpollack
authored andcommitted
Support URI in Media
Introduce defining Media objects from a URI, deprecating the previous URL support. Fixes gh-1147 Signed-off-by: Thomas Vitale <[email protected]>
1 parent 688eab1 commit 5397108

File tree

5 files changed

+151
-12
lines changed

5 files changed

+151
-12
lines changed

spring-ai-commons/src/main/java/org/springframework/ai/content/Media.java

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2023-2024 the original author or authors.
2+
* Copyright 2023-2025 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -17,6 +17,7 @@
1717
package org.springframework.ai.content;
1818

1919
import java.io.IOException;
20+
import java.net.URI;
2021
import java.net.URL;
2122

2223
import org.springframework.core.io.Resource;
@@ -64,6 +65,7 @@
6465
*
6566
* @author Christian Tzolov
6667
* @author Mark Pollack
68+
* @author Thomas Vitale
6769
* @since 1.0.0
6870
*/
6971
public class Media {
@@ -75,7 +77,7 @@ public class Media {
7577
* media it has been passed.
7678
*/
7779
@Nullable
78-
private String id;
80+
private final String id;
7981

8082
private final MimeType mimeType;
8183

@@ -98,13 +100,29 @@ public class Media {
98100
* <li>Square brackets
99101
* </ul>
100102
*/
101-
private String name;
103+
private final String name;
104+
105+
/**
106+
* Create a new Media instance.
107+
* @param mimeType the media MIME type
108+
* @param uri the URI for the media data
109+
*/
110+
public Media(MimeType mimeType, URI uri) {
111+
Assert.notNull(mimeType, "MimeType must not be null");
112+
Assert.notNull(uri, "URI must not be null");
113+
this.mimeType = mimeType;
114+
this.id = null;
115+
this.data = uri.toString();
116+
this.name = generateDefaultName(mimeType);
117+
}
102118

103119
/**
104120
* Create a new Media instance.
105121
* @param mimeType the media MIME type
106122
* @param url the URL for the media data
123+
* @deprecated in favour of {@link #Media(MimeType, URI)}
107124
*/
125+
@Deprecated
108126
public Media(MimeType mimeType, URL url) {
109127
Assert.notNull(mimeType, "MimeType must not be null");
110128
Assert.notNull(url, "URL must not be null");
@@ -138,7 +156,7 @@ public Media(MimeType mimeType, Resource resource) {
138156
* Creates a new Media builder.
139157
* @return a new Media builder instance
140158
*/
141-
public static final Builder builder() {
159+
public static Builder builder() {
142160
return new Builder();
143161
}
144162

@@ -148,7 +166,7 @@ public static final Builder builder() {
148166
* @param data the media data
149167
* @param id the media id
150168
*/
151-
private Media(MimeType mimeType, Object data, String id, String name) {
169+
private Media(MimeType mimeType, Object data, @Nullable String id, @Nullable String name) {
152170
Assert.notNull(mimeType, "MimeType must not be null");
153171
Assert.notNull(data, "Data must not be null");
154172
this.mimeType = mimeType;
@@ -171,7 +189,7 @@ public MimeType getMimeType() {
171189

172190
/**
173191
* Get the media data object
174-
* @return a java.net.URL.toString() or a byte[]
192+
* @return a java.net.URI.toString() or a byte[]
175193
*/
176194
public Object getData() {
177195
return this.data;
@@ -194,6 +212,7 @@ public byte[] getDataAsByteArray() {
194212
* Get the media id
195213
* @return the media id
196214
*/
215+
@Nullable
197216
public String getId() {
198217
return this.id;
199218
}
@@ -260,12 +279,26 @@ public Builder data(Object data) {
260279
return this;
261280
}
262281

282+
/**
283+
* Sets the media data from a URI.
284+
* @param uri the media URI, must not be null
285+
* @return the builder instance
286+
* @throws IllegalArgumentException if URI is null
287+
*/
288+
public Builder data(URI uri) {
289+
Assert.notNull(uri, "URI must not be null");
290+
this.data = uri.toString();
291+
return this;
292+
}
293+
263294
/**
264295
* Sets the media data from a URL.
265296
* @param url the media URL, must not be null
266297
* @return the builder instance
267298
* @throws IllegalArgumentException if url is null
299+
* @deprecated in favour of {@link #data(URI)}
268300
*/
301+
@Deprecated
269302
public Builder data(URL url) {
270303
Assert.notNull(url, "URL must not be null");
271304
this.data = url.toString();
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
/*
2+
* Copyright 2023-2024 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
/**
18+
* Core observation abstractions.
19+
*/
20+
package org.springframework.ai.content;

spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/mistralai-chat.adoc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,7 @@ or the image URL equivalent:
191191
----
192192
var userMessage = new UserMessage("Explain what do you see on this picture?",
193193
new Media(MimeTypeUtils.IMAGE_PNG,
194-
"https://docs.spring.io/spring-ai/reference/_images/multimodal.test.png"));
194+
URI.create("https://docs.spring.io/spring-ai/reference/_images/multimodal.test.png")));
195195
196196
ChatResponse response = chatModel.call(new Prompt(this.userMessage,
197197
ChatOptions.builder().model(MistralAiApi.ChatModel.PIXTRAL_LARGE.getValue()).build()));

spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/openai-chat.adoc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -221,7 +221,7 @@ or the image URL equivalent using the `gpt-4o` model:
221221
----
222222
var userMessage = new UserMessage("Explain what do you see on this picture?",
223223
new Media(MimeTypeUtils.IMAGE_PNG,
224-
"https://docs.spring.io/spring-ai/reference/_images/multimodal.test.png"));
224+
URI.create("https://docs.spring.io/spring-ai/reference/_images/multimodal.test.png")));
225225
226226
ChatResponse response = chatModel.call(new Prompt(this.userMessage,
227227
OpenAiChatOptions.builder().model(OpenAiApi.ChatModel.GPT_4_O.getValue()).build()));

spring-ai-model/src/test/java/org/springframework/ai/model/MediaTests.java

Lines changed: 90 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2024-2024 the original author or authors.
2+
* Copyright 2024-2025 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -18,6 +18,7 @@
1818

1919
import java.io.IOException;
2020
import java.net.MalformedURLException;
21+
import java.net.URI;
2122
import java.net.URL;
2223
import java.util.UUID;
2324

@@ -49,6 +50,22 @@ void testMediaBuilderWithByteArrayResource() {
4950
assertThat(media.getName()).isEqualTo(name);
5051
}
5152

53+
@Test
54+
void testMediaBuilderWithUri() {
55+
MimeType mimeType = MimeType.valueOf("image/png");
56+
URI uri = URI.create("http://example.com/image.png");
57+
String id = "123";
58+
String name = "test-media";
59+
60+
Media media = Media.builder().mimeType(mimeType).data(uri).id(id).name(name).build();
61+
62+
assertThat(media.getMimeType()).isEqualTo(mimeType);
63+
assertThat(media.getData()).isInstanceOf(String.class);
64+
assertThat(media.getData()).isEqualTo(uri.toString());
65+
assertThat(media.getId()).isEqualTo(id);
66+
assertThat(media.getName()).isEqualTo(name);
67+
}
68+
5269
@Test
5370
void testMediaBuilderWithURL() throws MalformedURLException {
5471
MimeType mimeType = MimeType.valueOf("image/png");
@@ -91,6 +108,13 @@ void testGetDataAsByteArrayWithInvalidData() {
91108
.hasMessageContaining("Media data is not a byte[]");
92109
}
93110

111+
@Test
112+
void testMediaBuilderWithNullUri() {
113+
assertThatThrownBy(() -> Media.builder().mimeType(MimeType.valueOf("image/png")).data((URI) null).build())
114+
.isInstanceOf(IllegalArgumentException.class)
115+
.hasMessageContaining("URI must not be null");
116+
}
117+
94118
@Test
95119
void testMediaBuilderWithNullURL() {
96120
assertThatThrownBy(() -> Media.builder().mimeType(MimeType.valueOf("image/png")).data((URL) null).build())
@@ -162,6 +186,20 @@ void testLastDataMethodWins() throws MalformedURLException {
162186
assertThat(media.getData()).isSameAs(bytes);
163187
}
164188

189+
@Test
190+
void testMediaConstructorWithUri() {
191+
MimeType mimeType = MimeType.valueOf("image/png");
192+
URI uri = URI.create("http://example.com/image.png");
193+
194+
Media media = new Media(mimeType, uri);
195+
196+
assertThat(media.getMimeType()).isEqualTo(mimeType);
197+
assertThat(media.getData()).isInstanceOf(String.class);
198+
assertThat(media.getData()).isEqualTo(uri.toString());
199+
assertThat(media.getId()).isNull();
200+
assertValidMediaName(media.getName(), "png");
201+
}
202+
165203
@Test
166204
void testMediaConstructorWithUrl() throws MalformedURLException {
167205
MimeType mimeType = MimeType.valueOf("image/png");
@@ -195,7 +233,7 @@ private void assertValidMediaName(String name, String expectedMimeSubtype) {
195233
}
196234

197235
@Test
198-
void testMediaConstructorWithResource() throws IOException {
236+
void testMediaConstructorWithResource() {
199237
MimeType mimeType = MimeType.valueOf("image/png");
200238
byte[] data = new byte[] { 1, 2, 3 };
201239
Resource resource = new ByteArrayResource(data);
@@ -210,7 +248,7 @@ void testMediaConstructorWithResource() throws IOException {
210248
}
211249

212250
@Test
213-
void testMediaConstructorWithResourceAndId() throws IOException {
251+
void testMediaConstructorWithResourceAndId() {
214252
MimeType mimeType = MimeType.valueOf("image/png");
215253
byte[] data = new byte[] { 1, 2, 3 };
216254
Resource resource = new ByteArrayResource(data);
@@ -256,6 +294,30 @@ public byte[] getContentAsByteArray() throws IOException {
256294

257295
/// Tests to ensure two arg ctors behave identically to the builder
258296

297+
@Test
298+
void testUriConstructorMatchesBuilder() {
299+
// Given
300+
MimeType mimeType = MimeType.valueOf("image/png");
301+
URI uri = URI.create("http://example.com/image.png");
302+
303+
// When
304+
Media mediaFromCtor = new Media(mimeType, uri);
305+
Media mediaFromBuilder = Media.builder().mimeType(mimeType).data(uri).build();
306+
307+
// Then - verify all properties match
308+
assertThat(mediaFromCtor.getMimeType()).isEqualTo(mediaFromBuilder.getMimeType());
309+
assertThat(mediaFromCtor.getData()).isEqualTo(mediaFromBuilder.getData());
310+
assertThat(mediaFromCtor.getId()).isEqualTo(mediaFromBuilder.getId());
311+
312+
// Verify name structure for both instances
313+
assertValidMediaName(mediaFromCtor.getName(), "png");
314+
assertValidMediaName(mediaFromBuilder.getName(), "png");
315+
316+
// Data type consistency
317+
assertThat(mediaFromCtor.getData()).isInstanceOf(String.class);
318+
assertThat(mediaFromBuilder.getData()).isInstanceOf(String.class);
319+
}
320+
259321
@Test
260322
void testURLConstructorMatchesBuilder() throws MalformedURLException {
261323
// Given
@@ -305,6 +367,30 @@ void testResourceConstructorMatchesBuilder() throws IOException {
305367
assertThat(mediaFromBuilder.getData()).isInstanceOf(byte[].class);
306368
}
307369

370+
@Test
371+
void testUriConstructorNullValidation() {
372+
MimeType mimeType = MimeType.valueOf("image/png");
373+
374+
// Test null mimeType
375+
assertThatThrownBy(() -> new Media(null, URI.create("http://example.com/image.png")))
376+
.isInstanceOf(IllegalArgumentException.class)
377+
.hasMessage("MimeType must not be null");
378+
379+
// Test null URL
380+
assertThatThrownBy(() -> new Media(mimeType, (URI) null)).isInstanceOf(IllegalArgumentException.class)
381+
.hasMessage("URI must not be null");
382+
383+
// Compare with builder validation
384+
assertThatThrownBy(
385+
() -> Media.builder().mimeType(null).data(URI.create("http://example.com/image.png")).build())
386+
.isInstanceOf(IllegalArgumentException.class)
387+
.hasMessage("MimeType must not be null");
388+
389+
assertThatThrownBy(() -> Media.builder().mimeType(mimeType).data((URI) null).build())
390+
.isInstanceOf(IllegalArgumentException.class)
391+
.hasMessage("URI must not be null");
392+
}
393+
308394
@Test
309395
void testURLConstructorNullValidation() {
310396
MimeType mimeType = MimeType.valueOf("image/png");
@@ -375,7 +461,7 @@ public byte[] getContentAsByteArray() throws IOException {
375461
}
376462

377463
@Test
378-
void testDifferentMimeTypesNameFormat() throws IOException {
464+
void testDifferentMimeTypesNameFormat() {
379465
// Test constructor name generation
380466
Media jpegMediaCtor = new Media(Media.Format.IMAGE_JPEG, new ByteArrayResource(new byte[] { 1, 2, 3 }));
381467
assertValidMediaName(jpegMediaCtor.getName(), "jpeg");

0 commit comments

Comments
 (0)