diff --git a/samples/xapi-server/README.md b/samples/xapi-server/README.md index ee47ef34..6b52011c 100644 --- a/samples/xapi-server/README.md +++ b/samples/xapi-server/README.md @@ -111,3 +111,24 @@ A servlet filter adds `X-Experience-API-Version: 1.0.3` to responses under `/xap The consistency timestamp is a conservative snapshot taken before request handling. It can lag behind statements accepted or retrieved during that request. Statement writes are tracked from timestamp assignment until repository commit (or completion of an enclosing Spring transaction). Concurrent writes completing out of order cannot advance the boundary past an earlier pending write. When no writes are pending, the boundary advances with the clock; clock regression cannot backdate subsequently assigned stored timestamps. This tracker assumes a single application instance and synchronous repository writes. Multiple writers in separate instances or asynchronous persistence would require coordinated tracking of durable writes. Returning the version header does not imply full conformance; request version validation is not yet implemented. + +## Alternate request syntax + +The xAPI 1.0 alternate syntax is translated by `AlternateRequestSyntaxFilter` +before controller handling. Send an HTTP POST with only `method` in the query +string. Put the resource parameters in the URL-encoded form body, and supply any +request content in the `content` field as a UTF-8 string: + +```bash +curl 'http://localhost:8080/xapi/statements?method=GET' \ + --data-urlencode 'statementId=da415905-dcd8-4421-b4ba-bc885dfbf28c' \ + --data-urlencode 'X-Experience-API-Version=1.0.3' +``` + +The form fields `Authorization`, `X-Experience-API-Version`, `Content-Type`, +`Content-Length`, `If-Match` and `If-None-Match` become request headers. The inner +content type defaults to JSON; the decoded content length is calculated in UTF-8 +bytes and a supplied inner length must match. Other form fields become resource +parameters. The existing controllers handle the translated method, content and +validation. Binary attachments are not supported by this syntax. Authentication +and request version validation retain the limitations described above. diff --git a/samples/xapi-server/src/main/java/dev/learning/xapi/samples/xapiserver/AlternateRequestSyntaxFilter.java b/samples/xapi-server/src/main/java/dev/learning/xapi/samples/xapiserver/AlternateRequestSyntaxFilter.java new file mode 100644 index 00000000..399926d4 --- /dev/null +++ b/samples/xapi-server/src/main/java/dev/learning/xapi/samples/xapiserver/AlternateRequestSyntaxFilter.java @@ -0,0 +1,82 @@ +/* + * Copyright 2016-2025 Berry Cloud Ltd. All rights reserved. + */ + +package dev.learning.xapi.samples.xapiserver; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.Set; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.filter.OncePerRequestFilter; + +/** Translates xAPI 1.0 alternate form requests before normal HTTP request handling. */ +@Component +@Order(Ordered.HIGHEST_PRECEDENCE + 1) +public class AlternateRequestSyntaxFilter extends OncePerRequestFilter { + + private static final Set METHODS = + Set.of("GET", "HEAD", "POST", "PUT", "DELETE", "OPTIONS"); + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + final var path = request.getRequestURI().substring(request.getContextPath().length()); + return !path.equals("/xapi") && !path.startsWith("/xapi/"); + } + + @Override + protected void doFilterInternal( + HttpServletRequest request, HttpServletResponse response, FilterChain chain) + throws ServletException, IOException { + final MultiValueMap query; + try { + query = decodeForm(request.getQueryString()); + } catch (IllegalArgumentException e) { + response.sendError(HttpServletResponse.SC_BAD_REQUEST, "Malformed query string"); + return; + } + if (!query.containsKey("method")) { + chain.doFilter(request, response); + return; + } + if (!"POST".equals(request.getMethod()) + || query.size() != 1 + || query.get("method").size() != 1 + || !METHODS.contains(query.getFirst("method"))) { + response.sendError(HttpServletResponse.SC_BAD_REQUEST, "Invalid alternate request method"); + return; + } + final AlternateRequestWrapper translated; + try { + final var form = + decodeForm(new String(request.getInputStream().readAllBytes(), StandardCharsets.UTF_8)); + translated = new AlternateRequestWrapper(request, query.getFirst("method"), form); + } catch (IllegalArgumentException e) { + response.sendError(HttpServletResponse.SC_BAD_REQUEST, "Invalid alternate request form"); + return; + } + chain.doFilter(translated, response); + } + + private static MultiValueMap decodeForm(String encoded) { + final var result = new LinkedMultiValueMap(); + if (encoded != null && !encoded.isEmpty()) { + for (String field : encoded.split("&", -1)) { + final var pair = field.split("=", 2); + result.add( + URLDecoder.decode(pair[0], StandardCharsets.UTF_8), + pair.length == 2 ? URLDecoder.decode(pair[1], StandardCharsets.UTF_8) : ""); + } + } + return result; + } +} diff --git a/samples/xapi-server/src/main/java/dev/learning/xapi/samples/xapiserver/AlternateRequestWrapper.java b/samples/xapi-server/src/main/java/dev/learning/xapi/samples/xapiserver/AlternateRequestWrapper.java new file mode 100644 index 00000000..7cf5872d --- /dev/null +++ b/samples/xapi-server/src/main/java/dev/learning/xapi/samples/xapiserver/AlternateRequestWrapper.java @@ -0,0 +1,185 @@ +/* + * Copyright 2016-2025 Berry Cloud Ltd. All rights reserved. + */ + +package dev.learning.xapi.samples.xapiserver; + +import jakarta.servlet.ReadListener; +import jakarta.servlet.ServletInputStream; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletRequestWrapper; +import java.io.BufferedReader; +import java.io.ByteArrayInputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.Enumeration; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.util.MultiValueMap; + +/** Presents the decoded form as an ordinary request to controllers and subsequent filters. */ +final class AlternateRequestWrapper extends HttpServletRequestWrapper { + + private static final Set FORM_HEADERS = + Set.of( + "authorization", + "x-experience-api-version", + "content-type", + "content-length", + "if-match", + "if-none-match"); + + private final String method; + private final HttpHeaders headers = new HttpHeaders(); + private final Map parameters = new LinkedHashMap<>(); + private final byte[] content; + private final ServletInputStream input; + + AlternateRequestWrapper( + HttpServletRequest request, String method, MultiValueMap form) { + super(request); + this.method = method; + Collections.list(request.getHeaderNames()) + .forEach(name -> headers.put(name, Collections.list(request.getHeaders(name)))); + form.forEach( + (name, values) -> { + if (FORM_HEADERS.contains(name.toLowerCase(java.util.Locale.ROOT))) { + if (values.size() != 1) { + throw new IllegalArgumentException("Repeated header"); + } + headers.put(name, values); + } else if (!name.equals("content")) { + parameters.put(name, values.toArray(String[]::new)); + } + }); + if ((method.equals("PUT") || method.equals("POST")) && !form.containsKey("content")) { + throw new IllegalArgumentException("Missing content"); + } + if (form.containsKey("content") && form.get("content").size() != 1) { + throw new IllegalArgumentException("Repeated content"); + } + content = + form.getFirst("content") == null + ? new byte[0] + : form.getFirst("content").getBytes(StandardCharsets.UTF_8); + // The wire headers describe the envelope, not the decoded content. + if (!form.keySet().stream().anyMatch("Content-Type"::equalsIgnoreCase)) { + headers.setContentType(MediaType.APPLICATION_JSON); + } + if (form.keySet().stream().anyMatch("Content-Length"::equalsIgnoreCase) + && headers.getContentLength() != content.length) { + throw new IllegalArgumentException("Content-Length does not match UTF-8 content"); + } + headers.setContentLength(content.length); + input = + new ServletInputStream() { + private final ByteArrayInputStream bytes = new ByteArrayInputStream(content); + + @Override + public int read() { + return bytes.read(); + } + + @Override + public boolean isFinished() { + return bytes.available() == 0; + } + + @Override + public boolean isReady() { + return true; + } + + @Override + public void setReadListener(ReadListener listener) { + throw new UnsupportedOperationException("Only synchronous reads are supported"); + } + }; + } + + @Override + public String getMethod() { + return method; + } + + @Override + public String getQueryString() { + return null; + } + + @Override + public String getParameter(String name) { + final var values = parameters.get(name); + return values == null ? null : values[0]; + } + + @Override + public Map getParameterMap() { + return Collections.unmodifiableMap(parameters); + } + + @Override + public Enumeration getParameterNames() { + return Collections.enumeration(parameters.keySet()); + } + + @Override + public String[] getParameterValues(String name) { + return parameters.get(name); + } + + @Override + public String getHeader(String name) { + return headers.getFirst(name); + } + + @Override + public Enumeration getHeaders(String name) { + return Collections.enumeration(headers.getOrEmpty(name)); + } + + @Override + public Enumeration getHeaderNames() { + return Collections.enumeration(headers.headerNames()); + } + + @Override + public int getIntHeader(String name) { + final var value = getHeader(name); + return value == null ? -1 : Integer.parseInt(value); + } + + @Override + public String getContentType() { + return getHeader(HttpHeaders.CONTENT_TYPE); + } + + @Override + public int getContentLength() { + return content.length; + } + + @Override + public long getContentLengthLong() { + return content.length; + } + + @Override + public String getCharacterEncoding() { + return StandardCharsets.UTF_8.name(); + } + + @Override + public ServletInputStream getInputStream() { + return input; + } + + @Override + public BufferedReader getReader() { + return new BufferedReader(new InputStreamReader(input, StandardCharsets.UTF_8)); + } +} diff --git a/samples/xapi-server/src/main/java/dev/learning/xapi/samples/xapiserver/XapiResponseHeadersFilter.java b/samples/xapi-server/src/main/java/dev/learning/xapi/samples/xapiserver/XapiResponseHeadersFilter.java index 671db119..391eca1a 100644 --- a/samples/xapi-server/src/main/java/dev/learning/xapi/samples/xapiserver/XapiResponseHeadersFilter.java +++ b/samples/xapi-server/src/main/java/dev/learning/xapi/samples/xapiserver/XapiResponseHeadersFilter.java @@ -9,11 +9,14 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; /** Adds xAPI response headers before controllers can commit a response, including errors. */ @Component +@Order(Ordered.HIGHEST_PRECEDENCE) public class XapiResponseHeadersFilter extends OncePerRequestFilter { private final StatementConsistency consistency; diff --git a/samples/xapi-server/src/test/java/dev/learning/xapi/samples/xapiserver/AlternateRequestSyntaxFilterTest.java b/samples/xapi-server/src/test/java/dev/learning/xapi/samples/xapiserver/AlternateRequestSyntaxFilterTest.java new file mode 100644 index 00000000..9a8a1f6e --- /dev/null +++ b/samples/xapi-server/src/test/java/dev/learning/xapi/samples/xapiserver/AlternateRequestSyntaxFilterTest.java @@ -0,0 +1,166 @@ +/* + * Copyright 2016-2025 Berry Cloud Ltd. All rights reserved. + */ + +package dev.learning.xapi.samples.xapiserver; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +import jakarta.servlet.http.HttpServletRequest; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.concurrent.atomic.AtomicBoolean; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +class AlternateRequestSyntaxFilterTest { + + private final AlternateRequestSyntaxFilter filter = new AlternateRequestSyntaxFilter(); + + @Test + void translatesHeadersParametersAndUtf8ContentBeforeTheChainRuns() throws Exception { + String content = "{\"text\":\"café + 日本語 & = %\"}"; + var request = + request( + "POST", + "method=PUT", + "content=" + + encode(content) + + "&statementId=123&tag=one&tag=two&Authorization=Basic+abc" + + "&x-experience-api-version=1.0.3&Content-Type=application%2Fjson" + + "&If-Match=%22etag%22&If-None-Match=*&Content-Length=" + + content.getBytes(StandardCharsets.UTF_8).length); + request.addHeader("Authorization", "old"); + request.addHeader("Accept-Language", "en-GB"); + filter.doFilter( + request, + new MockHttpServletResponse(), + (req, res) -> { + var translated = (HttpServletRequest) req; + assertEquals("PUT", translated.getMethod()); + assertEquals("Basic abc", translated.getHeader("authorization")); + assertEquals("1.0.3", translated.getHeader("X-Experience-API-Version")); + assertEquals("\"etag\"", translated.getHeader("if-match")); + assertEquals("*", translated.getHeader("If-None-Match")); + assertEquals("en-GB", translated.getHeader("Accept-Language")); + assertEquals("application/json", translated.getContentType()); + assertEquals("UTF-8", translated.getCharacterEncoding()); + assertEquals( + content.getBytes(StandardCharsets.UTF_8).length, translated.getContentLength()); + assertEquals(translated.getContentLength(), translated.getContentLengthLong()); + assertEquals(translated.getContentLength(), translated.getIntHeader("Content-Length")); + assertEquals("123", translated.getParameter("statementId")); + assertArrayEquals(new String[] {"one", "two"}, translated.getParameterValues("tag")); + assertEquals(2, translated.getParameterMap().size()); + assertEquals(2, Collections.list(translated.getParameterNames()).size()); + assertNull(translated.getParameter("content")); + assertNull(translated.getParameter("Authorization")); + assertNull(translated.getParameter("method")); + assertEquals( + Collections.singletonList("Basic abc"), + Collections.list(translated.getHeaders("AUTHORIZATION"))); + assertTrue(Collections.list(translated.getHeaderNames()).contains("If-Match")); + assertEquals(content, translated.getReader().readLine()); + assertTrue(translated.getInputStream().isFinished()); + }); + } + + @Test + void emptyGetFormDoesNotExposeTheEnvelopeAsContent() throws Exception { + filter.doFilter( + request("POST", "method=GET", "limit=1"), + new MockHttpServletResponse(), + (req, res) -> { + var translated = (HttpServletRequest) req; + assertEquals("GET", translated.getMethod()); + assertEquals("1", translated.getParameter("limit")); + assertEquals(0, translated.getContentLength()); + assertEquals("0", translated.getHeader("Content-Length")); + assertEquals(-1, translated.getInputStream().read()); + }); + } + + @Test + void rejectsInvalidEnvelopesWithoutCallingTheChain() throws Exception { + for (String query : + new String[] { + "method=PUT&statementId=123", + "method=GET&method=PUT", + "method=", + "method=PATCH", + "method=get", + "method=%ZZ" + }) { + assertBadRequest(request("POST", query, "")); + } + assertBadRequest(request("PUT", "method=POST", "content=%7B%7D")); + assertBadRequest(request("GET", "method=GET", "")); + assertBadRequest(request("POST", "method=PUT", "content=%ZZ")); + assertBadRequest(request("POST", "method=PUT", "statementId=123")); + assertBadRequest(request("POST", "method=POST", "")); + assertBadRequest(request("POST", "method=PUT", "content=abc&Content-Length=1")); + assertBadRequest(request("POST", "method=PUT", "content=abc&Content-Length=bad")); + assertBadRequest(request("POST", "method=PUT", "content=a&content=b")); + assertBadRequest(request("POST", "method=GET", "Authorization=a&Authorization=b")); + } + + @Test + void leavesOrdinaryRequestsAndUnrelatedPathsUntouched() throws Exception { + var ordinary = request("POST", "statementId=123", "unmodified body"); + var unrelated = request("POST", "method=GET", "unmodified body"); + unrelated.setRequestURI("/xapi-other"); + for (var request : new MockHttpServletRequest[] {ordinary, unrelated}) { + var called = new AtomicBoolean(); + filter.doFilter( + request, + new MockHttpServletResponse(), + (req, res) -> { + called.set(true); + assertSame(request, req); + assertEquals( + "unmodified body", + new String(req.getInputStream().readAllBytes(), StandardCharsets.UTF_8)); + }); + assertTrue(called.get()); + } + } + + @Test + void supportsContextPaths() throws Exception { + var request = request("POST", "method=DELETE", "stateId=progress"); + request.setContextPath("/lrs"); + request.setRequestURI("/lrs/xapi/activities/state"); + filter.doFilter( + request, + new MockHttpServletResponse(), + (req, res) -> { + assertEquals("DELETE", ((HttpServletRequest) req).getMethod()); + assertEquals("progress", req.getParameter("stateId")); + }); + } + + private void assertBadRequest(MockHttpServletRequest request) throws Exception { + var response = new MockHttpServletResponse(); + filter.doFilter(request, response, (req, res) -> fail("Rejected request reached the chain")); + assertEquals(400, response.getStatus()); + } + + private static MockHttpServletRequest request(String method, String query, String body) { + var request = new MockHttpServletRequest(method, "/xapi/statements"); + request.setQueryString(query); + request.setContentType("application/x-www-form-urlencoded"); + request.setContent(body.getBytes(StandardCharsets.UTF_8)); + return request; + } + + private static String encode(String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8); + } +} diff --git a/samples/xapi-server/src/test/java/dev/learning/xapi/samples/xapiserver/AlternateRequestSyntaxIntegrationTest.java b/samples/xapi-server/src/test/java/dev/learning/xapi/samples/xapiserver/AlternateRequestSyntaxIntegrationTest.java new file mode 100644 index 00000000..44863248 --- /dev/null +++ b/samples/xapi-server/src/test/java/dev/learning/xapi/samples/xapiserver/AlternateRequestSyntaxIntegrationTest.java @@ -0,0 +1,121 @@ +/* + * Copyright 2016-2025 Berry Cloud Ltd. All rights reserved. + */ + +package dev.learning.xapi.samples.xapiserver; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; + +@SpringBootTest +@AutoConfigureMockMvc +class AlternateRequestSyntaxIntegrationTest { + + @Autowired private MockMvc mvc; + + @Test + void storesAndRetrievesAStatementThroughTheExistingControllers() throws Exception { + var id = UUID.randomUUID(); + var statement = + "{\"actor\":{\"mbox\":\"mailto:alternate@example.com\"}," + + "\"verb\":{\"id\":\"https://example.com/verb\"}," + + "\"object\":{\"id\":\"https://example.com/activity\"}}"; + mvc.perform( + alternate( + "/xapi/statements", + "PUT", + "statementId=" + + id + + "&Content-Type=application%2Fjson&content=" + + encode(statement))) + .andExpect(status().isNoContent()); + mvc.perform(alternate("/xapi/statements", "GET", "statementId=" + id)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(id.toString())); + mvc.perform(get("/xapi/statements").param("statementId", id.toString())) + .andExpect(status().isOk()); + mvc.perform(alternate("/xapi/statements", "POST", "content=" + encode(statement))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0]").isString()); + } + + @Test + void supportsStateContentAndConditionalHeadersForPutPostGetAndDelete() throws Exception { + var endpoint = "/xapi/activities/state"; + var parameters = + "activityId=" + + encode("https://example.com/alternate") + + "&agent=" + + encode("{\"mbox\":\"mailto:alternate@example.com\"}") + + "&stateId=" + + UUID.randomUUID(); + var body = "{\"text\":\"café + 日本語 & = %\"}"; + mvc.perform( + alternate( + endpoint, + "PUT", + parameters + "&Content-Type=application%2Fjson&content=" + encode(body))) + .andExpect(status().isNoContent()); + var etag = StateService.etag(body.getBytes(StandardCharsets.UTF_8)); + mvc.perform(alternate(endpoint, "GET", parameters)) + .andExpect(status().isOk()) + .andExpect(content().bytes(body.getBytes(StandardCharsets.UTF_8))) + .andExpect(content().contentType("application/json")); + mvc.perform( + alternate(endpoint, "PUT", parameters + "&If-Match=%22wrong%22&content=replacement")) + .andExpect(status().isPreconditionFailed()); + mvc.perform( + alternate( + endpoint, + "POST", + parameters + + "&Content-Type=application%2Fjson&If-Match=" + + encode(etag) + + "&content=" + + encode("{\"updated\":true}"))) + .andExpect(status().isNoContent()); + mvc.perform(alternate(endpoint, "GET", parameters)) + .andExpect(jsonPath("$.text").value("café + 日本語 & = %")) + .andExpect(jsonPath("$.updated").value(true)); + mvc.perform(alternate(endpoint, "DELETE", parameters)).andExpect(status().isNoContent()); + mvc.perform(alternate(endpoint, "GET", parameters)).andExpect(status().isNotFound()); + } + + @Test + void invalidRequestsKeepXapiResponseHeadersAndNormalBodyValidation() throws Exception { + mvc.perform(alternate("/xapi/statements", "PUT", "statementId=" + UUID.randomUUID())) + .andExpect(status().isBadRequest()); + mvc.perform( + alternate( + "/xapi/statements", "PUT", "statementId=" + UUID.randomUUID() + "&content=%7B%7D")) + .andExpect(status().isBadRequest()); + mvc.perform(post("/xapi/statements?method=GET&limit=1")) + .andExpect(status().isBadRequest()) + .andExpect(header().string("X-Experience-API-Version", "1.0.3")) + .andExpect(header().exists("X-Experience-API-Consistent-Through")); + } + + private static MockHttpServletRequestBuilder alternate(String path, String method, String body) { + return post(path + "?method=" + method) + .contentType("application/x-www-form-urlencoded") + .content(body.getBytes(StandardCharsets.UTF_8)); + } + + private static String encode(String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8); + } +}