A complete step-by-step tutorial for building a production-ready REST API that generates personalized travel itineraries using AI.
Table of Contents
- What We're Building
- Project Setup & Dependencies
- The Application Entry Point
- Request & Response Models (DTOs)
- Prompt Engineering with Spring AI
- The Service Layer — Core Business Logic
- The Controller Layer — REST API
- Exception Handling & Error Responses
- OpenAPI / Swagger Configuration
- Application Configuration
- Running the Application
- Testing the API
- Real-World Use Cases
- Troubleshooting Common Issues
1. What We're Building
Imagine you're planning a trip to Goa. You have ₹25,000, 5 days, and you love beaches, food, and nightlife. Wouldn't it be great if an AI could instantly generate a complete day-by-day itinerary — including activities, meal suggestions, budget breakdown, packing checklists, and safety tips?
That's exactly what this project does.
The AI Travel Planner is a Spring Boot REST API that:
- Accepts your travel preferences (destination, days, budget, style, interests)
- Sends them to Azure OpenAI (GPT model)
- Returns a structured, realistic travel plan as JSON
Real-world example:
A user planning a graduation trip with friends can send a request for "Goa, 5 days, ₹25,000, Budget style, interested in Beaches/Food/Nightlife" and get back a complete itinerary including scooter rental advice, beach shack recommendations, and safety tips — all within seconds.
2. Project Setup & Dependencies
Let's start with the foundation — the pom.xml file. Think of this as the recipe card for your project; it tells Maven what ingredients (libraries) you need.
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.16</version>
</parent>
Key Dependencies Explained:
| Dependency | Purpose | Real-World Analogy |
|---|---|---|
spring-boot-starter-web |
Builds REST APIs | Like having a restaurant kitchen — gives you all the tools to serve HTTP requests |
spring-boot-starter-validation |
Validates user input | Like a bouncer checking IDs at the door — ensures only valid data enters your system |
spring-ai-starter-model-azure-openai |
Connects to Azure OpenAI | Like having a direct phone line to a genius travel agent who knows everything |
springdoc-openapi-starter-webmvc-ui |
Auto-generates API docs | Like a menu board showing all available dishes with descriptions |
lombok |
Reduces boilerplate code | Like a personal assistant who writes your repetitive code for you |
spring-boot-starter-actuator |
Health monitoring | Like a car's dashboard — shows you engine status, fuel level, etc. |
Real-world scenario:
In a real startup, you might swap Azure OpenAI for OpenAI directly, or add Spring Security for authentication. The modular dependency design lets you swap components without rewriting code.
3. The Application Entry Point
@SpringBootApplication
public class SpringAiTravelPlannerApplication {
public static void main(String[] args) {
SpringApplication.run(SpringAiTravelPlannerApplication.class, args);
}
}
This is the ignition switch of your application. @SpringBootApplication is a convenience annotation that combines three things:
- @Configuration — Marks this as a source of bean definitions
- @EnableAutoConfiguration — Tells Spring to automatically configure components based on dependencies
- @ComponentScan — Tells Spring to scan this package and sub-packages for components
Real-world analogy:
Think of
@SpringBootApplicationlike turning the key in your car ignition. One action triggers a cascade: fuel pumps activate, the starter motor turns, engine cylinders fire, and your car comes to life. Similarly, this one annotation triggers Spring to scan, configure, and bootstrap your entire application.
4. Request & Response Models (DTOs)
DTOs (Data Transfer Objects) are plain Java classes that carry data between the client and server. They're like order forms and invoices in a restaurant.
4.1 TravelPlanRequest — The Order Form
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class TravelPlanRequest {
@NotBlank(message = "Destination cannot be blank")
private String destination;
@Min(value = 1, message = "Days must be between 1 and 30")
@Max(value = 30, message = "Days must be between 1 and 30")
private int days;
@Positive(message = "Budget must be greater than 0")
private double budget;
private String travelStyle;
@NotEmpty(message = "Interests cannot be empty")
private List<String> interests;
}
Annotation explanations with real-world examples:
-
@NotBlank— "Destination cannot be blank"Imagine booking a flight without specifying where you're going. The airline wouldn't know where to send you!
-
@Min/@Max— Trip duration between 1 and 30 daysA 0-day trip or a 100-day trip might not make practical sense. These constraints keep data realistic.
-
@Positive— Budget must be > 0You can't travel with negative money (unfortunately!).
-
Lombok
@Datagenerates getters, setters,toString,equals, andhashCodeautomaticallyWithout Lombok, you'd write ~50 lines of boilerplate code. With Lombok, just one annotation.
Real-world validation scenarios:
A travel booking website like MakeMyTrip uses similar validation: you can't search for a "0-day trip" or a "negative budget". These validations catch errors before they reach the database or AI service.
4.2 TravelPlanResponse — The Invoice
The response model is more complex because the AI returns a rich, structured travel plan. It contains nested classes:
public class TravelPlanResponse {
private String destination;
private String tripOverview;
private List<DayItinerary> itinerary;
private EstimatedBudget estimatedBudget;
private List<String> recommendedPlaces;
private List<String> food;
private String transportation;
private List<String> packingChecklist;
private List<String> travelTips;
private List<String> safetyTips;
}
Nested classes:
-
DayItinerary— Contains day number, title, activities, meals, and accommodation for each day -
Meals— Breakfast, lunch, and dinner suggestions -
EstimatedBudget— Total budget with category breakdown (accommodation, food, transport, etc.)
Real-world example of the response:
When you ask an AI travel agent for a "5-day Goa trip," the response includes specific beach names (Baga, Anjuna, Vagator), exact restaurants, scooter rental prices (₹300-500/day), and even packing items like "waterproof phone pouch" — all generated dynamically by AI.
5. Prompt Engineering with Spring AI
Prompt engineering is the art of crafting instructions for AI models. This is where the magic happens.
5.1 The Prompt Template File
In src/main/resources/prompts/travel-plan-prompt.st:
Generate a detailed travel plan for {destination} for {days} days with a budget of ₹{budget}.
Travel Style: {travelStyle}
Interests: {interests}
Provide the response as a JSON object with the following structure:
{
"tripOverview": "...",
"itinerary": [...],
"estimatedBudget": {...},
...
}
The {variable} syntax is from Spring AI's PromptTemplate. Variables get replaced at runtime with actual values.
Why this approach is powerful:
Instead of hardcoding prompts in Java code, you keep them in separate
.stfiles. This means non-developers (like content writers or domain experts) can tweak prompts without touching code — a real productivity win in production teams.
5.2 PromptTemplateConfig — Wiring the Template
@Configuration
public class PromptTemplateConfig {
@Bean
public Resource travelPlanPromptResource() {
return new ClassPathResource("prompts/travel-plan-prompt.st");
}
}
This creates a Spring bean that loads the prompt template file from the classpath. The @Bean annotation makes this Resource available for dependency injection elsewhere.
Real-world analogy:
Think of this like registering a template document in your company's document management system. Anyone who needs the template can request it from the system instead of searching through folders.
5.3 TravelPromptBuilder — Building Prompts
@Component
public class TravelPromptBuilder {
private static final String SYSTEM_PROMPT_TEXT = """
You are an experienced travel planner. Generate accurate, realistic,
beginner-friendly travel itineraries with practical recommendations.
Always provide structured responses, estimated budgets, local travel
advice, and safety tips. Never invent nonexistent tourist attractions.
""";
private final Resource promptTemplateResource;
public Prompt buildTravelPlanPrompt(TravelPlanRequest request) {
SystemMessage systemMessage = new SystemMessage(SYSTEM_PROMPT_TEXT);
PromptTemplate promptTemplate = new PromptTemplate(promptTemplateResource);
Map<String, Object> variables = Map.of(
"destination", request.getDestination(),
"days", String.valueOf(request.getDays()),
"budget", String.valueOf((int) request.getBudget()),
"travelStyle", request.getTravelStyle() != null
? request.getTravelStyle() : "Standard",
"interests", String.join(", ", request.getInterests())
);
Message userMessage = promptTemplate.createMessage(variables);
return new Prompt(List.of(systemMessage, userMessage));
}
}
Breaking down the prompt structure:
| Component | Purpose | Real-World Analogy |
|---|---|---|
| System Message | Sets the AI's role and behavior | Like briefing a travel agent before they talk to a client: "You specialize in budget travel, always recommend local food, never make up attractions" |
| User Message | The actual request with variables | Like the client saying: "I want to go to Goa for 5 days with ₹25,000, I love beaches and food" |
| PromptTemplate | Fills variables into a template | Like a mail-merge — same letter structure, personalized details |
Real-world scenario:
A travel tech startup could A/B test different system prompts to see which generates more bookings. By keeping prompts in files, non-engineers can run experiments without deployment cycles.
6. The Service Layer — Core Business Logic
The TravelPlanService is the brain of the application. It orchestrates the entire flow.
@Service
public class TravelPlanService {
private final ChatClient chatClient;
private final TravelPromptBuilder promptBuilder;
private final ObjectMapper objectMapper;
public TravelPlanService(ChatClient.Builder chatClientBuilder,
TravelPromptBuilder promptBuilder,
ObjectMapper objectMapper) {
this.chatClient = chatClientBuilder
.defaultSystem("You are an experienced travel planner...")
.build();
this.promptBuilder = promptBuilder;
this.objectMapper = objectMapper;
}
Constructor Injection
Spring automatically provides the ChatClient.Builder (configured via application.yml), our TravelPromptBuilder, and Jackson's ObjectMapper. This is dependency injection — Spring wires everything together.
Real-world analogy:
Imagine a restaurant where the chef doesn't grow their own vegetables or make their own plates. The ingredients arrive from suppliers (Spring) already prepped. The chef just focuses on cooking.
The Generate Method — Step by Step
public TravelPlanResponse generateTravelPlan(TravelPlanRequest request) {
// Step 1: Build the prompt from user request
Prompt prompt = promptBuilder.buildTravelPlanPrompt(request);
// Step 2: Send to Azure OpenAI and get response
String response;
try {
response = chatClient.prompt(prompt).call().content();
} catch (Exception ex) {
String errorMsg = extractErrorMessage(ex);
throw new AiServiceException("Failed to generate travel plan: " + errorMsg, ex);
}
// Step 3: Validate response isn't empty
if (response == null || response.isBlank()) {
throw new AiServiceException("AI returned an empty response");
}
// Step 4: Parse JSON into Java object
TravelPlanResponse travelPlan = parseResponse(response, request.getDestination());
return travelPlan;
}
Step-by-step with a real-world example:
-
User sends:
{destination: "Goa", days: 5, budget: 25000, travelStyle: "Budget", interests: ["Beaches", "Food", "Nightlife"]} - Prompt built: System message + "Generate a detailed travel plan for Goa for 5 days with a budget of ₹25000..."
- AI responds: A 2000-word JSON document with 5-day itinerary, budget breakdown, safety tips, etc.
-
JSON parsed: The raw string is converted into a
TravelPlanResponseJava object - Response sent: The formatted travel plan is returned as HTTP JSON response
The extractJson Method — Handling AI Output
private String extractJson(String text) {
int start = text.indexOf('{');
int end = text.lastIndexOf('}');
return text.substring(start, end + 1);
}
Why this is needed:
AI models sometimes wrap JSON in markdown code blocks (
json ...
) or add explanatory text before/after. This method strips everything except the JSON structure — like panning for gold and keeping only the nuggets.
Real-world example:
Without this method, the AI might return:
Here's your travel plan!
json
{"destination": "Goa", ...}Hope you enjoy!The
extractJsonmethod cleans this into valid JSON.
The extractErrorMessage Method — User-Friendly Errors
private String extractErrorMessage(Exception ex) {
String message = ex.getMessage();
if (message != null) {
if (message.contains("rate limit") || message.contains("RateLimit")) {
return "Rate limit exceeded. Please try again later.";
}
if (message.contains("timeout") || message.contains("Timeout")) {
return "Request timed out. Please try again.";
}
if (message.contains("401") || message.contains("403")) {
return "Authentication failed. Check API credentials.";
}
if (message.contains("429")) {
return "Too many requests. Please slow down.";
}
}
return "AI service is currently unavailable. Please try again later.";
}
This translates cryptic Azure error messages into human-readable messages. Instead of exposing stack traces to users, they get clear, actionable feedback.
7. The Controller Layer — REST API
The controller is the front door of your application — it receives HTTP requests and sends back responses.
@RestController
@RequestMapping("/api/v1")
@Tag(name = "Travel Plan", description = "AI-powered travel plan generation endpoints")
public class TravelPlanController {
-
@RestController— Combines@Controllerand@ResponseBody; every method returns JSON automatically -
@RequestMapping("/api/v1")— All endpoints start with/api/v1 -
@Tag— For Swagger documentation grouping
The Endpoint
@PostMapping("/travel-plan")
public ResponseEntity<TravelPlanResponse> generateTravelPlan(
@Valid @RequestBody TravelPlanRequest request) {
Instant start = Instant.now();
log.info("Received travel plan request - destination: {}, days: {}, budget: {}",
request.getDestination(), request.getDays(), request.getBudget());
TravelPlanResponse response = travelPlanService.generateTravelPlan(request);
long duration = Duration.between(start, Instant.now()).toMillis();
log.info("Travel plan request completed in {} ms for {}", duration, request.getDestination());
return ResponseEntity.ok(response);
}
Key annotations:
| Annotation | Purpose | Real-World Analogy |
|---|---|---|
@PostMapping |
Maps HTTP POST requests to this method | Like a specific mailbox labeled "New Travel Plan Requests" |
@Valid |
Triggers validation before method executes | Like a security check before entering a building |
@RequestBody |
Maps HTTP request body to Java object | Like an automated form-filler |
ResponseEntity |
Wraps response with status code and headers | Like sending a package with proper shipping label |
Real-world API call:
POST http://localhost:8080/api/v1/travel-plan
Content-Type: application/json
{
"destination": "Goa",
"days": 5,
"budget": 25000,
"travelStyle": "Budget",
"interests": ["Beaches", "Food", "Nightlife"]
}
The controller also includes detailed OpenAPI annotations (@Operation, @ApiResponses, @ExampleObject) that auto-generate beautiful Swagger documentation — making it easy for frontend developers to understand and test the API.
8. Exception Handling & Error Responses
This project uses Spring's @RestControllerAdvice for centralized exception handling — one place to handle all errors.
@RestControllerAdvice
public class GlobalExceptionHandler {
ProblemDetail — Modern Error Responses
Spring Boot 3 introduced ProblemDetail (RFC 9457) for standardized error responses.
Validation errors (400 Bad Request):
{
"type": "urn:problem-type:validation",
"title": "Validation Failed",
"status": 400,
"detail": "destination: Destination cannot be blank",
"timestamp": "2026-07-21T12:00:00Z"
}
AI service errors (503 Service Unavailable):
{
"type": "urn:problem-type:ai-service",
"title": "AI Service Error",
"status": 503,
"detail": "Failed to generate travel plan: Rate limit exceeded...",
"timestamp": "2026-07-21T12:00:00Z"
}
Why centralized error handling matters:
Imagine 10 different controllers each handling errors differently. Some return 400, some return "bad request", some return cryptic stack traces.
@RestControllerAdviceensures every error response follows the same format — critical for frontend developers building error-handling logic.
Real-world analogy:
Think of this like a mall's customer service desk. Instead of each store handling complaints differently, there's one central desk with standard procedures. Customers know exactly where to go and what format to expect.
9. OpenAPI / Swagger Configuration
The OpenApiConfig class customizes the auto-generated API documentation:
@Configuration
public class OpenApiConfig {
@Bean
public OpenAPI travelPlannerOpenAPI() {
return new OpenAPI()
.info(new Info()
.title("AI Travel Planner API")
.description("REST API for generating personalized AI-powered travel plans")
.version("1.0.0")
.contact(new Contact()
.name("Developer")
.email("dev@example.com"))
.license(new License()
.name("MIT")))
.servers(List.of(
new Server()
.url("http://localhost:" + serverPort)
.description("Local development server")
));
}
}
What this provides:
- A
/swagger-ui.htmlpage where you can test all endpoints interactively - Auto-generated documentation based on your controller annotations
- Example requests and responses (from
@ExampleObjectannotations in the controller)
Real-world use:
In a team of 5 developers, the frontend mobile team can open Swagger UI, see the exact request format expected, test with real data, and download the OpenAPI spec to auto-generate their API client code — all without waiting for the backend team to write documentation.
10. Application Configuration
10.1 Main Configuration (application.yml)
spring:
ai:
azure:
openai:
api-key: ${AZURE_OPENAI_API_KEY}
endpoint: ${AZURE_OPENAI_ENDPOINT}
chat:
options:
deployment-name: ${AZURE_OPENAI_DEPLOYMENT:gpt-4o}
temperature: 1.0
Key settings explained:
| Setting | Purpose | Why It Matters |
|---|---|---|
api-key |
Your Azure OpenAI API key | Without this, Azure won't authenticate your requests |
endpoint |
Your Azure OpenAI service URL | Tells Spring which Azure resource to call |
deployment-name |
Which model deployment to use | Different models (GPT-4o, GPT-4, GPT-35-turbo) have different capabilities and costs |
temperature: 1.0 |
Controls AI creativity (0.0-1.0) | Lower = more deterministic; Higher = more creative. Some models only support specific values |
Real-world scenario:
In production, you'd never hardcode these values. Using
${AZURE_OPENAI_API_KEY}reads from environment variables, which can be set in:
- Your local terminal for development
- GitHub Actions secrets for CI/CD
- Azure Key Vault for production
- Docker Compose environment variables
10.2 Dev Profile (application-dev.yml)
logging:
level:
com.aytravelplanner: DEBUG
org.springframework.ai: DEBUG
org.springframework.web: DEBUG
spring:
devtools:
restart:
enabled: true
- DEBUG logging shows detailed logs about AI prompts and responses — invaluable during development
- DevTools restart automatically restarts the app when code changes — like hot-reload for Spring Boot
11. Running the Application
Step 1: Set Environment Variables
# Windows PowerShell
$env:AZURE_OPENAI_API_KEY="your-key-here"
$env:AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com"
$env:AZURE_OPENAI_DEPLOYMENT="gpt-4o"
Important: Keep your API key secret! Never commit it to Git. The .env.example file shows what variables are needed without exposing real values.
Step 2: Run the Application
mvn spring-boot:run -Dspring-boot.run.profiles=dev
The dev profile enables detailed logging and hot-reload.
Step 3: Verify It's Running
- Swagger UI: http://localhost:8080/swagger-ui.html
- API Docs: http://localhost:8080/v3/api-docs
- Health Check: http://localhost:8080/actuator/health
12. Testing the API
Using cURL
curl -X 'POST' \
'http://localhost:8080/api/v1/travel-plan' \
-H 'accept: application/json' \
-H 'Content-Type: application/json' \
-d '{
"destination": "Goa",
"days": 5,
"budget": 25000,
"travelStyle": "Budget",
"interests": ["Beaches", "Food", "Nightlife"]
}'
Using Swagger UI
- Open http://localhost:8080/swagger-ui.html
- Click on the "Travel Plan" endpoint
- Click "Try it out"
- Paste the JSON request body
- Click "Execute" — the response appears below with the full travel plan
Sample Response Snippet
{
"destination": "Goa",
"tripOverview": "5-day budget beach-and-food-focused trip...",
"itinerary": [
{
"day": 1,
"title": "Arrival, Settle in & North Goa Beach Evening",
"activities": [
"Arrive in Goa. Check in to your budget guesthouse.",
"Rent a scooter to explore nearby beaches.",
"Relax at Calangute/Candolim beach..."
]
}
],
"estimatedBudget": {
"total": 25000,
"breakdown": {
"accommodation": 6000,
"food": 4000,
"transportation": 5000,
"activities": 5000,
"miscellaneous": 5000
}
}
}
13. Real-World Use Cases
Use Case 1: Travel Booking Website
A website like MakeMyTrip or Booking.com could integrate this API to offer "AI-Generated Itineraries" as a premium feature:
- User searches for flights to Goa
- After booking, API generates a personalized 5-day itinerary
- User can modify and save it
- Monetization: charge ₹99 per AI-generated itinerary
Use Case 2: Travel Agency Automation
A small travel agency handling 100+ inquiries daily:
- Customer calls: "I want a 7-day Kerala trip on ₹50,000"
- Agent inputs details into internal tool
- API generates professional itinerary in seconds instead of hours
- Agent reviews, customizes slightly, and sends to client
Use Case 3: Chatbot Integration
A WhatsApp travel chatbot:
- User texts: "Plan a 3-day trip to Jaipur"
- Bot calls this API
- Returns formatted itinerary in chat
- User asks follow-ups: "Change budget to ₹30,000"
Use Case 4: Content Generation
A travel blog uses the API to generate content ideas:
- Blog writer inputs: "Goa, 5 days, ₹25,000, Budget"
- API generates structured content
- Writer uses it as a starting template
- Customizes with personal experiences and photos
14. Troubleshooting Common Issues
| Error | Cause | Solution |
|---|---|---|
No static resource swagger-ui.html |
Wrong Swagger path config | Set springdoc.swagger-ui.path: /swagger-ui.html
|
DeploymentNotFound |
Wrong model deployment name | Set AZURE_OPENAI_DEPLOYMENT to match your Azure deployment |
temperature does not support 0.7 |
Some models only support temperature=1 | Set temperature: 1.0 in configuration |
401 Authentication failed |
Invalid API key | Check AZURE_OPENAI_API_KEY is correct and not expired |
400 Bad Request |
Missing required fields | Ensure destination, days, budget, and interests are provided |
| AI returns invalid JSON | Model hallucination | The extractJson method handles this; improve the prompt as needed |
Architecture Diagram
┌─────────────┐ HTTP Request ┌──────────────────┐
│ Client │ ───────────────────> │ Controller │
│ (cURL/App) │ │ (REST API) │
└─────────────┘ └────────┬─────────┘
│
▼
┌──────────────────┐
│ Service │
│ TravelPlanService│
└────────┬─────────┘
│
┌──────────┴──────────┐
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ PromptBuilder │ │ ObjectMapper │
│ (Build prompt) │ │ (Parse JSON) │
└────────┬────────┘ └────────┬────────┘
│ │
▼ │
┌─────────────────┐ │
│ ChatClient │────────────┘
│ (Azure OpenAI) │
└─────────────────┘
Key Takeaways
-
Spring AI simplifies LLM integration — The
ChatClientAPI abstracts away HTTP calls, authentication, and streaming - Prompt engineering is crucial — The quality of output depends entirely on how you structure your prompts
- Always validate and parse AI output — AI models don't guarantee valid JSON; always add safety nets
-
Centralized error handling —
@RestControllerAdvicekeeps your error responses consistent - Externalize configuration — Use environment variables for secrets and profile-specific configs
- Document APIs automatically — SpringDoc + OpenAPI annotations generate interactive documentation for free
Next Steps & Enhancements
Want to extend this project? Try:
- Add caching — Store generated plans in Redis or database to avoid redundant AI calls
- Add PDF export — Convert the itinerary to a downloadable PDF using iText or Apache POI
- Add user authentication — Spring Security + JWT for multi-user support
- Add streaming responses — Use SSE (Server-Sent Events) to show the itinerary as it's being generated
- Support multiple AI providers — Add OpenAI, Anthropic Claude, or Google Gemini via Spring AI's abstraction
- Add flight/hotel API integration — Fetch real prices from Amadeus or Google Flights APIs
- Rate limiting — Protect your API from abuse with bucket4j or Spring Cloud Gateway
Built with Spring Boot 3.5, Spring AI 1.0.8, Java 21, and Azure OpenAI.
Top comments (0)