Build an AI-Powered Garage REST API
Build a secured Spring Boot REST API with JWT auth and AI diagnostics.
Introduction
30 Second Summary
When you take your car to a mechanic, the workshop juggles customer records, vehicle histories, and diagnostic expertise all at once. A well-built software system can tie all of that together and even suggest probable repairs.
In this project, you will build a REST API for a vehicle workshop that handles authentication, role-based access, and AI-powered diagnostics using Spring Boot, JWT tokens, and Google Gemini.
What You'll Build
You will run the finished API, log in as different roles, watch unauthorized requests get rejected, and send vehicle symptoms to an AI endpoint that returns structured repair suggestions in real time.
By the end of this project, you'll have:
- A JWT authentication system where you register as ADMIN, MECHANIC, or CUSTOMER, log in, and use the returned token to unlock role-specific endpoints.
- An AI diagnostic endpoint where you POST symptom descriptions and receive structured repair suggestions generated by Google Gemini via Spring AI.
- Full CRUD operations on vehicles and service records with input validation, proper error responses, and role-based access control you can test in Postman.
- Secret Mission: Generate a QR code linking to a vehicle's service history, scannable from your phone.
Are there any prerequisites?
This project assumes you have Java 25, IntelliJ IDEA, MySQL, Git, and Postman already installed.
You also need a Google account to generate a free Gemini API key from aistudio.google.com/apikey. No credit card is required.
Before We Start
Before diving in, let's lock in what you're about to build and why it matters for your Advanced API Development module. Committing to the goal now makes every step that follows purposeful.
Set Up the Spring Boot Project and Database
Every REST API needs a foundation: a running application framework and a connected database. Without these two pieces, you cannot serve a single endpoint or persist any data.
In this step, you will scaffold a Spring Boot project using Spring Initializr, connect it to a local MySQL database, and define the JPA entity classes that map to your database tables. By the end, Hibernate will have auto-created those tables for you.
In this step, get ready to:
- Create a Spring Boot 4.1.0 project with the required dependencies and import it into IntelliJ IDEA.
- Configure application.yml to connect to your local MySQL database.
- Define entity classes for Users, Vehicles, Customers, and Service Records with JPA annotations.
Create a Spring Boot project via Spring Initializr
Spring Initializr is a web tool that generates a pre-configured Spring Boot project as a downloadable ZIP file. You tell it which dependencies you need, and it wires them into a ready-to-run Maven project.
- Open your browser and navigate to start.spring.io.
- Set **Project** to Maven.
- Set Language to Java.
- Set Spring Boot version to 4.1.0.
- Under Project Metadata, set Group to com.nextwork.
- Set Artifact to garage-api.
- Leave Packaging as Jar and Java as 25.
- Click ADD DEPENDENCIES and search for each of the following, adding them one at a time: Spring Web, Spring Data JPA, MySQL Driver, Spring Security, Validation, and Spring Boot DevTools.
- Click GENERATE to download the ZIP file.
- Extract the ZIP to a location you can find easily (your Desktop works).
What did Spring Initializr just do?
It generated a Maven project with a pom.xml that already declares the six starter dependencies you selected. It also created the main application class GarageApiApplication.java annotated with @SpringBootApplication.
Spring Boot starters bundle related libraries so you do not have to hunt for individual JARs. For example, spring-boot-starter-data-jpa brings in Hibernate, the JPA API, and connection pooling.
- Open IntelliJ IDEA.
- Click Open on the welcome screen.
- Navigate to the extracted garage-api folder, select it, and click OK.
- IntelliJ will detect the pom.xml and begin importing Maven dependencies. Wait for the progress bar in the bottom-right to finish.
Trust the project if prompted
IntelliJ may show a trust dialog when opening a project for the first time. Click Trust Project to allow Maven to resolve dependencies.
Configure MySQL and application.yml
Spring Boot needs to know where your database lives and how to connect. You will create the MySQL database first, then point the application at it.
- Open a terminal and log in to MySQL by running this command:
mysql -u root -p
- Enter your MySQL root password when prompted.
- Create the project database by running this SQL statement:
CREATE DATABASE garage_api;
- Confirm the database exists by running:
SHOW DATABASES;
You should see garage_api in the list.
- Exit the MySQL shell by typing:
EXIT;
Now you will configure the Spring Boot application to connect to this database.
- In IntelliJ, expand src/main/resources in the Project panel on the left.
- If you see application.properties, right-click it, select Refactor, then Rename, and change the filename to application.yml.
- Replace the contents of application.yml with the following configuration:
spring:
datasource:
url: jdbc:mysql://localhost:3306/garage_api
username: root
password: your_password
driver-class-name: com.mysql.cj.jdbc.Driver
jpa:
hibernate:
ddl-auto: update
show-sql: true
logging:
level:
root: INFO
com.nextwork: DEBUG
What does this configuration do?
- datasource tells Spring how to connect to your MySQL instance. Replace your_password with your actual MySQL root password.
- ddl-auto: update tells Hibernate to automatically create or update database tables to match your Java entity classes.
- show-sql: true prints every SQL query Hibernate runs to the console, which helps you debug.
- The logging section sets your project package to DEBUG level so you can see detailed logs.
- Replace your_password in the file with your actual MySQL root password.
- Save the file.
Before you touch any entity code, run the application to confirm the database connection works.
- Open GarageApiApplication.java in src/main/java/com/nextwork/garageapi/.
- Click the green play button in the gutter next to the main method, then select Run 'GarageApiApplication'.
The console should show a line containing Started GarageApiApplication with no errors. This proves Spring Boot started and connected to MySQL successfully.
Seeing a connection refused error?
- Make sure your MySQL server is running. On macOS you can check with mysql.server status. On Windows, check the MySQL service in Services.
- Double-check the password in application.yml matches the password you use to log in to the MySQL shell.
- Confirm the database name is garage_api (with an underscore, not a hyphen).
Still stuck? Help me fix my MySQL connection error in Spring Boot.
- Stop the application by clicking the red square stop button in the Run panel at the bottom of IntelliJ.
Create the JPA entity classes
JPA entities are Java classes that map directly to database tables. Each field becomes a column. Hibernate reads these classes and creates the matching tables automatically because you set ddl-auto: update.
You will create five files in total: a Role enum and four entity classes. Start with Role and User since they have no dependencies on other entities.
- In the Project panel, right-click the com.nextwork.garageapi package.
- Select New, then Package, and name it entity.
- Right-click the new entity package, select New, then Java Class. Choose Enum from the kind dropdown, and name it Role.
- Replace the file contents with:
package com.nextwork.garageapi.entity;
public enum Role { ADMIN, MECHANIC, CUSTOMER }
- Create a new Java Class in the entity package named User.
- Replace the file contents with:
package com.nextwork.garageapi.entity;
import jakarta.persistence.*;
@Entity
@Table(name = "users")
public class User {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, nullable = false)
private String username;
@Column(nullable = false)
private String password;
@Enumerated(EnumType.STRING)
private Role role;
// getters and setters
}
What does this code do?
- @Entity tells Hibernate this class maps to a database table.
- @Table(name = "users") sets the table name explicitly (avoiding conflicts with the SQL reserved word user).
- @Id marks the primary key. @GeneratedValue(strategy = GenerationType.IDENTITY) tells MySQL to auto-increment it.
- @Enumerated(EnumType.STRING) stores the role as a readable string (ADMIN, MECHANIC, CUSTOMER) rather than an integer.
- Generate getters and setters for all fields by pressing Cmd+N (macOS) or Alt+Insert (Windows/Linux).
- In the popup, select Getter and Setter, select all fields, and click OK.
- Save Role.java and User.java.
Run the application again to verify that Hibernate creates the users table.
- Click the green play button next to main in GarageApiApplication.java.
In the console output, you should see SQL statements including create table users (or alter table if it already existed). This confirms Hibernate mapped your User entity to a real table.
- Stop the application.
Next, create the Customer and Vehicle entities. These two reference each other (a customer owns many vehicles, and each vehicle belongs to one customer), so you need both files before the project compiles.
- Create a new Java Class in the entity package named Customer.
- Replace the file contents with:
package com.nextwork.garageapi.entity;
import jakarta.persistence.*;
import java.util.List;
@Entity
@Table(name = "customers")
public class Customer {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String firstName;
private String lastName;
@Column(unique = true)
private String email;
private String phone;
@OneToMany(mappedBy = "customer", cascade = CascadeType.ALL)
private List<Vehicle> vehicles;
// getters and setters
}
- Create another Java Class in the entity package named Vehicle.
- Replace the file contents with:
package com.nextwork.garageapi.entity;
import jakarta.persistence.*;
import java.util.List;
@Entity
@Table(name = "vehicles")
public class Vehicle {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String make;
private String model;
private int year;
private String licensePlate;
private String vin;
@ManyToOne
@JoinColumn(name = "customer_id")
private Customer customer;
@OneToMany(mappedBy = "vehicle", cascade = CascadeType.ALL)
private List<ServiceRecord> serviceRecords;
// getters and setters
}
What are these relationship annotations?
@OneToMany(mappedBy = "customer") on Customer says one customer has many vehicles. The mappedBy value points to the field on the other side that owns the relationship.
@ManyToOne on Vehicle says many vehicles belong to one customer. @JoinColumn(name = "customer_id") tells Hibernate to create a customer_id foreign key column in the vehicles table.
- Generate getters and setters for both Customer and Vehicle using Cmd+N (macOS) or Alt+Insert (Windows/Linux).
- Save both files.
The project will not compile yet because Vehicle references ServiceRecord, which does not exist. Create it now.
- Create a new Java Class in the entity package named ServiceRecord.
- Replace the file contents with:
package com.nextwork.garageapi.entity;
import jakarta.persistence.*;
import java.math.BigDecimal;
import java.time.LocalDate;
@Entity
@Table(name = "service_records")
public class ServiceRecord {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String description;
private LocalDate date;
private BigDecimal cost;
@ManyToOne
@JoinColumn(name = "vehicle_id")
private Vehicle vehicle;
private String mechanicUsername;
// getters and setters
}
What does ServiceRecord model?
Each service record captures one visit to the workshop: a description of the work, the date, the cost, which vehicle it was performed on, and which mechanic did the work. The @ManyToOne relationship means many service records can belong to a single vehicle.
- Generate getters and setters for ServiceRecord using Cmd+N (macOS) or Alt+Insert (Windows/Linux).
- Save the file.
All entities are in place. Time to run the application and confirm Hibernate creates every table.
- What do you expect to see in the console when the application starts? Think about what Hibernate does with your four entity classes and ddl-auto: update.
- Run GarageApiApplication by clicking the green play button.
The console shows SQL create table statements for customers, vehicles, and service_records (in addition to users which already existed). The last relevant line reads Started GarageApiApplication with no errors.
Application fails to start?
- If you see a compile error mentioning ServiceRecord or Vehicle, make sure every entity class is saved and the package declaration matches com.nextwork.garageapi.entity.
- If you see an error about jakarta.persistence not being found, wait for Maven import to finish (check the progress bar in the bottom-right of IntelliJ).
- If the table creation SQL does not appear, check show-sql: true is set in your application.yml under the jpa key.
Still stuck? Help me fix my Spring Boot entity compilation error.
✔️ Awesome, I've got everything!
Great. Double check you have saved all five files in the entity package and that your application is running cleanly.
ⓧ I'd like to double check the full code
Here is each file you should have at this point. Scroll through and compare with your project.
application.yml:
spring:
datasource:
url: jdbc:mysql://localhost:3306/garage_api
username: root
password: your_password
driver-class-name: com.mysql.cj.jdbc.Driver
jpa:
hibernate:
ddl-auto: update
show-sql: true
logging:
level:
root: INFO
com.nextwork: DEBUG
Role.java:
package com.nextwork.garageapi.entity;
public enum Role { ADMIN, MECHANIC, CUSTOMER }
User.java:
package com.nextwork.garageapi.entity;
import jakarta.persistence.*;
@Entity
@Table(name = "users")
public class User {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, nullable = false)
private String username;
@Column(nullable = false)
private String password;
@Enumerated(EnumType.STRING)
private Role role;
// getters and setters
}
Customer.java:
package com.nextwork.garageapi.entity;
import jakarta.persistence.*;
import java.util.List;
@Entity
@Table(name = "customers")
public class Customer {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String firstName;
private String lastName;
@Column(unique = true)
private String email;
private String phone;
@OneToMany(mappedBy = "customer", cascade = CascadeType.ALL)
private List<Vehicle> vehicles;
// getters and setters
}
Vehicle.java:
package com.nextwork.garageapi.entity;
import jakarta.persistence.*;
import java.util.List;
@Entity
@Table(name = "vehicles")
public class Vehicle {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String make;
private String model;
private int year;
private String licensePlate;
private String vin;
@ManyToOne
@JoinColumn(name = "customer_id")
private Customer customer;
@OneToMany(mappedBy = "vehicle", cascade = CascadeType.ALL)
private List<ServiceRecord> serviceRecords;
// getters and setters
}
ServiceRecord.java:
package com.nextwork.garageapi.entity;
import jakarta.persistence.*;
import java.math.BigDecimal;
import java.time.LocalDate;
@Entity
@Table(name = "service_records")
public class ServiceRecord {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String description;
private LocalDate date;
private BigDecimal cost;
@ManyToOne
@JoinColumn(name = "vehicle_id")
private Vehicle vehicle;
private String mechanicUsername;
// getters and setters
}
Your project skeleton is alive. You have a running Spring Boot application backed by a MySQL database with four auto-created tables. Next up, you will build the REST endpoints that let clients interact with this data.
Build the REST Endpoints and Hit Your First Security Wall
Your data layer is in place and the application connects to MySQL. Now you need endpoints that actually let clients interact with that data.
In this step, you will build a full set of CRUD endpoints for vehicles, add input validation, and wire up a global error handler. Then you will try to call your shiny new API and discover that Spring Security blocks every request before it reaches your controller.
In this step, get ready to:
- Create repository interfaces and DTO classes for request/response separation.
- Build a VehicleController with full CRUD and Bean Validation.
- Hit the Spring Security wall and see every request blocked with 401 Unauthorized.
Create the repository interfaces
Spring Data JPA eliminates boilerplate database code. You declare an interface extending JpaRepository and Spring auto-generates the implementation at runtime, giving you methods like findAll(), findById(), save(), and deleteById() for free.
- In IntelliJ, right-click on the com.nextwork.garageapi package in your src/main/java folder.
- Select New then Package and name it repository.
- Inside the repository package, create a new Java interface called VehicleRepository with the following code:
package com.nextwork.garageapi.repository;
import com.nextwork.garageapi.entity.Vehicle;
import org.springframework.data.jpa.repository.JpaRepository;
// Provides CRUD operations for Vehicle entities
public interface VehicleRepository extends JpaRepository<Vehicle, Long> {}
- Create a second interface in the same package called CustomerRepository:
package com.nextwork.garageapi.repository;
import com.nextwork.garageapi.entity.Customer;
import org.springframework.data.jpa.repository.JpaRepository;
// Provides CRUD operations for Customer entities
public interface CustomerRepository extends JpaRepository<Customer, Long> {}
- Create a third interface called UserRepository with a custom query method:
package com.nextwork.garageapi.repository;
import com.nextwork.garageapi.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
// Provides CRUD plus a lookup-by-username method for authentication
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByUsername(String username);
}
- Create a fourth interface called ServiceRecordRepository with a custom query method:
package com.nextwork.garageapi.repository;
import com.nextwork.garageapi.entity.ServiceRecord;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
// Provides CRUD plus a lookup for all records belonging to a vehicle
public interface ServiceRecordRepository extends JpaRepository<ServiceRecord, Long> {
List<ServiceRecord> findByVehicleId(Long vehicleId);
}
What do these interfaces do?
Each interface extends JpaRepository<EntityType, IdType>. Spring Data JPA reads that generic signature and generates a full implementation behind the scenes. You get standard CRUD for free without writing a single SQL query.
The custom methods like findByUsername and findByVehicleId use Spring Data's query derivation. Spring parses the method name and builds the SQL automatically.
- Save all four files.
- Restart the application in IntelliJ by clicking the green Run button (or press Ctrl+Shift+F10).
- Confirm the console still shows Started GarageApiApplication with no errors.
Seeing a compilation error?
Check that each interface file is in the package com.nextwork.garageapi.repository and that the import paths match your entity class names exactly. A typo in the entity import is the most common cause.
Help me debug this
Create the DTO and exception handler classes
DTOs (Data Transfer Objects) separate what your API accepts from what your database stores. This lets you add validation rules to incoming requests without polluting your entity classes.
- Right-click on com.nextwork.garageapi, select New then Package, and name it dto.
- Inside the dto package, create a new Java class called VehicleRequestDto with validation annotations:
package com.nextwork.garageapi.dto;
import jakarta.validation.constraints.*;
// Defines the shape of incoming vehicle data with validation rules
public class VehicleRequestDto {
@NotBlank
private String make;
@NotBlank
private String model;
@Min(1900) @Max(2100)
private int year;
@NotBlank
private String licensePlate;
private String vin;
private Long customerId;
// getters and setters
}
- Create a second class in the same package called VehicleResponseDto:
package com.nextwork.garageapi.dto;
// Defines the shape of outgoing vehicle data sent to clients
public class VehicleResponseDto {
private Long id;
private String make;
private String model;
private int year;
private String licensePlate;
private String vin;
// getters and setters
}
What do the validation annotations do?
Bean Validation annotations on the request DTO tell Spring to reject invalid input before your controller logic ever runs. @NotBlank rejects null or empty strings. @Min(1900) and @Max(2100) enforce a sensible year range.
The response DTO has no annotations because it is output only. It controls what the client sees without exposing internal entity details.
- Save both DTO files.
- Restart the application and confirm Started GarageApiApplication still appears in the console.
Now you need a global exception handler so validation errors and not-found exceptions return consistent JSON instead of raw stack traces.
- Right-click on com.nextwork.garageapi, select New then Package, and name it exception.
- Inside the exception package, create a new Java class called GlobalExceptionHandler:
package com.nextwork.garageapi.exception;
import org.springframework.http.*;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.*;
import java.util.*;
// Catches exceptions globally and returns clean JSON error responses
@RestControllerAdvice
public class GlobalExceptionHandler {
// Handle validation failures (e.g. @NotBlank, @Min, @Max)
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, String>> handleValidation(MethodArgumentNotValidException ex) {
Map<String, String> errors = new HashMap<>();
ex.getBindingResult().getFieldErrors().forEach(e -> errors.put(e.getField(), e.getDefaultMessage()));
return ResponseEntity.badRequest().body(errors);
}
// Handle generic runtime exceptions (e.g. "Vehicle not found")
@ExceptionHandler(RuntimeException.class)
public ResponseEntity<Map<String, String>> handleRuntime(RuntimeException ex) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("error", ex.getMessage()));
}
}
What does this handler do?
The @RestControllerAdvice annotation makes this class a global interceptor for exceptions thrown by any controller. Without it, validation errors return a raw 500 with a stack trace.
The handleValidation method catches invalid request bodies and returns a 400 with a JSON map of field names to error messages. The handleRuntime method catches "not found" exceptions and returns a clean 404.
- Save GlobalExceptionHandler.java.
- Restart the application and confirm Started GarageApiApplication appears with no errors.
Seeing an import error on MethodArgumentNotValidException?
Make sure you have spring-boot-starter-validation in your pom.xml dependencies. This starter provides the validation classes. If it is missing, add it and re-import your Maven project.
Help me fix this import error
Build the VehicleController
With repositories, DTOs, and error handling in place, you can now build the controller that maps HTTP requests to database operations.
- Right-click on com.nextwork.garageapi, select New then Package, and name it controller.
- Inside the controller package, create a new Java class called VehicleController with the read endpoints:
package com.nextwork.garageapi.controller;
import com.nextwork.garageapi.dto.*;
import com.nextwork.garageapi.entity.Vehicle;
import com.nextwork.garageapi.repository.VehicleRepository;
import jakarta.validation.Valid;
import org.springframework.http.*;
import org.springframework.web.bind.annotation.*;
import java.util.List;
// Handles all /api/vehicles HTTP requests
@RestController
@RequestMapping("/api/vehicles")
public class VehicleController {
private final VehicleRepository vehicleRepository;
// Constructor injection for the repository
public VehicleController(VehicleRepository vehicleRepository) {
this.vehicleRepository = vehicleRepository;
}
// GET /api/vehicles - return all vehicles
@GetMapping
public List<Vehicle> getAll() {
return vehicleRepository.findAll();
}
// GET /api/vehicles/{id} - return one vehicle or 404
@GetMapping("/{id}")
public Vehicle getById(@PathVariable Long id) {
return vehicleRepository.findById(id)
.orElseThrow(() -> new RuntimeException("Vehicle not found"));
}
}
What does this code do?
The @RestController annotation tells Spring this class handles HTTP requests and returns JSON directly. @RequestMapping("/api/vehicles") sets the base URL path for all methods in this controller.
The getAll() method maps to GET requests at the base path. The getById() method uses @PathVariable to extract the ID from the URL and throws a RuntimeException if the vehicle does not exist. Your GlobalExceptionHandler catches that exception and returns a clean 404 JSON response.
- Now add the write endpoints (POST, PUT, DELETE) below the getById method, inside the same class:
// POST /api/vehicles - create a new vehicle
@PostMapping
public ResponseEntity<Vehicle> create(@Valid @RequestBody VehicleRequestDto dto) {
Vehicle v = new Vehicle();
v.setMake(dto.getMake()); v.setModel(dto.getModel()); v.setYear(dto.getYear());
v.setLicensePlate(dto.getLicensePlate()); v.setVin(dto.getVin());
return ResponseEntity.status(HttpStatus.CREATED).body(vehicleRepository.save(v));
}
// PUT /api/vehicles/{id} - update an existing vehicle
@PutMapping("/{id}")
public Vehicle update(@PathVariable Long id, @Valid @RequestBody VehicleRequestDto dto) {
Vehicle v = vehicleRepository.findById(id)
.orElseThrow(() -> new RuntimeException("Vehicle not found"));
v.setMake(dto.getMake()); v.setModel(dto.getModel()); v.setYear(dto.getYear());
v.setLicensePlate(dto.getLicensePlate()); v.setVin(dto.getVin());
return vehicleRepository.save(v);
}
// DELETE /api/vehicles/{id} - remove a vehicle
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id) {
vehicleRepository.deleteById(id);
return ResponseEntity.noContent().build();
}
What do these endpoints do?
The @Valid annotation on the request body triggers Bean Validation. If a client sends a blank make or a year outside the 1900-2100 range, Spring rejects the request and your GlobalExceptionHandler returns a 400 with the field errors.
The create method returns 201 Created on success. The update method finds the existing vehicle first (throwing 404 if missing) then overwrites its fields. The delete method returns 204 No Content to confirm removal.
- Make sure the closing } brace for the class is at the very end of the file, after the delete method.
- Save VehicleController.java.
- Restart the application and confirm Started GarageApiApplication appears with no errors in the console.
Seeing "Cannot resolve symbol VehicleRequestDto"?
Confirm that the import line import com.nextwork.garageapi.dto.*; is at the top of your controller file. Also verify that VehicleRequestDto.java is saved inside the dto package with the correct package declaration.
Help me fix my VehicleController imports
✔️ Awesome, I've got everything!
Great. Double check that you have saved all your files and that the console shows no errors.
ⓧ I'd like to double check the full code
Scroll up and confirm each of these chunks is present in your project, in order:
- VehicleRepository.java in the repository package.
- CustomerRepository.java in the repository package.
- UserRepository.java in the repository package with the findByUsername method.
- ServiceRecordRepository.java in the repository package with the findByVehicleId method.
- VehicleRequestDto.java in the dto package with @NotBlank, @Min, and @Max annotations.
- VehicleResponseDto.java in the dto package.
- GlobalExceptionHandler.java in the exception package with both @ExceptionHandler methods.
- VehicleController.java in the controller package with all five endpoints (GET all, GET by ID, POST, PUT, DELETE).
Test the API in Postman and hit the security wall
Your controller is wired up and the app starts cleanly. Time to send a real HTTP request and see your endpoint respond.
Before you send the request, think about what you expect to see. You built a GET endpoint that returns all vehicles from the database. What do you think Postman will show?
- Switch to Postman.
- Create a new request by clicking the + tab.
- Set the method to GET.
- Enter http://localhost:8080/api/vehicles in the URL field.
- Click Send.
You receive a 401 Unauthorized response. Your endpoint exists, but Spring Security is blocking the request before it ever reaches your controller.
Why did you get 401 instead of an empty list?
When spring-boot-starter-security is on the classpath, Spring Security auto-configures a default filter chain that requires authentication for every request. Postman sends requests as a service client (not a browser), so Spring Security responds with 401 Unauthorized instead of redirecting to a login page.
Your endpoints are correct. The problem is that no one can reach them yet. The next step solves this by implementing JWT token-based authentication so your API knows who is making each request.
Seeing something other than 401?
If you see a connection refused error, make sure your Spring Boot application is still running in IntelliJ. Check the console for Started GarageApiApplication. If you see a login page HTML instead, switch Postman's request header to not accept text/html by removing any custom Accept headers.
Help me troubleshoot my Postman response
This 401 wall is exactly the problem you will solve next. In Step 3, you will implement JWT authentication so users can register, log in, receive a token, and use that token to access your protected endpoints.
Implement JWT Authentication and Role-Based Access
In the last step, you hit a wall. Every endpoint returned 401 Unauthorized because Spring Security blocks all unauthenticated requests by default.
Now you will solve that by implementing JWT authentication. You will generate tokens containing the user's role, validate those tokens on every request, and enforce role-based access control so ADMIN, MECHANIC, and CUSTOMER each see only what they should.
In this step, get ready to:
- Add JJWT dependencies and create a JWT service that generates and validates tokens.
- Configure Spring Security with a custom filter chain and JWT authentication filter.
- Build authentication endpoints for user registration and login.
Add JJWT dependencies and configure JWT settings
JJWT is a library for creating and verifying JSON Web Tokens in Java. You need three artifacts: the API, the implementation, and a Jackson serializer for JSON processing.
- Open pom.xml in IntelliJ.
- Add the following three dependencies inside the <dependencies> block, below the existing DevTools dependency:
<dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-api</artifactId><version>0.13.0</version></dependency>
<dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-impl</artifactId><version>0.13.0</version><scope>runtime</scope></dependency>
<dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-jackson</artifactId><version>0.13.0</version><scope>runtime</scope></dependency>
What do these dependencies do?
jjwt-api provides the classes you write code against (like Jwts.builder()). jjwt-impl and jjwt-jackson are runtime-only libraries that handle the actual signing, parsing, and JSON serialization behind the scenes.
- Click the Maven reload icon in IntelliJ (or right-click pom.xml and select Maven > Reload project) to download the new dependencies.
- Confirm no red underlines appear on the JJWT dependency lines.
Seeing red underlines on the JJWT dependencies?
Make sure you placed the dependencies inside the existing <dependencies> block and that the version is exactly 0.13.0. Try File > Invalidate Caches / Restart if the reload does not resolve it.
Help me fix JJWT dependency errors in my pom.xml
Next, your JWT service needs a secret key and an expiration time. These belong in configuration, not hardcoded in Java.
- Open src/main/resources/application.yml and add the following block at the end of the file:
app:
jwt:
secret: your-256-bit-secret-key-here-make-it-long
expiration: 86400000
What do these values mean?
secret is the HMAC signing key. It must be at least 256 bits (32 characters) for HS256. expiration is the token lifetime in milliseconds. 86400000 ms equals 24 hours.
Now create the service that uses these values to build and verify tokens.
- Create a new package called security inside com.nextwork.garageapi.
- Create a new file JwtService.java in that package with the following code:
package com.nextwork.garageapi.security;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.security.Key;
import java.util.Date;
@Service
public class JwtService {
// Read the secret and expiration from application.yml
@Value("${app.jwt.secret}") private String secret;
@Value("${app.jwt.expiration}") private long expiration;
private Key getKey() { return Keys.hmacShaKeyFor(secret.getBytes()); }
// Build a signed JWT with username as subject and role as a custom claim
public String generateToken(String username, String role) {
return Jwts.builder().subject(username).claim("role", role)
.issuedAt(new Date()).expiration(new Date(System.currentTimeMillis() + expiration))
.signWith(getKey()).compact();
}
public String extractUsername(String token) { return getClaims(token).getSubject(); }
public String extractRole(String token) { return getClaims(token).get("role", String.class); }
public boolean isTokenValid(String token) {
try { getClaims(token); return true; } catch (Exception e) { return false; }
}
private Claims getClaims(String token) {
return Jwts.parser().verifyWith((javax.crypto.SecretKey) getKey()).build().parseSignedClaims(token).getPayload();
}
}
What does this service do?
generateToken creates a signed JWT containing the username and role. The token expires after 24 hours.
getClaims parses and verifies the token signature using the same secret key. If the signature is invalid or expired, it throws an exception, and isTokenValid returns false.
- Save JwtService.java and application.yml.
- Restart the application in IntelliJ (click the green Run button or press Shift+F10).
- Confirm the console shows Started GarageApiApplication with no errors. This proves the JWT configuration loads correctly.
Seeing a startup error about app.jwt.secret?
Check that application.yml uses correct indentation (2 spaces per level). The app: key must be at the root level, aligned with spring: and logging:.
Help me fix my application.yml JWT configuration
Create the security filter and configuration
Spring Security processes every incoming request through a chain of filters. You need a custom filter that reads the JWT from the Authorization header and tells Spring Security who the user is and what role they have.
- Create JwtAuthenticationFilter.java in the security package with the following code:
package com.nextwork.garageapi.security;
import jakarta.servlet.*;
import jakarta.servlet.http.*;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.List;
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtService jwtService;
public JwtAuthenticationFilter(JwtService jwtService) { this.jwtService = jwtService; }
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain) throws ServletException, IOException {
// Look for a Bearer token in the Authorization header
String header = req.getHeader("Authorization");
if (header != null && header.startsWith("Bearer ")) {
String token = header.substring(7);
if (jwtService.isTokenValid(token)) {
String username = jwtService.extractUsername(token);
String role = jwtService.extractRole(token);
// Set the authenticated user in Spring Security's context
var auth = new UsernamePasswordAuthenticationToken(username, null, List.of(new SimpleGrantedAuthority("ROLE_" + role)));
SecurityContextHolder.getContext().setAuthentication(auth);
}
}
chain.doFilter(req, res);
}
}
What does this filter do?
On every request, the filter checks for a Bearer token in the Authorization header. If the token is valid, it extracts the username and role, then places them into the SecurityContext. Spring Security treats the request as authenticated from that point forward.
- Save JwtAuthenticationFilter.java.
Now you need a configuration class that tells Spring Security how to use this filter and which endpoints require which roles.
- Create SecurityConfig.java in the security package with the following code:
package com.nextwork.garageapi.security;
import org.springframework.context.annotation.*;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
public class SecurityConfig {
private final JwtAuthenticationFilter jwtFilter;
public SecurityConfig(JwtAuthenticationFilter jwtFilter) { this.jwtFilter = jwtFilter; }
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http.csrf(c -> c.disable())
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(a -> a
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/api/diagnostics/**").hasAnyRole("MECHANIC", "ADMIN")
.requestMatchers(org.springframework.http.HttpMethod.DELETE, "/api/**").hasRole("ADMIN")
.anyRequest().authenticated())
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
.build();
}
@Bean
public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); }
}
What do these security rules mean?
- CSRF is disabled because the API uses stateless JWT tokens, not browser cookies.
- Sessions are set to STATELESS so the server never creates an HTTP session.
- /api/auth/** is open to everyone (registration and login need no token).
- /api/diagnostics/** requires MECHANIC or ADMIN role.
- DELETE requests anywhere under /api/** require ADMIN role.
- Everything else just requires a valid token (any role).
- Save SecurityConfig.java and restart the application.
- Return to Postman and send a GET request to http://localhost:8080/api/vehicles. You should still see 401 Unauthorized (no token yet).
- Now send a GET request to http://localhost:8080/api/auth/test. You should see 404 Not Found instead of 401. This proves the security config is working. The auth path is open, but there is no controller mapped to that URL yet.
Still seeing 401 on /api/auth/test?
Make sure SecurityConfig.java has the @Configuration annotation and that the requestMatchers("/api/auth/**").permitAll() line is present. Also verify the app restarted successfully.
Help me troubleshoot my Spring Security configuration
Build the authentication controller
The security infrastructure is in place. Now you need actual endpoints where users can register (create an account with a role) and log in (receive a JWT token).
- Create AuthRequestDto.java in the dto package with the following code:
package com.nextwork.garageapi.dto;
import jakarta.validation.constraints.NotBlank;
public class AuthRequestDto {
@NotBlank private String username;
@NotBlank private String password;
private String role;
// getters and setters
public String getUsername() { return username; }
public void setUsername(String username) { this.username = username; }
public String getPassword() { return password; }
public void setPassword(String password) { this.password = password; }
public String getRole() { return role; }
public void setRole(String role) { this.role = role; }
}
- Create AuthResponseDto.java in the same dto package:
package com.nextwork.garageapi.dto;
public class AuthResponseDto {
private String token;
// Constructor and getter for returning the JWT
public AuthResponseDto(String token) { this.token = token; }
public String getToken() { return token; }
}
Why separate DTOs for auth?
AuthRequestDto carries the username, password, and role from the client. AuthResponseDto wraps the JWT token so the response is structured JSON, not a raw string. This follows the same DTO pattern you used for vehicles.
Spring Security also needs a way to load user details from the database for authentication purposes.
- Create a new package called service inside com.nextwork.garageapi.
- Create UserDetailsServiceImpl.java in that package:
package com.nextwork.garageapi.service;
import com.nextwork.garageapi.entity.User;
import com.nextwork.garageapi.repository.UserRepository;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.*;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
private final UserRepository userRepository;
public UserDetailsServiceImpl(UserRepository userRepository) { this.userRepository = userRepository; }
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// Find the user in the database or throw
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
return new org.springframework.security.core.userdetails.User(
user.getUsername(), user.getPassword(),
List.of(new SimpleGrantedAuthority("ROLE_" + user.getRole().name())));
}
}
- Save both files. Now create the controller that ties everything together.
- Create AuthController.java in the controller package with the register endpoint:
package com.nextwork.garageapi.controller;
import com.nextwork.garageapi.dto.*;
import com.nextwork.garageapi.entity.*;
import com.nextwork.garageapi.repository.UserRepository;
import com.nextwork.garageapi.security.JwtService;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/auth")
public class AuthController {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final JwtService jwtService;
public AuthController(UserRepository userRepository, PasswordEncoder passwordEncoder, JwtService jwtService) {
this.userRepository = userRepository; this.passwordEncoder = passwordEncoder; this.jwtService = jwtService;
}
// Register a new user with a hashed password
@PostMapping("/register")
public ResponseEntity<String> register(@Valid @RequestBody AuthRequestDto dto) {
User user = new User();
user.setUsername(dto.getUsername());
user.setPassword(passwordEncoder.encode(dto.getPassword()));
user.setRole(Role.valueOf(dto.getRole().toUpperCase()));
userRepository.save(user);
return ResponseEntity.ok("User registered successfully");
}
}
What does the register endpoint do?
It receives a username, password, and role. The password is hashed with BCrypt before saving so it is never stored in plain text. The role string is converted to the Role enum (ADMIN, MECHANIC, or CUSTOMER).
- Save AuthController.java and restart the application.
- In Postman, create a new POST request to http://localhost:8080/api/auth/register.
- Set the Body tab to raw and select JSON from the dropdown.
- Paste the following JSON body:
{
"username": "mechanic1",
"password": "password123",
"role": "MECHANIC"
}
- Click Send. You should see a 200 OK response with the text User registered successfully.
Getting 403 Forbidden on register?
This often means CSRF is still enabled. Confirm your SecurityConfig has .csrf(c -> c.disable()) and that the app restarted after saving. Also check that the URL matches /api/auth/register exactly.
Help me fix 403 on my auth register endpoint
Now add the login endpoint that validates credentials and returns a JWT token.
- In AuthController.java, find the closing brace of the register method.
- Add the following login method below it, inside the class:
// Validate credentials and return a JWT token
@PostMapping("/login")
public ResponseEntity<AuthResponseDto> login(@Valid @RequestBody AuthRequestDto dto) {
User user = userRepository.findByUsername(dto.getUsername()).orElseThrow(() -> new RuntimeException("User not found"));
if (!passwordEncoder.matches(dto.getPassword(), user.getPassword())) throw new RuntimeException("Invalid credentials");
String token = jwtService.generateToken(user.getUsername(), user.getRole().name());
return ResponseEntity.ok(new AuthResponseDto(token));
}
What does the login endpoint do?
It looks up the user by username, verifies the raw password against the stored BCrypt hash, then generates a JWT token containing the username and role. The client stores this token and sends it with every subsequent request.
- Save AuthController.java and restart the application.
Before you test, think about what you expect to happen. You registered a user in the previous test. Will the login succeed or fail?
- In Postman, create a new POST request to http://localhost:8080/api/auth/login.
- Set the body to raw JSON:
{
"username": "mechanic1",
"password": "password123"
}
- Click Send. You should see a 200 OK response containing a JSON object with a token field. The token is a long string like eyJhbGciOiJIUzI1NiJ9....
- Copy the entire token value from the response.
Now for the final test. Do you think a GET to /api/vehicles with this token will succeed or fail?
- In Postman, open a new GET request to http://localhost:8080/api/vehicles.
- Go to the Authorization tab.
- Select Bearer Token as the type.
- Paste your copied token into the Token field.
- Click Send.
You should see a 200 OK response with an empty array [] (no vehicles exist yet). The 401 wall is gone.
✔️ Awesome, I've got everything!
Great. Double check you have saved all files in the security, service, dto, and controller packages.
ⓧ I'd like to double check the full code
Here is the full SecurityConfig.java for reference:
package com.nextwork.garageapi.security;
import org.springframework.context.annotation.*;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
public class SecurityConfig {
private final JwtAuthenticationFilter jwtFilter;
public SecurityConfig(JwtAuthenticationFilter jwtFilter) { this.jwtFilter = jwtFilter; }
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http.csrf(c -> c.disable())
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(a -> a
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/api/diagnostics/**").hasAnyRole("MECHANIC", "ADMIN")
.requestMatchers(org.springframework.http.HttpMethod.DELETE, "/api/**").hasRole("ADMIN")
.anyRequest().authenticated())
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
.build();
}
@Bean
public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); }
}
Here is the full JwtService.java:
package com.nextwork.garageapi.security;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.security.Key;
import java.util.Date;
@Service
public class JwtService {
@Value("${app.jwt.secret}") private String secret;
@Value("${app.jwt.expiration}") private long expiration;
private Key getKey() { return Keys.hmacShaKeyFor(secret.getBytes()); }
public String generateToken(String username, String role) {
return Jwts.builder().subject(username).claim("role", role)
.issuedAt(new Date()).expiration(new Date(System.currentTimeMillis() + expiration))
.signWith(getKey()).compact();
}
public String extractUsername(String token) { return getClaims(token).getSubject(); }
public String extractRole(String token) { return getClaims(token).get("role", String.class); }
public boolean isTokenValid(String token) {
try { getClaims(token); return true; } catch (Exception e) { return false; }
}
private Claims getClaims(String token) {
return Jwts.parser().verifyWith((javax.crypto.SecretKey) getKey()).build().parseSignedClaims(token).getPayload();
}
}
Scroll up and confirm your JwtAuthenticationFilter.java, AuthController.java, AuthRequestDto.java, AuthResponseDto.java, and UserDetailsServiceImpl.java match the code blocks above.
Why JJWT instead of Spring Security OAuth2 Resource Server?
Spring Security's built-in OAuth2 resource server auto-configures token validation for you. That is powerful in production, but it hides how tokens actually work.
JJWT gives you hands-on experience creating, signing, and parsing tokens yourself. You learn the mechanics of JWT rather than relying on framework magic. This understanding transfers to any language or framework you use in the future.
Your API now has full JWT authentication with role-based access control. In the next step, you will add an AI-powered diagnostic endpoint that only mechanics and admins can access.
Integrate Google Gemini for AI-Powered Diagnostics
Your API handles CRUD operations and security. Mechanics can authenticate, receive a JWT token, and access protected endpoints. The foundation is solid.
Now you add the feature that makes this API stand out: an AI diagnostic endpoint powered by Google Gemini via Spring AI. Mechanics describe vehicle symptoms and the API returns structured repair suggestions from the AI model.
In this step, get ready to:
- Add the Spring AI dependency and configure the Gemini connection.
- Create the diagnostic DTOs and service layer.
- Build the diagnostic controller and test the AI endpoint in Postman.
Add the Spring AI dependency and configure Gemini
Spring AI provides a typed ChatClient API that abstracts the underlying model provider. You configure which provider to use in application.yml, and Spring Boot auto-configures the client for you.
Why Spring AI instead of raw HTTP calls?
Spring AI gives you a portable, type-safe ChatClient that works identically across providers. If you ever swap Gemini for another model, you change one config property instead of rewriting HTTP code.
- Add the Spring AI BOM and the Google GenAI starter dependency to your pom.xml by pasting this <dependencyManagement> block between the closing </version> tag and the opening <dependencies> tag:
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>2.0.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
What does this code do?
The <dependencyManagement> section imports the Spring AI BOM (Bill of Materials) at version 2.0.0. This locks all Spring AI module versions so you do not need to specify a version on each individual Spring AI dependency.
- Inside the existing <dependencies> section, add the Google GenAI starter after the last JJWT dependency:
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-google-genai</artifactId>
</dependency>
What does this dependency do?
The spring-ai-starter-model-google-genai starter auto-configures a ChatClient.Builder bean backed by the Gemini model. Spring Boot handles the HTTP transport and authentication for you.
- Save pom.xml. IntelliJ will show a floating notification to reload Maven changes.
- Click Load Maven Changes (or press Cmd+Shift+I on macOS / Ctrl+Shift+O on Windows).
- Wait for the Maven sync to finish. You should see spring-ai-starter-model-google-genai appear in the External Libraries section of the Project panel on the left.
Maven sync failed?
Make sure you placed the <dependencyManagement> block outside of <dependencies>, not inside it. The BOM import and the starter dependency live in different XML sections.
help me fix my pom.xml Maven sync error for Spring AI
- Open src/main/resources/application.yml and add the ai configuration block under the existing spring: section. Find the line that says show-sql: true and paste the following directly below it (indented two spaces under spring:):
ai:
google:
genai:
api-key: ${GEMINI_API_KEY}
chat:
options:
model: gemini-2.5-flash
What does this configuration do?
- api-key: ${GEMINI_API_KEY} tells Spring to read your Gemini API key from an environment variable instead of hardcoding it in the file.
- model: gemini-2.5-flash selects the Gemini 2.5 Flash model, which is fast and available on the free tier.
- Set the GEMINI_API_KEY environment variable in your IntelliJ run configuration. Click the GarageApiApplication dropdown in the top toolbar.
- Click Edit Configurations... from the dropdown menu.
- In the Environment variables field, enter GEMINI_API_KEY= followed by your actual Gemini API key (the one you obtained from aistudio.google.com/apikey earlier).
- Click OK to save.
- Restart the application by clicking the green Run button (or press Shift+F10).
The console should show Started GarageApiApplication with no errors. Spring AI is now auto-configured and ready to use.
Seeing a placeholder resolution error?
If the app fails with a message about resolving ${GEMINI_API_KEY}, your environment variable is not set correctly. Double-check the Environment variables field in your run configuration has no extra spaces around the = sign.
help me fix my GEMINI_API_KEY environment variable in IntelliJ
Create the diagnostic DTOs and service
The diagnostic feature needs a request DTO to carry the symptom description in, a response DTO to carry the AI suggestions out, and a service that talks to Gemini through Spring AI's ChatClient.
- Create a new file DiagnosticRequestDto.java in the src/main/java/com/nextwork/garageapi/dto package. Right-click the dto package in IntelliJ, select New then Java Class, and name it DiagnosticRequestDto. Paste this code:
package com.nextwork.garageapi.dto;
import jakarta.validation.constraints.NotBlank;
public class DiagnosticRequestDto {
// The symptom description from the mechanic
@NotBlank private String symptoms;
public String getSymptoms() { return symptoms; }
public void setSymptoms(String symptoms) { this.symptoms = symptoms; }
}
What does this code do?
This DTO wraps the mechanic's symptom description. The @NotBlank annotation ensures the request body must include a non-empty symptoms field. Spring returns a 400 error automatically if validation fails.
- Create another file DiagnosticResponseDto.java in the same dto package. Paste this code:
package com.nextwork.garageapi.dto;
public class DiagnosticResponseDto {
// The AI-generated diagnostic suggestions
private String suggestions;
public DiagnosticResponseDto(String suggestions) { this.suggestions = suggestions; }
public String getSuggestions() { return suggestions; }
}
What does this code do?
This DTO carries the AI response back to the client. The constructor accepts Gemini's text output and the getter serializes it to JSON automatically.
- Create DiagnosticService.java in the src/main/java/com/nextwork/garageapi/service package. Right-click the service package, select New then Java Class, name it DiagnosticService, and paste this code:
package com.nextwork.garageapi.service;
import com.nextwork.garageapi.dto.DiagnosticResponseDto;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.stereotype.Service;
@Service
public class DiagnosticService {
private final ChatClient chatClient;
// Inject the auto-configured builder and set the system prompt
public DiagnosticService(ChatClient.Builder builder) {
this.chatClient = builder
.defaultSystem("You are an expert automotive diagnostic assistant. When given vehicle symptoms, provide structured JSON with possible causes and recommended actions.")
.build();
}
// Send symptoms to Gemini and return the response
public DiagnosticResponseDto analyze(String symptoms) {
String response = chatClient.prompt()
.user(symptoms)
.call()
.content();
return new DiagnosticResponseDto(response);
}
}
What does this code do?
- The constructor injects Spring AI's auto-configured ChatClient.Builder and sets a system prompt that tells Gemini to act as an automotive diagnostic expert.
- defaultSystem(...) is a Spring AI method that pre-loads the system message for every request, so you only need to supply the user message at call time.
- The analyze method chains .prompt().user(symptoms).call().content() to send the symptoms to Gemini and extract the text response.
- Save all three files.
- Restart the application. The console should show Started GarageApiApplication with no errors, confirming the ChatClient bean was created successfully.
Seeing a bean creation error for ChatClient?
If the app fails to create the DiagnosticService bean, verify that the Maven sync completed successfully in the previous substep. The spring-ai-starter-model-google-genai library must be in your External Libraries.
help me fix my ChatClient bean creation error in Spring AI
Build the diagnostic controller and test it
The service is ready. Now you expose it through a REST endpoint that only mechanics and admins can access.
- Create DiagnosticController.java in the src/main/java/com/nextwork/garageapi/controller package. Right-click the controller package, select New then Java Class, name it DiagnosticController, and paste this code:
package com.nextwork.garageapi.controller;
import com.nextwork.garageapi.dto.*;
import com.nextwork.garageapi.service.DiagnosticService;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/diagnostics")
public class DiagnosticController {
private final DiagnosticService diagnosticService;
public DiagnosticController(DiagnosticService diagnosticService) {
this.diagnosticService = diagnosticService;
}
// POST /api/diagnostics/analyze - restricted to MECHANIC and ADMIN
@PostMapping("/analyze")
public ResponseEntity<DiagnosticResponseDto> analyze(@Valid @RequestBody DiagnosticRequestDto dto) {
return ResponseEntity.ok(diagnosticService.analyze(dto.getSymptoms()));
}
}
What does this code do?
- The @RequestMapping("/api/diagnostics") base path combined with @PostMapping("/analyze") creates the endpoint POST /api/diagnostics/analyze.
- The endpoint is already protected by your SecurityConfig from the previous step, which restricts /api/diagnostics/** to users with MECHANIC or ADMIN roles.
- @Valid triggers Bean Validation, so empty symptom descriptions are rejected with a 400 error before reaching the service.
- Save DiagnosticController.java and restart the application.
Before you test this, predict what will happen: do you think Gemini will return a structured diagnostic response, or will the request fail?
- Switch to Postman. Create a new POST request with URL:
http://localhost:8080/api/diagnostics/analyze
- Go to the Authorization tab. Select Bearer Token and paste the MECHANIC JWT token you already have from the previous step.
- Go to the Body tab. Select raw and set the type to JSON. Enter this request body:
{
"symptoms": "Engine makes knocking sound at high RPM"
}
- Click Send.
You should receive a 200 OK response with a JSON body containing a suggestions field. Gemini returns possible causes (worn bearings, low oil pressure, pre-ignition) and recommended actions in structured text.
What should I see?
The response looks something like this (the exact wording varies each time because Gemini generates fresh output):
{
"suggestions": "{\"possible_causes\": [...], \"recommended_actions\": [...]}"
}
The suggestions field contains Gemini's full text output. Because you told the model to respond with structured JSON, the content should include causes and actions.
Getting a 401 Unauthorized?
Your JWT token may have expired (tokens last 24 hours). Log in again by sending a POST to /api/auth/login with your MECHANIC credentials and copy the new token into the Authorization header.
help me debug my 401 error on the diagnostics endpoint
Getting a 403 Forbidden?
A 403 means your token is valid but lacks the MECHANIC or ADMIN role. Make sure you registered as a MECHANIC user. Check the role in your login request body.
help me debug my 403 error on the diagnostics endpoint
✔️ Awesome, I've got everything!
Great. Double-check you have saved all files and your app is running without errors.
ⓧ I'd like to double check the full code
Here is the full application.yml for comparison:
spring:
datasource:
url: jdbc:mysql://localhost:3306/garage_api
username: root
password: your_password
driver-class-name: com.mysql.cj.jdbc.Driver
jpa:
hibernate:
ddl-auto: update
show-sql: true
ai:
google:
genai:
api-key: ${GEMINI_API_KEY}
chat:
options:
model: gemini-2.5-flash
logging:
level:
root: INFO
com.nextwork: DEBUG
app:
jwt:
secret: your-256-bit-secret-key-here-make-it-long
expiration: 86400000
For pom.xml, scroll up and confirm you have:
- A <dependencyManagement> block with spring-ai-bom version 2.0.0 between the project coordinates and the dependencies section.
- A <dependency> for spring-ai-starter-model-google-genai (no version tag needed) inside <dependencies>.
For the new Java files, scroll up and confirm you have all three: DiagnosticRequestDto.java, DiagnosticResponseDto.java, DiagnosticService.java, and DiagnosticController.java.
Your API now combines security with AI intelligence. Authenticated mechanics can describe any vehicle symptom and receive expert-level diagnostic suggestions. Next up, the Secret Mission challenges you to generate QR codes linking to a vehicle's service history.
Secret mission
QR Code Generation for Vehicle Service History
Your API handles CRUD, authentication, and AI diagnostics. But what if a customer could scan a physical sticker on their vehicle and instantly see its full service history? In this secret mission, you will integrate the ZXing barcode library to generate QR code images directly from your API.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally with a free-tier API key, so there are no ongoing costs.
Resources you used:
- MySQL database garage_api with tables (users, vehicles, customers, service_records).
- Gemini API key (free tier, 1,500 requests/day, resets daily).
- Spring Boot project files on your local machine.
Keep everything running
No action needed. Choose this if you are still actively building or plan to extend this project further.
- Leave MySQL running locally. Your garage_api database persists between restarts.
- Your Gemini API key stays active on the free tier indefinitely. No charges accrue.
- Your Spring Boot project files remain on disk, ready to open in IntelliJ whenever you want.
Pause - I'll come back to this later
Shut down running processes to free up memory. Your data and files stay intact for when you return.
- Stop the Spring Boot application in IntelliJ by clicking the red square Stop button in the Run panel.
- Your MySQL data persists on disk even if you stop the MySQL service.
- Your Gemini API key remains valid. No cost accrues while the app is not running.
Delete - I don't want to use this again
Remove all project resources and start fresh. This is irreversible.
- Stop the Spring Boot application in IntelliJ.
- Drop the MySQL database by opening a MySQL shell and running:
mysql -u root -p -e "DROP DATABASE IF EXISTS garage_api;"
- Delete your project folder. On macOS/Linux:
rm -rf ~/Desktop/garage-api
- On Windows:
Remove-Item -Recurse -Force ~\Desktop\garage-api
- Optionally delete your Gemini API key. Go to aistudio.google.com/apikey, locate the key you created for this project, and click the delete icon next to it.
Nice Work!
Nice Work!
You just built a production-grade REST API that authenticates users with JWT tokens, enforces role-based access control, and calls Google Gemini for AI-powered vehicle diagnostics.
You've learned how to:
- Build a Spring Boot 4.1.0 REST API with full CRUD operations, the DTO pattern, Bean Validation, and centralized exception handling.
- Implement JWT authentication using JJWT 0.13.0 with role-based access control across ADMIN, MECHANIC, and CUSTOMER roles.
- Integrate Google Gemini AI via Spring AI 2.0.0 to power an intelligent diagnostic suggestion endpoint that returns structured repair recommendations.
- Secret Mission: Generated QR codes with ZXing 3.5.4 for vehicle service history links.
Ready to quiz yourself?