diff --git a/samples/xapi-server/README.md b/samples/xapi-server/README.md index 66d66b91..c726ed3a 100644 --- a/samples/xapi-server/README.md +++ b/samples/xapi-server/README.md @@ -8,6 +8,25 @@ The server can be run with the following command: mvn spring-boot:run ``` +## Features + +### Timestamp Converter + +The server includes a custom timestamp converter (`InstantConverter`) that handles ISO 8601 formatted timestamps in HTTP request parameters with strict xAPI validation. The converter: + +- Accepts timestamps with UTC timezone (e.g., `2017-03-01T12:30:00.000Z`) +- Accepts timestamps with positive timezone offsets (e.g., `2017-03-01T12:30:00.000+00`, `2017-03-01T12:30:00.000+0000`, `2017-03-01T12:30:00.000+00:00`) +- Accepts timestamps with non-zero offsets (e.g., `2017-03-01T12:30:00.000+05:00`, `2017-03-01T12:30:00.000-05:00`) +- Assumes UTC when no timezone is specified (e.g., `2017-03-01T12:30:00.000`) +- Rejects negative zero offsets (e.g., `-00`, `-0000`, `-00:00`) per xAPI specification +- Supports nanosecond precision + +The converter is automatically registered with Spring MVC through the `WebConfig` configuration class and is used for all HTTP parameters of type `java.time.Instant`. + +## Examples + +### Post Statement + You can test the server with the following command: ```bash @@ -30,4 +49,14 @@ curl --location 'http://localhost:8080/xapi/statements' \ } } }' -``` \ No newline at end of file +``` + +### Query Statements with Timestamp + +Query statements created since a specific timestamp: + +```bash +curl --location 'http://localhost:8080/xapi/statements?since=2017-03-01T12:30:00.000Z' +``` + +Note: The timestamp parameter will be properly parsed and converted, though the endpoint currently returns `501 Not Implemented` as it's a demonstration server. \ No newline at end of file diff --git a/samples/xapi-server/src/main/java/dev/learning/xapi/samples/xapiserver/ServerControllerAdvice.java b/samples/xapi-server/src/main/java/dev/learning/xapi/samples/xapiserver/ServerControllerAdvice.java index 1b07d136..d02a7c9c 100644 --- a/samples/xapi-server/src/main/java/dev/learning/xapi/samples/xapiserver/ServerControllerAdvice.java +++ b/samples/xapi-server/src/main/java/dev/learning/xapi/samples/xapiserver/ServerControllerAdvice.java @@ -4,6 +4,7 @@ package dev.learning.xapi.samples.xapiserver; +import dev.learning.xapi.jackson.model.strict.XapiTimestamp.XapiTimestampParseException; import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.ConstraintViolationException; import org.springframework.http.HttpStatus; @@ -11,6 +12,7 @@ import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ResponseBody; import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; /** @@ -33,4 +35,28 @@ public ErrorResponse handleControllerException(HttpServletRequest request, Throw .build(); } + /** + * Handles timestamp parsing exceptions from the InstantConverter and provides clear error + * messages for invalid timestamp formats. + */ + @ResponseBody + @ExceptionHandler(MethodArgumentTypeMismatchException.class) + public ErrorResponse handleTypeMismatchException(HttpServletRequest request, + MethodArgumentTypeMismatchException e) { + + // Check if the root cause is a timestamp parsing error + Throwable cause = e.getCause(); + while (cause != null) { + if (cause instanceof XapiTimestampParseException) { + return ErrorResponse.builder(e, HttpStatus.BAD_REQUEST, + "Invalid timestamp format: " + cause.getMessage()).build(); + } + cause = cause.getCause(); + } + + // RFC 7807 error response for other type mismatches + return ErrorResponse.builder(e, HttpStatus.BAD_REQUEST, + "Invalid parameter type: " + e.getMessage()).build(); + } + } diff --git a/samples/xapi-server/src/main/java/dev/learning/xapi/samples/xapiserver/WebConfig.java b/samples/xapi-server/src/main/java/dev/learning/xapi/samples/xapiserver/WebConfig.java new file mode 100644 index 00000000..83b6cd60 --- /dev/null +++ b/samples/xapi-server/src/main/java/dev/learning/xapi/samples/xapiserver/WebConfig.java @@ -0,0 +1,46 @@ +/* + * Copyright 2016-2025 Berry Cloud Ltd. All rights reserved. + */ + +package dev.learning.xapi.samples.xapiserver; + +import org.springframework.context.annotation.Configuration; +import org.springframework.format.FormatterRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +/** + * Web MVC configuration for the xAPI server. + * + *
+ * Configures custom converters for HTTP request parameters, including the timestamp converter + * which handles ISO 8601 formatted timestamps with strict xAPI validation. + *
+ * + * @author István Rátkai (Selindek) + * @author Thomas Turrell-Croft + */ +@Configuration +public class WebConfig implements WebMvcConfigurer { + + private final InstantConverter instantConverter; + + public WebConfig(InstantConverter instantConverter) { + this.instantConverter = instantConverter; + } + + /** + * Registers custom converters for HTTP parameter binding. + * + *+ * The {@link InstantConverter} is explicitly registered to handle conversion of ISO 8601 + * timestamp strings to {@link java.time.Instant} objects. This converter enforces strict xAPI + * compliance, including proper timezone handling and rejection of negative zero offsets. + *
+ * + * @param registry the formatter registry to add converters to + */ + @Override + public void addFormatters(FormatterRegistry registry) { + registry.addConverter(instantConverter); + } +} diff --git a/samples/xapi-server/src/test/java/dev/learning/xapi/samples/xapiserver/InstantConverterTest.java b/samples/xapi-server/src/test/java/dev/learning/xapi/samples/xapiserver/InstantConverterTest.java new file mode 100644 index 00000000..a5591a15 --- /dev/null +++ b/samples/xapi-server/src/test/java/dev/learning/xapi/samples/xapiserver/InstantConverterTest.java @@ -0,0 +1,176 @@ +/* + * Copyright 2016-2025 Berry Cloud Ltd. All rights reserved. + */ + +package dev.learning.xapi.samples.xapiserver; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.notNullValue; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import dev.learning.xapi.jackson.model.strict.XapiTimestamp.XapiTimestampParseException; +import java.time.Instant; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link InstantConverter}. + * + * @author Thomas Turrell-Croft + */ +class InstantConverterTest { + + private final InstantConverter converter = new InstantConverter(); + + @Test + @DisplayName("When Converting Timestamp With UTC Timezone Then Conversion Succeeds") + void whenConvertingTimestampWithUtcTimezoneThenConversionSucceeds() { + + // When Converting Timestamp With UTC Timezone + final var result = converter.convert("2017-03-01T12:30:00.000Z"); + + // Then Conversion Succeeds + assertThat(result, is(notNullValue())); + assertThat(result, is(equalTo(Instant.parse("2017-03-01T12:30:00.000Z")))); + } + + @Test + @DisplayName("When Converting Timestamp With Positive Timezone Offset Then Conversion Succeeds") + void whenConvertingTimestampWithPositiveTimezoneOffsetThenConversionSucceeds() { + + // When Converting Timestamp With Positive Timezone Offset + final var result = converter.convert("2017-03-01T12:30:00.000+00"); + + // Then Conversion Succeeds + assertThat(result, is(notNullValue())); + assertThat(result, is(equalTo(Instant.parse("2017-03-01T12:30:00.000Z")))); + } + + @Test + @DisplayName("When Converting Timestamp With Positive Timezone Offset With Colon Then Conversion Succeeds") + void whenConvertingTimestampWithPositiveTimezoneOffsetWithColonThenConversionSucceeds() { + + // When Converting Timestamp With Positive Timezone Offset With Colon + final var result = converter.convert("2017-03-01T12:30:00.000+00:00"); + + // Then Conversion Succeeds + assertThat(result, is(notNullValue())); + assertThat(result, is(equalTo(Instant.parse("2017-03-01T12:30:00.000Z")))); + } + + @Test + @DisplayName("When Converting Timestamp With Positive Four Digit Timezone Offset Then Conversion Succeeds") + void whenConvertingTimestampWithPositiveFourDigitTimezoneOffsetThenConversionSucceeds() { + + // When Converting Timestamp With Positive Four Digit Timezone Offset + final var result = converter.convert("2017-03-01T12:30:00.000+0000"); + + // Then Conversion Succeeds + assertThat(result, is(notNullValue())); + assertThat(result, is(equalTo(Instant.parse("2017-03-01T12:30:00.000Z")))); + } + + @Test + @DisplayName("When Converting Timestamp With Non-Zero Positive Offset Then Conversion Succeeds") + void whenConvertingTimestampWithNonZeroPositiveOffsetThenConversionSucceeds() { + + // When Converting Timestamp With Non-Zero Positive Offset + final var result = converter.convert("2017-03-01T12:30:00.000+01:00"); + + // Then Conversion Succeeds + assertThat(result, is(notNullValue())); + assertThat(result, is(equalTo(Instant.parse("2017-03-01T11:30:00.000Z")))); + } + + @Test + @DisplayName("When Converting Timestamp With Non-Zero Negative Offset Then Conversion Succeeds") + void whenConvertingTimestampWithNonZeroNegativeOffsetThenConversionSucceeds() { + + // When Converting Timestamp With Non-Zero Negative Offset + final var result = converter.convert("2017-03-01T12:30:00.000-05:00"); + + // Then Conversion Succeeds + assertThat(result, is(notNullValue())); + assertThat(result, is(equalTo(Instant.parse("2017-03-01T17:30:00.000Z")))); + } + + @Test + @DisplayName("When Converting Timestamp Without Timezone Then UTC Is Assumed") + void whenConvertingTimestampWithoutTimezoneThenUtcIsAssumed() { + + // When Converting Timestamp Without Timezone + final var result = converter.convert("2017-03-01T12:30:00.000"); + + // Then UTC Is Assumed + assertThat(result, is(notNullValue())); + assertThat(result, is(equalTo(Instant.parse("2017-03-01T12:30:00.000Z")))); + } + + @Test + @DisplayName("When Converting Timestamp With Negative Zero Offset Two Digit Then Exception Is Thrown") + void whenConvertingTimestampWithNegativeZeroOffsetTwoDigitThenExceptionIsThrown() { + + // When Converting Timestamp With Negative Zero Offset Two Digit + // Then Exception Is Thrown + assertThrows(XapiTimestampParseException.class, + () -> converter.convert("2017-03-01T12:30:00.000-00")); + } + + @Test + @DisplayName("When Converting Timestamp With Negative Zero Offset Four Digit Then Exception Is Thrown") + void whenConvertingTimestampWithNegativeZeroOffsetFourDigitThenExceptionIsThrown() { + + // When Converting Timestamp With Negative Zero Offset Four Digit + // Then Exception Is Thrown + assertThrows(XapiTimestampParseException.class, + () -> converter.convert("2017-03-01T12:30:00.000-0000")); + } + + @Test + @DisplayName("When Converting Timestamp With Negative Zero Offset With Colon Then Exception Is Thrown") + void whenConvertingTimestampWithNegativeZeroOffsetWithColonThenExceptionIsThrown() { + + // When Converting Timestamp With Negative Zero Offset With Colon + // Then Exception Is Thrown + assertThrows(XapiTimestampParseException.class, + () -> converter.convert("2017-03-01T12:30:00.000-00:00")); + } + + @Test + @DisplayName("When Converting Timestamp Without Milliseconds Then Conversion Succeeds") + void whenConvertingTimestampWithoutMillisecondsThenConversionSucceeds() { + + // When Converting Timestamp Without Milliseconds + final var result = converter.convert("2017-03-01T12:30:00Z"); + + // Then Conversion Succeeds + assertThat(result, is(notNullValue())); + assertThat(result, is(equalTo(Instant.parse("2017-03-01T12:30:00Z")))); + } + + @Test + @DisplayName("When Converting Timestamp With Microseconds Then Conversion Succeeds") + void whenConvertingTimestampWithMicrosecondsThenConversionSucceeds() { + + // When Converting Timestamp With Microseconds + final var result = converter.convert("2017-03-01T12:30:00.123456Z"); + + // Then Conversion Succeeds + assertThat(result, is(notNullValue())); + assertThat(result, is(equalTo(Instant.parse("2017-03-01T12:30:00.123456Z")))); + } + + @Test + @DisplayName("When Converting Timestamp With Nanoseconds Then Conversion Succeeds") + void whenConvertingTimestampWithNanosecondsThenConversionSucceeds() { + + // When Converting Timestamp With Nanoseconds + final var result = converter.convert("2017-03-01T12:30:00.123456789Z"); + + // Then Conversion Succeeds + assertThat(result, is(notNullValue())); + assertThat(result, is(equalTo(Instant.parse("2017-03-01T12:30:00.123456789Z")))); + } +} diff --git a/samples/xapi-server/src/test/java/dev/learning/xapi/samples/xapiserver/StatementsControllerTest.java b/samples/xapi-server/src/test/java/dev/learning/xapi/samples/xapiserver/StatementsControllerTest.java index 2109fe2e..0c81d8f7 100644 --- a/samples/xapi-server/src/test/java/dev/learning/xapi/samples/xapiserver/StatementsControllerTest.java +++ b/samples/xapi-server/src/test/java/dev/learning/xapi/samples/xapiserver/StatementsControllerTest.java @@ -92,4 +92,59 @@ void whenGettingMultipleStatementsWithNegativeTimezoneOffsetThenStatusIsBadReque .andExpect(status().isBadRequest()); } + @Test + void whenGettingMultipleStatementsWithUtcTimestampThenStatusIsNotImplemented() + throws Exception { + + // When Getting Multiple Statements With UTC Timestamp + mvc.perform(get("/xapi/statements").param("since", "2017-03-01T12:30:00.000Z")) + + // Then Status Is Not Implemented + .andExpect(status().isNotImplemented()); + } + + @Test + void whenGettingMultipleStatementsWithTimestampWithColonThenStatusIsNotImplemented() + throws Exception { + + // When Getting Multiple Statements With Timestamp With Colon + mvc.perform(get("/xapi/statements").param("since", "2017-03-01T12:30:00.000+00:00")) + + // Then Status Is Not Implemented + .andExpect(status().isNotImplemented()); + } + + @Test + void whenGettingMultipleStatementsWithFourDigitOffsetThenStatusIsNotImplemented() + throws Exception { + + // When Getting Multiple Statements With Four Digit Offset + mvc.perform(get("/xapi/statements").param("since", "2017-03-01T12:30:00.000+0000")) + + // Then Status Is Not Implemented + .andExpect(status().isNotImplemented()); + } + + @Test + void whenGettingMultipleStatementsWithNonZeroOffsetThenStatusIsNotImplemented() + throws Exception { + + // When Getting Multiple Statements With Non-Zero Offset + mvc.perform(get("/xapi/statements").param("since", "2017-03-01T12:30:00.000+05:00")) + + // Then Status Is Not Implemented + .andExpect(status().isNotImplemented()); + } + + @Test + void whenGettingMultipleStatementsWithNoTimezoneThenStatusIsNotImplemented() + throws Exception { + + // When Getting Multiple Statements With No Timezone + mvc.perform(get("/xapi/statements").param("since", "2017-03-01T12:30:00.000")) + + // Then Status Is Not Implemented + .andExpect(status().isNotImplemented()); + } + }