Skip to content

Commit

Permalink
Merge pull request #15 from prdoyle/ServiceEndpointsTest
Browse files Browse the repository at this point in the history
Service endpoints test
  • Loading branch information
prdoyle authored Aug 4, 2024
2 parents a85ecef + 0dd2d30 commit de0d8b3
Show file tree
Hide file tree
Showing 6 changed files with 396 additions and 55 deletions.
9 changes: 7 additions & 2 deletions bosk-spring-boot-3/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ plugins {
id 'bosk.development'
id 'bosk.maven-publish'
id 'com.github.spotbugs' version '5.1.5'

// These are needed just for testing
id 'org.springframework.boot' version '3.3.2'
id 'io.spring.dependency-management' version '1.1.3'
}

java {
Expand All @@ -12,10 +16,11 @@ java {

dependencies {
api project(":bosk-jackson")
implementation 'org.springframework.boot:spring-boot-starter-web:3.3.2'
annotationProcessor "org.springframework.boot:spring-boot-configuration-processor:3.3.2"
implementation 'org.springframework.boot:spring-boot-starter-web'
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
testImplementation project(":bosk-testing")
testImplementation project(":lib-testing")
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

repositories {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package works.bosk.spring.boot;

import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
Expand Down Expand Up @@ -32,10 +31,9 @@ ReadContextFilter readContextFilter(
ServiceEndpoints serviceEndpoints(
Bosk<?> bosk,
ObjectMapper mapper,
JacksonPlugin plugin,
@Value("${bosk.web.service-path}") String contextPath
JacksonPlugin plugin
) {
return new ServiceEndpoints(bosk, mapper, plugin, contextPath);
return new ServiceEndpoints(bosk, mapper, plugin);
}

@Bean
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,15 @@
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.Reader;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import works.bosk.Bosk;
Expand All @@ -26,46 +26,61 @@
import works.bosk.exceptions.NonexistentReferenceException;
import works.bosk.jackson.JacksonPlugin;

import static org.springframework.http.HttpStatus.ACCEPTED;
import static org.springframework.http.HttpStatus.BAD_REQUEST;
import static org.springframework.http.HttpStatus.NOT_FOUND;

@RestController
@RequestMapping("${bosk.web.service-path}")
public class ServiceEndpoints {
private final Bosk<?> bosk;
private final ObjectMapper mapper;
private final JacksonPlugin plugin;
private final int prefixLength;

public ServiceEndpoints(
Bosk<?> bosk,
ObjectMapper mapper,
JacksonPlugin plugin,
@Value("${bosk.web.service-path}") String contextPath
JacksonPlugin plugin
) {
this.bosk = bosk;
this.mapper = mapper;
this.plugin = plugin;
this.prefixLength = contextPath.length();
}

@GetMapping(path = "/**", produces = MediaType.APPLICATION_JSON_VALUE)
Object getAny(HttpServletRequest req) {
@GetMapping(produces = MediaType.APPLICATION_JSON_VALUE, path = {
"${bosk.web.service-path}",
"${bosk.web.service-path}/{*path}"
})
Object getAny(
@PathVariable(value="path", required = false) String path,
HttpServletRequest req
) {
LOGGER.debug("{} {}", req.getMethod(), req.getRequestURI());
Reference<Object> ref = referenceForPath(req);
Reference<?> ref = referenceForPath(path);
try {
return ref.value();
} catch (NonexistentReferenceException e) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Object does not exist: " + ref, e);
throw new ResponseStatusException(NOT_FOUND, "Object does not exist: " + ref, e);
}
}

@PutMapping(path = "/**")
void putAny(HttpServletRequest req, HttpServletResponse rsp) throws IOException, InvalidTypeException {
@PutMapping(path = {
"${bosk.web.service-path}",
"${bosk.web.service-path}/{*path}"
})
<T> void putAny(
@PathVariable(value = "path", required = false) String path,
Reader body,
HttpServletRequest req,
HttpServletResponse rsp
) throws IOException, InvalidTypeException {
LOGGER.debug("{} {}", req.getMethod(), req.getRequestURI());
Reference<Object> ref = referenceForPath(req);
Object newValue;
@SuppressWarnings("unchecked")
Reference<T> ref = (Reference<T>) referenceForPath(path);
T newValue;
try (@SuppressWarnings("unused") SerializationPlugin.DeserializationScope scope = plugin.newDeserializationScope(ref)) {
newValue = mapper
.readerFor(mapper.constructType(ref.targetType()))
.readValue(req.getReader());
.readValue(body);
}
checkForMismatchedID(ref, newValue);
discriminatePreconditionCases(req, new PreconditionDiscriminator() {
Expand All @@ -86,13 +101,20 @@ public void ifMustNotExist() {
bosk.driver().submitInitialization(ref, newValue);
}
});
rsp.setStatus(HttpStatus.ACCEPTED.value());
rsp.setStatus(ACCEPTED.value());
}

@DeleteMapping(path = "/**")
void deleteAny(HttpServletRequest req, HttpServletResponse rsp) {
@DeleteMapping(path = {
"${bosk.web.service-path}",
"${bosk.web.service-path}/{*path}"
})
void deleteAny(
@PathVariable(value = "path", required = false) String path,
HttpServletRequest req,
HttpServletResponse rsp
) {
LOGGER.debug("{} {}", req.getMethod(), req.getRequestURI());
Reference<Object> ref = referenceForPath(req);
Reference<?> ref = referenceForPath(path);
discriminatePreconditionCases(req, new PreconditionDiscriminator() {
@Override
public void ifUnconditional() {
Expand All @@ -110,27 +132,25 @@ public void ifMustNotExist() {
// Request to delete a nonexistent object: nothing to do
}
});
rsp.setStatus(HttpStatus.ACCEPTED.value());
rsp.setStatus(ACCEPTED.value());
}

private Reference<Object> referenceForPath(HttpServletRequest req) {
String path = req.getServletPath();
String boskPath = path.substring(prefixLength);
if (boskPath.isBlank()) {
boskPath = "/";
private Reference<?> referenceForPath(String path) {
if (path == null) {
return bosk.rootReference();
}
try {
return bosk.rootReference().then(Object.class, Path.parse(boskPath));
return bosk.rootReference().then(Object.class, Path.parse(path));
} catch (InvalidTypeException e) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Invalid path: " + path, e);
throw new ResponseStatusException(NOT_FOUND, "Invalid path: " + path, e);
}
}

private Reference<Identifier> revisionRef(Reference<?> ref) {
try {
return ref.then(Identifier.class, "revision");
} catch (InvalidTypeException e) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Preconditions not supported for object with no suitable revision field: " + ref, e);
throw new ResponseStatusException(BAD_REQUEST, "Preconditions not supported for object with no suitable revision field: " + ref, e);
}
}

Expand All @@ -145,57 +165,66 @@ private interface PreconditionDiscriminator {
/**
* ETags are a little fiddly to decode. This logic handles the various cases and error conditions,
* and then calls the given <code>discriminator</code> to perform the desired action.
* <p>
* As a convenience, if the discriminator throws {@link IllegalArgumentException},
* we'll wrap that in a {@link ResponseStatusException} using {@link HttpStatus#BAD_REQUEST}.
* {@link works.bosk.BoskDriver} throws {@link IllegalArgumentException} for invalid inputs,
* so this behaviour means the caller doesn't need to worry about this case.
*/
static void discriminatePreconditionCases(HttpServletRequest req, PreconditionDiscriminator discriminator) {
String ifMatch = req.getHeader("If-Match");
String ifNoneMatch = req.getHeader("If-None-Match");
LOGGER.debug("| If-Match: {} -- If-None-Match: {}", ifMatch, ifNoneMatch);
if (ifMatch == null) {
if (ifNoneMatch == null) {
LOGGER.trace("| Unconditional");
discriminator.ifUnconditional();
} else if ("*".equals(ifNoneMatch)) {
LOGGER.trace("| MustNotExist");
discriminator.ifMustNotExist();
try {
if (ifMatch == null) {
if (ifNoneMatch == null) {
LOGGER.trace("| Unconditional");
discriminator.ifUnconditional();
} else if ("*".equals(ifNoneMatch)) {
LOGGER.trace("| MustNotExist");
discriminator.ifMustNotExist();
} else {
throw new ResponseStatusException(BAD_REQUEST, "If-None-Match header, if supplied, must be \"*\"");
}
} else if (ifNoneMatch == null) {
Identifier expectedRevision = Identifier.from(etagStringValue(ifMatch));
LOGGER.trace("| MustMatch({})", expectedRevision);
discriminator.ifMustMatch(expectedRevision);
} else {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "If-None-Match header, if supplied, must be \"*\"");
throw new ResponseStatusException(BAD_REQUEST, "Cannot supply both If-Match and If-None-Match");
}
} else if (ifNoneMatch == null) {
Identifier expectedRevision = Identifier.from(etagStringValue(ifMatch));
LOGGER.trace("| MustMatch({})", expectedRevision);
discriminator.ifMustMatch(expectedRevision);
} else {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Cannot supply both If-Match and If-None-Match");
} catch (IllegalArgumentException e) {
throw new ResponseStatusException(BAD_REQUEST, e.getMessage(), e);
}
}

private static String etagStringValue(String etagString) {
if (etagString.length() < 3 || etagString.charAt(0) != '"' || etagString.charAt(etagString.length() - 1) != '"') {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "ETag string must be a non-empty string surrounded by quotes: " + etagString);
throw new ResponseStatusException(BAD_REQUEST, "ETag string must be a non-empty string surrounded by quotes: " + etagString);
}
String value = etagString.substring(1, etagString.length() - 1);
// We permit only the ASCII subset of https://datatracker.ietf.org/doc/html/rfc7232#section-2.3
for (int i = 0; i < value.length(); i++) {
int ch = value.codePointAt(i);
if (ch == '"') {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Only a single ETag string is supported: " + etagString);
throw new ResponseStatusException(BAD_REQUEST, "Only a single ETag string is supported: " + etagString);
} else if (ch == 0x21 || (0x23 <= ch && ch <= 0x7E)) { // Note: 0x22 is the quote character
// all is well
} else {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "ETag string contains an unsupported character at position " + i + ", code point " + ch + ": " + etagString);
throw new ResponseStatusException(BAD_REQUEST, "ETag string contains an unsupported character at position " + i + ", code point " + ch + ": " + etagString);
}
}
return value;
}

private void checkForMismatchedID(Reference<Object> ref, Object newValue) throws InvalidTypeException {
private void checkForMismatchedID(Reference<?> ref, Object newValue) throws InvalidTypeException {
if (newValue instanceof Entity e && !ref.path().isEmpty()) {
Reference<?> enclosingRef = ref.enclosingReference(Object.class);
if (EnumerableByIdentifier.class.isAssignableFrom(enclosingRef.targetClass())) {
Identifier pathID = Identifier.from(ref.path().lastSegment());
Identifier entityID = e.id();
if (!pathID.equals(entityID)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, ref.getClass().getSimpleName() + " ID \"" + entityID + "\" does not match path ID \"" + pathID + "\"");
throw new ResponseStatusException(BAD_REQUEST, ref.getClass().getSimpleName() + " ID \"" + entityID + "\" does not match path ID \"" + pathID + "\"");
}
}
}
Expand Down
Loading

0 comments on commit de0d8b3

Please sign in to comment.