Made Exam API asynchronous a, fix tests and fix monitoring activity

This commit is contained in:
anhefti 2020-02-26 10:01:55 +01:00
parent a360bfffc8
commit 43578d3e1c
5 changed files with 294 additions and 195 deletions

View file

@ -335,10 +335,14 @@ public class ActivitiesPane implements TemplateComposer {
.create());
}
monitoring.setExpanded(
this.currentUser
.get()
.hasAnyRole(UserRole.EXAM_SUPPORTER));
if (monitoring.getItemCount() > 0) {
monitoring.setExpanded(
this.currentUser
.get()
.hasAnyRole(UserRole.EXAM_SUPPORTER));
} else {
monitoring.dispose();
}
}
// ---- MONITORING ---------------------------------------------------------------------

View file

@ -11,6 +11,7 @@ package ch.ethz.seb.sebserver.webservice.weblayer.api;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import java.util.concurrent.CompletionException;
import java.util.stream.Collectors;
import org.slf4j.Logger;
@ -205,4 +206,24 @@ public class APIExceptionHandler extends ResponseEntityExceptionHandler {
HttpStatus.BAD_REQUEST);
}
@ExceptionHandler(CompletionException.class)
public ResponseEntity<Object> handleCompletionException(
final CompletionException ex,
final WebRequest request) {
final Throwable cause = ex.getCause();
if (cause instanceof APIMessageException) {
return handleAPIMessageException((APIMessageException) cause, request);
} else if (cause instanceof APIConstraintViolationException) {
return handleIllegalAPIArgumentException((APIConstraintViolationException) cause, request);
} else if (cause instanceof ResourceNotFoundException) {
return APIMessage.ErrorMessage.RESOURCE_NOT_FOUND.createErrorResponse(cause.getMessage());
} else if (cause instanceof RuntimeException) {
return APIMessage.ErrorMessage.UNEXPECTED.createErrorResponse(cause.getMessage());
}
return APIMessage.ErrorMessage.GENERIC.createErrorResponse(cause.getMessage());
}
}

View file

