Skip to content
Draft
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
31 changes: 30 additions & 1 deletion samples/xapi-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -30,4 +49,14 @@ curl --location 'http://localhost:8080/xapi/statements' \
}
}
}'
```
```

### 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.
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@

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;
import org.springframework.web.ErrorResponse;
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;

/**
Expand All @@ -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();
}

}
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>
* Configures custom converters for HTTP request parameters, including the timestamp converter
* which handles ISO 8601 formatted timestamps with strict xAPI validation.
* </p>
*
* @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.
*
* <p>
* 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.
* </p>
*
* @param registry the formatter registry to add converters to
*/
@Override
public void addFormatters(FormatterRegistry registry) {
registry.addConverter(instantConverter);
}
}
Original file line number Diff line number Diff line change
@@ -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"))));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}

}