Call Google Gemini AI in Winter Boot
Adding AI features — summarising tickets, drafting replies, classifying feedback — means calling Google’s Gemini API. This guide calls the generateContent REST endpoint with Winter Boot’s built-in RestTemplate: no extra packages, and error messages that strip query strings (which is where the API key travels, as ?key=).
1. Prerequisites
Section titled “1. Prerequisites”Get a key from Google AI Studio and keep it in the environment:
GEMINI_API_KEY="AIza..."2. Configuration
Section titled “2. Configuration”Model and key as ordinary properties (with EnvPropertySource registered — see Configuration):
gemini: apiKey: "$env.GEMINI_API_KEY" model: "gemini-2.0-flash"3. Service and endpoint
Section titled “3. Service and endpoint”Two files: a service owning the API call (dedicated long-timeout client, response parsing, status mapping) and a thin controller. Switch between them:
The endpoint — start here. Takes a prompt, returns the answer text, and maps AI failures to generic statuses (never forwards provider errors or keys to the browser).
<?phpdeclare(strict_types=1);
namespace dev\example\rest;
use dev\example\service\GeminiService;use dev\example\service\GeminiException;use dev\winterframework\stereotype\Autowired;use dev\winterframework\stereotype\RestController;use dev\winterframework\stereotype\web\PostMapping;use dev\winterframework\stereotype\web\RequestMapping;use dev\winterframework\stereotype\web\RequestParam;use dev\winterframework\web\http\HttpStatus;use dev\winterframework\web\http\ResponseEntity;
#[RestController]#[RequestMapping(path: 'assist')]class AssistController{ #[Autowired] protected GeminiService $gemini;
#[PostMapping(path: 'ask')] public function ask(#[RequestParam(name: 'prompt')] string $prompt): ResponseEntity { $prompt = trim($prompt); if ($prompt === '' || strlen($prompt) > 4000) { return ResponseEntity::badRequest()->withJson(['error' => 'Prompt required (max 4000 chars)']); }
try { $answer = $this->gemini->ask($prompt); } catch (GeminiException $e) { return ResponseEntity::status(HttpStatus::getStatus($e->getHttpStatus())) ->withJson(['error' => $e->getPublicMessage()]); }
return ResponseEntity::ok(['success' => true, 'data' => ['answer' => $answer]]); }}The integration. Posts generateContent with the key as a query param,
extracts the first text part, and converts every failure into a
GeminiException carrying an HTTP status plus a browser-safe message.
<?phpdeclare(strict_types=1);
namespace dev\example\service;
use dev\winterframework\stereotype\Service;use dev\winterframework\stereotype\Value;use dev\winterframework\web\client\HttpClientErrorException;use dev\winterframework\web\client\RestClientException;use dev\winterframework\web\client\RestTemplate;
#[Service]class GeminiService{ private RestTemplate $http;
#[Value('${gemini.apiKey}')] protected string $apiKey;
#[Value('${gemini.model}')] protected string $model;
public function __construct() { // Dedicated client: token generation outlasts the default 10s bean. $this->http = new RestTemplate(timeout: 60.0, connectTimeout: 10.0); }
public function ask(string $prompt): string { try { $data = $this->http->postForObject( 'https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent', [ 'contents' => [['parts' => [['text' => $prompt]]]], 'generationConfig' => ['temperature' => 0.2, 'maxOutputTokens' => 512], ], 'array', ['model' => $this->model], [], ['key' => $this->apiKey] ); } catch (HttpClientErrorException $e) { // 400 bad key/model, 429 quota, 403 blocked — key itself never in the message if ($e->getStatusCode() === 429) { throw new GeminiException(503, 'AI quota exceeded, try again later.'); } throw new GeminiException(502, 'AI request rejected.'); } catch (RestClientException $e) { throw new GeminiException(503, 'AI service unavailable.'); }
$text = is_array($data) ? ($data['candidates'][0]['content']['parts'][0]['text'] ?? null) : null; if (!is_string($text) || trim($text) === '') { throw new GeminiException(502, 'AI returned no answer.'); } return $text; }}<?phpdeclare(strict_types=1);
namespace dev\example\service;
use RuntimeException;
class GeminiException extends RuntimeException{ public function __construct( private int $httpStatus, private string $publicMessage, ) { parent::__construct($publicMessage); }
public function getHttpStatus(): int { return $this->httpStatus; }
public function getPublicMessage(): string { return $this->publicMessage; }}4. Verify it works
Section titled “4. Verify it works”- Set
GEMINI_API_KEYand boot the app. curl -X POST 'http://127.0.0.1:8080/assist/ask?prompt=Summarise+this+in+five+words%3A+the+cat+sat+on+the+mat'→200with an answer.- Unset the key and retry →
502withAI request rejected.and no key material in logs or response. - Unit-test
ask()with a fakeRestClientTransportreturning canned JSON (the RestTemplate testing pattern) — no network in tests.
Next steps
Section titled “Next steps”- RestTemplate — URI templates, response mapping, transports, and fake-transport tests.
- Caching — caching repeated answers.
- Email / Images — async and queue patterns for slow work.