@ -13,6 +13,7 @@ import java.security.Principal;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executor;
import java.util.stream.Collectors;
@ -86,7 +87,7 @@ public class ExamAPI_V1_Controller {
method = RequestMethod.POST,
consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE,
produces = MediaType.APPLICATION_JSON_UTF8_VALUE)
public Collection<RunningExamInfo> handshakeCreate(
public CompletableFuture<Collection<RunningExamInfo>> handshakeCreate(
@RequestParam(name = API.PARAM_INSTITUTION_ID, required = false) final Long instIdRequestParam,
@RequestParam(name = API.EXAM_API_PARAM_EXAM_ID, required = false) final Long examIdRequestParam,
@RequestBody(required = false) final MultiValueMap<String, String> formParams,
@ -94,217 +95,179 @@ public class ExamAPI_V1_Controller {
final HttpServletRequest request,
final HttpServletResponse response) {
final POSTMapper mapper = new POSTMapper(formParams);
return CompletableFuture.supplyAsync(
() -> {
final String remoteAddr = request.getRemoteAddr();
final Long institutionId = (instIdRequestParam != null)
? instIdRequestParam
: mapper.getLong(API.PARAM_INSTITUTION_ID);
final Long examId = (examIdRequestParam != null)
? examIdRequestParam
: mapper.getLong(API.EXAM_API_PARAM_EXAM_ID);
final POSTMapper mapper = new POSTMapper(formParams);
// Create and get new ClientConnection if all integrity checks passes
final ClientConnection clientConnection = this.sebClientConnectionService
.createClientConnection(principal, institutionId, remoteAddr, examId)
.getOrThrow();
final String remoteAddr = request.getRemoteAddr();
final Long institutionId = (instIdRequestParam != null)
? instIdRequestParam
: mapper.getLong(API.PARAM_INSTITUTION_ID);
final Long examId = (examIdRequestParam != null)
? examIdRequestParam
: mapper.getLong(API.EXAM_API_PARAM_EXAM_ID);
response.setHeader(
API.EXAM_API_SEB_CONNECTION_TOKEN,
clientConnection.connectionToken);
// Create and get new ClientConnection if all integrity checks passes
final ClientConnection clientConnection = this.sebClientConnectionService
.createClientConnection(principal, institutionId, remoteAddr, examId)
.getOrThrow();
// Crate list of running exams
List<RunningExamInfo> result;
if (examId == null) {
result = this.examSessionService.getRunningExamsForInstitution(institutionId)
.getOrThrow()
.stream()
.map(this::createRunningExamInfo)
.collect(Collectors.toList());
} else {
final Exam exam = this.examSessionService.getExamDAO().byPK(examId)
.getOrThrow();
response.setHeader(
API.EXAM_API_SEB_CONNECTION_TOKEN,
clientConnection.connectionToken);
result = Arrays.asList(createRunningExamInfo(exam));
}
// Crate list of running exams
List<RunningExamInfo> result;
if (examId == null) {
result = this.examSessionService.getRunningExamsForInstitution(institutionId)
.getOrThrow()
.stream()
.map(this::createRunningExamInfo)
.collect(Collectors.toList());
} else {
final Exam exam = this.examSessionService.getExamDAO().byPK(examId)
.getOrThrow();
if (result.isEmpty()) {
log.warn("There are no currently running exams for institution: {}. SEB connection creation denied",
institutionId);
throw new IllegalStateException("There are no currently running exams");
}
result = Arrays.asList(createRunningExamInfo(exam));
}
return result;
if (result.isEmpty()) {
log.warn(
"There are no currently running exams for institution: {}. SEB connection creation denied",
institutionId);
throw new IllegalStateException("There are no currently running exams");
}
return result;
},
this.executor);
}
@RequestMapping(
path = API.EXAM_API_HANDSHAKE_ENDPOINT,
method = RequestMethod.PATCH,
consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
public void handshakeUpdate(
public CompletableFuture<Void> handshakeUpdate(
@RequestHeader(name = API.EXAM_API_SEB_CONNECTION_TOKEN, required = true) final String connectionToken,
@RequestParam(name = API.EXAM_API_PARAM_EXAM_ID, required = false) final Long examId,
@RequestParam(name = API.EXAM_API_USER_SESSION_ID, required = false) final String userSessionId,
final Principal principal,
final HttpServletRequest request) {
final String remoteAddr = request.getRemoteAddr();
final Long institutionId = getInstitutionId(principal);
return CompletableFuture.runAsync(
() -> {
if (log.isDebugEnabled()) {
log.debug("Request received on SEB Client Connection update endpoint: "
+ "institution: {} "
+ "exam: {} "
+ "userSessionId: {} "
+ "client-address: {}",
institutionId,
examId,
userSessionId,
remoteAddr);
}
final String remoteAddr = request.getRemoteAddr();
final Long institutionId = getInstitutionId(principal);
this.sebClientConnectionService.updateClientConnection(
connectionToken,
institutionId,
examId,
remoteAddr,
userSessionId)
.getOrThrow();
if (log.isDebugEnabled()) {
log.debug("Request received on SEB Client Connection update endpoint: "
+ "institution: {} "
+ "exam: {} "
+ "userSessionId: {} "
+ "client-address: {}",
institutionId,
examId,
userSessionId,
remoteAddr);
}
this.sebClientConnectionService.updateClientConnection(
connectionToken,
institutionId,
examId,
remoteAddr,
userSessionId)
.getOrThrow();
},
this.executor);
}
@RequestMapping(
path = API.EXAM_API_HANDSHAKE_ENDPOINT,
method = RequestMethod.PUT,
consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
public void handshakeEstablish(
public CompletableFuture<Void> handshakeEstablish(
@RequestHeader(name = API.EXAM_API_SEB_CONNECTION_TOKEN, required = true) final String connectionToken,
@RequestParam(name = API.EXAM_API_PARAM_EXAM_ID, required = false) final Long examId,
@RequestParam(name = API.EXAM_API_USER_SESSION_ID, required = false) final String userSessionId,
final Principal principal,
final HttpServletRequest request) {
final String remoteAddr = request.getRemoteAddr();
final Long institutionId = getInstitutionId(principal);
return CompletableFuture.runAsync(
() -> {
if (log.isDebugEnabled()) {
log.debug("Request received on SEB Client Connection establish endpoint: "
+ "institution: {} "
+ "exam: {} "
+ "client-address: {}",
institutionId,
examId,
remoteAddr);
}
final String remoteAddr = request.getRemoteAddr();
final Long institutionId = getInstitutionId(principal);
this.sebClientConnectionService.establishClientConnection(
connectionToken,
institutionId,
examId,
remoteAddr,
userSessionId)
.getOrThrow();
if (log.isDebugEnabled()) {
log.debug("Request received on SEB Client Connection establish endpoint: "
+ "institution: {} "
+ "exam: {} "
+ "client-address: {}",
institutionId,
examId,
remoteAddr);
}
this.sebClientConnectionService.establishClientConnection(
connectionToken,
institutionId,
examId,
remoteAddr,
userSessionId)
.getOrThrow();
},
this.executor);
}
@RequestMapping(
path = API.EXAM_API_HANDSHAKE_ENDPOINT,
method = RequestMethod.DELETE,
consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
public void handshakeDelete(
public CompletableFuture<Void> handshakeDelete(
@RequestHeader(name = API.EXAM_API_SEB_CONNECTION_TOKEN, required = true) final String connectionToken,
final Principal principal,
final HttpServletRequest request) {
final String remoteAddr = request.getRemoteAddr();
final Long institutionId = getInstitutionId(principal);
return CompletableFuture.runAsync(
() -> {
if (log.isDebugEnabled()) {
log.debug("Request received on SEB Client Connection close endpoint: "
+ "institution: {} "
+ "client-address: {}",
institutionId,
remoteAddr);
}
final String remoteAddr = request.getRemoteAddr();
final Long institutionId = getInstitutionId(principal);
this.sebClientConnectionService.closeConnection(
connectionToken,
institutionId,
remoteAddr)
.getOrThrow();
if (log.isDebugEnabled()) {
log.debug("Request received on SEB Client Connection close endpoint: "
+ "institution: {} "
+ "client-address: {}",
institutionId,
remoteAddr);
}
this.sebClientConnectionService.closeConnection(
connectionToken,
institutionId,
remoteAddr)
.getOrThrow();
},
this.executor);
}
@RequestMapping(
path = API.EXAM_API_CONFIGURATION_REQUEST_ENDPOINT,
method = RequestMethod.GET,
produces = MediaType.APPLICATION_OCTET_STREAM_VALUE)
public void getConfig(
public CompletableFuture<Void> getConfig(
@RequestHeader(name = API.EXAM_API_SEB_CONNECTION_TOKEN, required = true) final String connectionToken,
@RequestParam(required = false) final MultiValueMap<String, String> formParams,
final Principal principal,
final HttpServletRequest request,
final HttpServletResponse response) throws IOException {
// if an examId is provided with the request, update the connection first
if (formParams != null && formParams.containsKey(API.EXAM_API_PARAM_EXAM_ID)) {
final String examId = formParams.getFirst(API.EXAM_API_PARAM_EXAM_ID);
final Long institutionId = getInstitutionId(principal);
final ClientConnection connection = this.sebClientConnectionService.updateClientConnection(
connectionToken,
institutionId,
Long.valueOf(examId),
null,
null)
.getOrThrow();
if (log.isDebugEnabled()) {
log.debug("Updated connection: {}", connection);
}
}
final ServletOutputStream outputStream = response.getOutputStream();
try {
final ClientConnectionData connection = this.examSessionService
.getConnectionData(connectionToken)
.getOrThrow();
// exam integrity check
if (connection.clientConnection.examId == null ||
!this.examSessionService.isExamRunning(connection.clientConnection.examId)) {
log.error("Missing exam identifer or requested exam is not running for connection: {}", connection);
throw new IllegalStateException("Missing exam identider or requested exam is not running");
}
} catch (final Exception e) {
log.error("Unexpected error: ", e);
final APIMessage errorMessage = APIMessage.ErrorMessage.GENERIC.of(e.getMessage());
outputStream.write(Utils.toByteArray(this.jsonMapper.writeValueAsString(errorMessage)));
response.setStatus(HttpStatus.BAD_REQUEST.value());
outputStream.flush();
outputStream.close();
return;
}
try {
this.examSessionService
.streamDefaultExamConfig(
connectionToken,
outputStream);
response.setStatus(HttpStatus.OK.value());
} catch (final Exception e) {
final APIMessage errorMessage = APIMessage.ErrorMessage.GENERIC.of(e.getMessage());
outputStream.write(Utils.toByteArray(this.jsonMapper.writeValueAsString(errorMessage)));
response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());
} finally {
outputStream.flush();
outputStream.close();
}
return CompletableFuture.runAsync(
() -> streamExamConfig(connectionToken, formParams, principal, response),
this.executor);
}
private static final ResponseEntity<String> EMPTY_PING_RESPONSE = ResponseEntity
@ -316,49 +279,38 @@ public class ExamAPI_V1_Controller {
method = RequestMethod.POST,
consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE,
produces = MediaType.APPLICATION_JSON_UTF8_VALUE)
public ResponseEntity<String> ping(
public CompletableFuture<ResponseEntity<String>> ping(
@RequestHeader(name = API.EXAM_API_SEB_CONNECTION_TOKEN, required = true) final String connectionToken,
@RequestParam(name = API.EXAM_API_PING_TIMESTAMP, required = true) final long timestamp,
@RequestParam(name = API.EXAM_API_PING_NUMBER, required = false) final int pingNumber) {
final String notifyPing = this.sebClientConnectionService
.notifyPing(connectionToken, timestamp, pingNumber);
return CompletableFuture.supplyAsync(
() -> {
final String notifyPing = this.sebClientConnectionService
.notifyPing(connectionToken, timestamp, pingNumber);
if (notifyPing == null) {
return EMPTY_PING_RESPONSE;
}
if (notifyPing == null) {
return EMPTY_PING_RESPONSE;
}
return ResponseEntity
.ok()
.body(notifyPing);
return ResponseEntity
.ok()
.body(notifyPing);
},
this.executor);
}
// @RequestMapping(
// path = API.EXAM_API_PING_ENDPOINT,
// method = RequestMethod.POST,
// consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE,
// produces = MediaType.APPLICATION_JSON_UTF8_VALUE)
// public CompletableFuture<String> ping(
// @RequestHeader(name = API.EXAM_API_SEB_CONNECTION_TOKEN, required = true) final String connectionToken,
// @RequestParam(name = API.EXAM_API_PING_TIMESTAMP, required = true) final long timestamp,
// @RequestParam(name = API.EXAM_API_PING_NUMBER, required = false) final int pingNumber) {
//
// return CompletableFuture.supplyAsync(
// () -> this.sebClientConnectionService
// .notifyPing(connectionToken, timestamp, pingNumber),
// this.executor);
// }
@RequestMapping(
path = API.EXAM_API_EVENT_ENDPOINT,
method = RequestMethod.POST,
consumes = MediaType.APPLICATION_JSON_UTF8_VALUE)
public void event(
public CompletableFuture<Void> event(
@RequestHeader(name = API.EXAM_API_SEB_CONNECTION_TOKEN, required = true) final String connectionToken,
@RequestBody(required = true) final ClientEvent event) {
this.sebClientConnectionService
.notifyClientEvent(connectionToken, event);
return CompletableFuture.runAsync(
() -> this.sebClientConnectionService
.notifyClientEvent(connectionToken, event),
this.executor);
}
private Long getInstitutionId(final Principal principal) {
@ -375,4 +327,91 @@ public class ExamAPI_V1_Controller {
.getOr(null));
}
private void streamExamConfig(
final String connectionToken,
final MultiValueMap<String, String> formParams,
final Principal principal,
final HttpServletResponse response) {
try {
// if an examId is provided with the request, update the connection first
if (formParams != null && formParams.containsKey(API.EXAM_API_PARAM_EXAM_ID)) {
final String examId = formParams.getFirst(API.EXAM_API_PARAM_EXAM_ID);
final Long institutionId = getInstitutionId(principal);
final ClientConnection connection = this.sebClientConnectionService.updateClientConnection(
connectionToken,
institutionId,
Long.valueOf(examId),
null,
null)
.getOrThrow();
if (log.isDebugEnabled()) {
log.debug("Updated connection: {}", connection);
}
}
final ServletOutputStream outputStream = response.getOutputStream();
try {
final ClientConnectionData connection = this.examSessionService
.getConnectionData(connectionToken)
.getOrThrow();
// exam integrity check
if (connection.clientConnection.examId == null ||
!this.examSessionService.isExamRunning(connection.clientConnection.examId)) {
log.error("Missing exam identifier or requested exam is not running for connection: {}",
connection);
throw new IllegalStateException("Missing exam identifier or requested exam is not running");
}
} catch (final Exception e) {
log.error("Unexpected error: ", e);
final APIMessage errorMessage = APIMessage.ErrorMessage.GENERIC.of(e.getMessage());
outputStream.write(Utils.toByteArray(this.jsonMapper.writeValueAsString(errorMessage)));
response.setStatus(HttpStatus.BAD_REQUEST.value());
outputStream.flush();
outputStream.close();
return;
}
try {
this.examSessionService
.streamDefaultExamConfig(
connectionToken,
outputStream);
response.setStatus(HttpStatus.OK.value());
} catch (final Exception e) {
final APIMessage errorMessage = APIMessage.ErrorMessage.GENERIC.of(e.getMessage());
outputStream.write(Utils.toByteArray(this.jsonMapper.writeValueAsString(errorMessage)));
response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());
} finally {
outputStream.flush();
outputStream.close();
}
} catch (final Exception e) {
log.error("Unexpected error while trying to stream SEB Exam Configuration to client with connection: {}",
connectionToken,
e);
final APIMessage errorMessage = APIMessage.ErrorMessage.GENERIC.of(e.getMessage());
response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());
try {
response.getOutputStream().write(Utils.toByteArray(this.jsonMapper.writeValueAsString(errorMessage)));
} catch (final Exception e1) {
log.error("Failed to write error to response: ", e1);
}
}
}
}

View file

@ -11,6 +11,7 @@ package ch.ethz.seb.sebserver.webservice.integration.api.exam;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.httpBasic;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.request;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import java.util.Collections;
@ -39,8 +40,10 @@ import org.springframework.security.web.FilterChainProxy;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.test.web.servlet.ResultActions;
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
import org.springframework.test.web.servlet.result.MockMvcResultHandlers;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
@ -153,7 +156,12 @@ public abstract class ExamAPIIntegrationTester {
builder.content(body);
final ResultActions result = this.mockMvc.perform(builder)
final MvcResult mvcResult = this.mockMvc.perform(builder)
.andExpect(request().asyncStarted())
.andDo(MockMvcResultHandlers.log())
.andReturn();
final ResultActions result = this.mockMvc.perform(asyncDispatch(mvcResult))
.andExpect(content().contentType(MediaType.APPLICATION_JSON_UTF8_VALUE));
return result.andReturn().getResponse();
@ -209,7 +217,13 @@ public abstract class ExamAPIIntegrationTester {
}
builder.content(body);
final ResultActions result = this.mockMvc.perform(builder);
final MvcResult mvcResult = this.mockMvc
.perform(builder)
.andExpect(request().asyncStarted())
.andDo(MockMvcResultHandlers.log())
.andReturn();
final ResultActions result = this.mockMvc.perform(asyncDispatch(mvcResult));
return result.andReturn().getResponse();
}
@ -221,7 +235,13 @@ public abstract class ExamAPIIntegrationTester {
.header("Authorization", "Bearer " + accessToken)
.header(API.EXAM_API_SEB_CONNECTION_TOKEN, connectionToken)
.accept(MediaType.APPLICATION_JSON_UTF8_VALUE);
final ResultActions result = this.mockMvc.perform(builder);
final MvcResult mvcResult = this.mockMvc
.perform(builder)
.andExpect(request().asyncStarted())
.andDo(MockMvcResultHandlers.log())
.andReturn();
final ResultActions result = this.mockMvc.perform(asyncDispatch(mvcResult));
return result.andReturn().getResponse();
}
@ -239,7 +259,12 @@ public abstract class ExamAPIIntegrationTester {
+ "&" + API.EXAM_API_PING_NUMBER + "=" + num;
builder.content(body);
final ResultActions result = this.mockMvc.perform(builder);
final MvcResult mvcResult = this.mockMvc
.perform(builder)
.andExpect(request().asyncStarted())
.andDo(MockMvcResultHandlers.log())
.andReturn();
final ResultActions result = this.mockMvc.perform(asyncDispatch(mvcResult));
return result.andReturn().getResponse();
}
@ -259,7 +284,12 @@ public abstract class ExamAPIIntegrationTester {
final String body = "{ \"type\": \"%s\", \"timestamp\": %s, \"numericValue\": %s, \"text\": \"%s\" }";
builder.content(String.format(body, type, timestamp, value, text));
final ResultActions result = this.mockMvc.perform(builder);
final MvcResult mvcResult = this.mockMvc
.perform(builder)
.andExpect(request().asyncStarted())
.andDo(MockMvcResultHandlers.log())
.andReturn();
final ResultActions result = this.mockMvc.perform(asyncDispatch(mvcResult));
return result.andReturn().getResponse();
}
@ -278,8 +308,13 @@ public abstract class ExamAPIIntegrationTester {
builder.content("examId=" + examId);
}
final ResultActions result = this.mockMvc
.perform(builder);
final MvcResult mvcResult = this.mockMvc
.perform(builder)
.andExpect(request().asyncStarted())
.andDo(MockMvcResultHandlers.log())
.andReturn();
final ResultActions result = this.mockMvc.perform(asyncDispatch(mvcResult));
return result.andReturn().getResponse();
}

View file

@ -210,7 +210,7 @@ public class SebExamConfigurationRequestTest extends ExamAPIIntegrationTester {
// check error
final String contentAsString = configResponse.getContentAsString();
assertEquals(
"{\"messageCode\":\"0\",\"systemMessage\":\"Generic error message\",\"details\":\"Missing exam identider or requested exam is not running\",\"attributes\":[]}",
"{\"messageCode\":\"0\",\"systemMessage\":\"Generic error message\",\"details\":\"Missing exam identifier or requested exam is not running\",\"attributes\":[]}",
contentAsString);
// check connection cache
@ -253,7 +253,7 @@ public class SebExamConfigurationRequestTest extends ExamAPIIntegrationTester {
assertTrue(HttpStatus.OK.value() != configResponse.getStatus());
final String contentAsString = configResponse.getContentAsString();
assertEquals(
"{\"messageCode\":\"0\",\"systemMessage\":\"Generic error message\",\"details\":\"Missing exam identider or requested exam is not running\",\"attributes\":[]}",
"{\"messageCode\":\"0\",\"systemMessage\":\"Generic error message\",\"details\":\"Missing exam identifier or requested exam is not running\",\"attributes\":[]}",
contentAsString);
}