Spring AI · Alət çağırışları və əməliyyatların icrası · Dərs 2
API və verilənlər bazası ilə inteqrasiya
Modelin API və verilənlər bazasından məlumat alması, sorğuya uyğun əməliyyatlar yerinə yetirməsi üçün alətlər təqdim et.
API və verilənlər bazası ilə inteqrasiya
Yalnız oxuyan alət heç nəyi dəyişməsə də, həssas məlumatı aça bilər. İki dar əməliyyat əlavə edəcəyik: sabit lokal API-dən bir açıq tədris qeydi oxumaq və autentifikasiya edilmiş öyrənənin irəliləyişini bazadan almaq. Hər ikisi əvvəlki dərsin ToolSession sinfindən və mövzu enum-undan istifadə edir.
1. Ünvanları və sorğunun əhatəsini serverdə saxlayın
Model yalnız INHERITANCE və ya RESOURCES verir. URL, SQL sorğusu, cədvəl adı və ya başqa öyrənənin ID-sini verə bilməz. API-nin başlanğıc ünvanı etibarlı tətbiq konfiqurasiyasından, bazadakı sahib şərti isə ToolContext-dən gəlir.
Bu laboratoriya qəsdən yalnız loopback HTTP ünvanını qəbul edir və giriş məlumatlarından istifadə etmir. Yerləşdirilmiş adapter üçün təsdiqlənmiş HTTPS ünvanı, çıxış şəbəkəsi siyasəti və serverin idarə etdiyi autentifikasiya lazımdır. İcazəli ünvan siyahısını çıxarıb model arqumentlərindən URL qəbul etməyin. Yalnız oxuyan sorğular da məlumat aça, resurs sərf edə və nəzərdə tutulmayan daxili servislərə çata bilər.
2. Test bazası asılılığını əlavə edin
Bu asılılığı mövcud POM-un tətbiq <dependencies> bölməsinə, <dependencyManagement> xaricinə əlavə edin:
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>Versiyanı Spring Boot-un mövcud asılılıq idarəetməsi verir. H2 yalnız testlərdə istifadə olunur; Study Coach tətbiqini yerləşdirilmiş baza servisinə çevirmir. WebClient üçün mövcud WebFlux asılılığını saxlayın. Əsas sinif etibarlı adapterin verdiyi DataSource qəbul edir və standart JDBC istifadə edir.
3. Məhdud axtarışlar qurun
src/main/java/academy/study/StudyDataTools.java faylını yaradın:
package academy.study;
import java.net.URI;
import java.sql.SQLException;
import java.time.Duration;
import java.util.Objects;
import javax.sql.DataSource;
import org.springframework.ai.chat.model.ToolContext;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.web.reactive.function.client.WebClient;
public final class StudyDataTools {
private final WebClient api;
private final DataSource database;
public StudyDataTools(URI base, DataSource database) {
if (!"http".equals(base.getScheme()) || !"127.0.0.1".equals(base.getHost())
|| base.getPort() < 1 || base.getUserInfo() != null || base.getQuery() != null
|| base.getFragment() != null || !base.getPath().isEmpty()) {
throw new IllegalArgumentException("Use the fixed loopback lab API origin");
}
this.api = WebClient.builder().baseUrl(base.toString())
.codecs(codecs -> codecs.defaultCodecs().maxInMemorySize(4_096)).build();
this.database = Objects.requireNonNull(database);
}
@Tool(name = "read_study_note", description = "Read one approved note from the configured study API")
public Note note(@ToolParam(description = "One supported Java topic") StudyTipTools.Topic topic,
ToolContext context) {
ToolSession.from(context).use(ToolSession.Permission.READ_NOTES);
requireTopic(topic);
try {
Note note = api.get().uri("/notes/{topic}", topic.name()).retrieve()
.bodyToMono(Note.class).timeout(Duration.ofSeconds(1)).block();
if (note == null || note.topic() != topic || note.text() == null
|| note.text().isBlank() || note.text().length() > 1_500) {
throw new IllegalStateException("Invalid note");
}
return note;
} catch (RuntimeException failure) {
throw new IllegalStateException("Study API unavailable");
}
}
@Tool(name = "read_my_progress", description = "Read the authenticated learner's progress for one topic")
public Progress progress(@ToolParam(description = "One supported Java topic") StudyTipTools.Topic topic,
ToolContext context) {
String owner = ToolSession.from(context).use(ToolSession.Permission.READ_PROGRESS);
requireTopic(topic);
try (var connection = database.getConnection();
var statement = connection.prepareStatement(
"SELECT completed FROM learner_progress WHERE owner_id = ? AND topic = ?")) {
statement.setString(1, owner);
statement.setString(2, topic.name());
statement.setMaxRows(1);
statement.setQueryTimeout(1);
try (var rows = statement.executeQuery()) {
if (!rows.next()) return new Progress(topic, null);
int completed = rows.getInt(1);
if (rows.wasNull() || completed < 0 || completed > 100) {
throw new IllegalStateException("Invalid progress value");
}
return new Progress(topic, completed);
}
} catch (SQLException failure) {
throw new IllegalStateException("Study database unavailable");
}
}
private static void requireTopic(StudyTipTools.Topic topic) {
if (topic == null) throw new IllegalArgumentException("Topic required");
}
public record Note(StudyTipTools.Topic topic, String text) {}
public record Progress(StudyTipTools.Topic topic, Integer completed) {}
}WebClient 4,096 baytlıq codec bufer limiti ilə bir JSON obyekti oxuyur. Reaktiv timeout bu əməliyyatın nəticəsini gözləmə müddətini əhatə edir; xətalar sabit tətbiq mesajı yaradır. Qaytarılan qeyd istənilən mövzuya uyğun olmalı və 1,500 UTF-16 kod vahidini aşmamalıdır. Nə uğurlu HTTP statusu, nə də düzgün sxem məzmunu etibarlı təlimata çevirir. WebClient limitləri, cavabın oxunması.
Təkrar cəhd yoxdur. Kod çağıran thread-i bloklayır, buna görə onu reaktiv event-loop thread-ində deyil, bloklayan işlər üçün ayrılmış worker-də və ya adi sinxron servisdə işlədin. Timeout-dan sonra ləğv uzaq serverin işi dayandırdığını sübut etmir. Test API-si lokal və sintetikdir.
4. Baza sərhədlərini ayrıca nəzarətlər kimi nəzərdən keçirin
Sorğu həm sahib, həm də mövzu üçün parametr bağlamasından istifadə edir, ən çox bir sətir istəyir və connection, statement və result set obyektlərini bağlayır. Cədvəlin mürəkkəb primary key-i təkrarsızlığı təmin edir. Sətir tapılmadıqda completed: null qaytarılır; bu, həm uğursuz sorğudan, həm də sıfır irəliləyişdən fərqlidir.
setQueryTimeout(1) JDBC driver-dən bir saniyəlik sorğu timeout-u tətbiq etməsini istəyir. Connection pool-dan bağlantı almağı, bağlantının qurulmasını və bütün şəbəkə xətalarını məhdudlaşdırmır. İstehsal mühitinin DataSource obyekti üçün ayrıca pool, connection və socket limitləri lazımdır; baza hesabı da yalnız tələb edilən oxuma icazəsinə sahib olmalıdır. Dərs bütün driver-lərin timeout implementasiyasını deyil, H2 davranışını test edir.
Sahib filtri tətbiqin icazə yoxlamasının hissəsidir. Baza hesabının bütün cədvəli oxuya bilməsi hər sətrin cari öyrənənə göstərilə biləcəyi demək deyil. Məxfi parametr dəyərləri ilə tam sorğuları loglamayın və baza xətasının mətnini modelə ötürməyin.
5. Lokal HTTP-ni və baza izolyasiyasını test edin
src/test/java/academy/study/StudyDataToolsTest.java faylını yaradın:
package academy.study;
import com.sun.net.httpserver.HttpServer;
import java.net.InetSocketAddress;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.sql.Connection;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import org.h2.jdbcx.JdbcDataSource;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.model.ToolContext;
import static academy.study.StudyTipTools.Topic.INHERITANCE;
import static org.junit.jupiter.api.Assertions.*;
class StudyDataToolsTest {
private HttpServer server;
private Connection keeper;
private StudyDataTools tools;
private final AtomicInteger calls = new AtomicInteger();
private final AtomicInteger delayMillis = new AtomicInteger();
private final AtomicReference<String> body = new AtomicReference<>(
"{\"topic\":\"INHERITANCE\",\"text\":\"One direct superclass.\"}");
@BeforeEach
void start() throws Exception {
var database = new JdbcDataSource();
database.setURL("jdbc:h2:mem:" + UUID.randomUUID());
keeper = database.getConnection();
try (var statement = keeper.createStatement()) {
statement.execute("CREATE TABLE learner_progress (owner_id VARCHAR(100), topic VARCHAR(30), "
+ "completed INT, PRIMARY KEY (owner_id, topic))");
statement.execute("INSERT INTO learner_progress VALUES ('learner-a', 'INHERITANCE', 25), "
+ "('learner-b', 'INHERITANCE', 80)");
}
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/notes/INHERITANCE", exchange -> {
calls.incrementAndGet();
try {
Thread.sleep(delayMillis.get());
byte[] bytes = body.get().getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().add("Content-Type", "application/json");
exchange.sendResponseHeaders(200, bytes.length);
exchange.getResponseBody().write(bytes);
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
} finally {
exchange.close();
}
});
server.start();
tools = new StudyDataTools(URI.create("http://127.0.0.1:" + server.getAddress().getPort()), database);
}
@AfterEach
void stop() throws Exception {
if (server != null) server.stop(0);
if (keeper != null) keeper.close();
}
private ToolContext context(String owner, ToolSession.Permission permission) {
return new ToolContext(new ToolSession(owner, Set.of(permission)).context());
}
@Test
void readsTheFixedLocalApi() {
var note = tools.note(INHERITANCE, context("learner-a", ToolSession.Permission.READ_NOTES));
assertEquals("One direct superclass.", note.text());
assertEquals(1, calls.get());
}
@Test
void rejectsOversizedApiBody() {
body.set("{\"topic\":\"INHERITANCE\",\"text\":\"" + "x".repeat(8_000) + "\"}");
var error = assertThrows(IllegalStateException.class,
() -> tools.note(INHERITANCE, context("learner-a", ToolSession.Permission.READ_NOTES)));
assertEquals("Study API unavailable", error.getMessage());
}
@Test
void timeoutReturnsUnavailableWithoutRetry() {
delayMillis.set(1_500);
assertThrows(IllegalStateException.class,
() -> tools.note(INHERITANCE, context("learner-a", ToolSession.Permission.READ_NOTES)));
assertEquals(1, calls.get());
}
@Test
void missingPermissionPreventsApiCall() {
assertThrows(IllegalArgumentException.class,
() -> tools.note(INHERITANCE, context("learner-a", ToolSession.Permission.READ_PROGRESS)));
assertEquals(0, calls.get());
}
@Test
void databaseQueryUsesOnlyServerContextOwner() {
assertEquals(25, tools.progress(INHERITANCE,
context("learner-a", ToolSession.Permission.READ_PROGRESS)).completed());
assertEquals(80, tools.progress(INHERITANCE,
context("learner-b", ToolSession.Permission.READ_PROGRESS)).completed());
assertNull(tools.progress(INHERITANCE,
context("learner-c", ToolSession.Permission.READ_PROGRESS)).completed());
}
@Test
void ownerTextCannotChangeTheSqlPredicate() {
assertNull(tools.progress(INHERITANCE,
context("learner-a' OR '1'='1", ToolSession.Permission.READ_PROGRESS)).completed());
}
}Test dəsti loopback HTTP serveri və sintetik sətirlərlə təcrid olunmuş H2 bazası başladır. Adi oxumanı, böyük cavabın rəddini, gecikən cavabı, HTTP-dən əvvəl icazə rəddini, sahiblərin ayrılmasını və parametr bağlamasını yoxlayır. Timeout testi dəqiq keçən müddət tələb etmir, çünki planlaşdırma və təmizləmə vaxtı dəyişə bilər. Keeper bağlantısı hər test boyunca adlı yaddaşdaxili bazanı açıq saxlayır; son bağlantı bağlandıqda baza silinir. H2 yaddaşdaxili bazasının həyat dövrü
Etibarlı adapterin yaratdığı ToolStudyClient daxilində məlumat əməliyyatlarını ToolCallbacks.from(dataTools) ilə qeydiyyata alın. Həmin istifadə ssenarisi üçün yalnız lazım olan callback-ləri təqdim edin. Bu dərsdə açıq yönləndirici məlumatı əlavə model çağırışı olmadan çağıran tərəfə qaytarır. Sonrakı dizayn alət nəticəsini provayderə geri göndərərsə, bu, ayrıca məlumat açıqlama qərarıdır.
6. Yoxlayın və xətanı nəticənin olmamasından ayırın
İcra edin:
env -u RUN_LIVE_EVALUATION mvn clean verifyYetmiş dörd oflayn metod keçməli, bir canlı evaluator isə ötürülməlidir. HTTP trafiki loopback daxilində qalır; xarici API və ya model hesabı lazım deyil.
Tapşırıq: bazadan learner-a sətrini silin, sonra ayrıca API-nin səhv JSON obyekti qaytarmasını təmin edin. Nəticələri müqayisə edin. Alət JSON-una owner sahəsi əlavə etməklə başqa sahib istəməyə çalışın.
Cavab yoxlaması: irəliləyiş sətri olmadıqda null dəyər alınır. Səhv API gövdəsi daxili detalları gizlədən əlçatmazlıq mesajı ilə uğursuz olur. Modelin əlavə sahələri etibarlı kontekstdəki sahibi əvəz etmir; SQL şərti yenə autentifikasiya edilmiş kimliyi istifadə edir. Gələcək UI-da bu nəticələri ayrı emal edin.