Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@
import com.retailsvc.http.spec.Parameter;
import com.retailsvc.http.spec.RequestBody;
import com.retailsvc.http.spec.Spec;
import com.retailsvc.http.spec.schema.BooleanSchema;
import com.retailsvc.http.spec.schema.IntegerSchema;
import com.retailsvc.http.spec.schema.NumberSchema;
import com.retailsvc.http.spec.schema.Schema;
import com.retailsvc.http.validate.ValidationError;
import com.retailsvc.http.validate.Validator;
import com.sun.net.httpserver.Filter;
Expand Down Expand Up @@ -124,10 +128,48 @@ private void validateParameters(
}
continue;
}
validator.validate(value, p.schema(), pointer);
validator.validate(coerceParameterValue(value, p.schema(), pointer), p.schema(), pointer);
}
}

/**
* Converts a raw parameter string into a typed value matching the schema's primitive kind, so the
* validator (which is faithful to JSON Schema {@code type} semantics) sees the value the spec
* describes rather than its string serialization. Strings that fail to parse are passed through
* unchanged so the validator surfaces a {@code type} error with the original input.
*/
private static Object coerceParameterValue(String raw, Schema schema, String pointer) {
return switch (schema) {
case IntegerSchema _ -> {
try {
yield Long.parseLong(raw);
} catch (NumberFormatException _) {
throw new ValidationException(
new ValidationError(pointer, "type", "expected integer", raw));
}
}
case NumberSchema _ -> {
try {
yield Double.parseDouble(raw);
} catch (NumberFormatException _) {
throw new ValidationException(
new ValidationError(pointer, "type", "expected number", raw));
}
}
case BooleanSchema _ -> {
if ("true".equals(raw)) {
yield Boolean.TRUE;
}
if ("false".equals(raw)) {
yield Boolean.FALSE;
}
throw new ValidationException(
new ValidationError(pointer, "type", "expected boolean", raw));
}
default -> raw;
};
}

