1. What is REST? Principles and Constraints
In Lecture 1, we briefly mentioned REST APIs. Now let us dive deeper into what REST actually means and why it is the dominant architecture for web APIs today.
REST — Representational State Transfer
REST was defined by Roy Fielding in his 2000 doctoral dissertation. It is not a protocol, not a standard, and not a library. It is an architectural style — a set of principles for designing networked applications.
When we say “REST API” or “RESTful API,” we mean an API that follows these principles.
The Six Constraints of REST
REST defines six constraints. An API that follows all of them is considered “RESTful.”
1. Client-Server Separation
The client and the server are independent. The client does not know how the server stores data. The server does not know how the client displays data. They communicate only through HTTP requests and responses.
This is exactly what we discussed in Lecture 1. Your Spring Boot server returns JSON data. A React app, an Android app, or a curl command can all consume that data differently.
2. Statelessness
Every request from the client must contain all the information the server needs to process it. The server does not store any client state between requests.
This means: no server-side sessions (in the traditional sense). If the client needs to be authenticated, it must send its credentials or token with every request. We will implement this with JWT tokens in Lecture 12.
# Each request is independent — the server does not remember previous requests
GET /api/users/1 → Server responds with user data
GET /api/users/1 → Server responds the same way (it did not "remember" the first request)
Why is this important? Statelessness makes your API scalable. If you have 10 servers behind a load balancer, any server can handle any request because no server holds client-specific state.
3. Cacheability
Responses should indicate whether they can be cached. If a response is cacheable, the client can reuse it for future identical requests without hitting the server again. HTTP headers like Cache-Control, ETag, and Last-Modified control caching behavior.
4. Uniform Interface
This is the most important constraint. The API should have a consistent, predictable structure. REST achieves this through:
- Resource identification through URIs: Every entity is a “resource” with a unique URL.
- Manipulation through representations: Resources are manipulated by sending representations (usually JSON) in HTTP requests.
- Self-descriptive messages: Each request/response contains enough information to understand itself (HTTP method, status code, Content-Type header).
- HATEOAS (Hypermedia as the Engine of Application State): Responses include links to related resources. In practice, many APIs skip this constraint.
5. Layered System
The client does not know whether it is communicating directly with the server or through an intermediary (a load balancer, a CDN, a proxy). Each layer only knows about the layer it directly interacts with.
6. Code on Demand (Optional)
The server can extend client functionality by sending executable code (like JavaScript). This constraint is optional and rarely used in REST APIs.
Resources: The Core of REST
In REST, everything is a resource. A resource is any entity that can be named and addressed:
- A user →
/api/users/42 - A blog post →
/api/posts/15 - A comment on a post →
/api/posts/15/comments/3 - All tags →
/api/tags
Resources are identified by URIs (Uniform Resource Identifiers). The URI tells you what resource you want. The HTTP method tells you what you want to do with it.
REST is Not the Only Option
It is worth knowing that REST is not the only approach to building APIs. Alternatives include:
- GraphQL — The client specifies exactly what data it needs. Popular with complex frontends.
- gRPC — A high-performance binary protocol. Popular for server-to-server communication.
- WebSocket — Full-duplex communication for real-time applications (chat, live notifications).
REST remains the most popular choice for web APIs due to its simplicity and the ubiquity of HTTP. For this series, we focus entirely on REST.
2. HTTP Methods Mapped to CRUD Operations
REST uses HTTP methods to express what action you want to perform on a resource. The four main methods map directly to CRUD (Create, Read, Update, Delete) operations:
| HTTP Method | CRUD Operation | Description | Example |
|---|---|---|---|
POST | Create | Create a new resource | POST /api/posts — Create a new blog post |
GET | Read | Retrieve a resource | GET /api/posts/5 — Get post with ID 5 |
PUT | Update | Replace an entire resource | PUT /api/posts/5 — Replace post 5 with new data |
PATCH | Update (partial) | Update part of a resource | PATCH /api/posts/5 — Update only the title of post 5 |
DELETE | Delete | Remove a resource | DELETE /api/posts/5 — Delete post 5 |
Additional HTTP Methods
There are two more methods you should know about:
HEAD— Same asGET, but returns only headers (no body). Used to check if a resource exists or to get metadata.OPTIONS— Returns the allowed HTTP methods for a URL. Used by browsers for CORS preflight requests.
Safe and Idempotent Methods
Two important properties distinguish HTTP methods:
Safe methods do not modify the resource. GET and HEAD are safe — calling them should never change data on the server.
Idempotent methods produce the same result no matter how many times you call them:
| Method | Safe? | Idempotent? | Explanation |
|---|---|---|---|
GET | Yes | Yes | Reading data never changes anything |
POST | No | No | Each call creates a new resource (calling twice = two resources) |
PUT | No | Yes | Replacing the same resource with the same data gives the same result |
PATCH | No | No* | Depends on the implementation |
DELETE | No | Yes | Deleting an already-deleted resource has no further effect |
Understanding idempotency matters for error handling. If a PUT request times out, the client can safely retry it — the result will be the same. If a POST request times out, retrying might create a duplicate resource.
URL Design Best Practices
REST URLs should follow these conventions:
# Use nouns, not verbs — the HTTP method IS the verb
✅ GET /api/posts (get all posts)
❌ GET /api/getPosts (redundant — GET already means "get")
❌ GET /api/posts/getAll (redundant)
# Use plural nouns for collections
✅ /api/posts
✅ /api/users
❌ /api/post (singular feels inconsistent)
# Use path variables for specific resources
✅ GET /api/posts/42 (get post with ID 42)
# Use query parameters for filtering, sorting, and pagination
✅ GET /api/posts?status=published&sort=date&page=2
# Use nesting for sub-resources (relationships)
✅ GET /api/posts/42/comments (all comments on post 42)
✅ GET /api/posts/42/comments/7 (comment 7 on post 42)
# Keep URLs lowercase, use hyphens for multi-word resources
✅ /api/blog-posts
❌ /api/BlogPosts
❌ /api/blog_posts
3. @RestController vs @Controller
In Lecture 2, we mentioned that @RestController combines @Controller and @ResponseBody. Let us understand the difference in depth.
@Controller — For Server-Rendered Web Pages
@Controller is used when your server generates HTML pages. The return value of a method is treated as a view name — Spring looks for an HTML template with that name and renders it.
@Controller
public class PageController {
// Spring looks for a template named "home" (e.g., home.html in the templates folder)
// and renders it as an HTML page.
@GetMapping("/home")
public String homePage(Model model) {
model.addAttribute("title", "Welcome to My Blog");
model.addAttribute("posts", postService.getRecentPosts());
return "home"; // This is a VIEW NAME, not data
}
}
This approach is called server-side rendering (SSR). The server generates the complete HTML and sends it to the browser. Technologies like Thymeleaf, FreeMarker, and JSP work with @Controller.
@RestController — For REST APIs
@RestController is used when your server returns data (usually JSON). The return value of a method is written directly to the HTTP response body.
@RestController
public class PostApiController {
// The return value (a List of Post objects) is automatically
// serialized to JSON and written to the response body.
@GetMapping("/api/posts")
public List<Post> getAllPosts() {
return postService.getAllPosts(); // This is DATA, not a view name
}
}
The Equivalence
@RestController is literally a shortcut. These two examples are identical:
// Using @RestController (the shortcut)
@RestController
public class PostApiController {
@GetMapping("/api/posts")
public List<Post> getAllPosts() {
return postService.getAllPosts();
}
}
// Using @Controller + @ResponseBody (the long way)
@Controller
@ResponseBody
public class PostApiController {
@GetMapping("/api/posts")
public List<Post> getAllPosts() {
return postService.getAllPosts();
}
}
You can also apply @ResponseBody to individual methods if you need both views and data in the same controller:
@Controller
public class HybridController {
// Returns an HTML view
@GetMapping("/dashboard")
public String dashboard() {
return "dashboard";
}
// Returns JSON data (because of @ResponseBody)
@GetMapping("/api/stats")
@ResponseBody
public Map<String, Object> getStats() {
return Map.of("users", 150, "posts", 340);
}
}
Which One for This Series?
We are building a REST API, so we will use @RestController exclusively. Our server returns JSON data, and we leave the UI to be built by a separate frontend application (React, Angular, Vue, or a mobile app).
4. Request Mapping — @GetMapping, @PostMapping, @PutMapping, @DeleteMapping
The Basics
Spring MVC provides shortcut annotations for mapping HTTP methods to handler methods:
@RestController
@RequestMapping("/api/posts") // Base path for all methods in this controller
public class PostController {
@GetMapping // GET /api/posts
public List<Post> getAll() { ... }
@GetMapping("/{id}") // GET /api/posts/42
public Post getById(@PathVariable Long id) { ... }
@PostMapping // POST /api/posts
public Post create(@RequestBody Post post) { ... }
@PutMapping("/{id}") // PUT /api/posts/42
public Post update(@PathVariable Long id, @RequestBody Post post) { ... }
@PatchMapping("/{id}") // PATCH /api/posts/42
public Post partialUpdate(@PathVariable Long id, @RequestBody Map<String, Object> updates) { ... }
@DeleteMapping("/{id}") // DELETE /api/posts/42
public void delete(@PathVariable Long id) { ... }
}
@RequestMapping — The Parent Annotation
All the shortcut annotations are specializations of @RequestMapping:
// These are equivalent:
@GetMapping("/api/posts")
@RequestMapping(value = "/api/posts", method = RequestMethod.GET)
// These are equivalent:
@PostMapping("/api/posts")
@RequestMapping(value = "/api/posts", method = RequestMethod.POST)
The shortcut annotations are more readable, so always prefer them. Use @RequestMapping at the class level to set a base path for all methods in the controller.
Multiple Paths
A single method can handle multiple paths:
// This method handles both /api/posts and /api/articles
@GetMapping({"/api/posts", "/api/articles"})
public List<Post> getAll() { ... }
Consumes and Produces
You can restrict which content types a method accepts and returns:
// Only accepts JSON input and returns JSON output
@PostMapping(
value = "/api/posts",
consumes = "application/json", // Only accepts JSON request body
produces = "application/json" // Only returns JSON responses
)
public Post create(@RequestBody Post post) { ... }
In practice, Spring Boot defaults to JSON for @RestController, so you rarely need to specify these explicitly.
5. Path Variables and Query Parameters
There are two main ways to pass data in a URL: path variables and query parameters. Understanding when to use each is essential for good API design.
Path Variables — Identifying a Specific Resource
Path variables are part of the URL path itself. They are used to identify a specific resource.
GET /api/users/42 → 42 is a path variable (the user's ID)
GET /api/posts/15/comments → 15 is a path variable (the post's ID)
In Spring, use @PathVariable to extract them:
@RestController
@RequestMapping("/api/users")
public class UserController {
// Single path variable
// URL: GET /api/users/42
@GetMapping("/{id}")
public User getUser(@PathVariable Long id) {
// id = 42
return userService.getUserById(id);
}
// Multiple path variables
// URL: GET /api/users/42/posts/15
@GetMapping("/{userId}/posts/{postId}")
public Post getUserPost(
@PathVariable Long userId,
@PathVariable Long postId) {
// userId = 42, postId = 15
return postService.getPostByUserAndId(userId, postId);
}
// Path variable with a different name than the method parameter
// URL: GET /api/users/42
@GetMapping("/{user_id}")
public User getUser(@PathVariable("user_id") Long userId) {
// The @PathVariable annotation maps {user_id} in the URL
// to the userId parameter in Java
return userService.getUserById(userId);
}
}
Query Parameters — Filtering, Sorting, Pagination
Query parameters come after the ? in a URL. They are used for optional modifiers like filtering, sorting, and pagination.
GET /api/posts?status=published → filter by status
GET /api/posts?sort=date&order=desc → sort by date descending
GET /api/posts?page=2&size=10 → pagination
GET /api/posts?status=published&sort=date → combine multiple parameters
In Spring, use @RequestParam to extract them:
@RestController
@RequestMapping("/api/posts")
public class PostController {
// Required query parameter
// URL: GET /api/posts/search?keyword=spring
// If keyword is missing, Spring returns 400 Bad Request
@GetMapping("/search")
public List<Post> search(@RequestParam String keyword) {
return postService.searchByKeyword(keyword);
}
// Optional query parameter with a default value
// URL: GET /api/posts?page=2&size=20
// URL: GET /api/posts (uses defaults: page=0, size=10)
@GetMapping
public List<Post> getAllPosts(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size) {
return postService.getPosts(page, size);
}
// Explicitly optional parameter (can be null)
// URL: GET /api/posts/filter?status=published
// URL: GET /api/posts/filter (status will be null)
@GetMapping("/filter")
public List<Post> filterPosts(
@RequestParam(required = false) String status,
@RequestParam(required = false) String category) {
return postService.filterPosts(status, category);
}
// Receiving a list of values
// URL: GET /api/posts/by-tags?tags=java&tags=spring&tags=boot
// Also works: GET /api/posts/by-tags?tags=java,spring,boot
@GetMapping("/by-tags")
public List<Post> getPostsByTags(@RequestParam List<String> tags) {
// tags = ["java", "spring", "boot"]
return postService.getPostsByTags(tags);
}
}
When to Use Which?
The rule of thumb is simple:
| Use Case | Approach | Example |
|---|---|---|
| Identify a specific resource | Path variable | GET /api/users/42 |
| Required identifier | Path variable | GET /api/posts/15/comments/3 |
| Filtering / searching | Query parameter | GET /api/posts?status=draft |
| Sorting | Query parameter | GET /api/posts?sort=date |
| Pagination | Query parameter | GET /api/posts?page=2&size=10 |
| Optional modifiers | Query parameter | GET /api/users?role=admin |
Think of it this way: path variables identify which resource, query parameters specify how you want it.
6. Request Body & @RequestBody
For POST and PUT requests, data is sent in the request body, not in the URL. The request body typically contains a JSON representation of the resource you want to create or update.
How @RequestBody Works
@RequestBody tells Spring to read the HTTP request body and convert it into a Java object. This conversion is handled by Jackson (the JSON library included in spring-boot-starter-web).
@RestController
@RequestMapping("/api/posts")
public class PostController {
// The client sends JSON in the request body:
// {
// "title": "Learning Spring Boot",
// "content": "Spring Boot makes Java web development easy...",
// "category": "TUTORIAL"
// }
//
// Spring reads this JSON, creates a Post object, and populates its fields.
// This process is called DESERIALIZATION (JSON → Java object).
@PostMapping
public Post createPost(@RequestBody Post post) {
// At this point, post.getTitle() = "Learning Spring Boot"
// post.getContent() = "Spring Boot makes Java web development easy..."
// post.getCategory() = "TUTORIAL"
return postService.createPost(post);
}
}
The Deserialization Process
When Jackson converts JSON to a Java object, it follows these rules:
- Jackson creates an instance of the target class using the default (no-argument) constructor.
- Jackson matches JSON field names to Java field names (or getter/setter names).
- Jackson sets each field using the corresponding setter method.
This means your class needs:
public class Post {
private Long id;
private String title;
private String content;
private String category;
// Default constructor — REQUIRED for Jackson deserialization
public Post() {
}
// Getters — REQUIRED for Jackson serialization (Java → JSON)
public Long getId() { return id; }
public String getTitle() { return title; }
public String getContent() { return content; }
public String getCategory() { return category; }
// Setters — REQUIRED for Jackson deserialization (JSON → Java)
public void setId(Long id) { this.id = id; }
public void setTitle(String title) { this.title = title; }
public void setContent(String content) { this.content = content; }
public void setCategory(String category) { this.category = category; }
}
Note: In Lecture 8, we will introduce DTOs (Data Transfer Objects) to separate the JSON structure from our internal model. For now, we use the model class directly.
Using @RequestBody with a Map
For simple cases, you can use a Map instead of creating a dedicated class:
@PostMapping("/api/feedback")
public Map<String, String> submitFeedback(@RequestBody Map<String, String> feedback) {
String name = feedback.get("name");
String message = feedback.get("message");
// Process feedback...
return Map.of("status", "received");
}
This is convenient for quick prototyping but lacks type safety. For production code, always create proper classes.
What Happens When JSON Does Not Match?
If the client sends JSON with extra fields, Jackson ignores them by default:
// Client sends:
{
"title": "My Post",
"content": "Hello",
"unknownField": "this will be ignored"
}
// Jackson maps title and content, ignores unknownField. No error.
If the client sends JSON with missing fields, those fields will be null (for objects) or their default value (for primitives):
// Client sends:
{
"title": "My Post"
}
// Jackson maps title = "My Post", content = null, category = null
You can configure Jackson to be stricter, but the default lenient behavior is usually what you want.
Combining @RequestBody with @PathVariable
It is common to combine path variables with a request body, especially for update operations:
// PUT /api/posts/42
// Request body: { "title": "Updated Title", "content": "Updated content" }
@PutMapping("/{id}")
public Post updatePost(
@PathVariable Long id, // From the URL path
@RequestBody Post updatedPost) { // From the request body
return postService.updatePost(id, updatedPost);
}
7. Response Entity and HTTP Status Codes
Why Status Codes Matter
HTTP status codes tell the client what happened with their request. Using the correct status code is a fundamental part of building a proper REST API.
Common Status Codes
Here are the status codes you will use most often:
2xx — Success:
| Code | Name | When to Use |
|---|---|---|
| 200 | OK | General success. Use for GET, PUT, PATCH |
| 201 | Created | A new resource was created. Use for POST |
| 204 | No Content | Success, but nothing to return. Use for DELETE |
4xx — Client Error:
| Code | Name | When to Use |
|---|---|---|
| 400 | Bad Request | The request data is invalid (validation failure) |
| 401 | Unauthorized | Authentication required (not logged in) |
| 403 | Forbidden | Authenticated but not authorized (no permission) |
| 404 | Not Found | The requested resource does not exist |
| 405 | Method Not Allowed | HTTP method not supported for this URL |
| 409 | Conflict | Resource conflict (e.g., duplicate email) |
| 422 | Unprocessable Entity | Request is well-formed but semantically invalid |
5xx — Server Error:
| Code | Name | When to Use |
|---|---|---|
| 500 | Internal Server Error | Unexpected error on the server |
| 503 | Service Unavailable | Server is temporarily overloaded or under maintenance |
Returning Status Codes with ResponseEntity
By default, Spring returns 200 OK for all successful responses. To return a different status code, use ResponseEntity:
@RestController
@RequestMapping("/api/posts")
public class PostController {
private final PostService postService;
public PostController(PostService postService) {
this.postService = postService;
}
// GET /api/posts — Returns 200 OK with the list
// Spring returns 200 by default, so no ResponseEntity needed here
@GetMapping
public List<Post> getAllPosts() {
return postService.getAllPosts();
}
// GET /api/posts/42 — Returns 200 OK or 404 Not Found
@GetMapping("/{id}")
public ResponseEntity<Post> getPostById(@PathVariable Long id) {
// Try to find the post
return postService.findById(id)
// If found, return 200 OK with the post
.map(post -> ResponseEntity.ok(post))
// If not found, return 404 Not Found with no body
.orElse(ResponseEntity.notFound().build());
}
// POST /api/posts — Returns 201 Created with the created post
@PostMapping
public ResponseEntity<Post> createPost(@RequestBody Post post) {
Post created = postService.createPost(post);
// Return 201 Created with the created post in the body
return ResponseEntity
.status(HttpStatus.CREATED)
.body(created);
}
// PUT /api/posts/42 — Returns 200 OK with the updated post
@PutMapping("/{id}")
public ResponseEntity<Post> updatePost(
@PathVariable Long id,
@RequestBody Post post) {
try {
Post updated = postService.updatePost(id, post);
return ResponseEntity.ok(updated);
} catch (RuntimeException e) {
return ResponseEntity.notFound().build();
}
}
// DELETE /api/posts/42 — Returns 204 No Content
@DeleteMapping("/{id}")
public ResponseEntity<Void> deletePost(@PathVariable Long id) {
try {
postService.deletePost(id);
// 204 No Content — successful deletion with no response body
return ResponseEntity.noContent().build();
} catch (RuntimeException e) {
return ResponseEntity.notFound().build();
}
}
}
ResponseEntity Builder Methods
ResponseEntity provides a fluent builder API for common status codes:
// 200 OK with body
ResponseEntity.ok(body)
// 200 OK with no body
ResponseEntity.ok().build()
// 201 Created with body
ResponseEntity.status(HttpStatus.CREATED).body(createdObject)
// 204 No Content
ResponseEntity.noContent().build()
// 400 Bad Request with body
ResponseEntity.badRequest().body(errorObject)
// 404 Not Found
ResponseEntity.notFound().build()
// Any status code
ResponseEntity.status(HttpStatus.CONFLICT).body(errorObject)
ResponseEntity.status(409).body(errorObject)
Adding Custom Headers
ResponseEntity also lets you add custom HTTP headers to the response:
@PostMapping
public ResponseEntity<Post> createPost(@RequestBody Post post) {
Post created = postService.createPost(post);
// Build a URI for the created resource
URI location = URI.create("/api/posts/" + created.getId());
return ResponseEntity
.created(location) // 201 Created + Location header
.header("X-Custom-Header", "my-value") // Custom header
.body(created);
}
The Location header is a REST convention for POST requests — it tells the client where to find the newly created resource.
8. Content Negotiation (JSON by Default with Jackson)
What is Content Negotiation?
Content negotiation is the process by which the client and server agree on the format of the data being exchanged. The client uses the Accept header to say what format it wants, and the Content-Type header to say what format it is sending.
# Client says: "I want JSON back" and "I'm sending JSON"
GET /api/posts HTTP/1.1
Accept: application/json
Content-Type: application/json
Jackson — The Default JSON Library
Spring Boot includes Jackson automatically with spring-boot-starter-web. Jackson handles:
- Serialization: Converting Java objects → JSON (for responses)
- Deserialization: Converting JSON → Java objects (for request bodies)
You have already been using Jackson without realizing it. Every time you return a Java object from a @RestController method, Jackson serializes it to JSON.
How Jackson Serializes Objects
Jackson uses getters to determine what fields to include in the JSON output:
public class Post {
private Long id;
private String title;
private String content;
private LocalDateTime createdAt;
// Jackson calls getId(), getTitle(), getContent(), getCreatedAt()
// and creates a JSON object with those field names
public Long getId() { return id; }
public String getTitle() { return title; }
public String getContent() { return content; }
public LocalDateTime getCreatedAt() { return createdAt; }
}
This produces:
{
"id": 1,
"title": "My First Post",
"content": "Hello World",
"createdAt": "2024-01-15T10:30:00"
}
Customizing JSON Output with Annotations
Jackson provides annotations to control serialization and deserialization:
public class Post {
private Long id;
private String title;
private String content;
private String internalNotes;
private LocalDateTime createdAt;
// @JsonProperty — changes the JSON field name.
// Java uses camelCase, but your API might need snake_case.
@JsonProperty("created_at")
public LocalDateTime getCreatedAt() { return createdAt; }
// @JsonIgnore — excludes this field from JSON completely.
// Use for sensitive or internal data.
@JsonIgnore
public String getInternalNotes() { return internalNotes; }
// Other getters...
public Long getId() { return id; }
public String getTitle() { return title; }
public String getContent() { return content; }
}
This produces:
{
"id": 1,
"title": "My First Post",
"content": "Hello World",
"created_at": "2024-01-15T10:30:00"
}
// Notice: "internalNotes" is NOT included (because of @JsonIgnore)
// Notice: "createdAt" is serialized as "created_at" (because of @JsonProperty)
More useful Jackson annotations:
// @JsonInclude — controls when null fields appear in JSON
@JsonInclude(JsonInclude.Include.NON_NULL)
public class Post {
// Fields with null values will be omitted from the JSON output
}
// @JsonFormat — controls date/time formatting
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime createdAt;
// Produces: "createdAt": "2024-01-15 10:30:00"
// @JsonIgnoreProperties — ignore unknown fields during deserialization
@JsonIgnoreProperties(ignoreUnknown = true)
public class Post {
// If the client sends extra fields not in this class, Jackson ignores them
// (This is actually the default behavior, but you can be explicit)
}
Global Jackson Configuration
You can configure Jackson globally in application.properties:
# Use snake_case for ALL JSON field names (instead of annotating every field)
spring.jackson.property-naming-strategy=SNAKE_CASE
# Pretty-print JSON (useful for development, disable in production)
spring.jackson.serialization.indent-output=true
# Do not fail on unknown properties (this is the default)
spring.jackson.deserialization.fail-on-unknown-properties=false
# Format dates globally
spring.jackson.date-format=yyyy-MM-dd HH:mm:ss
spring.jackson.time-zone=UTC
Returning XML (If Needed)
Although JSON is the standard for modern REST APIs, Spring can also return XML. Add this dependency:
<dependency>
<groupId>com.fasterxml.jackson.dataformat</groupId>
<artifactId>jackson-dataformat-xml</artifactId>
</dependency>
Now, if a client sends Accept: application/xml, Spring returns XML automatically. If the client sends Accept: application/json (or no Accept header), Spring returns JSON. This is content negotiation in action.
For this series, we only use JSON. No additional configuration needed.
9. API Versioning Strategies
As your API evolves, you will need to make breaking changes — renaming fields, changing response structures, removing endpoints. If clients are already using your API, these changes will break their applications. API versioning lets you introduce changes without breaking existing clients.
Strategy 1: URL Path Versioning (Most Common)
Include the version number in the URL path:
// Version 1 — returns basic user info
@RestController
@RequestMapping("/api/v1/users")
public class UserControllerV1 {
@GetMapping("/{id}")
public Map<String, Object> getUser(@PathVariable Long id) {
// V1 response format
return Map.of(
"id", id,
"name", "Alice",
"email", "alice@example.com"
);
}
}
// Version 2 — returns extended user info with different structure
@RestController
@RequestMapping("/api/v2/users")
public class UserControllerV2 {
@GetMapping("/{id}")
public Map<String, Object> getUser(@PathVariable Long id) {
// V2 response format — different structure
return Map.of(
"id", id,
"profile", Map.of(
"firstName", "Alice",
"lastName", "Smith",
"email", "alice@example.com"
),
"metadata", Map.of(
"createdAt", "2024-01-15",
"lastLogin", "2024-06-20"
)
);
}
}
Clients choose which version to use:
GET /api/v1/users/42 → old format
GET /api/v2/users/42 → new format
This is the most popular approach because it is simple and visible.
Strategy 2: Request Header Versioning
Use a custom header to specify the version:
@RestController
@RequestMapping("/api/users")
public class UserController {
// Matches when X-API-Version header is "1"
@GetMapping(value = "/{id}", headers = "X-API-Version=1")
public Map<String, Object> getUserV1(@PathVariable Long id) {
return Map.of("name", "Alice", "email", "alice@example.com");
}
// Matches when X-API-Version header is "2"
@GetMapping(value = "/{id}", headers = "X-API-Version=2")
public Map<String, Object> getUserV2(@PathVariable Long id) {
return Map.of("profile", Map.of("firstName", "Alice", "lastName", "Smith"));
}
}
Client usage:
GET /api/users/42
X-API-Version: 2
Strategy 3: Query Parameter Versioning
Use a query parameter for the version:
@RestController
@RequestMapping("/api/users")
public class UserController {
@GetMapping(value = "/{id}", params = "version=1")
public Map<String, Object> getUserV1(@PathVariable Long id) { ... }
@GetMapping(value = "/{id}", params = "version=2")
public Map<String, Object> getUserV2(@PathVariable Long id) { ... }
}
Client usage:
GET /api/users/42?version=2
Which Strategy to Choose?
| Strategy | Pros | Cons |
|---|---|---|
URL Path (/v1/, /v2/) | Simple, visible, easy to test | URL changes for every version |
Header (X-API-Version) | Clean URLs | Hidden, harder to test in browser |
Query Param (?version=2) | Easy to switch | Pollutes query parameters |
For this series and for most projects, URL path versioning is recommended. It is the most widely used approach and the easiest to understand and implement.
Practical Advice
In real projects, try to minimize the need for versioning:
- Add fields instead of removing them — adding new fields is not a breaking change.
- Use optional fields — make new fields optional so old clients are not affected.
- Deprecate before removing — give clients time to migrate.
- Version when you must — only create a new version for genuinely breaking changes.
10. Hands-on: Build a Complete CRUD REST API (In-Memory, No Database Yet)
Let us apply everything from this lecture to build a full-featured blog post API. We will use the layered architecture from Lecture 2 (Controller → Service → Repository) and implement all CRUD operations with proper HTTP methods and status codes.
Step 1: The Post Model
File: src/main/java/com/example/blogapi/model/Post.java
package com.example.blogapi.model;
import com.fasterxml.jackson.annotation.JsonInclude;
import java.time.LocalDateTime;
// @JsonInclude(NON_NULL) means: if a field is null, do not include it
// in the JSON response. This keeps responses clean.
@JsonInclude(JsonInclude.Include.NON_NULL)
public class Post {
private Long id;
private String title;
private String content;
private String category;
private String status; // "DRAFT" or "PUBLISHED"
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
// Default constructor for Jackson deserialization
public Post() {
}
// Constructor for convenience when creating posts in code
public Post(String title, String content, String category) {
this.title = title;
this.content = content;
this.category = category;
this.status = "DRAFT";
this.createdAt = LocalDateTime.now();
}
// --- Getters and Setters ---
// Jackson uses getters for serialization (Java → JSON)
// and setters for deserialization (JSON → Java)
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public String getContent() { return content; }
public void setContent(String content) { this.content = content; }
public String getCategory() { return category; }
public void setCategory(String category) { this.category = category; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
public LocalDateTime getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
@Override
public String toString() {
return "Post{id=" + id + ", title='" + title + "', status='" + status + "'}";
}
}
Step 2: The Post Repository
File: src/main/java/com/example/blogapi/repository/PostRepository.java
package com.example.blogapi.repository;
import com.example.blogapi.model.Post;
import org.springframework.stereotype.Repository;
import java.util.*;
import java.util.stream.Collectors;
@Repository
public class PostRepository {
// In-memory storage — simulates a database table.
// We use a Map instead of a List for efficient lookup by ID.
// In Lecture 5, this will be replaced by Spring Data JPA.
private final Map<Long, Post> posts = new HashMap<>();
private long nextId = 1;
// CREATE — equivalent to SQL: INSERT INTO posts (...) VALUES (...)
public Post save(Post post) {
if (post.getId() == null) {
// New post — assign an ID
post.setId(nextId++);
}
// Store or update the post in our "database"
posts.put(post.getId(), post);
return post;
}
// READ ONE — equivalent to SQL: SELECT * FROM posts WHERE id = ?
public Optional<Post> findById(Long id) {
return Optional.ofNullable(posts.get(id));
}
// READ ALL — equivalent to SQL: SELECT * FROM posts
public List<Post> findAll() {
return new ArrayList<>(posts.values());
}
// READ FILTERED — equivalent to SQL: SELECT * FROM posts WHERE status = ?
public List<Post> findByStatus(String status) {
return posts.values().stream()
.filter(post -> post.getStatus().equalsIgnoreCase(status))
.collect(Collectors.toList());
}
// READ FILTERED — equivalent to SQL: SELECT * FROM posts WHERE category = ?
public List<Post> findByCategory(String category) {
return posts.values().stream()
.filter(post -> post.getCategory().equalsIgnoreCase(category))
.collect(Collectors.toList());
}
// SEARCH — equivalent to SQL: SELECT * FROM posts
// WHERE LOWER(title) LIKE '%keyword%' OR LOWER(content) LIKE '%keyword%'
public List<Post> searchByKeyword(String keyword) {
String lowerKeyword = keyword.toLowerCase();
return posts.values().stream()
.filter(post ->
post.getTitle().toLowerCase().contains(lowerKeyword) ||
post.getContent().toLowerCase().contains(lowerKeyword))
.collect(Collectors.toList());
}
// DELETE — equivalent to SQL: DELETE FROM posts WHERE id = ?
public boolean deleteById(Long id) {
return posts.remove(id) != null;
}
// EXISTS — equivalent to SQL: SELECT EXISTS(SELECT 1 FROM posts WHERE id = ?)
public boolean existsById(Long id) {
return posts.containsKey(id);
}
// COUNT — equivalent to SQL: SELECT COUNT(*) FROM posts
public long count() {
return posts.size();
}
}
Step 3: The Post Service
File: src/main/java/com/example/blogapi/service/PostService.java
package com.example.blogapi.service;
import com.example.blogapi.model.Post;
import com.example.blogapi.repository.PostRepository;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.List;
@Service
public class PostService {
private final PostRepository postRepository;
// Constructor injection — Spring provides the PostRepository bean
public PostService(PostRepository postRepository) {
this.postRepository = postRepository;
}
// CREATE — with validation and business logic
public Post createPost(Post post) {
// --- Business Rules ---
// Rule: Title is required
if (post.getTitle() == null || post.getTitle().trim().isEmpty()) {
throw new IllegalArgumentException("Post title cannot be empty");
}
// Rule: Content is required
if (post.getContent() == null || post.getContent().trim().isEmpty()) {
throw new IllegalArgumentException("Post content cannot be empty");
}
// Rule: Trim whitespace
post.setTitle(post.getTitle().trim());
post.setContent(post.getContent().trim());
// Rule: Default status is DRAFT
if (post.getStatus() == null) {
post.setStatus("DRAFT");
}
// Rule: Set creation timestamp
post.setCreatedAt(LocalDateTime.now());
return postRepository.save(post);
}
// READ ONE — with not-found handling
public Post getPostById(Long id) {
return postRepository.findById(id)
.orElseThrow(() -> new RuntimeException(
"Post not found with id: " + id));
}
// READ ALL — with optional filtering
public List<Post> getAllPosts(String status, String category) {
if (status != null && !status.isEmpty()) {
return postRepository.findByStatus(status);
}
if (category != null && !category.isEmpty()) {
return postRepository.findByCategory(category);
}
return postRepository.findAll();
}
// SEARCH
public List<Post> searchPosts(String keyword) {
if (keyword == null || keyword.trim().isEmpty()) {
throw new IllegalArgumentException("Search keyword cannot be empty");
}
return postRepository.searchByKeyword(keyword.trim());
}
// UPDATE (full replacement) — PUT semantics
public Post updatePost(Long id, Post updatedPost) {
// Verify the post exists
Post existingPost = postRepository.findById(id)
.orElseThrow(() -> new RuntimeException(
"Post not found with id: " + id));
// Validate the updated data
if (updatedPost.getTitle() == null || updatedPost.getTitle().trim().isEmpty()) {
throw new IllegalArgumentException("Post title cannot be empty");
}
if (updatedPost.getContent() == null || updatedPost.getContent().trim().isEmpty()) {
throw new IllegalArgumentException("Post content cannot be empty");
}
// Update all fields (PUT = full replacement)
existingPost.setTitle(updatedPost.getTitle().trim());
existingPost.setContent(updatedPost.getContent().trim());
existingPost.setCategory(updatedPost.getCategory());
existingPost.setStatus(updatedPost.getStatus());
existingPost.setUpdatedAt(LocalDateTime.now());
return postRepository.save(existingPost);
}
// PARTIAL UPDATE — PATCH semantics
public Post partialUpdatePost(Long id, Post partialPost) {
Post existingPost = postRepository.findById(id)
.orElseThrow(() -> new RuntimeException(
"Post not found with id: " + id));
// Only update fields that are provided (non-null)
// This is the key difference between PUT and PATCH:
// PUT replaces everything; PATCH updates only what is provided.
if (partialPost.getTitle() != null) {
existingPost.setTitle(partialPost.getTitle().trim());
}
if (partialPost.getContent() != null) {
existingPost.setContent(partialPost.getContent().trim());
}
if (partialPost.getCategory() != null) {
existingPost.setCategory(partialPost.getCategory());
}
if (partialPost.getStatus() != null) {
existingPost.setStatus(partialPost.getStatus());
}
existingPost.setUpdatedAt(LocalDateTime.now());
return postRepository.save(existingPost);
}
// DELETE
public void deletePost(Long id) {
if (!postRepository.existsById(id)) {
throw new RuntimeException("Post not found with id: " + id);
}
postRepository.deleteById(id);
}
// COUNT
public long getPostCount() {
return postRepository.count();
}
}
Step 4: The Post Controller
File: src/main/java/com/example/blogapi/controller/PostController.java
package com.example.blogapi.controller;
import com.example.blogapi.model.Post;
import com.example.blogapi.service.PostService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/posts")
public class PostController {
private final PostService postService;
public PostController(PostService postService) {
this.postService = postService;
}
// ==================== CREATE ====================
// POST /api/posts
// Request body: { "title": "...", "content": "...", "category": "..." }
// Returns: 201 Created with the created post
@PostMapping
public ResponseEntity<Post> createPost(@RequestBody Post post) {
Post created = postService.createPost(post);
return ResponseEntity.status(HttpStatus.CREATED).body(created);
}
// ==================== READ ====================
// GET /api/posts
// GET /api/posts?status=PUBLISHED
// GET /api/posts?category=TUTORIAL
// Returns: 200 OK with list of posts
@GetMapping
public List<Post> getAllPosts(
@RequestParam(required = false) String status,
@RequestParam(required = false) String category) {
return postService.getAllPosts(status, category);
}
// GET /api/posts/42
// Returns: 200 OK with the post, or 404 Not Found
@GetMapping("/{id}")
public ResponseEntity<Post> getPostById(@PathVariable Long id) {
try {
Post post = postService.getPostById(id);
return ResponseEntity.ok(post);
} catch (RuntimeException e) {
return ResponseEntity.notFound().build();
}
}
// GET /api/posts/search?keyword=spring
// Returns: 200 OK with matching posts
@GetMapping("/search")
public ResponseEntity<?> searchPosts(@RequestParam String keyword) {
try {
List<Post> results = postService.searchPosts(keyword);
return ResponseEntity.ok(results);
} catch (IllegalArgumentException e) {
// Return 400 Bad Request with error message
Map<String, String> error = new HashMap<>();
error.put("error", e.getMessage());
return ResponseEntity.badRequest().body(error);
}
}
// GET /api/posts/count
// Returns: 200 OK with count
@GetMapping("/count")
public Map<String, Long> getPostCount() {
return Map.of("count", postService.getPostCount());
}
// ==================== UPDATE ====================
// PUT /api/posts/42
// Request body: complete post data (full replacement)
// Returns: 200 OK with updated post, or 404 Not Found
@PutMapping("/{id}")
public ResponseEntity<?> updatePost(
@PathVariable Long id,
@RequestBody Post post) {
try {
Post updated = postService.updatePost(id, post);
return ResponseEntity.ok(updated);
} catch (RuntimeException e) {
Map<String, String> error = new HashMap<>();
error.put("error", e.getMessage());
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
} catch (IllegalArgumentException e) {
Map<String, String> error = new HashMap<>();
error.put("error", e.getMessage());
return ResponseEntity.badRequest().body(error);
}
}
// PATCH /api/posts/42
// Request body: partial post data (only fields to update)
// Returns: 200 OK with updated post, or 404 Not Found
@PatchMapping("/{id}")
public ResponseEntity<?> partialUpdatePost(
@PathVariable Long id,
@RequestBody Post post) {
try {
Post updated = postService.partialUpdatePost(id, post);
return ResponseEntity.ok(updated);
} catch (RuntimeException e) {
Map<String, String> error = new HashMap<>();
error.put("error", e.getMessage());
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
}
}
// ==================== DELETE ====================
// DELETE /api/posts/42
// Returns: 204 No Content, or 404 Not Found
@DeleteMapping("/{id}")
public ResponseEntity<?> deletePost(@PathVariable Long id) {
try {
postService.deletePost(id);
return ResponseEntity.noContent().build();
} catch (RuntimeException e) {
Map<String, String> error = new HashMap<>();
error.put("error", e.getMessage());
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
}
}
}
Step 5: Testing the Complete API
Start the application and run through these curl commands to test every operation:
Create posts:
# Create post 1
curl -X POST http://localhost:8080/api/posts \
-H "Content-Type: application/json" \
-d '{
"title": "Getting Started with Spring Boot",
"content": "Spring Boot is a framework that simplifies Java web development...",
"category": "TUTORIAL"
}'
# Expected: 201 Created
# {
# "id": 1,
# "title": "Getting Started with Spring Boot",
# "content": "Spring Boot is a framework...",
# "category": "TUTORIAL",
# "status": "DRAFT",
# "createdAt": "2024-01-15T10:30:00"
# }
# Create post 2
curl -X POST http://localhost:8080/api/posts \
-H "Content-Type: application/json" \
-d '{
"title": "Understanding REST APIs",
"content": "REST stands for Representational State Transfer...",
"category": "TUTORIAL",
"status": "PUBLISHED"
}'
# Create post 3
curl -X POST http://localhost:8080/api/posts \
-H "Content-Type: application/json" \
-d '{
"title": "Java 17 New Features",
"content": "Java 17 introduced sealed classes, pattern matching...",
"category": "NEWS"
}'
Read posts:
# Get all posts
curl http://localhost:8080/api/posts
# Get a specific post
curl http://localhost:8080/api/posts/1
# Get a non-existent post (should return 404)
curl -v http://localhost:8080/api/posts/999
# Filter by status
curl http://localhost:8080/api/posts?status=PUBLISHED
# Filter by category
curl http://localhost:8080/api/posts?category=TUTORIAL
# Search by keyword
curl http://localhost:8080/api/posts/search?keyword=spring
# Get post count
curl http://localhost:8080/api/posts/count
Update posts:
# Full update (PUT) — replace all fields of post 1
curl -X PUT http://localhost:8080/api/posts/1 \
-H "Content-Type: application/json" \
-d '{
"title": "Getting Started with Spring Boot 3",
"content": "Spring Boot 3 requires Java 17 and brings Jakarta EE...",
"category": "TUTORIAL",
"status": "PUBLISHED"
}'
# Expected: 200 OK with updatedAt timestamp set
# Partial update (PATCH) — only update the status of post 3
curl -X PATCH http://localhost:8080/api/posts/3 \
-H "Content-Type: application/json" \
-d '{
"status": "PUBLISHED"
}'
# Expected: 200 OK — only status changed, title and content unchanged
Delete posts:
# Delete post 2
curl -X DELETE http://localhost:8080/api/posts/2 -v
# Expected: 204 No Content (empty response body)
# Try to delete a non-existent post
curl -X DELETE http://localhost:8080/api/posts/999 -v
# Expected: 404 Not Found with error message
Test validation:
# Try to create a post with empty title
curl -X POST http://localhost:8080/api/posts \
-H "Content-Type: application/json" \
-d '{
"title": "",
"content": "Some content"
}'
# Expected: 500 Internal Server Error (we will improve this in Lecture 8)
Step 6: Understanding PUT vs PATCH
This is a common source of confusion, so let us clarify with an example.
Assume we have a post:
{
"id": 1,
"title": "Original Title",
"content": "Original content",
"category": "TUTORIAL",
"status": "DRAFT"
}
PUT /api/posts/1 — Full replacement. You must send ALL fields:
{
"title": "New Title",
"content": "New content",
"category": "NEWS",
"status": "PUBLISHED"
}
// Result: ALL fields are replaced with the new values
If you send a PUT with missing fields, those fields become null:
{
"title": "New Title",
"content": "New content"
}
// Result: category = null, status = null (because they were not provided)
PATCH /api/posts/1 — Partial update. Send only the fields you want to change:
{
"status": "PUBLISHED"
}
// Result: Only status changes. Title, content, and category remain unchanged.
The difference is important: PUT means “replace this entire resource,” while PATCH means “update only these specific fields.”
Step 7: Exercises
- Add a publish endpoint: Create a
PATCH /api/posts/{id}/publishendpoint that changes a post’s status to “PUBLISHED” and sets theupdatedAttimestamp. No request body needed. - Add pagination: Modify
GET /api/poststo acceptpageandsizequery parameters. Return only the posts for the requested page. For example, withpage=0&size=2, return the first 2 posts. - Add a summary endpoint: Create a
GET /api/posts/summarythat returns a list of posts with onlyid,title,status, andcreatedAt(no content). Hint: you can create a separate class for the summary or use aMap. - Error consistency: Notice how error responses are inconsistent — sometimes we return
404with an error message, sometimes without. Create a consistentErrorResponseclass:public class ErrorResponse { private int status; private String message; private LocalDateTime timestamp; // constructor, getters, setters }Use this class for all error responses across the controller.
Summary
In this lecture, you have built a complete CRUD REST API and learned the essential concepts behind it:
- REST principles: Resources are identified by URIs, manipulated through HTTP methods, and represented as JSON. Statelessness enables scalability.
- HTTP methods map to CRUD: POST=Create, GET=Read, PUT=Update (full), PATCH=Update (partial), DELETE=Delete. Each has specific semantics around safety and idempotency.
- @RestController vs @Controller: Use
@RestControllerfor REST APIs (returns JSON),@Controllerfor server-rendered HTML pages. - Request mapping annotations:
@GetMapping,@PostMapping,@PutMapping,@PatchMapping,@DeleteMappingmap HTTP methods to handler methods.@RequestMappingon the class sets the base path. - Path variables and query parameters: Path variables (
/users/{id}) identify specific resources. Query parameters (?status=published) filter, sort, and paginate. - @RequestBody: Tells Spring to deserialize the HTTP request body (JSON) into a Java object using Jackson.
- ResponseEntity: Gives you control over HTTP status codes and headers. Use
201 Createdfor POST,204 No Contentfor DELETE,404 Not Foundfor missing resources. - Jackson: The JSON library that handles serialization (Java → JSON) and deserialization (JSON → Java). Customize with annotations like
@JsonProperty,@JsonIgnore, and@JsonInclude. - API versioning: URL path versioning (
/v1/,/v2/) is the most popular strategy. Only version when you make breaking changes.
What is Next
In Lecture 4, we will set up MariaDB — installing it, designing our blog database schema, and refreshing your SQL skills. Then in Lecture 5, we will connect Spring Boot to MariaDB using Spring Data JPA, replacing our in-memory storage with a real database.
Quick Reference
| Concept | Description |
|---|---|
| REST | Architectural style for web APIs based on resources and HTTP methods |
| Resource | An entity identified by a URI (/api/posts/42) |
| CRUD | Create (POST), Read (GET), Update (PUT/PATCH), Delete (DELETE) |
| Idempotent | Same result no matter how many times called (GET, PUT, DELETE) |
@RestController | Controller that returns data (JSON) instead of view names |
@RequestMapping | Maps a URL path to a controller (class level = base path) |
@GetMapping | Maps HTTP GET to a method |
@PostMapping | Maps HTTP POST to a method |
@PutMapping | Maps HTTP PUT to a method (full update) |
@PatchMapping | Maps HTTP PATCH to a method (partial update) |
@DeleteMapping | Maps HTTP DELETE to a method |
@PathVariable | Extracts a value from the URL path (/users/{id}) |
@RequestParam | Extracts a query parameter (?name=value) |
@RequestBody | Deserializes request body JSON into a Java object |
ResponseEntity | Wraps the response with status code and headers |
| Jackson | JSON serialization/deserialization library (included by default) |
@JsonProperty | Changes the JSON field name |
@JsonIgnore | Excludes a field from JSON |
@JsonInclude | Controls when null/empty fields appear in JSON |
| Content Negotiation | Client and server agree on data format via Accept/Content-Type headers |
