> ## Content Index
> Fetch the complete content index at: https://www.codyssey.tech/llms.txt
> Use this file to discover other available public pages before exploring further.

# 🌐 REST Assured: Designing APIs Developers Actually Want to Use
- URL: https://www.codyssey.tech/rest-assured/
- Published: 2026-04-29T08:00:00.000Z
- Updated: 2026-05-14T07:36:50.000Z
- Description: A perfectly engineered microservice is useless if developers hate the API. This article covers the CLEAR framework for REST design — consistent naming, multi-layer validation, RFC 7807 error contracts, and OpenAPI documentation that turns your endpoints into a product people want to use.
- Author: Robert Marcel Saveanu
- Tags: Architecture & Infra, API Design, Microservices, Spring Boot, #format-tutorial, #series-spring-boot, #syntax-highlight

📚 **Series Navigation:**  
← **Previous:** [Part 2 - Spring Boot Alchemy](https://www.codyssey.tech/spring-boot-alchemy/)  
👉 **You are here:** Part 3 - REST Assured  
**Next:** [Part 4 - The Data Foundation](https://www.codyssey.tech/the-data-foundation/) →

---

## 📋 Introduction

You've built an incredible microservice. It processes weather data with the speed of a caffeinated cheetah. Your architecture is layered like a fine pastry. Your database schema is a work of art. And then someone tries to use your API.

*"What parameters does this endpoint take?"*  
*"Why did I get a 500 error when I sent an invalid latitude?"*  
*"Is it `/api/weather/location/42` or `/api/locations/42/weather`?"*

The reality is blunt: **the most perfectly engineered microservice is useless if developers hate the API.** Your REST endpoints are the front door to your application. If they're confusing, inconsistent, or undocumented, nobody will want to walk through them — no matter how beautiful the architecture behind the door.

In this article, we'll explore how the Weather Microservice designs APIs that are intuitive, well-validated, thoroughly documented, and consistent. We'll see real code patterns for multi-layer validation, custom composed annotations, and OpenAPI documentation that generates itself from your code. Let's build APIs that developers actually want to use. ☕

---

## 🧩 The CLEAR Framework: Five Principles of Great API Design

I call it **CLEAR** — five principles that guide REST API design:

| Letter | Principle                  | What It Means                                                   |
| ------ | -------------------------- | --------------------------------------------------------------- |
| **C**  | **Consistent Naming**      | URLs follow predictable patterns, resources are plural nouns    |
| **L**  | **Levels of Validation**   | Input is validated at every layer — controller, service, domain |
| **E**  | **Error Contracts**        | Errors follow RFC 7807, always structured, always helpful       |
| **A**  | **API Documentation**      | OpenAPI/Swagger docs generated from code, always in sync        |
| **R**  | **Resource-oriented URLs** | Endpoints model resources and relationships, not actions        |

---

## 🏗️ Resource-Oriented URL Design

The Weather Microservice organizes its endpoints around three resources:

### URL Hierarchy

```
/api/weather/          → Current weather & history
/api/locations/        → Location CRUD
/api/forecast/         → Forecast data

```

Each resource follows standard REST conventions:

| HTTP Method | URL Pattern                       | Description           | Status Code |
| ----------- | --------------------------------- | --------------------- | ----------- |
| GET         | /api/locations                    | List all locations    | 200         |
| GET         | /api/locations/{id}               | Get a single location | 200 / 404   |
| GET         | /api/locations/search?name=London | Search locations      | 200         |
| POST        | /api/locations                    | Create a location     | 201         |
| PUT         | /api/locations/{id}               | Update a location     | 200 / 404   |
| DELETE      | /api/locations/{id}               | Delete a location     | 204 / 404   |

Notice the consistency:

- **Plural nouns** for resource names (`locations`, not `location`)
- **Path variables** for specific resources (`/{id}`)
- **Query parameters** for filtering and options (`?name=`, `?save=`)
- **Appropriate HTTP methods** for each operation
- **Correct status codes** (201 for creation, 204 for deletion)

### The LocationController: Full CRUD

```java
@RestController
@RequestMapping("/api/locations")
@Tag(name = "Location Management", description = "APIs for managing weather locations")
@RequiredArgsConstructor
@Validated
public class LocationController {

  private final LocationService locationService;

  @PostMapping
  public ResponseEntity<LocationDto> createLocation(
      @Valid @RequestBody CreateLocationRequest request) {
    LocationDto location = locationService.createLocation(request);
    return ResponseEntity.status(HttpStatus.CREATED).body(location);
  }

  @GetMapping("/{id}")
  public ResponseEntity<LocationDto> getLocationById(
      @PathVariable @Positive Long id) {
    LocationDto location = locationService.getLocationById(id);
    return ResponseEntity.ok(location);
  }

  @GetMapping
  public ResponseEntity<List<LocationDto>> getAllLocations() {
    List<LocationDto> locations = locationService.getAllLocations();
    return ResponseEntity.ok(locations);
  }

  @PutMapping("/{id}")
  public ResponseEntity<LocationDto> updateLocation(
      @PathVariable @Positive Long id,
      @Valid @RequestBody CreateLocationRequest request) {
    LocationDto location = locationService.updateLocation(id, request);
    return ResponseEntity.ok(location);
  }

  @DeleteMapping("/{id}")
  public ResponseEntity<Void> deleteLocation(
      @PathVariable @Positive Long id) {
    locationService.deleteLocation(id);
    return ResponseEntity.noContent().build();
  }
}

```

Key patterns:

- **`@Validated` on the class** — Enables method-level validation for `@Positive`, `@NotBlank`, etc.
- **`@Valid` on `@RequestBody`** — Triggers Bean Validation on the request DTO
- **`@Positive` on path variables** — Rejects invalid IDs before they reach the service layer
- **`ResponseEntity.status(HttpStatus.CREATED)`** — Returns 201 for resource creation (not 200)
- **`ResponseEntity.noContent().build()`** — Returns 204 with no body for deletion

### Pagination with Spring Data

```java
@GetMapping("/search/page")
public ResponseEntity<Page<LocationDto>> searchLocationsPaginated(
    @RequestParam
    @NotBlank(message = "Location name cannot be blank")
    @Size(min = 1, max = ValidationConstants.LOCATION_NAME_MAX_LENGTH)
    String name,
    @PageableDefault(size = 20, sort = "name", direction = Sort.Direction.ASC)
    Pageable pageable) {
  Page<LocationDto> locations = locationService.searchLocationsByName(name, pageable);
  return ResponseEntity.ok(locations);
}

```

The `@PageableDefault` annotation provides sensible defaults when clients don't specify pagination parameters. The client can override them:

```
GET /api/locations/search/page?name=London&page=0&size=10&sort=name,asc

```

Spring automatically parses `page`, `size`, and `sort` query parameters into a `Pageable` object. No manual parsing needed.

---

## 🛡️ Multi-Layer Validation: Defense in Depth

Validation isn't something you do once. The Weather Microservice validates at three distinct layers, each catching different types of errors:

### Layer 1: Controller Validation (HTTP Boundary)

```java
// ForecastController.java
@GetMapping
public ResponseEntity<List<ForecastDto>> getForecast(
    @RequestParam
    @NotBlank(message = "Location cannot be blank")
    String location,

    @RequestParam(defaultValue = "3")
    @Min(value = ValidationConstants.FORECAST_DAYS_MIN,
         message = "Days must be at least " + ValidationConstants.FORECAST_DAYS_MIN)
    @Max(value = ValidationConstants.FORECAST_DAYS_MAX,
         message = "Days cannot exceed " + ValidationConstants.FORECAST_DAYS_MAX)
    int days,

    @RequestParam(defaultValue = "true")
    boolean save) {
  List<ForecastDto> forecast = forecastService.getForecast(location, days, save);
  return ResponseEntity.ok(forecast);
}

```

Controller validation catches invalid HTTP input:

- `@NotBlank` — Rejects empty or whitespace-only strings
- `@Min` / `@Max` — Constrains numeric ranges (forecast days: 1-14)
- `@Positive` — Rejects zero or negative IDs
- `@Size` — Limits string lengths
- `@NotNull` — Rejects missing required parameters

### Layer 2: Request Object Validation (Domain Boundary)

```java
// CreateLocationRequest.java
public record CreateLocationRequest(
    @ValidLocationName String name,
    @ValidCountryName String country,
    @ValidLatitude Double latitude,
    @ValidLongitude Double longitude,
    String region) {}

```

This request object uses **custom composed annotations** — but before we explore those, notice that the `region` field has no validation. It's optional. Validation annotations are only on fields that have constraints.

### Layer 3: Service Validation (Business Logic)

```java
// WeatherService.java
public List<WeatherDto> getWeatherHistoryByDateRange(
    @NotNull Long locationId,
    @NotNull LocalDateTime startDate,
    @NotNull LocalDateTime endDate) {
  DateRangeValidator.validateWeatherHistoryRange(startDate, endDate);
  // ...
}

```

Service-level validation catches business rule violations that can't be expressed with annotations. The `DateRangeValidator` enforces rules like:

- Date ranges can't exceed 90 days
- Queries can't go back more than 1 year
- Start date must be before end date

### Why Three Layers?

| Layer       | What It Catches          | Example                                  |
| ----------- | ------------------------ | ---------------------------------------- |
| Controller  | Invalid HTTP input       | Blank location name, negative ID         |
| Request DTO | Invalid domain objects   | Latitude of 999, name of 1 char          |
| Service     | Business rule violations | Date range > 90 days, duplicate location |

Each layer rejects different types of invalid data. Without controller validation, invalid requests reach the service layer (wasteful). Without service validation, business rules can be violated by internal callers (dangerous).

---

## 🎨 Custom Composed Annotations: DRY Validation

The Weather Microservice creates domain-specific validation annotations that combine multiple standard constraints:

### @ValidLatitude: A Composed Constraint

```java
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@NotNull(message = "Latitude is required")
@Min(value = ValidationConstants.LATITUDE_MIN, message = ValidationConstants.LATITUDE_RANGE_MESSAGE)
@Max(value = ValidationConstants.LATITUDE_MAX, message = ValidationConstants.LATITUDE_RANGE_MESSAGE)
@Constraint(validatedBy = {})
public @interface ValidLatitude {
  String message() default "Invalid latitude coordinate";
  Class<?>[] groups() default {};
  Class<? extends Payload>[] payload() default {};
}

```

This single annotation replaces three separate annotations everywhere latitude is validated. Instead of writing:

```java
// Without composed annotation (repeated everywhere)
@NotNull(message = "Latitude is required")
@Min(value = -90, message = "Latitude must be between -90 and 90")
@Max(value = 90, message = "Latitude must be between -90 and 90")
Double latitude;

```

You write:

```java
// With composed annotation (clean and reusable)
@ValidLatitude
Double latitude;

```

Benefits:

- **DRY** — Validation rules defined once, used everywhere
- **Consistent messages** — All latitude validation uses the same error messages
- **Single source of truth** — Change the range once, it updates everywhere
- **Readable** — `@ValidLatitude` is more expressive than three separate annotations

### Centralized Validation Constants

```java
public final class ValidationConstants {

  private ValidationConstants() {
    throw new UnsupportedOperationException("Utility class cannot be instantiated");
  }

  // Location Name Validation
  public static final int LOCATION_NAME_MIN_LENGTH = 2;
  public static final int LOCATION_NAME_MAX_LENGTH = 100;
  public static final String LOCATION_NAME_SIZE_MESSAGE =
      "Name must be between " + LOCATION_NAME_MIN_LENGTH
          + " and " + LOCATION_NAME_MAX_LENGTH + " characters";

  // Latitude Validation
  public static final long LATITUDE_MIN = -90;
  public static final long LATITUDE_MAX = 90;
  public static final String LATITUDE_RANGE_MESSAGE =
      "Latitude must be between " + LATITUDE_MIN + " and " + LATITUDE_MAX;

  // Forecast Days Validation
  public static final int FORECAST_DAYS_MIN = 1;
  public static final int FORECAST_DAYS_MAX = 14;
}

```

All validation constants live in one place. When the weather API adds support for 21-day forecasts, you change one constant. Every controller, every validator, every error message updates automatically.

> 💡 **Pro Tip:** The private constructor with `throw new UnsupportedOperationException` prevents accidental instantiation of utility classes. It's a Java best practice that ArchUnit could also enforce.

---

## 📖 OpenAPI Documentation: Self-Documenting APIs

The Weather Microservice generates comprehensive API documentation from code annotations:

### OpenAPI Configuration

```java
@Configuration
public class OpenApiConfig {

  @Value("${openapi.server.url}")
  private String serverUrl;

  @Value("${openapi.contact.email}")
  private String contactEmail;

  @Bean
  public OpenAPI weatherServiceOpenAPI() {
    Server localServer = new Server();
    localServer.setUrl(serverUrl);
    localServer.setDescription("Local development server");

    Contact contact = new Contact();
    contact.setName("Weather Service Team");
    contact.setEmail(contactEmail);

    Info info = new Info()
        .title("Weather Microservice API")
        .version("1.0.0")
        .description("Weather microservice providing current weather data, "
            + "forecasts, and historical weather records.")
        .contact(contact)
        .license(new License().name("MIT License"));

    return new OpenAPI().info(info).servers(List.of(localServer));
  }
}

```

### Controller-Level Documentation

```java
@RestController
@RequestMapping("/api/weather")
@Tag(name = "Weather Data", description = "APIs for current weather and historical weather data")
public class WeatherController {

  @Operation(
      summary = "Get current weather by location name",
      description = "Fetches current weather data for a location from external API")
  @ApiResponses(value = {
      @ApiResponse(responseCode = "200", description = "Weather data retrieved successfully"),
      @ApiResponse(responseCode = "400", description = "Invalid location name"),
      @ApiResponse(responseCode = "503", description = "External API unavailable")
  })
  @GetMapping("/current")
  public ResponseEntity<WeatherDto> getCurrentWeather(
      @Parameter(description = "Location name (e.g., 'London', 'New York')", required = true)
      @RequestParam @NotBlank String location,
      @Parameter(description = "Whether to save weather data to database")
      @RequestParam(defaultValue = "true") boolean save) {
    // ...
  }
}

```

Annotation breakdown:

| Annotation    | Level      | Purpose                                   |
| ------------- | ---------- | ----------------------------------------- |
| @Tag          | Class      | Groups endpoints in Swagger UI            |
| @Operation    | Method     | Summary and description for the endpoint  |
| @ApiResponses | Method     | Documents possible HTTP status codes      |
| @ApiResponse  | Per status | Describes each response scenario          |
| @Parameter    | Parameter  | Describes each input parameter            |
| @Schema       | DTO field  | Describes data model fields with examples |

### DTO-Level Documentation

```java
@Schema(description = "Current weather information")
public record WeatherDto(
    @Schema(description = "Weather record ID", example = "1") @Nullable Long id,
    @Schema(description = "Location name", example = "London") String locationName,
    @Schema(description = "Temperature in Celsius", example = "15.5") Double temperature,
    @Schema(description = "Humidity percentage", example = "65") Integer humidity,
    // ...
    @Schema(description = "Timestamp", example = "2024-01-15T14:30:00") LocalDateTime timestamp
) {}

```

The `example` values in `@Schema` annotations appear in Swagger UI, giving developers real-world samples without needing to make actual API calls.

### Accessing the Documentation

```yaml
springdoc:
  api-docs:
    path: /api-docs
  swagger-ui:
    path: /swagger-ui.html
    enabled: true

```

Navigate to `http://localhost:8080/swagger-ui.html` for an interactive UI, or `http://localhost:8080/api-docs` for the raw OpenAPI JSON spec. The documentation stays in sync with the code because it's generated *from* the code.

---

## 🔄 Request/Response Patterns

### Consistent Response Wrapping

The Weather Microservice returns different response types depending on the operation:

```java
// Single resource → 200 OK
return ResponseEntity.ok(weatherDto);

// Resource creation → 201 Created
return ResponseEntity.status(HttpStatus.CREATED).body(locationDto);

// Resource deletion → 204 No Content
return ResponseEntity.noContent().build();

// Paginated list → 200 OK with Page metadata
return ResponseEntity.ok(pageOfLocations);

```

The `Page` response includes metadata that pagination consumers need:

```json
{
  "content": [...],
  "totalElements": 42,
  "totalPages": 3,
  "size": 20,
  "number": 0,
  "first": true,
  "last": false
}

```

### Default Values That Make Sense

```java
@RequestParam(defaultValue = "true") boolean save
@RequestParam(defaultValue = "3") int days
@PageableDefault(size = 20, sort = "name", direction = Sort.Direction.ASC) Pageable pageable

```

Every optional parameter has a sensible default. Developers can start making API calls immediately without reading the documentation — the defaults do the right thing.

---

## 🔍 Search and Filtering Patterns

The Weather Microservice implements search in a clean, consistent way:

### Simple Search

```java
@GetMapping("/search")
public ResponseEntity<List<LocationDto>> searchLocations(
    @RequestParam
    @NotBlank(message = "Location name cannot be blank")
    @Size(min = 1, max = ValidationConstants.LOCATION_NAME_MAX_LENGTH)
    String name) {
  List<LocationDto> locations = locationService.searchLocationsByName(name);
  return ResponseEntity.ok(locations);
}

```

### Paginated Search

```java
@GetMapping("/search/page")
public ResponseEntity<Page<LocationDto>> searchLocationsPaginated(
    @RequestParam @NotBlank @Size(min = 1, max = 100) String name,
    @PageableDefault(size = 20, sort = "name", direction = Sort.Direction.ASC) Pageable pageable) {
  Page<LocationDto> locations = locationService.searchLocationsByName(name, pageable);
  return ResponseEntity.ok(locations);
}

```

### Date Range Queries

```java
@GetMapping("/history/location/{locationId}/range")
public ResponseEntity<List<WeatherDto>> getWeatherHistoryByDateRange(
    @PathVariable @Positive Long locationId,
    @RequestParam @NotNull @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
        LocalDateTime startDate,
    @RequestParam @NotNull @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
        LocalDateTime endDate) {
  List<WeatherDto> history =
      weatherService.getWeatherHistoryByDateRange(locationId, startDate, endDate);
  return ResponseEntity.ok(history);
}

```

The `@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)` annotation tells Spring how to parse the date string from the query parameter. Clients send ISO 8601 format: `2024-01-15T14:30:00`.

---

## 📐 URL Design Patterns

The Weather Microservice's URL structure follows a consistent pattern for nested resources:

```
# Resource-based
/api/weather/current                           → Current weather by name
/api/weather/current/location/{locationId}     → Current weather by location ID
/api/weather/history/location/{locationId}     → Weather history for a location

# Forecast endpoints
/api/forecast                                  → Forecast by name
/api/forecast/location/{locationId}            → Forecast by location ID
/api/forecast/stored/location/{locationId}     → Stored forecasts
/api/forecast/future/location/{locationId}     → Future-only forecasts
/api/forecast/range/location/{locationId}      → Date range query

# Location CRUD
/api/locations                                 → List/Create
/api/locations/{id}                            → Get/Update/Delete
/api/locations/search                          → Search by name
/api/locations/search/page                     → Paginated search

```

Patterns to notice:

- **Nested resources** use the parent ID in the path: `/weather/history/location/{locationId}`
- **Filtering** uses query parameters: `?name=London`, `?days=7`
- **Sub-resources** use descriptive path segments: `/stored/`, `/future/`, `/range/`
- **Pagination variants** add `/page` to the base search URL

---

## ✅ API Design Checklist

- \[ \] **Plural nouns** for resource names (`/locations`, not `/location`)
- \[ \] **Consistent HTTP methods** — GET reads, POST creates, PUT updates, DELETE removes
- \[ \] **Correct status codes** — 201 for creation, 204 for deletion, 400 for validation errors
- \[ \] **Multi-layer validation** — Controller, DTO, and Service layers each validate appropriately
- \[ \] **Custom composed annotations** — `@ValidLatitude` \> three separate annotations
- \[ \] **Centralized constants** — Validation rules in one place (`ValidationConstants`)
- \[ \] **Sensible defaults** — Every optional parameter has a reasonable default value
- \[ \] **OpenAPI annotations** — `@Tag`, `@Operation`, `@ApiResponse`, `@Parameter`, `@Schema`
- \[ \] **Pagination support** — `@PageableDefault` with Spring Data `Page` responses
- \[ \] **Date formatting** — ISO 8601 with `@DateTimeFormat`
- \[ \] **Documentation accessible** — Swagger UI at `/swagger-ui.html`

---

## 🎓 Conclusion: APIs Are Products

Your API is the product — and these are the design principles that make it usable:

1. **The CLEAR framework** (Consistent naming, Levels of validation, Error contracts, API documentation, Resource-oriented URLs) guides REST API design
2. **Resource-oriented URLs** with plural nouns, path variables, and query parameters create predictable, intuitive APIs
3. **Multi-layer validation** catches different types of errors at controller, DTO, and service levels
4. **Custom composed annotations** like `@ValidLatitude` consolidate multiple validation constraints into reusable, domain-specific annotations
5. **Centralized `ValidationConstants`** ensure consistency across the entire codebase
6. **OpenAPI annotations** generate interactive documentation that stays in sync with code
7. **Pagination with `@PageableDefault`** gives clients control over result sets with sensible defaults
8. **Proper HTTP status codes** (201 Created, 204 No Content) communicate operation results correctly

Your API is a product. Treat it like one. Document it like someone's job depends on understanding it — because someday, it will.

**Coming Next Week:**  
Part 4: The Data Foundation - JPA, Hibernate, and the Database Migration Playbook 🗄️

---

📚 **Series Progress**

✅ Part 1: The Blueprint Before the Build  
✅ Part 2: Spring Boot Alchemy  
✅ Part 3: REST Assured ← You just finished this!  
⬜ Part 4: The Data Foundation  
⬜ Part 5: When the World Breaks  
⬜ Part 6: Cache Me If You Can  
⬜ Part 7: Guarding the Gates  
⬜ Part 8: Fail Gracefully  
⬜ Part 9: 10,000 Threads and a Dream  
⬜ Part 10: Can You See Me Now?  
⬜ Part 11: Trust, But Verify  
⬜ Part 12: Ship It  
⬜ Part 13: To Production and Beyond

---

*Happy coding, and remember — the best API is the one developers figure out without reading the docs (but document it anyway).* ☕