Skip to content

Commit 67caf98

Browse files
committed
feat: Add BadRequestException for 4xx handler errors
1 parent 41bfe36 commit 67caf98

2 files changed

Lines changed: 104 additions & 0 deletions

File tree

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package com.retailsvc.http;
2+
3+
import java.util.Objects;
4+
import java.util.Optional;
5+
6+
/**
7+
* Thrown by user handlers to signal a 4xx client error. The default {@link ExceptionHandler}
8+
* renders this as an RFC 7807 {@code application/problem+json} response carrying the supplied
9+
* status, detail, and optional JSON-pointer / validation-keyword fields.
10+
*
11+
* <p>Use for cases like {@code 422 Unprocessable Content} (payload is syntactically valid but
12+
* violates a business rule), {@code 409 Conflict}, {@code 412 Precondition Failed}, etc. For 5xx
13+
* errors, throw an ordinary {@link RuntimeException} and let the default handler render 500.
14+
*/
15+
public final class BadRequestException extends RuntimeException {
16+
17+
private static final int DEFAULT_STATUS = 400;
18+
19+
private final int status;
20+
private final String pointer;
21+
private final String keyword;
22+
23+
public BadRequestException(String detail) {
24+
this(DEFAULT_STATUS, detail, null, null);
25+
}
26+
27+
public BadRequestException(int status, String detail) {
28+
this(status, detail, null, null);
29+
}
30+
31+
public BadRequestException(int status, String detail, String pointer, String keyword) {
32+
super(Objects.requireNonNull(detail, "detail must not be null"));
33+
if (status < 400 || status > 499) {
34+
throw new IllegalArgumentException("status must be 4xx, got " + status);
35+
}
36+
this.status = status;
37+
this.pointer = pointer;
38+
this.keyword = keyword;
39+
}
40+
41+
public int status() {
42+
return status;
43+
}
44+
45+
public Optional<String> pointer() {
46+
return Optional.ofNullable(pointer);
47+
}
48+
49+
public Optional<String> keyword() {
50+
return Optional.ofNullable(keyword);
51+
}
52+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package com.retailsvc.http;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
import static org.assertj.core.api.Assertions.assertThatThrownBy;
5+
6+
import org.junit.jupiter.api.Test;
7+
8+
class BadRequestExceptionTest {
9+
10+
@Test
11+
void defaultsStatusTo400() {
12+
BadRequestException e = new BadRequestException("bad input");
13+
14+
assertThat(e.status()).isEqualTo(400);
15+
assertThat(e.getMessage()).isEqualTo("bad input");
16+
assertThat(e.pointer()).isEmpty();
17+
assertThat(e.keyword()).isEmpty();
18+
}
19+
20+
@Test
21+
void honorsExplicitStatus() {
22+
BadRequestException e = new BadRequestException(422, "email taken");
23+
24+
assertThat(e.status()).isEqualTo(422);
25+
assertThat(e.getMessage()).isEqualTo("email taken");
26+
}
27+
28+
@Test
29+
void carriesPointerAndKeyword() {
30+
BadRequestException e = new BadRequestException(422, "email taken", "/email", "unique");
31+
32+
assertThat(e.pointer()).contains("/email");
33+
assertThat(e.keyword()).contains("unique");
34+
}
35+
36+
@Test
37+
void rejectsNon4xxStatus() {
38+
assertThatThrownBy(() -> new BadRequestException(500, "boom"))
39+
.isInstanceOf(IllegalArgumentException.class)
40+
.hasMessageContaining("4xx");
41+
assertThatThrownBy(() -> new BadRequestException(399, "x"))
42+
.isInstanceOf(IllegalArgumentException.class)
43+
.hasMessageContaining("4xx");
44+
}
45+
46+
@Test
47+
void rejectsNullDetail() {
48+
assertThatThrownBy(() -> new BadRequestException(null))
49+
.isInstanceOf(NullPointerException.class)
50+
.hasMessageContaining("detail");
51+
}
52+
}

0 commit comments

Comments
 (0)