private Object validateAndParseBody(HttpExchange exchange, Operation op, byte[] body) {
Optional<RequestBody> rb = op.requestBody();
if (rb.isEmpty()) {
Expand Down
38 changes: 8 additions & 30 deletions src/main/java/com/retailsvc/http/validate/DefaultValidator.java
Original file line number Diff line number Diff line change
Expand Up @@ -347,22 +347,11 @@ private static boolean isHextet(String hextet) {
}

private void validateInteger(Object value, IntegerSchema s, String pointer) {
long n;
switch (value) {
case Number num -> n = num.longValue();
case String str -> {
try {
n = Long.parseLong(str);
} catch (NumberFormatException _) {
fail(pointer, "type", "expected integer", value);
return;
}
}
case null, default -> {
fail(pointer, "type", "expected integer", value);
return;
}
if (!(value instanceof Number num)) {
fail(pointer, "type", "expected integer", value);
return;
}
long n = num.longValue();

if (s.minimum() != null && n < s.minimum()) {
fail(pointer, "minimum", "integer below minimum " + s.minimum(), n);
Expand All @@ -385,22 +374,11 @@ private void validateInteger(Object value, IntegerSchema s, String pointer) {
}

private void validateNumber(Object value, NumberSchema s, String pointer) {
double n;
switch (value) {
case Number num -> n = num.doubleValue();
case String str -> {
try {
n = Double.parseDouble(str);
} catch (NumberFormatException _) {
fail(pointer, "type", "expected number", value);
return;
}
}
case null, default -> {
fail(pointer, "type", "expected number", value);
return;
}
if (!(value instanceof Number num)) {
fail(pointer, "type", "expected number", value);
return;
}
double n = num.doubleValue();

if (s.minimum() != null && n < s.minimum().doubleValue()) {
fail(pointer, "minimum", "number below minimum " + s.minimum(), n);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.retailsvc.http;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;

import com.retailsvc.http.spec.HttpMethod;
import com.retailsvc.http.validate.ValidationError;
Expand All @@ -13,7 +14,7 @@

class HandlersDefaultExceptionTest {
private HttpExchange newExchange(ByteArrayOutputStream sink) {
HttpExchange ex = Mockito.mock(HttpExchange.class);
HttpExchange ex = mock(HttpExchange.class);
Mockito.when(ex.getResponseHeaders()).thenReturn(new Headers());
Mockito.when(ex.getResponseBody()).thenReturn(sink);
return ex;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.retailsvc.http.internal;

import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;

import com.retailsvc.http.MissingOperationHandlerException;
import com.retailsvc.http.Request;
Expand All @@ -20,8 +21,8 @@ private static void withOperationId(

@Test
void invokesRegisteredHandler() throws Exception {
HttpHandler handler = Mockito.mock(HttpHandler.class);
HttpExchange ex = Mockito.mock(HttpExchange.class);
HttpHandler handler = mock(HttpHandler.class);
HttpExchange ex = mock(HttpExchange.class);

withOperationId(
"get-x",
Expand All @@ -37,7 +38,7 @@ void invokesRegisteredHandler() throws Exception {
@Test
void throwsWhenHandlerMissing() {
DispatchHandler d = new DispatchHandler(Map.of());
HttpExchange ex = Mockito.mock(HttpExchange.class);
HttpExchange ex = mock(HttpExchange.class);

assertThatThrownBy(
() ->
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package com.retailsvc.http.internal;

import static org.mockito.Mockito.mock;

import com.retailsvc.http.ExceptionHandler;
import com.retailsvc.http.NotFoundException;
import com.sun.net.httpserver.Filter;
Expand All @@ -10,21 +12,21 @@
class ExceptionFilterTest {
@Test
void delegatesToExceptionHandler() throws Exception {
HttpExchange ex = Mockito.mock(HttpExchange.class);
ExceptionHandler handler = Mockito.mock(ExceptionHandler.class);
HttpExchange ex = mock(HttpExchange.class);
ExceptionHandler handler = mock(ExceptionHandler.class);
Filter f = new ExceptionFilter(handler);
Filter.Chain chain = Mockito.mock(Filter.Chain.class);
Filter.Chain chain = mock(Filter.Chain.class);
Mockito.doThrow(new NotFoundException("x")).when(chain).doFilter(ex);
f.doFilter(ex, chain);
Mockito.verify(handler).handle(Mockito.eq(ex), Mockito.any(NotFoundException.class));
}

@Test
void passThroughOnSuccess() throws Exception {
HttpExchange ex = Mockito.mock(HttpExchange.class);
ExceptionHandler handler = Mockito.mock(ExceptionHandler.class);
HttpExchange ex = mock(HttpExchange.class);
ExceptionHandler handler = mock(ExceptionHandler.class);
Filter f = new ExceptionFilter(handler);
Filter.Chain chain = Mockito.mock(Filter.Chain.class);
Filter.Chain chain = mock(Filter.Chain.class);
f.doFilter(ex, chain);
Mockito.verify(chain).doFilter(ex);
Mockito.verifyNoInteractions(handler);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;

import com.retailsvc.http.JsonMapper;
import com.retailsvc.http.MethodNotAllowedException;
Expand All @@ -15,6 +16,9 @@
import com.retailsvc.http.spec.PathTemplate;
import com.retailsvc.http.spec.Server;
import com.retailsvc.http.spec.Spec;
import com.retailsvc.http.spec.schema.BooleanSchema;
import com.retailsvc.http.spec.schema.IntegerSchema;
import com.retailsvc.http.spec.schema.NumberSchema;
import com.retailsvc.http.spec.schema.StringSchema;
import com.retailsvc.http.spec.schema.TypeName;
import com.retailsvc.http.validate.DefaultValidator;
Expand All @@ -34,7 +38,7 @@
class RequestPreparationFilterTest {

private HttpExchange exchange(String method, String path, byte[] body) {
HttpExchange ex = Mockito.mock(HttpExchange.class);
HttpExchange ex = mock(HttpExchange.class);
Mockito.when(ex.getRequestMethod()).thenReturn(method);
Mockito.when(ex.getRequestURI()).thenReturn(URI.create(path));
Mockito.when(ex.getRequestHeaders()).thenReturn(new Headers());
Expand Down Expand Up @@ -78,7 +82,7 @@ void successPathBindsRequestContextDuringChain() throws Exception {
AtomicReference<String> seenOpId = new AtomicReference<>();
AtomicReference<Map<String, String>> seenPathParams = new AtomicReference<>();

Filter.Chain chain = Mockito.mock(Filter.Chain.class);
Filter.Chain chain = mock(Filter.Chain.class);
Mockito.doAnswer(
inv -> {
seenOpId.set(Request.operationId());
Expand Down Expand Up @@ -109,7 +113,7 @@ void unknownPathThrowsNotFound() {
Filter f = newFilter(spec);

HttpExchange ex = exchange("GET", "/missing", new byte[0]);
assertThatThrownBy(() -> f.doFilter(ex, Mockito.mock(Filter.Chain.class)))
assertThatThrownBy(() -> f.doFilter(ex, mock(Filter.Chain.class)))
.isInstanceOf(NotFoundException.class);
}

Expand All @@ -127,7 +131,7 @@ void wrongMethodThrowsMethodNotAllowed() {
Filter f = newFilter(spec);

HttpExchange ex = exchange("POST", "/x", new byte[0]);
assertThatThrownBy(() -> f.doFilter(ex, Mockito.mock(Filter.Chain.class)))
assertThatThrownBy(() -> f.doFilter(ex, mock(Filter.Chain.class)))
.isInstanceOf(MethodNotAllowedException.class);
}

Expand All @@ -146,9 +150,136 @@ void invalidQueryParamThrowsValidation() {
Filter f = newFilter(spec);

HttpExchange ex = exchange("GET", "/x?q=ab", new byte[0]);
assertThatThrownBy(() -> f.doFilter(ex, Mockito.mock(Filter.Chain.class)))
assertThatThrownBy(() -> f.doFilter(ex, mock(Filter.Chain.class)))
.isInstanceOf(ValidationException.class)
.extracting(t -> ((ValidationException) t).error().pointer())
.isEqualTo("/query/q");
}

@Test
void integerQueryParamIsCoercedFromStringBeforeValidation() throws Exception {
var intSchema = new IntegerSchema(Set.of(TypeName.INTEGER), 1L, 100L, null, null, null, null);
var op =
new Operation(
"a",
HttpMethod.GET,
PathTemplate.compile("/x"),
Optional.empty(),
List.of(new Parameter("n", Parameter.Location.QUERY, true, intSchema)),
Map.of());
Spec spec = specWith(op);
Filter f = newFilter(spec);

HttpExchange ex = exchange("GET", "/x?n=42", new byte[0]);
Filter.Chain chain = mock(Filter.Chain.class);
f.doFilter(ex, chain);
Mockito.verify(chain).doFilter(ex);
}

@Test
void integerQueryParamRejectsNonNumericString() {
var intSchema = new IntegerSchema(Set.of(TypeName.INTEGER), null, null, null, null, null, null);
var op =
new Operation(
"a",
HttpMethod.GET,
PathTemplate.compile("/x"),
Optional.empty(),
List.of(new Parameter("n", Parameter.Location.QUERY, true, intSchema)),
Map.of());
Spec spec = specWith(op);
Filter f = newFilter(spec);

HttpExchange ex = exchange("GET", "/x?n=abc", new byte[0]);
assertThatThrownBy(() -> f.doFilter(ex, mock(Filter.Chain.class)))
.isInstanceOf(ValidationException.class)
.extracting(t -> ((ValidationException) t).error().keyword())
.isEqualTo("type");
}

@Test
void numberQueryParamIsCoercedFromStringBeforeValidation() throws Exception {
var numSchema = new NumberSchema(Set.of(TypeName.NUMBER), null, null, null, null, null, null);
var op =
new Operation(
"a",
HttpMethod.GET,
PathTemplate.compile("/x"),
Optional.empty(),
List.of(new Parameter("n", Parameter.Location.QUERY, true, numSchema)),
Map.of());
Spec spec = specWith(op);
Filter f = newFilter(spec);

HttpExchange ex = exchange("GET", "/x?n=1.5", new byte[0]);
Filter.Chain chain = mock(Filter.Chain.class);
f.doFilter(ex, chain);
Mockito.verify(chain).doFilter(ex);
}

@Test
void numberQueryParamRejectsNonNumericString() {
var numSchema = new NumberSchema(Set.of(TypeName.NUMBER), null, null, null, null, null, null);
var op =
new Operation(
"a",
HttpMethod.GET,
PathTemplate.compile("/x"),
Optional.empty(),
List.of(new Parameter("n", Parameter.Location.QUERY, true, numSchema)),
Map.of());
Spec spec = specWith(op);
Filter f = newFilter(spec);

HttpExchange ex = exchange("GET", "/x?n=abc", new byte[0]);
assertThatThrownBy(() -> f.doFilter(ex, mock(Filter.Chain.class)))
.isInstanceOf(ValidationException.class)
.extracting(t -> ((ValidationException) t).error().keyword())
.isEqualTo("type");
}

@Test
void booleanQueryParamCoercesTrueAndFalse() throws Exception {
var boolSchema = new BooleanSchema(Set.of(TypeName.BOOLEAN));
var op =
new Operation(
"a",
HttpMethod.GET,
PathTemplate.compile("/x"),
Optional.empty(),
List.of(new Parameter("b", Parameter.Location.QUERY, true, boolSchema)),
Map.of());
Spec spec = specWith(op);
Filter f = newFilter(spec);

Filter.Chain trueChain = mock(Filter.Chain.class);
Filter.Chain falseChain = mock(Filter.Chain.class);
HttpExchange trueEx = exchange("GET", "/x?b=true", new byte[0]);
HttpExchange falseEx = exchange("GET", "/x?b=false", new byte[0]);
f.doFilter(trueEx, trueChain);
f.doFilter(falseEx, falseChain);
Mockito.verify(trueChain).doFilter(trueEx);
Mockito.verify(falseChain).doFilter(falseEx);
}

@Test
void booleanQueryParamRejectsNonBooleanString() {
var boolSchema = new BooleanSchema(Set.of(TypeName.BOOLEAN));
var op =
new Operation(
"a",
HttpMethod.GET,
PathTemplate.compile("/x"),
Optional.empty(),
List.of(new Parameter("b", Parameter.Location.QUERY, true, boolSchema)),
Map.of());
Spec spec = specWith(op);
Filter f = newFilter(spec);

HttpExchange ex = exchange("GET", "/x?b=yes", new byte[0]);
assertThatThrownBy(() -> f.doFilter(ex, mock(Filter.Chain.class)))
.isInstanceOf(ValidationException.class)
.extracting(t -> ((ValidationException) t).error().keyword())
.isEqualTo("type");
}
}
Loading
Loading