Skip to content
Open
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
21 changes: 21 additions & 0 deletions samples/xapi-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Original file line number Diff line number Diff line change
@@ -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<String> 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<String, String> 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<String, String> decodeForm(String encoded) {
final var result = new LinkedMultiValueMap<String, String>();
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;
}
}
Original file line number Diff line number Diff line change
@@ -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<String> 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<String, String[]> parameters = new LinkedHashMap<>();
private final byte[] content;
private final ServletInputStream input;

AlternateRequestWrapper(
HttpServletRequest request, String method, MultiValueMap<String, String> 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<String, String[]> getParameterMap() {
return Collections.unmodifiableMap(parameters);
}

@Override
public Enumeration<String> 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<String> getHeaders(String name) {
return Collections.enumeration(headers.getOrEmpty(name));
}

@Override
public Enumeration<String> 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));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading